From ee540dd7879a91ba2e365c77eec44470b80e9878 Mon Sep 17 00:00:00 2001 From: musi Date: Tue, 23 Jun 2026 09:08:24 +0800 Subject: [PATCH] Support provider catalog model discovery in deep links --- src/main/ipc.ts | 4 +- src/main/preload.ts | 3 + src/main/provider-model-catalog.ts | 473 ++++++++++++++++++++ src/renderer/pages/home/App.tsx | 74 ++- src/renderer/pages/home/shared/providers.ts | 33 +- src/renderer/types/electron.d.ts | 3 + src/shared/app.ts | 15 + src/shared/ipc-channels.ts | 1 + 8 files changed, 583 insertions(+), 23 deletions(-) create mode 100644 src/main/provider-model-catalog.ts diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 673e5961..80209fa1 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -14,6 +14,7 @@ import { gatewayService } from "../server/gateway/service"; import { getProviderAccountSnapshots, invalidateProviderAccountSnapshotCache, testProviderAccountConnector } from "./provider-account-service"; import { detectProviderIcon } from "./provider-icons"; import { fetchProviderManifest } from "./provider-manifest-service"; +import { getProviderCatalogModels } from "./provider-model-catalog"; import { getProviderPresets } from "./presets"; import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "./provider-probe"; import { applyProfileConfig } from "./profile-service"; @@ -26,7 +27,7 @@ import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; import { getUsageStats } from "./usage-store"; import windowsManager from "./windows"; -import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayMcpServerConfig, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app"; +import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayMcpServerConfig, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app"; const pluginMarketplace: PluginMarketplaceEntry[] = [ { @@ -80,6 +81,7 @@ ipcMain.handle(IPC_CHANNELS.appGetProfileRuntimeStatus, () => { return getProfileRuntimeStatus(); }); ipcMain.handle(IPC_CHANNELS.appGetProviderAccountSnapshots, (_event, provider?: string, options?: ProviderAccountSnapshotRequestOptions) => getProviderAccountSnapshots(provider, options)); +ipcMain.handle(IPC_CHANNELS.appGetProviderCatalogModels, (_event, request: ProviderCatalogModelsRequest) => getProviderCatalogModels(request)); ipcMain.handle(IPC_CHANNELS.appGetProviderPresets, () => getProviderPresets()); ipcMain.handle(IPC_CHANNELS.appGetAgentAnalysis, (_event, filter?: AgentAnalysisFilter) => getAgentAnalysis(filter)); ipcMain.handle(IPC_CHANNELS.appGetGatewayStatus, () => gatewayService.getStatus()); diff --git a/src/main/preload.ts b/src/main/preload.ts index da9c7f29..36bd3f11 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -41,6 +41,8 @@ import type { ProviderIconDetectionRequest, ProviderIconDetectionResult, ProviderAccountSnapshot, + ProviderCatalogModelsRequest, + ProviderCatalogModelsResult, ProviderDeepLinkRequest, ProviderManifestFetchRequest, ProviderManifestFetchResult, @@ -76,6 +78,7 @@ contextBridge.exposeInMainWorld("ccr", { getProfileOpenCommand: (request: ProfileOpenRequest) => ipcRenderer.invoke(IPC_CHANNELS.appGetProfileOpenCommand, request) as Promise, getProfileRuntimeStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProfileRuntimeStatus) as Promise, getProviderAccountSnapshots: (provider?: string, options?: ProviderAccountSnapshotRequestOptions) => ipcRenderer.invoke(IPC_CHANNELS.appGetProviderAccountSnapshots, provider, options) as Promise, + getProviderCatalogModels: (request: ProviderCatalogModelsRequest) => ipcRenderer.invoke(IPC_CHANNELS.appGetProviderCatalogModels, request) as Promise, getProviderPresets: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProviderPresets) as Promise, getPluginMarketplace: () => ipcRenderer.invoke(IPC_CHANNELS.appGetPluginMarketplace) as Promise, getProxyCertificateStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProxyCertificateStatus) as Promise, diff --git a/src/main/provider-model-catalog.ts b/src/main/provider-model-catalog.ts new file mode 100644 index 00000000..55cea18a --- /dev/null +++ b/src/main/provider-model-catalog.ts @@ -0,0 +1,473 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve as pathResolve } from "node:path"; +import type { ProviderCatalogModelsRequest, ProviderCatalogModelsResult } from "../shared/app"; +import { providerUrlWithDefaultScheme } from "../shared/provider-url"; +import { findProviderPreset, findProviderPresetByBaseUrl } from "./presets"; + +type CatalogProviderEntry = { + apiUrls: string[]; + models: string[]; + provider: string; + providerName?: string; + tokens: string[]; +}; + +type CatalogIndex = { + loadedFrom?: string; + providers: CatalogProviderEntry[]; +}; + +type MutableCatalogProviderEntry = Omit & { + apiUrls: Set; + models: string[]; + modelSet: Set; + tokens: Set; +}; + +type CatalogMatch = { + entry: CatalogProviderEntry; + matchedBy: NonNullable; + score: number; +}; + +const presetCatalogProviderIds: Record = { + anthropic: ["anthropic"], + bailian: ["alibaba-cn"], + deepseek: ["deepseek"], + gemini: ["google"], + mistral: ["mistral"], + moonshot: ["moonshotai-cn"], + openai: ["openai"], + openrouter: ["openrouter"], + siliconflow: ["siliconflow-cn"], + "zai-global-coding": ["zai-coding-plan"], + "zai-global-general": ["zai"], + "zhipu-cn-coding": ["zhipuai-coding-plan"], + "zhipu-cn-general": ["zhipuai"] +}; + +let catalogIndex: CatalogIndex | undefined; + +export function getProviderCatalogModels(request: ProviderCatalogModelsRequest): ProviderCatalogModelsResult { + const index = loadCatalogIndex(); + const match = findBestCatalogProviderMatch(index.providers, request); + if (!match) { + return { + loadedFrom: index.loadedFrom, + models: [] + }; + } + + return { + loadedFrom: index.loadedFrom, + matchedBy: match.matchedBy, + models: match.entry.models, + provider: match.entry.provider, + providerName: match.entry.providerName + }; +} + +function loadCatalogIndex(): CatalogIndex { + if (catalogIndex) { + return catalogIndex; + } + + for (const candidate of catalogPathCandidates()) { + if (!existsSync(candidate)) { + continue; + } + try { + const payload = JSON.parse(readFileSync(candidate, "utf8")) as unknown; + catalogIndex = buildCatalogIndex(payload, candidate); + return catalogIndex; + } catch (error) { + console.warn(`Failed to load provider model catalog from ${candidate}:`, error); + } + } + + catalogIndex = { + providers: [] + }; + return catalogIndex; +} + +function catalogPathCandidates(): string[] { + return uniqueStrings([ + process.env.CCR_MODEL_CATALOG_PATH?.trim() || "", + process.env.CCR_MODELS_JSON_PATH?.trim() || "", + pathResolve(process.cwd(), "models.json"), + pathResolve(__dirname, "..", "models.json"), + pathResolve(__dirname, "..", "assets", "models.json"), + pathResolve(__dirname, "..", "..", "..", "models.json") + ]); +} + +function buildCatalogIndex(payload: unknown, loadedFrom: string): CatalogIndex { + const providers = new Map(); + const models = isRecord(payload) && Array.isArray(payload.models) ? payload.models : []; + + for (const item of models) { + if (!isRecord(item)) { + continue; + } + + const sourceRecords = Array.isArray(item.sourceRecords) ? item.sourceRecords : []; + for (const sourceRecord of sourceRecords) { + if (!isRecord(sourceRecord)) { + continue; + } + if (!catalogModelCanRouteText(item, sourceRecord)) { + continue; + } + const provider = stringValue(sourceRecord.provider); + const model = providerModelName(sourceRecord, item); + if (!provider || !model) { + continue; + } + + const entry = providers.get(provider) ?? createMutableCatalogProviderEntry(provider); + const providerName = stringValue(sourceRecord.providerName); + const providerApi = stringValue(sourceRecord.providerApi); + if (!entry.providerName && providerName) { + entry.providerName = providerName; + } + addSetValue(entry.tokens, normalizeProviderToken(provider)); + addSetValue(entry.tokens, normalizeProviderToken(providerName)); + addSetValue(entry.tokens, normalizeProviderToken(providerApiHost(providerApi))); + addSetValue(entry.apiUrls, normalizeProviderUrl(providerApi)); + if (!entry.modelSet.has(model)) { + entry.modelSet.add(model); + entry.models.push(model); + } + providers.set(provider, entry); + } + } + + return { + loadedFrom, + providers: Array.from(providers.values()).map((entry) => ({ + apiUrls: Array.from(entry.apiUrls), + models: sortCatalogProviderModels(entry.models), + provider: entry.provider, + providerName: entry.providerName, + tokens: Array.from(entry.tokens) + })) + }; +} + +function createMutableCatalogProviderEntry(provider: string): MutableCatalogProviderEntry { + return { + apiUrls: new Set(), + models: [], + modelSet: new Set(), + provider, + tokens: new Set([normalizeProviderToken(provider)]) + }; +} + +function providerModelName(sourceRecord: Record, modelEntry: Record): string { + return stringValue(sourceRecord.model) || + stringValue(sourceRecord.modelKey) || + stringValue(modelEntry.model) || + stringValue(modelEntry.id); +} + +function sortCatalogProviderModels(models: string[]): string[] { + return models + .map((model, index) => ({ index, model })) + .sort((left, right) => + catalogProviderModelRank(left.model) - catalogProviderModelRank(right.model) || + left.index - right.index + ) + .map((item) => item.model); +} + +function catalogProviderModelRank(model: string): number { + const normalized = model.toLowerCase(); + if (normalized.startsWith("ft:") || normalized.includes("/ft:")) { + return 30; + } + if (normalized.includes("sonnet")) return 0; + if (normalized.includes("gpt-5") || normalized.includes("gpt-4o") || normalized.includes("gpt-4.1")) return 0; + if (/\bo[34]\b/.test(normalized) || /(^|[-_/])o[34]([-_/]|$)/.test(normalized)) return 1; + if (normalized.includes("opus")) return 1; + if (normalized.includes("gemini") && normalized.includes("pro")) return 1; + if (normalized.includes("deepseek-chat") || normalized.includes("kimi-k2") || normalized.includes("qwen3") || normalized.includes("glm-4.5") || normalized.includes("mistral-large")) return 2; + if (normalized.includes("haiku") || normalized.includes("flash")) return 3; + if (normalized.includes("mini") || normalized.includes("lite")) return 4; + return 10; +} + +function catalogModelCanRouteText(modelEntry: Record, sourceRecord: Record): boolean { + const mode = (stringValue(sourceRecord.mode) || stringValue(modelEntry.mode)).toLowerCase(); + if (/embedding|image|audio|speech|transcription|moderation|rerank/.test(mode)) { + return false; + } + + const modalities = isRecord(modelEntry.modalities) ? modelEntry.modalities : undefined; + const output = stringListValue(modalities?.output).map((item) => item.toLowerCase()); + return output.length === 0 || output.includes("text"); +} + +function findBestCatalogProviderMatch( + providers: CatalogProviderEntry[], + request: ProviderCatalogModelsRequest +): CatalogMatch | undefined { + const urlKeys = providerUrlLookupKeys(request.baseUrl); + const explicitProviderTokens = explicitProviderLookupTokens(request); + const nameTokens = providerNameLookupTokens(request); + const matches = providers + .map((entry) => catalogProviderMatch(entry, urlKeys, explicitProviderTokens, nameTokens)) + .filter((match): match is CatalogMatch => Boolean(match)) + .sort((left, right) => + left.score - right.score || + right.entry.models.length - left.entry.models.length || + left.entry.provider.localeCompare(right.entry.provider) + ); + + return matches[0]; +} + +function catalogProviderMatch( + entry: CatalogProviderEntry, + urlKeys: ProviderUrlKey[], + explicitProviderTokens: string[], + nameTokens: string[] +): CatalogMatch | undefined { + const urlScore = catalogProviderUrlScore(entry, urlKeys); + const explicitScore = catalogProviderTokenScore(entry, explicitProviderTokens); + if (urlScore !== undefined) { + return { + entry, + matchedBy: "base-url", + score: urlScore + (urlScore >= 12 ? explicitScore ?? 8 : 0) + }; + } + + if (explicitScore !== undefined) { + return { + entry, + matchedBy: "provider-id", + score: 20 + explicitScore + }; + } + + const nameScore = catalogProviderTokenScore(entry, nameTokens); + if (nameScore !== undefined) { + return { + entry, + matchedBy: "provider-name", + score: 40 + nameScore + }; + } + + return undefined; +} + +function explicitProviderLookupTokens(request: ProviderCatalogModelsRequest): string[] { + const presetIds = uniqueStrings([ + request.providerPresetId?.trim() || "", + request.baseUrl ? findProviderPresetByBaseUrl(request.baseUrl)?.id ?? "" : "" + ]); + const presetProviderIds = uniqueStrings(presetIds.flatMap((presetId) => presetCatalogProviderIds[presetId] ?? [])); + const presetTokens = presetProviderIds.length > 0 ? presetProviderIds : presetIds.flatMap((presetId) => { + const preset = findProviderPreset(presetId); + return [ + presetId, + preset?.name ?? "", + ...(preset?.aliases ?? []) + ]; + }); + + return uniqueStrings([ + ...(request.providerIds ?? []), + ...presetTokens + ].map(normalizeProviderToken)); +} + +function providerNameLookupTokens(request: ProviderCatalogModelsRequest): string[] { + return uniqueStrings([ + request.name ?? "", + request.baseUrl ? providerApiHost(request.baseUrl) : "" + ].map(normalizeProviderToken)); +} + +function catalogProviderUrlScore(entry: CatalogProviderEntry, urlKeys: ProviderUrlKey[]): number | undefined { + let bestScore: number | undefined; + for (const apiUrl of entry.apiUrls) { + const apiKey = providerUrlKey(apiUrl); + if (!apiKey) { + continue; + } + for (const key of urlKeys) { + const score = providerUrlMatchScore(apiKey, key); + if (score === undefined) { + continue; + } + bestScore = bestScore === undefined ? score : Math.min(bestScore, score); + } + } + return bestScore; +} + +function catalogProviderTokenScore(entry: CatalogProviderEntry, tokens: string[]): number | undefined { + let bestScore: number | undefined; + for (const token of tokens) { + if (!token) { + continue; + } + for (const entryToken of entry.tokens) { + if (!entryToken) { + continue; + } + const score = token === entryToken + ? 0 + : token.length >= 4 && entryToken.includes(token) + ? 8 + : entryToken.length >= 4 && token.includes(entryToken) + ? 10 + : undefined; + if (score === undefined) { + continue; + } + bestScore = bestScore === undefined ? score : Math.min(bestScore, score); + } + } + return bestScore; +} + +type ProviderUrlKey = { + host: string; + pathname: string; + protocol: string; +}; + +function providerUrlLookupKeys(value: string | undefined): ProviderUrlKey[] { + const normalized = normalizeProviderUrl(value); + const key = providerUrlKey(normalized); + if (!key) { + return []; + } + + const rootKey = providerUrlRootKey(key); + return rootKey.host !== key.host || rootKey.pathname !== key.pathname || rootKey.protocol !== key.protocol + ? [key, rootKey] + : [key]; +} + +function providerUrlKey(value: string): ProviderUrlKey | undefined { + if (!value) { + return undefined; + } + try { + const url = new URL(providerUrlWithDefaultScheme(value)); + url.username = ""; + url.password = ""; + url.hash = ""; + url.search = ""; + return { + host: url.host.toLowerCase(), + pathname: normalizeProviderPath(url.pathname), + protocol: url.protocol.toLowerCase() + }; + } catch { + return undefined; + } +} + +function providerUrlRootKey(key: ProviderUrlKey): ProviderUrlKey { + return { + ...key, + pathname: key.pathname.replace(/\/(v1|v1beta)$/i, "") || "/" + }; +} + +function providerUrlMatchScore(left: ProviderUrlKey, right: ProviderUrlKey): number | undefined { + if (left.protocol !== right.protocol || left.host !== right.host) { + return undefined; + } + if (left.pathname === right.pathname) { + return 0; + } + if (left.pathname === "/" || right.pathname === "/") { + return 12; + } + if (right.pathname.startsWith(`${left.pathname}/`) || left.pathname.startsWith(`${right.pathname}/`)) { + return 4; + } + return undefined; +} + +function normalizeProviderUrl(value: string | undefined): string { + const trimmed = value?.trim(); + if (!trimmed) { + return ""; + } + try { + const url = new URL(providerUrlWithDefaultScheme(trimmed)); + url.username = ""; + url.password = ""; + url.hash = ""; + url.search = ""; + url.pathname = normalizeProviderPath(url.pathname); + return url.toString().replace(/\/$/, ""); + } catch { + return trimmed.replace(/[?#].*$/, "").replace(/\/+$/, ""); + } +} + +function normalizeProviderPath(value: string): string { + const trimmed = value.replace(/\/+$/, ""); + return trimmed || "/"; +} + +function providerApiHost(value: string | undefined): string { + const normalized = normalizeProviderUrl(value); + if (!normalized) { + return ""; + } + try { + const host = new URL(providerUrlWithDefaultScheme(normalized)).hostname; + return host.replace(/^api\./i, ""); + } catch { + return ""; + } +} + +function normalizeProviderToken(value: string | undefined): string { + return value?.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, "") ?? ""; +} + +function addSetValue(values: Set, value: string): void { + if (value) { + values.add(value); + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function stringListValue(value: unknown): string[] { + return Array.isArray(value) + ? value.map(stringValue).filter(Boolean) + : []; +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const trimmed = value.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + result.push(trimmed); + } + return result; +} diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index 95208fe2..92073699 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -29,7 +29,7 @@ import { profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage, ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage, - ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkIcon, RouterRule, ServerActionBusy, SettingsPageId, + ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, resolveProviderDeepLinkIcon, RouterRule, ServerActionBusy, SettingsPageId, routingRewriteFromDraftRow, setProviderPresets, splitLines, translateProxyCertificateMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig, uniqueRoutingRuleId, updateApiKeyEditableConfig, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, useEffect, useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId, @@ -303,6 +303,41 @@ function App() { }); }, [providerDeepLinkRequest?.id, providerDeepLinkRequest?.provider?.baseUrl, providerDeepLinkRequest?.provider?.icon, providerPresetsLoaded]); + useEffect(() => { + const request = providerDeepLinkRequest; + const payload = request?.provider; + providerDeepLinkCatalogModelsRequestId.current += 1; + const requestId = providerDeepLinkCatalogModelsRequestId.current; + const hasApiKey = Boolean(payload?.apiKey?.trim()); + + if (!request || !payload || (!hasApiKey && payload.models.length > 0) || !providerPresetsLoaded) { + return; + } + + const modelsPromise = hasApiKey + ? probeProviderDeepLinkPayload(payload).then((probe) => mergeProviderModelLists(probe?.models ?? [])) + : resolveProviderDeepLinkCatalogModels(payload); + + void modelsPromise + .then((models) => { + if (providerDeepLinkCatalogModelsRequestId.current !== requestId || models.length === 0) { + return; + } + setProviderDeepLinkRequest((current) => { + if (!current?.provider || current.id !== request.id || (!hasApiKey && current.provider.models.length > 0)) { + return current; + } + return { + ...current, + provider: { + ...current.provider, + models + } + }; + }); + }); + }, [providerDeepLinkRequest?.id, providerDeepLinkRequest?.provider?.apiKey, providerDeepLinkRequest?.provider?.baseUrl, providerDeepLinkRequest?.provider?.name, providerPresetsLoaded]); + useEffect(() => { if (!window.ccr) { setUsageStats(createEmptyUsageStats(usageRange)); @@ -530,6 +565,7 @@ function App() { const autoSaveRequestId = useRef(0); const providerProbeRequestId = useRef(0); const providerConnectivityRequestId = useRef(0); + const providerDeepLinkCatalogModelsRequestId = useRef(0); const providerDeepLinkIconRequestId = useRef(0); const toastTimer = useRef(); @@ -909,13 +945,9 @@ function App() { providerProbeRequestId.current += 1; const requestId = providerProbeRequestId.current; const candidates = providerProbeCandidates(providerDraft).filter(isProviderProbeCandidateReady); - const shouldDiscoverPresetModels = Boolean( - providerDraft.presetId && - providerDraft.presetId !== customProviderPresetId && - providerDraft.apiKey.trim() - ); - const probeMode = shouldDiscoverPresetModels ? "models" : "protocols"; - const probeApiKey = shouldDiscoverPresetModels ? providerDraft.apiKey.trim() : ""; + const shouldDiscoverModels = Boolean(providerDraft.apiKey.trim()); + const probeMode = shouldDiscoverModels ? "models" : "protocols"; + const probeApiKey = shouldDiscoverModels ? providerDraft.apiKey.trim() : ""; const inputKey = providerProbeInputKey(candidates, probeApiKey, []); setProviderProbeError(""); @@ -940,12 +972,8 @@ function App() { setProviderProbe(result.probe); setProviderDraft((current) => { const currentCandidates = providerProbeCandidates(current).filter(isProviderProbeCandidateReady); - const currentShouldDiscoverPresetModels = Boolean( - current.presetId && - current.presetId !== customProviderPresetId && - current.apiKey.trim() - ); - const currentProbeApiKey = currentShouldDiscoverPresetModels ? current.apiKey.trim() : ""; + const currentShouldDiscoverModels = Boolean(current.apiKey.trim()); + const currentProbeApiKey = currentShouldDiscoverModels ? current.apiKey.trim() : ""; const currentKey = providerProbeInputKey(currentCandidates, currentProbeApiKey, []); if (currentKey !== inputKey) { return current; @@ -1211,9 +1239,6 @@ function App() { setProviderDeepLinkBusy(false); return; } - if (payload.apiKey?.trim()) { - throw new Error("Provider links cannot include API keys. Add the key manually after verifying the endpoint."); - } const identityIssue = providerIdentitySafetyIssue({ baseUrl: payload.baseUrl, name: payload.name @@ -1228,7 +1253,22 @@ function App() { icon: iconResolution.persistentIcon }; } + if (payload.models.length === 0) { + const catalogModels = await resolveProviderDeepLinkCatalogModels(payload); + if (catalogModels.length > 0) { + payload = { + ...payload, + models: catalogModels + }; + } + } const probe = await probeProviderDeepLinkPayload(payload); + if (payload.apiKey?.trim() && probe?.models.length) { + payload = { + ...payload, + models: probe.models + }; + } let importedProviderName = payload.name?.trim() || ""; const next = buildConfigUpdate((config) => { const provider = createProviderConfigFromDeepLink(payload, config.Providers, probe); diff --git a/src/renderer/pages/home/shared/providers.ts b/src/renderer/pages/home/shared/providers.ts index ecd72baa..3d1f04ac 100644 --- a/src/renderer/pages/home/shared/providers.ts +++ b/src/renderer/pages/home/shared/providers.ts @@ -729,12 +729,13 @@ export async function probeProviderDeepLinkPayload(payload: ProviderDeepLinkPayl return undefined; } + const apiKey = payload.apiKey?.trim(); try { return await window.ccr.probeProvider({ - apiKey: undefined, + apiKey: apiKey || undefined, baseUrl: payload.baseUrl, - mode: "protocols", - models: payload.models, + mode: apiKey ? "models" : "protocols", + models: apiKey ? [] : payload.models, protocols: payload.protocol ? [payload.protocol] : providerProtocolOptions.map((option) => option.value) }); } catch { @@ -758,6 +759,25 @@ export function providerDeepLinkDisplayIcon(payload: ProviderDeepLinkPayload): s return presetIcon || payload.icon?.trim() || ""; } +export async function resolveProviderDeepLinkCatalogModels(payload: ProviderDeepLinkPayload): Promise { + const ccr = window.ccr; + if (!ccr?.getProviderCatalogModels) { + return []; + } + + const preset = resolveProviderDeepLinkPreset(payload); + try { + const result = await ccr.getProviderCatalogModels({ + baseUrl: payload.baseUrl, + name: payload.name, + providerPresetId: preset?.id + }); + return mergeProviderModelLists(result.models); + } catch { + return []; + } +} + export async function resolveProviderDeepLinkIcon(payload: ProviderDeepLinkPayload): Promise { const existingIcon = payload.icon?.trim(); const preset = resolveProviderDeepLinkPreset(payload); @@ -794,7 +814,10 @@ export function createProviderConfigFromDeepLink( ): GatewayProviderConfig { const protocol = probe?.detectedProtocol ?? payload.protocol ?? "openai_chat_completions"; const baseUrl = probe?.normalizedBaseUrl || payload.baseUrl; - const models = payload.models.length > 0 + const apiKey = payload.apiKey?.trim() || ""; + const models = apiKey && probe?.models.length + ? mergeProviderModelLists(probe.models) + : payload.models.length > 0 ? mergeProviderModelLists(payload.models) : mergeProviderModelLists(probe?.models ?? []); if (models.length === 0) { @@ -828,7 +851,7 @@ export function createProviderConfigFromDeepLink( return { account: payload.account ? cloneProviderAccountConfig(payload.account) : defaultProviderAccountConfigForBaseUrl(baseUrl), api_base_url: normalizeProviderBaseUrl(baseUrl, protocol), - api_key: "", + api_key: apiKey, capabilities: capabilities.length > 0 ? capabilities : undefined, icon: payload.icon?.trim() || undefined, models, diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index e2f6824e..e7a002b4 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -41,6 +41,8 @@ import type { ProviderIconDetectionRequest, ProviderIconDetectionResult, ProviderAccountSnapshot, + ProviderCatalogModelsRequest, + ProviderCatalogModelsResult, ProviderDeepLinkRequest, ProviderManifestFetchRequest, ProviderManifestFetchResult, @@ -78,6 +80,7 @@ declare global { getProfileOpenCommand: (request: ProfileOpenRequest) => Promise; getProfileRuntimeStatus: () => Promise; getProviderAccountSnapshots: (provider?: string, options?: ProviderAccountSnapshotRequestOptions) => Promise; + getProviderCatalogModels: (request: ProviderCatalogModelsRequest) => Promise; getProviderPresets: () => Promise; getPluginMarketplace: () => Promise; getProxyCertificateStatus: () => Promise; diff --git a/src/shared/app.ts b/src/shared/app.ts index b54deb00..8c3fd821 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -232,6 +232,21 @@ export type ProviderManifestFetchResult = { url: string; }; +export type ProviderCatalogModelsRequest = { + baseUrl?: string; + name?: string; + providerIds?: string[]; + providerPresetId?: string; +}; + +export type ProviderCatalogModelsResult = { + loadedFrom?: string; + matchedBy?: "base-url" | "provider-id" | "provider-name"; + models: string[]; + provider?: string; + providerName?: string; +}; + export type ProviderAccountTestRequest = { apiKey?: string; baseUrl: string; diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 6307a6e4..d8ee20ba 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -11,6 +11,7 @@ export const IPC_CHANNELS = { appGetProfileOpenCommand: "ccr:app:get-profile-open-command", appGetProfileRuntimeStatus: "ccr:app:get-profile-runtime-status", appGetProviderAccountSnapshots: "ccr:app:get-provider-account-snapshots", + appGetProviderCatalogModels: "ccr:app:get-provider-catalog-models", appGetProviderPresets: "ccr:app:get-provider-presets", appGetProxyCertificateStatus: "ccr:app:get-proxy-certificate-status", appGetProxyNetworkCaptures: "ccr:app:get-proxy-network-captures",