Fetch live provider model lists instead of the hardcoded catalog
This commit is contained in:
1 parent
5737f76d6b
commit
cc876e6e16
11 files changed
+235
-11
No files matched your search
@@ -122,6 +122,13 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
|||||||
return fail("provider_health_failed", error instanceof Error ? error.message : String(error));
|
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) => {
|
ipcMain.handle(IPC_CHANNELS.launchProviderSignIn, (_event, input) => {
|
||||||
try {
|
try {
|
||||||
providers.launchSignInWindow(input);
|
providers.launchSignInWindow(input);
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ import {
|
|||||||
type ProviderRevokeHandler,
|
type ProviderRevokeHandler,
|
||||||
} from "./provider-auth-lifecycle";
|
} from "./provider-auth-lifecycle";
|
||||||
import { getJson, postEventStream, postJson, type JsonRecord } from "./provider-http";
|
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 {
|
import {
|
||||||
cancelUnsupported,
|
cancelUnsupported,
|
||||||
mapAnthropicMessageStream,
|
mapAnthropicMessageStream,
|
||||||
@@ -124,7 +128,9 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
|||||||
refresh: refreshUnsupported("OpenAI API key"),
|
refresh: refreshUnsupported("OpenAI API key"),
|
||||||
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, "OpenAI"); }),
|
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, "OpenAI"); }),
|
||||||
parseAuthProfile: parseApiKey,
|
parseAuthProfile: parseApiKey,
|
||||||
listModels: () => listStaticModels("openai"),
|
listModels: (ctx) => fetchOpenAiModels("https://api.openai.com/v1/models", {
|
||||||
|
authorization: `Bearer ${requireApiKeyProfile(ctx.authProfile, "OpenAI").apiKey}`,
|
||||||
|
}),
|
||||||
getRateLimitState: providerRateLimitUnknown,
|
getRateLimitState: providerRateLimitUnknown,
|
||||||
checkHealth: (input) => checkOpenAiHealth(input.authProfile, "https://api.openai.com/v1/models"),
|
checkHealth: (input) => checkOpenAiHealth(input.authProfile, "https://api.openai.com/v1/models"),
|
||||||
async streamResponse(input) {
|
async streamResponse(input) {
|
||||||
@@ -202,7 +208,10 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
|||||||
refresh: refreshUnsupported("Anthropic API key"),
|
refresh: refreshUnsupported("Anthropic API key"),
|
||||||
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, "Anthropic"); }),
|
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, "Anthropic"); }),
|
||||||
parseAuthProfile: parseApiKey,
|
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,
|
getRateLimitState: providerRateLimitUnknown,
|
||||||
async checkHealth(input) {
|
async checkHealth(input) {
|
||||||
const profile = requireApiKeyProfile(input.authProfile, "Anthropic");
|
const profile = requireApiKeyProfile(input.authProfile, "Anthropic");
|
||||||
@@ -278,7 +287,14 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
|||||||
"github-copilot": browserTokenAdapter("github-copilot", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
|
"github-copilot": browserTokenAdapter("github-copilot", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
|
||||||
"google-antigravity": browserTokenAdapter("google-antigravity", 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),
|
"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": {
|
"azure-openai": {
|
||||||
providerKey: "azure-openai",
|
providerKey: "azure-openai",
|
||||||
authType: PROVIDER_AUTH_TYPE.CLOUD,
|
authType: PROVIDER_AUTH_TYPE.CLOUD,
|
||||||
@@ -341,7 +357,10 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
|||||||
refresh: refreshUnsupported("Custom OpenAI-compatible endpoint"),
|
refresh: refreshUnsupported("Custom OpenAI-compatible endpoint"),
|
||||||
revoke: revokeLocalProfile((profile) => { requireOpenAiCompatProfile(profile); }),
|
revoke: revokeLocalProfile((profile) => { requireOpenAiCompatProfile(profile); }),
|
||||||
parseAuthProfile: parseOpenAiCompatProfile,
|
parseAuthProfile: parseOpenAiCompatProfile,
|
||||||
listModels: () => listStaticModels("custom-openai"),
|
listModels: (ctx) => {
|
||||||
|
const profile = requireOpenAiCompatProfile(ctx.authProfile);
|
||||||
|
return fetchOpenAiModels(`${profile.baseUrl}/models`, { authorization: `Bearer ${profile.apiKey}` });
|
||||||
|
},
|
||||||
getRateLimitState: providerRateLimitUnknown,
|
getRateLimitState: providerRateLimitUnknown,
|
||||||
streamResponse: streamUnsupported("Custom OpenAI-compatible endpoint"),
|
streamResponse: streamUnsupported("Custom OpenAI-compatible endpoint"),
|
||||||
cancelResponse: cancelUnsupported("Custom OpenAI-compatible endpoint"),
|
cancelResponse: cancelUnsupported("Custom OpenAI-compatible endpoint"),
|
||||||
@@ -359,7 +378,13 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
|||||||
refresh: refreshUnsupported("Custom Anthropic-compatible endpoint"),
|
refresh: refreshUnsupported("Custom Anthropic-compatible endpoint"),
|
||||||
revoke: revokeLocalProfile((profile) => { requireAnthropicCompatProfile(profile); }),
|
revoke: revokeLocalProfile((profile) => { requireAnthropicCompatProfile(profile); }),
|
||||||
parseAuthProfile: parseAnthropicCompatProfile,
|
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,
|
getRateLimitState: providerRateLimitUnknown,
|
||||||
streamResponse: streamUnsupported("Custom Anthropic-compatible endpoint"),
|
streamResponse: streamUnsupported("Custom Anthropic-compatible endpoint"),
|
||||||
cancelResponse: cancelUnsupported("Custom Anthropic-compatible endpoint"),
|
cancelResponse: cancelUnsupported("Custom Anthropic-compatible endpoint"),
|
||||||
@@ -419,7 +444,9 @@ function openAiCompatibleAdapter({
|
|||||||
refresh: refreshUnsupported(`${label} API key`),
|
refresh: refreshUnsupported(`${label} API key`),
|
||||||
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, label); }),
|
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, label); }),
|
||||||
parseAuthProfile: parseApiKey,
|
parseAuthProfile: parseApiKey,
|
||||||
listModels: () => listStaticModels(providerKey),
|
listModels: (ctx) => fetchOpenAiModels(healthUrl, {
|
||||||
|
authorization: `Bearer ${requireApiKeyProfile(ctx.authProfile, label).apiKey}`,
|
||||||
|
}),
|
||||||
getRateLimitState: providerRateLimitUnknown,
|
getRateLimitState: providerRateLimitUnknown,
|
||||||
checkHealth: (input) => checkOpenAiHealth(input.authProfile, healthUrl),
|
checkHealth: (input) => checkOpenAiHealth(input.authProfile, healthUrl),
|
||||||
async streamResponse(input) {
|
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 type { DatabaseSync } from "node:sqlite";
|
||||||
import { createHash, randomBytes } from "node:crypto";
|
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 { PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, type ProviderAuthType } from "../../shared/domain";
|
||||||
import type { ProviderStreamRendererEvent } from "../../shared/provider-request-domain";
|
import type { ProviderStreamRendererEvent } from "../../shared/provider-request-domain";
|
||||||
import { AuditService } from "./audit-service";
|
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> {
|
async checkHealth(orgId: string, providerId: string): Promise<void> {
|
||||||
const provider = loadConfiguredProvider(this.db, orgId, providerId);
|
const provider = loadConfiguredProvider(this.db, orgId, providerId);
|
||||||
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);
|
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const api: DesktopApi = {
|
|||||||
startProviderOAuth: (input) => invoke(IPC_CHANNELS.startProviderOAuth, input),
|
startProviderOAuth: (input) => invoke(IPC_CHANNELS.startProviderOAuth, input),
|
||||||
cancelProviderOAuth: (input) => invoke(IPC_CHANNELS.cancelProviderOAuth, input),
|
cancelProviderOAuth: (input) => invoke(IPC_CHANNELS.cancelProviderOAuth, input),
|
||||||
checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input),
|
checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input),
|
||||||
|
listProviderModels: (input) => invoke(IPC_CHANNELS.listProviderModels, input),
|
||||||
updateProviderPolicy: (input) => invoke(IPC_CHANNELS.updateProviderPolicy, input),
|
updateProviderPolicy: (input) => invoke(IPC_CHANNELS.updateProviderPolicy, input),
|
||||||
openExternalUrl: (url) => invoke(IPC_CHANNELS.openExternalUrl, url),
|
openExternalUrl: (url) => invoke(IPC_CHANNELS.openExternalUrl, url),
|
||||||
createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input),
|
createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input),
|
||||||
|
|||||||
@@ -297,6 +297,33 @@ function ProviderCredentialFields({
|
|||||||
}) {
|
}) {
|
||||||
const isOAuthLike = selected.authType === "oauth_pkce" || selected.authType === "account_subscription";
|
const isOAuthLike = selected.authType === "oauth_pkce" || selected.authType === "account_subscription";
|
||||||
const isCustom = Boolean(selected.customEndpoint);
|
const isCustom = Boolean(selected.customEndpoint);
|
||||||
|
const [fetched, setFetched] = useState<ProviderCatalogEntry["models"] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(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 (
|
return (
|
||||||
<div className="provider-inline-form">
|
<div className="provider-inline-form">
|
||||||
{selected.credentialHelp && (
|
{selected.credentialHelp && (
|
||||||
@@ -308,7 +335,10 @@ function ProviderCredentialFields({
|
|||||||
providerKey={selected.key}
|
providerKey={selected.key}
|
||||||
label={selected.label}
|
label={selected.label}
|
||||||
buildInput={() => ({ providerKey: selected.key })}
|
buildInput={() => ({ providerKey: selected.key })}
|
||||||
onComplete={(secret) => onChange({ providerSecret: secret ?? "" })}
|
onComplete={(secret) => {
|
||||||
|
onChange({ providerSecret: secret ?? "" });
|
||||||
|
if (secret) void loadModels(secret);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : isCustom ? (
|
) : isCustom ? (
|
||||||
<>
|
<>
|
||||||
@@ -341,14 +371,20 @@ function ProviderCredentialFields({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
{!isOAuthLike && (
|
||||||
|
<Button tone="ghost" onClick={loadTypedModels} disabled={!canLoad || loading}>
|
||||||
|
{loading ? "Loading models…" : fetched ? "Reload models" : "Load models"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{loadError && <div style={{ color: "var(--red)", fontSize: 12 }}>{loadError}</div>}
|
||||||
<Field label="Model">
|
<Field label="Model">
|
||||||
{selected.models.length ? (
|
{models.length ? (
|
||||||
<select
|
<select
|
||||||
className="input"
|
className="input"
|
||||||
value={draft.providerModel}
|
value={draft.providerModel}
|
||||||
onChange={(event) => onChange({ providerModel: event.target.value })}
|
onChange={(event) => onChange({ providerModel: event.target.value })}
|
||||||
>
|
>
|
||||||
{selected.models.map((model) => (
|
{models.map((model) => (
|
||||||
<option key={model.id} value={model.id}>{model.name}</option>
|
<option key={model.id} value={model.id}>{model.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -210,6 +210,15 @@ export interface CheckProviderHealthInput {
|
|||||||
providerId: string;
|
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 {
|
export interface UpdateProviderPolicyInput {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { DesktopResult } from "./result";
|
import type { DesktopResult } from "./result";
|
||||||
import type { AppSnapshot, SearchResultRecord } from "./domain";
|
import type { AppSnapshot, SearchResultRecord } from "./domain";
|
||||||
|
import type { ProviderModelEntry } from "./provider-models";
|
||||||
import type {
|
import type {
|
||||||
BindSkillInput,
|
BindSkillInput,
|
||||||
BootstrapOnboardingInput,
|
BootstrapOnboardingInput,
|
||||||
@@ -14,6 +15,7 @@ import type {
|
|||||||
CreateLegalHoldInput,
|
CreateLegalHoldInput,
|
||||||
CreateMigrationReportInput,
|
CreateMigrationReportInput,
|
||||||
CheckProviderHealthInput,
|
CheckProviderHealthInput,
|
||||||
|
ListProviderModelsInput,
|
||||||
StartProviderOAuthInput,
|
StartProviderOAuthInput,
|
||||||
StartProviderOAuthResult,
|
StartProviderOAuthResult,
|
||||||
CancelProviderOAuthInput,
|
CancelProviderOAuthInput,
|
||||||
@@ -103,6 +105,7 @@ export interface DesktopApi {
|
|||||||
startProviderOAuth(input: StartProviderOAuthInput): Promise<DesktopResult<StartProviderOAuthResult>>;
|
startProviderOAuth(input: StartProviderOAuthInput): Promise<DesktopResult<StartProviderOAuthResult>>;
|
||||||
cancelProviderOAuth(input: CancelProviderOAuthInput): Promise<DesktopResult<null>>;
|
cancelProviderOAuth(input: CancelProviderOAuthInput): Promise<DesktopResult<null>>;
|
||||||
checkProviderHealth(input: CheckProviderHealthInput): Promise<DesktopResult<AppSnapshot>>;
|
checkProviderHealth(input: CheckProviderHealthInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
listProviderModels(input: ListProviderModelsInput): Promise<DesktopResult<ProviderModelEntry[]>>;
|
||||||
updateProviderPolicy(input: UpdateProviderPolicyInput): Promise<DesktopResult<AppSnapshot>>;
|
updateProviderPolicy(input: UpdateProviderPolicyInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
openExternalUrl(url: string): Promise<DesktopResult<null>>;
|
openExternalUrl(url: string): Promise<DesktopResult<null>>;
|
||||||
createProviderResponse(input: CreateProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
|
createProviderResponse(input: CreateProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const IPC_CHANNELS = {
|
|||||||
startProviderOAuth: "agentcompany:start-provider-oauth",
|
startProviderOAuth: "agentcompany:start-provider-oauth",
|
||||||
cancelProviderOAuth: "agentcompany:cancel-provider-oauth",
|
cancelProviderOAuth: "agentcompany:cancel-provider-oauth",
|
||||||
checkProviderHealth: "agentcompany:check-provider-health",
|
checkProviderHealth: "agentcompany:check-provider-health",
|
||||||
|
listProviderModels: "agentcompany:list-provider-models",
|
||||||
updateProviderPolicy: "agentcompany:update-provider-policy",
|
updateProviderPolicy: "agentcompany:update-provider-policy",
|
||||||
openExternalUrl: "agentcompany:open-external-url",
|
openExternalUrl: "agentcompany:open-external-url",
|
||||||
createProviderResponse: "agentcompany:create-provider-response",
|
createProviderResponse: "agentcompany:create-provider-response",
|
||||||
|
|||||||
@@ -87,11 +87,20 @@ describe("provider contracts", () => {
|
|||||||
}))).toThrow("Azure base URL must use HTTPS.");
|
}))).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 () => {
|
test("exposes model listing and rate limit adapter contracts", async () => {
|
||||||
for (const provider of PROVIDER_CATALOG) {
|
for (const provider of PROVIDER_CATALOG) {
|
||||||
const adapter = providerAdapters[provider.key];
|
const adapter = providerAdapters[provider.key];
|
||||||
const authProfile = adapter.parseAuthProfile(PROVIDER_SECRET_SAMPLES[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();
|
await expect(adapter.getRateLimitState({ authProfile, model: provider.defaultModel })).resolves.toBeNull();
|
||||||
expect(typeof adapter.streamResponse).toBe("function");
|
expect(typeof adapter.streamResponse).toBe("function");
|
||||||
expect(typeof adapter.cancelResponse).toBe("function");
|
expect(typeof adapter.cancelResponse).toBe("function");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user