mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-29 03:12:10 +08:00
Support provider catalog model discovery in deep links
This commit is contained in:
+3
-1
@@ -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());
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+3
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user