From cc876e6e16dff3f6889fe1b397630eace348a08b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 30 May 2026 00:51:42 -0400 Subject: [PATCH] Fetch live provider model lists instead of the hardcoded catalog --- AgentCompany-New/src/main/app/ipc-handlers.ts | 7 +++ .../src/main/services/provider-adapters.ts | 39 +++++++++++-- .../main/services/provider-models-fetch.ts | 51 ++++++++++++++++ .../src/main/services/provider-service.ts | 24 +++++++- AgentCompany-New/src/preload/index.ts | 1 + .../src/renderer/src/app/Onboarding.tsx | 42 +++++++++++++- AgentCompany-New/src/shared/commands.ts | 9 +++ AgentCompany-New/src/shared/desktop-api.ts | 3 + AgentCompany-New/src/shared/ipc.ts | 1 + .../tests/contract/provider-contracts.test.ts | 11 +++- .../tests/unit/provider-models-fetch.test.ts | 58 +++++++++++++++++++ 11 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 AgentCompany-New/src/main/services/provider-models-fetch.ts create mode 100644 AgentCompany-New/tests/unit/provider-models-fetch.test.ts diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index 6b55bf6b..c0a6e38f 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -122,6 +122,13 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("provider_health_failed", error instanceof Error ? error.message : String(error)); } }); + ipcMain.handle(IPC_CHANNELS.listProviderModels, async (_event, input) => { + try { + return ok(await providers.listModels(input)); + } catch (error) { + return fail("provider_models_failed", error instanceof Error ? error.message : String(error)); + } + }); ipcMain.handle(IPC_CHANNELS.launchProviderSignIn, (_event, input) => { try { providers.launchSignInWindow(input); diff --git a/AgentCompany-New/src/main/services/provider-adapters.ts b/AgentCompany-New/src/main/services/provider-adapters.ts index 5e8c9a70..e073396b 100644 --- a/AgentCompany-New/src/main/services/provider-adapters.ts +++ b/AgentCompany-New/src/main/services/provider-adapters.ts @@ -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) { diff --git a/AgentCompany-New/src/main/services/provider-models-fetch.ts b/AgentCompany-New/src/main/services/provider-models-fetch.ts new file mode 100644 index 00000000..113931de --- /dev/null +++ b/AgentCompany-New/src/main/services/provider-models-fetch.ts @@ -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): Promise { + const response = await getJson(modelsUrl, headers); + return mapOpenAiModels(response.data); +} + +export async function fetchAnthropicModels(modelsUrl: string, headers: Record): Promise { + 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(); + 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); +} diff --git a/AgentCompany-New/src/main/services/provider-service.ts b/AgentCompany-New/src/main/services/provider-service.ts index df046495..88b7ba31 100644 --- a/AgentCompany-New/src/main/services/provider-service.ts +++ b/AgentCompany-New/src/main/services/provider-service.ts @@ -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 { + 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 { const provider = loadConfiguredProvider(this.db, orgId, providerId); const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type); diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index a0c034eb..e4b8172d 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -32,6 +32,7 @@ const api: DesktopApi = { startProviderOAuth: (input) => invoke(IPC_CHANNELS.startProviderOAuth, input), cancelProviderOAuth: (input) => invoke(IPC_CHANNELS.cancelProviderOAuth, input), checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input), + listProviderModels: (input) => invoke(IPC_CHANNELS.listProviderModels, input), updateProviderPolicy: (input) => invoke(IPC_CHANNELS.updateProviderPolicy, input), openExternalUrl: (url) => invoke(IPC_CHANNELS.openExternalUrl, url), createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input), diff --git a/AgentCompany-New/src/renderer/src/app/Onboarding.tsx b/AgentCompany-New/src/renderer/src/app/Onboarding.tsx index 25b71990..5286f969 100644 --- a/AgentCompany-New/src/renderer/src/app/Onboarding.tsx +++ b/AgentCompany-New/src/renderer/src/app/Onboarding.tsx @@ -297,6 +297,33 @@ function ProviderCredentialFields({ }) { const isOAuthLike = selected.authType === "oauth_pkce" || selected.authType === "account_subscription"; const isCustom = Boolean(selected.customEndpoint); + const [fetched, setFetched] = useState(null); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + + const models = fetched ?? selected.models; + const canLoad = isCustom ? Boolean(draft.providerBaseUrl.trim() && draft.providerSecret.trim()) : Boolean(draft.providerSecret.trim()); + + const loadModels = async (secret: string) => { + setLoading(true); + setLoadError(null); + const result = await window.agentCompany.listProviderModels({ providerKey: selected.key, secret }); + setLoading(false); + if (!result.ok) { + setLoadError(result.error.message); + return; + } + setFetched(result.data); + if (result.data[0]) onChange({ providerModel: result.data[0].id }); + }; + + const loadTypedModels = () => { + const secret = isCustom + ? JSON.stringify({ baseUrl: draft.providerBaseUrl.trim(), secret: draft.providerSecret.trim() }) + : draft.providerSecret.trim(); + void loadModels(secret); + }; + return (
{selected.credentialHelp && ( @@ -308,7 +335,10 @@ function ProviderCredentialFields({ providerKey={selected.key} label={selected.label} buildInput={() => ({ providerKey: selected.key })} - onComplete={(secret) => onChange({ providerSecret: secret ?? "" })} + onComplete={(secret) => { + onChange({ providerSecret: secret ?? "" }); + if (secret) void loadModels(secret); + }} /> ) : isCustom ? ( <> @@ -341,14 +371,20 @@ function ProviderCredentialFields({ /> )} + {!isOAuthLike && ( + + )} + {loadError &&
{loadError}
} - {selected.models.length ? ( + {models.length ? ( diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index 004d1391..4b041ca8 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -210,6 +210,15 @@ export interface CheckProviderHealthInput { providerId: string; } +export interface ListProviderModelsInput { + providerKey: string; + /** A just-typed/authorized credential (onboarding or the connect-new dialog). */ + secret?: string; + /** A stored provider whose secret is decrypted server-side (settings refresh). */ + orgId?: string; + providerId?: string; +} + export interface UpdateProviderPolicyInput { orgId: string; providerId: string; diff --git a/AgentCompany-New/src/shared/desktop-api.ts b/AgentCompany-New/src/shared/desktop-api.ts index 04cfd912..c7a9ab49 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -1,5 +1,6 @@ import type { DesktopResult } from "./result"; import type { AppSnapshot, SearchResultRecord } from "./domain"; +import type { ProviderModelEntry } from "./provider-models"; import type { BindSkillInput, BootstrapOnboardingInput, @@ -14,6 +15,7 @@ import type { CreateLegalHoldInput, CreateMigrationReportInput, CheckProviderHealthInput, + ListProviderModelsInput, StartProviderOAuthInput, StartProviderOAuthResult, CancelProviderOAuthInput, @@ -103,6 +105,7 @@ export interface DesktopApi { startProviderOAuth(input: StartProviderOAuthInput): Promise>; cancelProviderOAuth(input: CancelProviderOAuthInput): Promise>; checkProviderHealth(input: CheckProviderHealthInput): Promise>; + listProviderModels(input: ListProviderModelsInput): Promise>; updateProviderPolicy(input: UpdateProviderPolicyInput): Promise>; openExternalUrl(url: string): Promise>; createProviderResponse(input: CreateProviderResponseInput): Promise>; diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index 548b69fa..9528b89a 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -24,6 +24,7 @@ export const IPC_CHANNELS = { startProviderOAuth: "agentcompany:start-provider-oauth", cancelProviderOAuth: "agentcompany:cancel-provider-oauth", checkProviderHealth: "agentcompany:check-provider-health", + listProviderModels: "agentcompany:list-provider-models", updateProviderPolicy: "agentcompany:update-provider-policy", openExternalUrl: "agentcompany:open-external-url", createProviderResponse: "agentcompany:create-provider-response", diff --git a/AgentCompany-New/tests/contract/provider-contracts.test.ts b/AgentCompany-New/tests/contract/provider-contracts.test.ts index 955413a5..0c531751 100644 --- a/AgentCompany-New/tests/contract/provider-contracts.test.ts +++ b/AgentCompany-New/tests/contract/provider-contracts.test.ts @@ -87,11 +87,20 @@ describe("provider contracts", () => { }))).toThrow("Azure base URL must use HTTPS."); }); + // Providers with no enumerable models endpoint keep returning the curated + // catalog; the rest fetch live (covered offline by provider-models-fetch.test). + const CURATED_MODEL_PROVIDERS = new Set([ + "github-copilot", "google-antigravity", "gpt-account", "azure-openai", "aws-bedrock", "vertex-ai", + ]); + test("exposes model listing and rate limit adapter contracts", async () => { for (const provider of PROVIDER_CATALOG) { const adapter = providerAdapters[provider.key]; const authProfile = adapter.parseAuthProfile(PROVIDER_SECRET_SAMPLES[provider.key]); - await expect(adapter.listModels({ authProfile, model: provider.defaultModel })).resolves.toEqual(provider.models); + expect(typeof adapter.listModels).toBe("function"); + if (CURATED_MODEL_PROVIDERS.has(provider.key)) { + await expect(adapter.listModels({ authProfile, model: provider.defaultModel })).resolves.toEqual(provider.models); + } await expect(adapter.getRateLimitState({ authProfile, model: provider.defaultModel })).resolves.toBeNull(); expect(typeof adapter.streamResponse).toBe("function"); expect(typeof adapter.cancelResponse).toBe("function"); diff --git a/AgentCompany-New/tests/unit/provider-models-fetch.test.ts b/AgentCompany-New/tests/unit/provider-models-fetch.test.ts new file mode 100644 index 00000000..c3f556b5 --- /dev/null +++ b/AgentCompany-New/tests/unit/provider-models-fetch.test.ts @@ -0,0 +1,58 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { mapAnthropicModels, mapOpenAiModels } from "../../src/main/services/provider-models-fetch"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { ProviderService } from "../../src/main/services/provider-service"; +import { providerAdapters } from "../../src/main/services/provider-adapters"; + +const testVault = { + encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), + decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"), +}; + +describe("provider model-list mapping", () => { + test("maps the OpenAI /models shape, deduping by id", () => { + const models = mapOpenAiModels({ data: [{ id: "gpt-x" }, { id: "gpt-y" }, { id: "gpt-x" }, { id: "" }, { other: 1 }] }); + expect(models).toEqual([{ id: "gpt-x", name: "gpt-x" }, { id: "gpt-y", name: "gpt-y" }]); + }); + + test("maps the Anthropic /models shape, preferring display_name", () => { + const models = mapAnthropicModels({ data: [{ id: "claude-x", display_name: "Claude X" }, { id: "claude-y" }] }); + expect(models).toEqual([{ id: "claude-x", name: "Claude X" }, { id: "claude-y", name: "claude-y" }]); + }); + + test("throws on a malformed response instead of returning a static list", () => { + expect(() => mapOpenAiModels({})).toThrow(/expected shape/); + expect(() => mapOpenAiModels({ data: [] })).toThrow(/no models/); + expect(() => mapAnthropicModels({ data: [{ id: "" }] })).toThrow(/no models/); + }); +}); + +describe("ProviderService.listModels routing", () => { + test("routes a typed credential to the matching adapter and returns its live models", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-models-${randomUUID()}.db`)); + let parsedSecret = ""; + const fakeAdapter = { + ...providerAdapters.openai, + authType: "api_key" as const, + parseAuthProfile: (secret: string) => { parsedSecret = secret; return { type: "api_key" as const, apiKey: secret }; }, + listModels: async () => [{ id: "live-1", name: "live-1" }, { id: "live-2", name: "live-2" }], + }; + const registry = { ...providerAdapters, openai: fakeAdapter }; + const service = new ProviderService(connection.db, registry, testVault); + + const models = await service.listModels({ providerKey: "openai", secret: "sk-live-key" }); + expect(parsedSecret).toBe("sk-live-key"); + expect(models).toEqual([{ id: "live-1", name: "live-1" }, { id: "live-2", name: "live-2" }]); + connection.close(); + }); + + test("requires a credential reference when no secret is provided", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-models-${randomUUID()}.db`)); + const service = new ProviderService(connection.db, providerAdapters, testVault); + await expect(service.listModels({ providerKey: "openai" })).rejects.toThrow(/credential is required/); + connection.close(); + }); +});