Support provider catalog model discovery in deep links

This commit is contained in:
musi
2026-06-23 09:08:24 +08:00
parent baa3604712
commit ee540dd787
8 changed files with 583 additions and 23 deletions
+3 -1
View File
@@ -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());
+3
View File
@@ -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<ProfileOpenCommandResult>,
getProfileRuntimeStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProfileRuntimeStatus) as Promise<ProfileRuntimeStatus>,
getProviderAccountSnapshots: (provider?: string, options?: ProviderAccountSnapshotRequestOptions) => ipcRenderer.invoke(IPC_CHANNELS.appGetProviderAccountSnapshots, provider, options) as Promise<ProviderAccountSnapshot[]>,
getProviderCatalogModels: (request: ProviderCatalogModelsRequest) => ipcRenderer.invoke(IPC_CHANNELS.appGetProviderCatalogModels, request) as Promise<ProviderCatalogModelsResult>,
getProviderPresets: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProviderPresets) as Promise<ProviderPreset[]>,
getPluginMarketplace: () => ipcRenderer.invoke(IPC_CHANNELS.appGetPluginMarketplace) as Promise<PluginMarketplaceEntry[]>,
getProxyCertificateStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProxyCertificateStatus) as Promise<ProxyCertificateStatus>,
+473
View File
@@ -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<CatalogProviderEntry, "apiUrls" | "models" | "tokens"> & {
apiUrls: Set<string>;
models: string[];
modelSet: Set<string>;
tokens: Set<string>;
};
type CatalogMatch = {
entry: CatalogProviderEntry;
matchedBy: NonNullable<ProviderCatalogModelsResult["matchedBy"]>;
score: number;
};
const presetCatalogProviderIds: Record<string, string[]> = {
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<string, MutableCatalogProviderEntry>();
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<string, unknown>, modelEntry: Record<string, unknown>): 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<string, unknown>, sourceRecord: Record<string, unknown>): 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<string>, value: string): void {
if (value) {
values.add(value);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<string>();
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;
}
+57 -17
View File
@@ -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<number>();
@@ -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);
+28 -5
View File
@@ -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<string[]> {
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<ProviderDeepLinkIconResolution> {
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,
+3
View File
@@ -41,6 +41,8 @@ import type {
ProviderIconDetectionRequest,
ProviderIconDetectionResult,
ProviderAccountSnapshot,
ProviderCatalogModelsRequest,
ProviderCatalogModelsResult,
ProviderDeepLinkRequest,
ProviderManifestFetchRequest,
ProviderManifestFetchResult,
@@ -78,6 +80,7 @@ declare global {
getProfileOpenCommand: (request: ProfileOpenRequest) => Promise<ProfileOpenCommandResult>;
getProfileRuntimeStatus: () => Promise<ProfileRuntimeStatus>;
getProviderAccountSnapshots: (provider?: string, options?: ProviderAccountSnapshotRequestOptions) => Promise<ProviderAccountSnapshot[]>;
getProviderCatalogModels: (request: ProviderCatalogModelsRequest) => Promise<ProviderCatalogModelsResult>;
getProviderPresets: () => Promise<ProviderPreset[]>;
getPluginMarketplace: () => Promise<PluginMarketplaceEntry[]>;
getProxyCertificateStatus: () => Promise<ProxyCertificateStatus>;
+15
View File
@@ -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;
+1
View File
@@ -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",