diff --git a/apps/api/src/services/agent-options-service.test.ts b/apps/api/src/services/agent-options-service.test.ts index 836a674c..a0ebd290 100644 --- a/apps/api/src/services/agent-options-service.test.ts +++ b/apps/api/src/services/agent-options-service.test.ts @@ -51,11 +51,76 @@ describe("getProviderOptions", () => { expect(mockRetrieveSecret).not.toHaveBeenCalled(); }); - it("returns the baseline when no API key is configured", async () => { - mockRetrieveSecret.mockRejectedValueOnce(new Error("Secret not found")); + it("returns the baseline when no credential is configured", async () => { + mockRetrieveSecret.mockRejectedValue(new Error("Secret not found")); const result = await getProviderOptions("anthropic"); expect(result.source).toBe("baseline"); expect(mockRetrieveSecret).toHaveBeenCalledWith("ANTHROPIC_API_KEY", "global", undefined); + expect(mockRetrieveSecret).toHaveBeenCalledWith("CLAUDE_CODE_OAUTH_TOKEN", "global", undefined); + }); + + it("falls back to the Claude OAuth token when no API key is configured", async () => { + mockRetrieveSecret.mockImplementation((name: unknown) => + name === "CLAUDE_CODE_OAUTH_TOKEN" + ? Promise.resolve("sk-ant-oat01-xxx") + : Promise.reject(new Error("Secret not found")), + ); + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: [{ id: "claude-new-model-id" }] }), + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await getProviderOptions("anthropic"); + expect(result.source).toBe("live"); + expect(result.catalog.models.some((m) => m.id === "claude-new-model-id")).toBe(true); + const headers = fetchSpy.mock.calls[0][1].headers as Record; + expect(headers.Authorization).toBe("Bearer sk-ant-oat01-xxx"); + expect(headers["anthropic-beta"]).toBe("oauth-2025-04-20"); + expect(headers["x-api-key"]).toBeUndefined(); + }); + + it("follows pagination on the anthropic models endpoint", async () => { + mockRetrieveSecret.mockResolvedValueOnce("sk-ant-xxx"); + const fetchSpy = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [{ id: "claude-page-1" }], + has_more: true, + last_id: "claude-page-1", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [{ id: "claude-page-2" }], has_more: false }), + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await getProviderOptions("anthropic"); + expect(fetchSpy).toHaveBeenCalledTimes(2); + const secondUrl = new URL(String(fetchSpy.mock.calls[1][0])); + expect(secondUrl.searchParams.get("after_id")).toBe("claude-page-1"); + expect(result.catalog.models.some((m) => m.id === "claude-page-1")).toBe(true); + expect(result.catalog.models.some((m) => m.id === "claude-page-2")).toBe(true); + }); + + it("uses upstream display names as labels for live models", async () => { + mockRetrieveSecret.mockResolvedValueOnce("sk-ant-xxx"); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + data: [{ id: "claude-opus-5", display_name: "Claude Opus 5" }], + }), + }) as unknown as typeof fetch; + + const result = await getProviderOptions("anthropic"); + const added = result.catalog.models.find((m) => m.id === "claude-opus-5"); + expect(added?.label).toBe("Claude Opus 5"); + expect(added?.family).toBe("opus"); }); it("probes upstream and merges when no cache entry exists", async () => { @@ -79,7 +144,7 @@ describe("getProviderOptions", () => { mockRetrieveSecret.mockResolvedValueOnce("sk-ant-xxx"); mockRedisGet.mockResolvedValueOnce( JSON.stringify({ - ids: ["claude-from-cache"], + models: [{ id: "claude-from-cache", displayName: "From Cache" }], refreshedAt: 1700000000, }), ); @@ -91,6 +156,24 @@ describe("getProviderOptions", () => { expect(result.cached).toBe(true); expect(result.refreshedAt).toBe(1700000000); expect(fetchSpy).not.toHaveBeenCalled(); + const cachedModel = result.catalog.models.find((m) => m.id === "claude-from-cache"); + expect(cachedModel?.label).toBe("From Cache"); + }); + + it("reads the legacy ids-only cache shape", async () => { + mockRetrieveSecret.mockResolvedValueOnce("sk-ant-xxx"); + mockRedisGet.mockResolvedValueOnce( + JSON.stringify({ + ids: ["claude-from-cache"], + refreshedAt: 1700000000, + }), + ); + const fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await getProviderOptions("anthropic"); + expect(result.cached).toBe(true); + expect(fetchSpy).not.toHaveBeenCalled(); expect(result.catalog.models.some((m) => m.id === "claude-from-cache")).toBe(true); }); diff --git a/apps/api/src/services/agent-options-service.ts b/apps/api/src/services/agent-options-service.ts index a91ab1ac..bfb70525 100644 --- a/apps/api/src/services/agent-options-service.ts +++ b/apps/api/src/services/agent-options-service.ts @@ -3,6 +3,7 @@ import { PROVIDER_CATALOGS, mergeLiveModels, type AgentProviderId, + type LiveModel, type ProviderCatalog, } from "@optio/shared"; import { getRedisClient } from "./event-bus.js"; @@ -14,57 +15,110 @@ const CACHE_TTL_SECONDS = 60 * 60; /** Redis key prefix for cached live model lists. */ const CACHE_KEY_PREFIX = "optio:agent-options"; -type LiveProbe = (apiKey: string) => Promise; +/** + * Credential used for an upstream list-models probe. `api-key` is sent in the + * provider's native key header; `oauth` is sent as a Bearer token (Anthropic + * OAuth tokens from `claude setup-token` additionally need the oauth beta + * header). + */ +interface ProbeCredential { + value: string; + kind: "api-key" | "oauth"; +} -/** Anthropic: GET /v1/models → data[].id. */ -async function probeAnthropic(apiKey: string): Promise { - const res = await fetch("https://api.anthropic.com/v1/models?limit=100", { - headers: { - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - }, - }); - if (!res.ok) throw new Error(`Anthropic /v1/models returned ${res.status}`); - const body = (await res.json()) as { data?: Array<{ id?: string }> }; - return (body.data ?? []).map((m) => m.id ?? "").filter(Boolean); +type LiveProbe = (credential: ProbeCredential) => Promise; + +/** Safety bound on list-models pagination — well above any real model count. */ +const MAX_PROBE_PAGES = 10; + +/** Anthropic: GET /v1/models → data[].{id,display_name}, paginated via after_id. */ +async function probeAnthropic(credential: ProbeCredential): Promise { + const headers: Record = { "anthropic-version": "2023-06-01" }; + if (credential.kind === "oauth") { + headers.Authorization = `Bearer ${credential.value}`; + headers["anthropic-beta"] = "oauth-2025-04-20"; + } else { + headers["x-api-key"] = credential.value; + } + + const models: LiveModel[] = []; + let afterId: string | undefined; + for (let page = 0; page < MAX_PROBE_PAGES; page++) { + const url = new URL("https://api.anthropic.com/v1/models"); + url.searchParams.set("limit", "100"); + if (afterId) url.searchParams.set("after_id", afterId); + const res = await fetch(url, { headers }); + if (!res.ok) throw new Error(`Anthropic /v1/models returned ${res.status}`); + const body = (await res.json()) as { + data?: Array<{ id?: string; display_name?: string }>; + has_more?: boolean; + last_id?: string; + }; + for (const m of body.data ?? []) { + if (m.id) models.push({ id: m.id, displayName: m.display_name }); + } + if (!body.has_more || !body.last_id) break; + afterId = body.last_id; + } + return models; } /** OpenAI: GET /v1/models → data[].id. */ -async function probeOpenAI(apiKey: string): Promise { +async function probeOpenAI(credential: ProbeCredential): Promise { const res = await fetch("https://api.openai.com/v1/models", { - headers: { Authorization: `Bearer ${apiKey}` }, + headers: { Authorization: `Bearer ${credential.value}` }, }); if (!res.ok) throw new Error(`OpenAI /v1/models returned ${res.status}`); const body = (await res.json()) as { data?: Array<{ id?: string }> }; - return (body.data ?? []).map((m) => m.id ?? "").filter(Boolean); + return (body.data ?? []).flatMap((m) => (m.id ? [{ id: m.id }] : [])); } -/** Gemini: GET /v1beta/models?key=... → models[].name (strip "models/" prefix). */ -async function probeGemini(apiKey: string): Promise { +/** Gemini: GET /v1beta/models?key=... → models[].{name,displayName} (strip "models/" prefix). */ +async function probeGemini(credential: ProbeCredential): Promise { const res = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`, + `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(credential.value)}`, ); if (!res.ok) throw new Error(`Gemini /v1beta/models returned ${res.status}`); - const body = (await res.json()) as { models?: Array<{ name?: string }> }; - return (body.models ?? []) - .map((m) => m.name ?? "") - .map((name) => (name.startsWith("models/") ? name.slice("models/".length) : name)) - .filter(Boolean); + const body = (await res.json()) as { + models?: Array<{ name?: string; displayName?: string }>; + }; + return (body.models ?? []).flatMap((m) => { + const name = m.name ?? ""; + const id = name.startsWith("models/") ? name.slice("models/".length) : name; + return id ? [{ id, displayName: m.displayName }] : []; + }); } interface ProbeConfig { /** Redis cache key suffix (distinguishes providers sharing a DB column). */ probeKey: AgentProviderId; - /** Secret name that holds the API key for the upstream probe. */ - secretName: string; - /** Probe function that returns a list of upstream model ids. */ + /** Ordered credential sources for the upstream probe — first one found wins. */ + secretCandidates: Array<{ name: string; kind: ProbeCredential["kind"] }>; + /** Probe function that returns a list of upstream models. */ probe: LiveProbe; } const PROBE_CONFIG: Partial> = { - anthropic: { probeKey: "anthropic", secretName: "ANTHROPIC_API_KEY", probe: probeAnthropic }, - openai: { probeKey: "openai", secretName: "OPENAI_API_KEY", probe: probeOpenAI }, - gemini: { probeKey: "gemini", secretName: "GEMINI_API_KEY", probe: probeGemini }, + anthropic: { + probeKey: "anthropic", + // OAuth-token deployments (the recommended k8s mode) have no API key, so + // fall back to the Claude Code OAuth token for the probe. + secretCandidates: [ + { name: "ANTHROPIC_API_KEY", kind: "api-key" }, + { name: "CLAUDE_CODE_OAUTH_TOKEN", kind: "oauth" }, + ], + probe: probeAnthropic, + }, + openai: { + probeKey: "openai", + secretCandidates: [{ name: "OPENAI_API_KEY", kind: "api-key" }], + probe: probeOpenAI, + }, + gemini: { + probeKey: "gemini", + secretCandidates: [{ name: "GEMINI_API_KEY", kind: "api-key" }], + probe: probeGemini, + }, }; /** @@ -99,18 +153,27 @@ interface GetOptions { forceRefresh?: boolean; } -async function readLiveIdsFromCache( +async function readLiveModelsFromCache( provider: AgentProviderId, keyHash: string, -): Promise<{ ids: string[]; refreshedAt: number } | null> { +): Promise<{ models: LiveModel[]; refreshedAt: number } | null> { try { const redis = getRedisClient(); const raw = await redis.get(buildCacheKey(provider, keyHash)); if (!raw) return null; - const parsed = JSON.parse(raw) as { ids?: string[]; refreshedAt?: number }; - if (!Array.isArray(parsed.ids)) return null; + const parsed = JSON.parse(raw) as { + models?: LiveModel[]; + ids?: string[]; // pre-displayName cache shape + refreshedAt?: number; + }; + const models = Array.isArray(parsed.models) + ? parsed.models + : Array.isArray(parsed.ids) + ? parsed.ids.map((id) => ({ id })) + : null; + if (!models) return null; return { - ids: parsed.ids, + models, refreshedAt: parsed.refreshedAt ?? Math.floor(Date.now() / 1000), }; } catch { @@ -118,17 +181,17 @@ async function readLiveIdsFromCache( } } -async function writeLiveIdsToCache( +async function writeLiveModelsToCache( provider: AgentProviderId, keyHash: string, - ids: string[], + models: LiveModel[], ): Promise { const refreshedAt = Math.floor(Date.now() / 1000); try { const redis = getRedisClient(); await redis.set( buildCacheKey(provider, keyHash), - JSON.stringify({ ids, refreshedAt }), + JSON.stringify({ models, refreshedAt }), "EX", CACHE_TTL_SECONDS, ); @@ -171,15 +234,22 @@ export async function getProviderOptions( }; } - // Look up the configured API key for the probe. Missing → baseline only. - let apiKey: string | null = null; - try { - apiKey = await retrieveSecret(probeConfig.secretName, "global", opts.workspaceId ?? undefined); - } catch { - // No configured key — nothing to probe with. + // Look up a configured credential for the probe (first candidate that + // resolves wins). None found → baseline only. + let credential: ProbeCredential | null = null; + for (const candidate of probeConfig.secretCandidates) { + try { + const value = await retrieveSecret(candidate.name, "global", opts.workspaceId ?? undefined); + if (value) { + credential = { value, kind: candidate.kind }; + break; + } + } catch { + // Not configured — try the next candidate. + } } - if (!apiKey) { + if (!credential) { return { catalog: baseline, source: "baseline", @@ -188,13 +258,13 @@ export async function getProviderOptions( }; } - const keyHash = hashKey(apiKey); + const keyHash = hashKey(credential.value); if (!opts.forceRefresh) { - const cached = await readLiveIdsFromCache(provider, keyHash); + const cached = await readLiveModelsFromCache(provider, keyHash); if (cached) { return { - catalog: mergeLiveModels(baseline, cached.ids), + catalog: mergeLiveModels(baseline, cached.models), source: "live", cached: true, refreshedAt: cached.refreshedAt, @@ -203,10 +273,10 @@ export async function getProviderOptions( } try { - const ids = await probeConfig.probe(apiKey); - const refreshedAt = await writeLiveIdsToCache(provider, keyHash, ids); + const models = await probeConfig.probe(credential); + const refreshedAt = await writeLiveModelsToCache(provider, keyHash, models); return { - catalog: mergeLiveModels(baseline, ids), + catalog: mergeLiveModels(baseline, models), source: "live", cached: false, refreshedAt, diff --git a/packages/shared/src/agent-options/agent-options.test.ts b/packages/shared/src/agent-options/agent-options.test.ts index 1cfb0ba0..50e9fdb5 100644 --- a/packages/shared/src/agent-options/agent-options.test.ts +++ b/packages/shared/src/agent-options/agent-options.test.ts @@ -158,6 +158,33 @@ describe("mergeLiveModels", () => { expect(merged.models.find((m) => m.id === "")).toBeUndefined(); expect(merged.models.find((m) => m.id === "legit-id")).toBeDefined(); }); + + it("uses the provider display name as the label when present", () => { + const merged = mergeLiveModels(ANTHROPIC_CATALOG, [ + { id: "claude-opus-5", displayName: "Claude Opus 5" }, + { id: "claude-mystery-1" }, + ]); + expect(merged.models.find((m) => m.id === "claude-opus-5")!.label).toBe("Claude Opus 5"); + expect(merged.models.find((m) => m.id === "claude-mystery-1")!.label).toBe("claude-mystery-1"); + }); + + it("assigns live models to a baseline family when the id contains one", () => { + const merged = mergeLiveModels(ANTHROPIC_CATALOG, [ + "claude-opus-5", + "claude-sonnet-5-20270101", + "claude-unrelated-model", + ]); + expect(merged.models.find((m) => m.id === "claude-opus-5")!.family).toBe("opus"); + expect(merged.models.find((m) => m.id === "claude-sonnet-5-20270101")!.family).toBe("sonnet"); + expect(merged.models.find((m) => m.id === "claude-unrelated-model")!.family).toBeUndefined(); + }); + + it("groups family-inferred live models with their baseline family", () => { + const merged = mergeLiveModels(ANTHROPIC_CATALOG, ["claude-opus-5"]); + const groups = groupModelsByFamily(merged); + const opusGroup = groups.find((g) => g.family === "opus")!; + expect(opusGroup.models.some((m) => m.id === "claude-opus-5")).toBe(true); + }); }); describe("groupModelsByFamily", () => { diff --git a/packages/shared/src/agent-options/index.ts b/packages/shared/src/agent-options/index.ts index 97e75b3b..4cabf321 100644 --- a/packages/shared/src/agent-options/index.ts +++ b/packages/shared/src/agent-options/index.ts @@ -4,10 +4,11 @@ import { GEMINI_CATALOG } from "./gemini.js"; import { COPILOT_CATALOG } from "./copilot.js"; import { OPENCODE_CATALOG } from "./opencode.js"; import { OPENCLAW_CATALOG } from "./openclaw.js"; -import type { AgentProviderId, ModelOption, ProviderCatalog } from "./types.js"; +import type { AgentProviderId, LiveModel, ModelOption, ProviderCatalog } from "./types.js"; export type { AgentProviderId, + LiveModel, ModelOption, OptionChoice, OptionField, @@ -130,17 +131,35 @@ export function resolveModelId( } /** - * Merge a list of live model ids (from a provider's list-models API) into the - * hardcoded baseline. Live ids not present in the baseline are appended; + * Merge a list of live models (from a provider's list-models API) into the + * hardcoded baseline. Live entries not present in the baseline are appended; * existing entries are preserved so we don't lose labels/family metadata. + * + * Live additions are labeled with the provider's display name when available + * and assigned to a baseline family when their id contains one (longest match + * wins), so they slot into the UI's grouped dropdown instead of each forming + * a single-model group. */ -export function mergeLiveModels(catalog: ProviderCatalog, liveIds: string[]): ProviderCatalog { +export function mergeLiveModels( + catalog: ProviderCatalog, + live: Array, +): ProviderCatalog { const known = new Set(catalog.models.map((m) => m.id)); + const families = [...new Set(catalog.models.map((m) => m.family))] + .filter((f): f is string => Boolean(f)) + .sort((a, b) => b.length - a.length); const additions: ModelOption[] = []; - for (const id of liveIds) { - if (!id || known.has(id)) continue; - known.add(id); - additions.push({ id, label: id, source: "live" }); + for (const entry of live) { + const model = typeof entry === "string" ? { id: entry } : entry; + if (!model.id || known.has(model.id)) continue; + known.add(model.id); + const family = families.find((f) => model.id.includes(f)); + additions.push({ + id: model.id, + label: model.displayName || model.id, + ...(family ? { family } : {}), + source: "live", + }); } if (additions.length === 0) return catalog; return { diff --git a/packages/shared/src/agent-options/types.ts b/packages/shared/src/agent-options/types.ts index 1217f4f0..b5a3b951 100644 --- a/packages/shared/src/agent-options/types.ts +++ b/packages/shared/src/agent-options/types.ts @@ -40,6 +40,15 @@ export interface ModelOption { source?: "baseline" | "live"; } +/** + * A model entry returned by a provider's list-models API. `displayName` is + * the provider's human-readable label (e.g. Anthropic's `display_name`). + */ +export interface LiveModel { + id: string; + displayName?: string; +} + export interface OptionChoice { value: string; label: string;