Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 144 additions & 13 deletions src/daemon/providers/qwen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ export class QwenProvider implements SessionProvider {
#seenSubagents = new Set<string>();
/** Model the live loop was built with — the turn_done attribution fallback. */
#currentModel: string | null = null;
/** Resolved gateway URL + credential path of the live loop — see #loadCatalog. */
#currentBaseUrl: string | null = null;
#currentAuthType: "openai" | "qwen-oauth" | null = null;

constructor(init: QwenProviderInit) {
this.#backingId = coerceBackingId(init.initialBackingId, init.sessionId);
Expand Down Expand Up @@ -198,12 +201,59 @@ export class QwenProvider implements SessionProvider {
}

async listModels(): Promise<ModelInfo[]> {
if (!this.#query) return [];
return this.#loadCatalog();
}

/**
* The model catalog, unioned from both sources it can come from.
*
* qwen-code's `getAvailableModels()` is NOT a catalog fetch — it returns
* `modelRegistry.getModelsForAuthType(currentAuthType)`, an in-memory
* registry seeded once at construction from (a) the hardcoded
* `QWEN_OAUTH_MODELS` (a single entry, `coder-model`) for `qwen-oauth` and
* (b) the user's `modelProviders` setting for every other authType. Under
* `authType: "openai"` with no `modelProviders` declared it returns `[]`,
* which is why the picker used to render empty against a gateway serving a
* dozen models: qwen-code never asks the gateway what it hosts.
*
* So on the `openai` path we ask the gateway ourselves — it is an
* OpenAI-compatible endpoint, so `GET /models` is authoritative and stays
* current as the provider adds models. The registry is still unioned in
* (rather than replaced) because it carries real display labels, and because
* `qwen-oauth` has a dynamic base URL and no key to present, so HTTP is not
* an option there and the registry is the only source.
*
* Everything the endpoint reports is returned. The response carries only
* `{id, object, created, owned_by}` — no modality field — so filtering
* non-chat entries (image/audio models) would mean pattern-matching ids,
* which is exactly the kind of hardcoded list this method exists to remove.
*/
async #loadCatalog(): Promise<ModelInfo[]> {
const registry = this.#query
? await this.#query
.getAvailableModels()
.then(normalizeModelCatalog)
.catch(() => [] as ModelInfo[])
: [];

if (this.#currentAuthType !== "openai") return registry;

const baseUrl = this.#currentBaseUrl ?? process.env.OPENAI_BASE_URL;
const apiKey = process.env.OPENAI_API_KEY;
if (!baseUrl || !apiKey) return registry;

try {
const raw = await this.#query.getAvailableModels();
return normalizeModelCatalog(raw);
} catch {
return [];
const live = await fetchOpenAiModelCatalog(baseUrl, apiKey);
return unionCatalogs(live, registry);
} catch (err) {
// A catalog fetch is best-effort: the session still runs on the
// configured model, only the picker is poorer for it.
console.error(
`[qwen-provider ${this.#init.sessionId.slice(0, 8)}] model catalog fetch failed (${
err instanceof Error ? err.message : String(err)
}) — falling back to the qwen-code registry`,
);
return registry;
}
}

Expand Down Expand Up @@ -313,6 +363,8 @@ export class QwenProvider implements SessionProvider {
const baseUrl = resolveQwenBaseUrl(qwenCfg?.baseUrl);
const model = opts.model ?? qwenCfg?.model;
this.#currentModel = model ?? null;
this.#currentBaseUrl = baseUrl ?? null;
this.#currentAuthType = authType;

this.#query = query({
prompt: this.#inputQueue,
Expand Down Expand Up @@ -385,10 +437,10 @@ export class QwenProvider implements SessionProvider {
this.#hasQueried = true;

if (init.onModels) {
void this.#query
.getAvailableModels()
.then((raw) => {
const models = normalizeModelCatalog(raw);
// Fired on every loop build, so a model added to the gateway shows up on
// the next session rather than waiting for a daemon restart.
void this.#loadCatalog()
.then((models) => {
if (models.length > 0) {
init.onModels?.(
models.map((m) => ({
Expand Down Expand Up @@ -752,9 +804,17 @@ export function resolveQwenAuthType(

/**
* Normalize `Query.getAvailableModels()`, which is typed only as
* `Record<string, unknown> | null`. Accepts the observed
* `{ availableModels: [{ modelId, name, description }] }` shape plus a bare
* array, and ignores anything else rather than throwing.
* `Record<string, unknown> | null`.
*
* The shape actually emitted by @qwen-code/sdk 0.1.8 is
* `{ subtype, models: [{ id, label, capabilities, contextWindowSize }] }` —
* note `label`, not `name`, and no `description` (the CLI's
* `handleGetAvailableModels` projects the registry entry down to those four
* fields, dropping the description the registry itself carries). `name` /
* `modelId` / `availableModels` are kept as accepted aliases so a future
* rename doesn't silently empty the picker.
*
* A bare array is accepted too; anything else yields `[]` rather than throwing.
*/
export function normalizeModelCatalog(raw: unknown): ModelInfo[] {
const list = Array.isArray(raw)
Expand All @@ -774,15 +834,86 @@ export function normalizeModelCatalog(raw: unknown): ModelInfo[] {
const e = entry as Record<string, unknown>;
const id = typeof e.modelId === "string" ? e.modelId : typeof e.id === "string" ? e.id : null;
if (!id) continue;
const label =
typeof e.label === "string" ? e.label : typeof e.name === "string" ? e.name : id;
out.push({
id,
displayName: typeof e.name === "string" ? e.name : id,
displayName: label,
...(typeof e.description === "string" ? { description: e.description } : {}),
});
}
return out;
}

/** Give up on a catalog fetch well inside any reasonable session-start budget. */
const CATALOG_FETCH_TIMEOUT_MS = 10_000;

/**
* Fetch an OpenAI-compatible `GET /models` catalog.
*
* Every gateway codeoid points the qwen backend at (DashScope, the Bailian
* token-plan host, or a bring-your-own URL) speaks the OpenAI wire protocol —
* that is the whole premise of `authType: "openai"` — so `/models` is
* available and authoritative. Response shape is
* `{ object: "list", data: [{ id, object, created, owned_by }] }`; only `id`
* is load-bearing here.
*
* Exported for unit testing. Throws on transport error or non-2xx.
*/
export async function fetchOpenAiModelCatalog(
baseUrl: string,
apiKey: string,
fetchImpl: typeof fetch = fetch,
): Promise<ModelInfo[]> {
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
const res = await fetchImpl(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
const body: unknown = await res.json();
const data = (body as { data?: unknown })?.data;
if (!Array.isArray(data)) return [];
const out: ModelInfo[] = [];
for (const entry of data) {
const id =
typeof entry === "string"
? entry
: typeof (entry as { id?: unknown })?.id === "string"
? ((entry as { id: string }).id)
: null;
if (id) out.push({ id, displayName: id });
}
return out;
}

/**
* Union two catalogs, deduped by model id, `primary` order first.
*
* Where both sources carry the same id, the richer entry wins: an entry whose
* `displayName` differs from its id has a real human label behind it (the
* qwen-code registry supplies these; `/models` returns bare ids), so that one
* is kept and its description carried over.
*
* Exported for unit testing.
*/
export function unionCatalogs(
primary: readonly ModelInfo[],
secondary: readonly ModelInfo[],
): ModelInfo[] {
const labelled = (m: ModelInfo): boolean => m.displayName !== m.id;
const byId = new Map<string, ModelInfo>();
for (const m of [...primary, ...secondary]) {
const existing = byId.get(m.id);
if (!existing) {
byId.set(m.id, m);
continue;
}
if (!labelled(existing) && labelled(m)) byId.set(m.id, m);
}
return [...byId.values()];
}

/**
* Registry servers for the qwen backend, as SDK MCP configs — a native mount,
* since qwen-code owns its own MCP client. `${VAR}` env refs and
Expand Down
28 changes: 19 additions & 9 deletions src/daemon/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1366,15 +1366,23 @@ mcpHub: this.#mcpHub,
}

/**
* Cache the live model catalog a provider reported. The list is
* version-static per provider within a daemon lifetime, so the first
* report per provider wins and we stop overwriting (cheap idempotence;
* avoids churn from every new session).
* Cache the live model catalog a provider reported.
*
* The first report of each daemon lifetime is also persisted to SQLite
* (keyed by provider id), so subsequent boots serve current model names
* before any turn runs (see `#currentModels`) instead of the baked-in
* fallback that goes stale between codeoid releases.
* Every non-empty report wins, overwriting the previous one. Providers
* report on each query-loop build (i.e. session start), so a model added to
* a backend's gateway appears in the picker on the next session instead of
* waiting for a daemon restart — which matters now that the qwen backend
* resolves its catalog from a live `/models` call rather than a static list.
* An identical report is dropped before touching SQLite, so the common case
* (nothing changed since the last session) costs one comparison.
*
* EMPTY reports are ignored rather than cached: a backend that fails to
* answer must not clobber a good catalog with nothing.
*
* Each accepted report is persisted to SQLite (keyed by provider id), so
* subsequent boots serve current model names before any turn runs (see
* `#currentModels`) instead of the baked-in fallback that goes stale
* between codeoid releases.
*
* TypeScript-private (not `#`) so unit tests can exercise the persistence
* path directly without a live backend query — same convention as
Expand All @@ -1385,13 +1393,15 @@ mcpHub: this.#mcpHub,
providerId: string,
raw: ReadonlyArray<{ value: string; displayName: string; description?: string }>,
): void {
if (this.#modelsCache.has(providerId) || raw.length === 0) return;
if (raw.length === 0) return;
const models = raw.map((m) => ({
value: m.value,
displayName: m.displayName,
...(m.description ? { description: m.description } : {}),
isDefault: m.value === "default",
}));
const previous = this.#modelsCache.get(providerId);
if (previous && JSON.stringify(previous) === JSON.stringify(models)) return;
this.#modelsCache.set(providerId, models);
try {
this.#store.saveModelCatalog(providerId, models);
Expand Down
14 changes: 13 additions & 1 deletion src/tests/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,14 +384,26 @@ describe("models.list serves live → persisted → baked-in fallback, per provi
expect(res.models.map((m) => m.value)).toEqual(["default", "fable", "opus"]);
});

it("first live report wins per provider for the lifetime; empty reports ignored", async () => {
// Providers report on every query-loop build, so the newest report is the
// most current view of what the backend serves — a model added to a gateway
// must appear on the next session, not after a daemon restart.
it("latest live report wins per provider; empty reports ignored", async () => {
const manager = new SessionManager(store, new TranscriptStore(join(tmp, "t")));
const cache = manager as unknown as CacheModels;
cache._cacheModels("claude", []);
expect((await listModels(manager)).live).toBe(false);
cache._cacheModels("claude", LIVE);
cache._cacheModels("claude", [{ value: "other", displayName: "Other" }]);
expect((await listModels(manager)).models.map((m) => m.value)).toEqual(["other"]);
});

it("an empty report never clobbers an already-cached catalog", async () => {
const manager = new SessionManager(store, new TranscriptStore(join(tmp, "t")));
const cache = manager as unknown as CacheModels;
cache._cacheModels("claude", LIVE);
cache._cacheModels("claude", []);
const res = await listModels(manager);
expect(res.models.map((m) => m.value)).toEqual(["default", "fable", "opus"]);
expect(res.live).toBe(true);
});
});
Loading
Loading