Fetch live provider model lists instead of the hardcoded catalog

This commit is contained in:
ZacharyZhang-NY committed 2026-05-30 00:51:42 -04:00
1 parent 5737f76d6b
commit cc876e6e16
11 files changed
+235 -11

No files matched your search

@@ -35,6 +35,10 @@ import {
type ProviderRevokeHandler,
} from "./provider-auth-lifecycle";
import { getJson, postEventStream, postJson, type JsonRecord } from "./provider-http";
import { fetchAnthropicModels, fetchOpenAiModels } from "./provider-models-fetch";
const ANTHROPIC_VERSION = "2023-06-01";
const CLAUDE_PRO_BETA = "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14";
import {
cancelUnsupported,
mapAnthropicMessageStream,
@@ -124,7 +128,9 @@ export const providerAdapters: ProviderAdapterRegistry = {
refresh: refreshUnsupported("OpenAI API key"),
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, "OpenAI"); }),
parseAuthProfile: parseApiKey,
listModels: () => listStaticModels("openai"),
listModels: (ctx) => fetchOpenAiModels("https://api.openai.com/v1/models", {
authorization: `Bearer ${requireApiKeyProfile(ctx.authProfile, "OpenAI").apiKey}`,
}),
getRateLimitState: providerRateLimitUnknown,
checkHealth: (input) => checkOpenAiHealth(input.authProfile, "https://api.openai.com/v1/models"),
async streamResponse(input) {
@@ -202,7 +208,10 @@ export const providerAdapters: ProviderAdapterRegistry = {
refresh: refreshUnsupported("Anthropic API key"),
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, "Anthropic"); }),
parseAuthProfile: parseApiKey,
listModels: () => listStaticModels("anthropic"),
listModels: (ctx) => fetchAnthropicModels("https://api.anthropic.com/v1/models", {
"x-api-key": requireApiKeyProfile(ctx.authProfile, "Anthropic").apiKey,
"anthropic-version": ANTHROPIC_VERSION,
}),
getRateLimitState: providerRateLimitUnknown,
async checkHealth(input) {
const profile = requireApiKeyProfile(input.authProfile, "Anthropic");
@@ -278,7 +287,14 @@ export const providerAdapters: ProviderAdapterRegistry = {
"github-copilot": browserTokenAdapter("github-copilot", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
"google-antigravity": browserTokenAdapter("google-antigravity", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
"gpt-account": browserTokenAdapter("gpt-account", PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION),
"claude-pro": browserTokenAdapter("claude-pro", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
"claude-pro": {
...browserTokenAdapter("claude-pro", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
listModels: (ctx) => fetchAnthropicModels("https://api.anthropic.com/v1/models", {
authorization: `Bearer ${requireAccessTokenProfile(ctx.authProfile).accessToken}`,
"anthropic-version": ANTHROPIC_VERSION,
"anthropic-beta": CLAUDE_PRO_BETA,
}),
},
"azure-openai": {
providerKey: "azure-openai",
authType: PROVIDER_AUTH_TYPE.CLOUD,
@@ -341,7 +357,10 @@ export const providerAdapters: ProviderAdapterRegistry = {
refresh: refreshUnsupported("Custom OpenAI-compatible endpoint"),
revoke: revokeLocalProfile((profile) => { requireOpenAiCompatProfile(profile); }),
parseAuthProfile: parseOpenAiCompatProfile,
listModels: () => listStaticModels("custom-openai"),
listModels: (ctx) => {
const profile = requireOpenAiCompatProfile(ctx.authProfile);
return fetchOpenAiModels(`${profile.baseUrl}/models`, { authorization: `Bearer ${profile.apiKey}` });
},
getRateLimitState: providerRateLimitUnknown,
streamResponse: streamUnsupported("Custom OpenAI-compatible endpoint"),
cancelResponse: cancelUnsupported("Custom OpenAI-compatible endpoint"),
@@ -359,7 +378,13 @@ export const providerAdapters: ProviderAdapterRegistry = {
refresh: refreshUnsupported("Custom Anthropic-compatible endpoint"),
revoke: revokeLocalProfile((profile) => { requireAnthropicCompatProfile(profile); }),
parseAuthProfile: parseAnthropicCompatProfile,
listModels: () => listStaticModels("custom-anthropic"),
listModels: (ctx) => {
const profile = requireAnthropicCompatProfile(ctx.authProfile);
return fetchAnthropicModels(`${profile.baseUrl}/v1/models`, {
"x-api-key": profile.apiKey,
"anthropic-version": ANTHROPIC_VERSION,
});
},
getRateLimitState: providerRateLimitUnknown,
streamResponse: streamUnsupported("Custom Anthropic-compatible endpoint"),
cancelResponse: cancelUnsupported("Custom Anthropic-compatible endpoint"),
@@ -419,7 +444,9 @@ function openAiCompatibleAdapter({
refresh: refreshUnsupported(`${label} API key`),
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, label); }),
parseAuthProfile: parseApiKey,
listModels: () => listStaticModels(providerKey),
listModels: (ctx) => fetchOpenAiModels(healthUrl, {
authorization: `Bearer ${requireApiKeyProfile(ctx.authProfile, label).apiKey}`,
}),
getRateLimitState: providerRateLimitUnknown,
checkHealth: (input) => checkOpenAiHealth(input.authProfile, healthUrl),
async streamResponse(input) {
@@ -0,0 +1,51 @@
import type { ProviderModelEntry } from "../../shared/provider-models";
import { getJson, type JsonRecord } from "./provider-http";
/**
* Live model-list fetching. Each provider that exposes a standard models
* endpoint (OpenAI `/v1/models`, Anthropic `/v1/models`) returns its current
* catalog; we map it to {id, name} with no curation and no fabricated entries.
* An empty or malformed response throws — the caller surfaces the real error
* rather than falling back to a stale static list.
*/
export function mapOpenAiModels(data: unknown): ProviderModelEntry[] {
return collectModels(data, (item, id) => ({ id, name: id }));
}
export function mapAnthropicModels(data: unknown): ProviderModelEntry[] {
return collectModels(data, (item, id) => ({
id,
name: typeof item.display_name === "string" && item.display_name.trim() ? item.display_name.trim() : id,
}));
}
export async function fetchOpenAiModels(modelsUrl: string, headers: Record<string, string>): Promise<ProviderModelEntry[]> {
const response = await getJson(modelsUrl, headers);
return mapOpenAiModels(response.data);
}
export async function fetchAnthropicModels(modelsUrl: string, headers: Record<string, string>): Promise<ProviderModelEntry[]> {
const response = await getJson(modelsUrl, headers);
return mapAnthropicModels(response.data);
}
function collectModels(data: unknown, toEntry: (item: JsonRecord, id: string) => ProviderModelEntry): ProviderModelEntry[] {
const list = isRecord(data) && Array.isArray(data.data) ? data.data : null;
if (!list) throw new Error("Provider model list response was not in the expected shape.");
const entries: ProviderModelEntry[] = [];
const seen = new Set<string>();
for (const item of list) {
if (!isRecord(item)) continue;
const id = typeof item.id === "string" ? item.id.trim() : "";
if (!id || seen.has(id)) continue;
seen.add(id);
entries.push(toEntry(item, id));
}
if (entries.length === 0) throw new Error("Provider returned no models.");
return entries;
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -1,6 +1,7 @@
import type { DatabaseSync } from "node:sqlite";
import { createHash, randomBytes } from "node:crypto";
import type { CancelProviderResponseInput, LaunchProviderSignInInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput } from "../../shared/commands";
import type { CancelProviderResponseInput, LaunchProviderSignInInput, ListProviderModelsInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput } from "../../shared/commands";
import type { ProviderModelEntry } from "../../shared/provider-models";
import { PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, type ProviderAuthType } from "../../shared/domain";
import type { ProviderStreamRendererEvent } from "../../shared/provider-request-domain";
import { AuditService } from "./audit-service";
@@ -160,6 +161,27 @@ export class ProviderService {
});
}
/**
* Fetches the live model catalog from the provider. Onboarding passes the
* just-typed (or just-authorized) credential as `secret`; settings passes a
* stored provider reference and the secret is decrypted server-side. Either
* way the credential never round-trips through the renderer's persisted state.
*/
async listModels(input: ListProviderModelsInput): Promise<ProviderModelEntry[]> {
if (typeof input.secret === "string") {
const entry = requireCatalogEntry(input.providerKey);
const adapter = requireAdapter(this.adapters, input.providerKey, entry.authType);
const authProfile = adapter.parseAuthProfile(input.secret);
return adapter.listModels({ authProfile, model: null });
}
if (!input.orgId || !input.providerId) throw new Error("A credential is required to list provider models.");
const provider = loadConfiguredProvider(this.db, input.orgId, input.providerId);
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);
const credential = loadCredential(this.authBroker, input.orgId, provider);
const authProfile = adapter.parseAuthProfile(credential.secret);
return adapter.listModels({ authProfile, model: provider.model });
}
async checkHealth(orgId: string, providerId: string): Promise<void> {
const provider = loadConfiguredProvider(this.db, orgId, providerId);
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);