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
89 changes: 86 additions & 3 deletions apps/api/src/services/agent-options-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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 () => {
Expand All @@ -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,
}),
);
Expand All @@ -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);
});

Expand Down
170 changes: 120 additions & 50 deletions apps/api/src/services/agent-options-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
PROVIDER_CATALOGS,
mergeLiveModels,
type AgentProviderId,
type LiveModel,
type ProviderCatalog,
} from "@optio/shared";
import { getRedisClient } from "./event-bus.js";
Expand All @@ -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<string[]>;
/**
* 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<string[]> {
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<LiveModel[]>;

/** 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<LiveModel[]> {
const headers: Record<string, string> = { "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<string[]> {
async function probeOpenAI(credential: ProbeCredential): Promise<LiveModel[]> {
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<string[]> {
/** Gemini: GET /v1beta/models?key=... → models[].{name,displayName} (strip "models/" prefix). */
async function probeGemini(credential: ProbeCredential): Promise<LiveModel[]> {
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<Record<AgentProviderId, ProbeConfig>> = {
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,
},
};

/**
Expand Down Expand Up @@ -99,36 +153,45 @@ 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 {
return null;
}
}

async function writeLiveIdsToCache(
async function writeLiveModelsToCache(
provider: AgentProviderId,
keyHash: string,
ids: string[],
models: LiveModel[],
): Promise<number> {
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,
);
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading