Skip to content

Commit 60c0c74

Browse files
committed
feat(provider): bundled model seeds for hosted API-key providers
Hosted OpenAI-compat providers returned an EMPTY model list until the first /models fetch succeeded — so selecting xai/mistral/gemini/etc. before setting a key showed an empty picker. list_models_for now falls back to a small per-provider seed catalog (keyed on the registry id) when the live fetch comes back empty (no key yet, network down, or a gated list). The real catalog supersedes it the moment the account is reachable; the front entry becomes the default when no -m is given. Seeds cover xai, mistral, gemini, fireworks, deepseek, groq, cerebras, together (current agent-capable ids, newest first). openrouter, custom hosts, and locals have no seed and legitimately stay empty. Locked in openai_transport_test "bundled model seed when no key / fetch empty". 275/275 green.
1 parent 4ec2a66 commit 60c0c74

2 files changed

Lines changed: 99 additions & 2 deletions

File tree

src/provider/selection.cpp

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include <mutex>
77
#include <string>
88
#include <string_view>
9+
#include <vector>
910

1011
#include "agentty/provider/registry.hpp"
1112
#include "agentty/provider/acp_agents.hpp"
@@ -30,6 +31,54 @@ std::string env_or_empty(std::string_view name) {
3031
const char* v = std::getenv(std::string{name}.c_str());
3132
return (v && *v) ? std::string{v} : std::string{};
3233
}
34+
35+
// Small per-provider seed catalog used ONLY when the live /models fetch comes
36+
// back empty (no API key set yet, network down, or a provider that gates its
37+
// list behind auth). Lets a freshly selected hosted provider show sensible
38+
// models in the picker immediately; the real catalog supersedes it the moment
39+
// the account is reachable. Keyed on the registry `id` (== endpoint label).
40+
// Deliberately short — a couple of current, agent-capable ids per provider,
41+
// newest first (front() becomes the default when the user gives no -m).
42+
std::vector<ModelInfo> bundled_models_for(std::string_view label) {
43+
auto mk = [&](const char* id) {
44+
return ModelInfo{ .id = ModelId{id}, .display_name = id,
45+
.provider = std::string{label} };
46+
};
47+
std::vector<ModelInfo> v;
48+
if (label == "xai") {
49+
v = { mk("grok-4.6"), mk("grok-4"), mk("grok-code-fast-1"),
50+
mk("grok-3"), mk("grok-3-mini") };
51+
} else if (label == "mistral") {
52+
v = { mk("mistral-large-latest"), mk("magistral-medium-latest"),
53+
mk("codestral-latest"), mk("mistral-medium-latest"),
54+
mk("mistral-small-latest") };
55+
} else if (label == "gemini") {
56+
v = { mk("gemini-2.5-pro"), mk("gemini-2.5-flash"),
57+
mk("gemini-2.5-flash-lite"), mk("gemini-2.0-flash") };
58+
} else if (label == "fireworks") {
59+
v = { mk("accounts/fireworks/models/kimi-k2-instruct"),
60+
mk("accounts/fireworks/models/deepseek-v3"),
61+
mk("accounts/fireworks/models/qwen3-235b-a22b"),
62+
mk("accounts/fireworks/models/llama-v3p3-70b-instruct") };
63+
} else if (label == "deepseek") {
64+
v = { mk("deepseek-chat"), mk("deepseek-reasoner"),
65+
mk("deepseek-v4-pro"), mk("deepseek-v4-flash") };
66+
} else if (label == "groq") {
67+
v = { mk("llama-3.3-70b-versatile"), mk("moonshotai/kimi-k2-instruct"),
68+
mk("qwen/qwen3-32b"), mk("llama-3.1-8b-instant") };
69+
} else if (label == "cerebras") {
70+
v = { mk("llama-3.3-70b"), mk("qwen-3-235b-a22b-instruct-2507"),
71+
mk("llama3.1-8b") };
72+
} else if (label == "together") {
73+
v = { mk("deepseek-ai/DeepSeek-V3"),
74+
mk("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
75+
mk("Qwen/Qwen3-235B-A22B-Instruct-2507-tput") };
76+
}
77+
// openrouter, custom hosts, and locals have no seed — their catalogs are
78+
// too large / user-defined to guess; they legitimately stay empty until
79+
// the live fetch lands.
80+
return v;
81+
}
3382
} // namespace
3483

3584
void set_custom_auth_header(std::string name) {
@@ -251,8 +300,18 @@ std::vector<ModelInfo> list_models_for(const Selection& sel,
251300
if (sel.is_copilot()) return copilot::list_models();
252301
if (sel.is_kimi()) return kimi::list_models();
253302
if (sel.is_oauth_native()) return chatgpt::list_models();
254-
if (sel.kind == Kind::OpenAI)
255-
return openai::list_models(auth, sel.openai_endpoint);
303+
if (sel.kind == Kind::OpenAI) {
304+
auto models = openai::list_models(auth, sel.openai_endpoint);
305+
// Hosted API-key providers return an EMPTY list when no key is set yet
306+
// (or the /models fetch fails / the network is down). Seed the picker
307+
// with a small bundled catalog keyed on the provider id so a freshly
308+
// selected provider shows sensible models immediately — the live fetch
309+
// supersedes this the moment the account can be reached. Local /
310+
// custom-host endpoints have no seed and legitimately stay empty.
311+
if (models.empty())
312+
return bundled_models_for(sel.openai_endpoint.label);
313+
return models;
314+
}
256315
return anthropic::list_models(auth);
257316
}
258317

tests/openai_transport_test.cpp

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,44 @@ TEST_CASE("test_endpoint_presets") {
675675
}
676676
}
677677

678+
// ── Bundled model seed: hosted API-key providers show models before a key ──
679+
// With an EMPTY auth header, openai::list_models short-circuits to empty for a
680+
// TLS endpoint (no network). list_models_for must then fall back to the
681+
// per-provider bundled seed so a freshly selected hosted provider shows models
682+
// in the picker before any key is set. The live fetch supersedes it later.
683+
TEST_CASE("bundled model seed when no key / fetch empty") {
684+
namespace P = agentty::provider;
685+
const auth::AuthHeader none{};
686+
687+
for (const char* id : {"xai", "mistral", "gemini", "fireworks",
688+
"deepseek", "groq", "cerebras", "together"}) {
689+
auto models = P::list_models_for(P::parse_selection(id), none);
690+
CHECK(!models.empty());
691+
// The seed stamps the provider label so the picker groups it right.
692+
if (!models.empty())
693+
CHECK(models.front().provider == std::string{id});
694+
}
695+
696+
// A couple of concrete slugs land where expected (newest first).
697+
{
698+
auto xai = P::list_models_for(P::parse_selection("xai"), none);
699+
CHECK(!xai.empty());
700+
if (!xai.empty()) CHECK(xai.front().id.value == "grok-4.6");
701+
}
702+
{
703+
auto gem = P::list_models_for(P::parse_selection("gemini"), none);
704+
CHECK(!gem.empty());
705+
if (!gem.empty()) CHECK(gem.front().id.value == "gemini-2.5-pro");
706+
}
707+
708+
// Providers WITHOUT a seed (openrouter, custom hosts, locals) legitimately
709+
// stay empty with no key rather than showing guessed models.
710+
{
711+
auto orr = P::list_models_for(P::parse_selection("openrouter"), none);
712+
CHECK(orr.empty());
713+
}
714+
}
715+
678716
// ── provider_display_name: URL-form labels collapse to host[:port] ──
679717
// A custom OpenAI-compatible host entered as "https://chat.example.org/api"
680718
// has Endpoint::label == the full URL (see Endpoint::from_spec, transport.cpp).

0 commit comments

Comments
 (0)