diff --git a/packages/provider/src/config-service.ts b/packages/provider/src/config-service.ts index 8467cb7..9ec23e2 100644 --- a/packages/provider/src/config-service.ts +++ b/packages/provider/src/config-service.ts @@ -579,6 +579,7 @@ export class ProviderConfigService implements ProviderSource>, ): Promise { const normalizedProviderId = normalizeId("providerId", providerId); const builtin = await this.#kcodeBuiltinSource.read(); @@ -598,10 +599,13 @@ export class ProviderConfigService implements ProviderSource !(rule.type === "model" && rule.modelMatch === ".*")), + ); + } + resolve(input: ModelConfigRuleResolutionInput): ModelConfig { let result = ModelConfig.empty(); const baseUrl = input.baseUrl == null ? undefined : normalizeBaseURLForRuleMatch(input.baseUrl); diff --git a/packages/provider/src/facades.ts b/packages/provider/src/facades.ts index e262866..c6f0525 100644 --- a/packages/provider/src/facades.ts +++ b/packages/provider/src/facades.ts @@ -88,6 +88,7 @@ export interface ProviderSettingsMutationTarget { providerId: ProviderId, remoteModelIds: readonly ModelId[], membership?: ProviderModelMembership, + modelConfigs?: Readonly>, ): Promise; clearVisibleModels( providerId: ProviderId, @@ -314,6 +315,25 @@ export class ProviderSettingsFacade { }); } + /** 不含通用 `.*` 模型兜底,供目录信息只填补真正空缺的字段。 */ + resolveSpecificBuiltinModelConfig(input: { + readonly providerId: ProviderId; + readonly modelId: ModelId; + }): ModelConfigObject { + const snapshot = requireSnapshot(this.#source); + const provider = requireEffectiveProvider(snapshot, input.providerId); + return snapshot.config.kcodeBuiltinModelRules + .withoutGenericModelFallback() + .resolve({ + providerId: input.providerId, + templateId: provider.templateId, + modelId: input.modelId, + apiType: provider.config.api?.type, + baseUrl: provider.config.api?.baseUrl, + }) + .toJSON(); + } + onDidChange(listener: (view: ProviderSettingsView) => void): () => void { return this.#source.onDidChange(() => listener(this.getView())); } @@ -413,6 +433,7 @@ export class ProviderSettingsFacade { async importRemoteModels( providerId: ProviderId, remoteModelIds: readonly ModelId[], + modelConfigs?: Readonly>, ): Promise { let plan: RemoteModelImportPlan | undefined; const view = await this.#mutateProvider(providerId, "import-remote-models", async (target) => { @@ -420,6 +441,7 @@ export class ProviderSettingsFacade { providerId, remoteModelIds, this.#modelMembership(providerId), + modelConfigs, ); }); if (!plan) throw new Error(`导入远端模型失败: ${providerId}`); diff --git a/packages/services/src/model-provider/modelInfoCatalog.ts b/packages/services/src/model-provider/modelInfoCatalog.ts new file mode 100644 index 0000000..fa0f322 --- /dev/null +++ b/packages/services/src/model-provider/modelInfoCatalog.ts @@ -0,0 +1,251 @@ +import { ModelConfig, type ModelConfigObject } from "@kcode/provider"; + +import { + arrayOfStrings, + bestMatch, + compactInfo, + hasParameter, + inputModalities, + modalityFlags, + modalityWords, + modelEntries, + nested, + nestedArray, + nestedNumber, + nestedString, + numberValue, + positiveInt, + readId, + reasoningLevelsFromOpenRouter, + reasoningLevelsFromOptions, + mapReasoningLevels, +} from "./modelInfoCatalogParse.js"; + +/** 供应商 /models 与公开目录都能填进模型配置的字段。供应商返回值优先。 */ +export interface CatalogModelInfo { + readonly contextWindow?: number; + readonly maxOutputTokens?: number; + readonly supportsImage?: boolean; + readonly supportsVideo?: boolean; + readonly supportsAudio?: boolean; + readonly supportsPdf?: boolean; + readonly supportsToolCall?: boolean; + readonly supportsNativeWebSearch?: boolean; + readonly reasoningLevels?: readonly string[]; +} + +export function parseProviderModelCatalog(payload: unknown): { + readonly modelIds: string[]; + readonly info: Readonly>; +} { + const entries = modelEntries(payload); + const modelIds: string[] = []; + const seen = new Set(); + const info: Record = {}; + for (const entry of entries) { + const modelId = readId(entry); + if (!modelId || seen.has(modelId)) continue; + seen.add(modelId); + modelIds.push(modelId); + const parsed = infoFromProviderEntry(entry); + if (parsed) info[modelId] = parsed; + } + return { modelIds, info }; +} + +export function mergeCatalogModelInfo( + current: CatalogModelInfo | undefined, + incoming: CatalogModelInfo, +): CatalogModelInfo { + return { + contextWindow: current?.contextWindow ?? incoming.contextWindow, + maxOutputTokens: current?.maxOutputTokens ?? incoming.maxOutputTokens, + supportsImage: current?.supportsImage ?? incoming.supportsImage, + supportsVideo: current?.supportsVideo ?? incoming.supportsVideo, + supportsAudio: current?.supportsAudio ?? incoming.supportsAudio, + supportsPdf: current?.supportsPdf ?? incoming.supportsPdf, + supportsToolCall: current?.supportsToolCall ?? incoming.supportsToolCall, + supportsNativeWebSearch: + current?.supportsNativeWebSearch ?? incoming.supportsNativeWebSearch, + reasoningLevels: + current?.reasoningLevels && current.reasoningLevels.length > 0 + ? current.reasoningLevels + : incoming.reasoningLevels, + }; +} + +export function findModelsDevInfo(catalog: unknown, modelId: string): CatalogModelInfo | undefined { + if (!catalog || typeof catalog !== "object") return undefined; + return bestMatch(modelId, function* () { + for (const provider of Object.values(catalog as Record)) { + if (!provider || typeof provider !== "object") continue; + const models = (provider as { models?: unknown }).models; + if (!models || typeof models !== "object") continue; + for (const [key, value] of Object.entries(models as Record)) { + if (!value || typeof value !== "object") continue; + const record = value as Record; + const remoteId = typeof record.id === "string" ? record.id : key; + const limit = record.limit; + const contextWindow = positiveInt(nestedNumber(limit, "context")); + const maxOutputTokens = positiveInt(nestedNumber(limit, "output")); + const modalities = inputModalities(record.modalities); + const info = compactInfo({ + contextWindow, + maxOutputTokens, + ...modalityFlags(modalities), + supportsToolCall: record.tool_call === true ? true : undefined, + reasoningLevels: reasoningLevelsFromOptions(record.reasoning_options), + }); + if (info) yield { remoteId, info }; + if (info) yield { remoteId: key, info }; + } + } + }); +} + +export function findOpenRouterInfo( + catalog: unknown, + modelId: string, +): CatalogModelInfo | undefined { + const data = nestedArray(catalog, "data"); + if (!data) return undefined; + return bestMatch(modelId, function* () { + for (const item of data) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + if (typeof record.id !== "string") continue; + const architecture = record.architecture; + const modalities = arrayOfStrings(nested(architecture, "input_modalities")); + const info = compactInfo({ + contextWindow: positiveInt( + numberValue(record.context_length) ?? + nestedNumber(record.top_provider, "context_length"), + ), + maxOutputTokens: positiveInt( + nestedNumber(record.top_provider, "max_completion_tokens"), + ), + ...modalityFlags(modalities), + supportsToolCall: hasParameter(record.supported_parameters, [ + "tools", + "tool_choice", + ]) + ? true + : undefined, + supportsNativeWebSearch: undefined, + reasoningLevels: reasoningLevelsFromOpenRouter(record.reasoning), + }); + if (info) yield { remoteId: record.id, info }; + } + }); +} + +export function findLiteLlmInfo(catalog: unknown, modelId: string): CatalogModelInfo | undefined { + if (!catalog || typeof catalog !== "object") return undefined; + return bestMatch(modelId, function* () { + for (const [key, value] of Object.entries(catalog as Record)) { + if (key === "sample_spec" || !value || typeof value !== "object") continue; + const record = value as Record; + const info = compactInfo({ + contextWindow: positiveInt( + numberValue(record.max_input_tokens) ?? numberValue(record.max_tokens), + ), + maxOutputTokens: positiveInt(numberValue(record.max_output_tokens)), + supportsImage: record.supports_vision === true ? true : undefined, + supportsToolCall: record.supports_function_calling === true ? true : undefined, + supportsNativeWebSearch: record.supports_web_search === true ? true : undefined, + }); + if (info) yield { remoteId: key, info }; + } + }); +} + +/** 只生成具体内置规则还没写过的叶子,避免目录覆盖用户或模型专用配置。 */ +export function modelConfigFromCatalog( + specific: ModelConfigObject, + info: CatalogModelInfo | undefined, +): ModelConfig | undefined { + if (!info) return undefined; + const properties: NonNullable = {}; + if (specific.properties?.contextWindow == null && info.contextWindow) { + properties.contextWindow = info.contextWindow; + } + const inputFormat: NonNullable["inputFormat"]> = + {}; + assignMissingModality(inputFormat, specific, "supportsImage", info.supportsImage); + assignMissingModality(inputFormat, specific, "supportsVideo", info.supportsVideo); + assignMissingModality(inputFormat, specific, "supportsAudio", info.supportsAudio); + assignMissingModality(inputFormat, specific, "supportsPdf", info.supportsPdf); + if (Object.keys(inputFormat).length > 0) properties.inputFormat = inputFormat; + if (specific.properties?.supportsToolCall == null && info.supportsToolCall === true) { + properties.supportsToolCall = true; + } + if ( + specific.properties?.supportsNativeWebSearch == null && + info.supportsNativeWebSearch === true + ) { + properties.supportsNativeWebSearch = true; + } + const optionSpecs: NonNullable = {}; + if (specific.optionSpecs?.maxOutputTokens?.max == null && info.maxOutputTokens) { + optionSpecs.maxOutputTokens = { max: info.maxOutputTokens }; + } + if ( + specific.optionSpecs?.reasoningLevel?.values == null && + info.reasoningLevels && + info.reasoningLevels.length > 0 + ) { + optionSpecs.reasoningLevel = { values: info.reasoningLevels }; + } + if (Object.keys(properties).length === 0 && Object.keys(optionSpecs).length === 0) { + return undefined; + } + return ModelConfig.fromData({ + ...(Object.keys(properties).length > 0 ? { properties } : {}), + ...(Object.keys(optionSpecs).length > 0 ? { optionSpecs } : {}), + }); +} + +function assignMissingModality( + target: NonNullable["inputFormat"]>, + specific: ModelConfigObject, + key: "supportsImage" | "supportsVideo" | "supportsAudio" | "supportsPdf", + value: boolean | undefined, +) { + if (specific.properties?.inputFormat?.[key] != null || value == null) return; + target[key] = value; +} + +function infoFromProviderEntry(entry: unknown): CatalogModelInfo | undefined { + if (!entry || typeof entry !== "object") return undefined; + const record = entry as Record; + const modalities = [ + ...arrayOfStrings(nested(record.architecture, "input_modalities")), + ...modalityWords(nestedString(record.architecture, "modality")), + ]; + const capabilities = arrayOfStrings(record.capabilities).map((item) => item.toLowerCase()); + return compactInfo({ + contextWindow: positiveInt( + numberValue(record.context_length) ?? + numberValue(record.context_window) ?? + numberValue(record.max_model_len) ?? + nestedNumber(record.top_provider, "context_length"), + ), + maxOutputTokens: positiveInt( + numberValue(record.max_output_tokens) ?? + numberValue(record.max_completion_tokens) ?? + nestedNumber(record.top_provider, "max_completion_tokens"), + ), + ...modalityFlags(modalities.length > 0 ? modalities : undefined), + supportsToolCall: + capabilities.some( + (item) => + item === "tools" || item === "tool_calling" || item === "function_calling", + ) || hasParameter(record.supported_parameters, ["tools", "tool_choice"]) + ? true + : undefined, + supportsNativeWebSearch: capabilities.includes("web_search") ? true : undefined, + reasoningLevels: + reasoningLevelsFromOpenRouter(record.reasoning) ?? + mapReasoningLevels(arrayOfStrings(record.reasoning_options)), + }); +} diff --git a/packages/services/src/model-provider/modelInfoCatalogFetch.ts b/packages/services/src/model-provider/modelInfoCatalogFetch.ts new file mode 100644 index 0000000..fe62852 --- /dev/null +++ b/packages/services/src/model-provider/modelInfoCatalogFetch.ts @@ -0,0 +1,117 @@ +import type { CatalogModelInfo } from "./modelInfoCatalog.js"; +import { + findLiteLlmInfo, + findModelsDevInfo, + findOpenRouterInfo, + mergeCatalogModelInfo, +} from "./modelInfoCatalog.js"; + +const MODELS_DEV_URL = "https://models.dev/api.json"; +const OPENROUTER_URL = "https://openrouter.ai/api/v1/models"; +const LITELLM_URL = + "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; +const CATALOG_TIMEOUT_MS = 8_000; + +type CatalogKind = "models-dev" | "openrouter" | "litellm"; + +const catalogCache = new Map(); +const catalogMisses = new Set(); +const catalogInflight = new Map>(); +const providerInfoCache = new Map(); + +export function rememberProviderModelInfo( + providerId: string, + info: Readonly>, +): void { + for (const [modelId, modelInfo] of Object.entries(info)) { + providerInfoCache.set(cacheKey(providerId, modelId), modelInfo); + } +} + +export function cachedProviderModelInfo( + providerId: string, + modelId: string, +): CatalogModelInfo | undefined { + return providerInfoCache.get(cacheKey(providerId, modelId)); +} + +/** 公开目录失败时返回空,不挡住供应商自己已经给出的模型信息。 */ +export async function lookupPublicModelInfo( + modelId: string, + fetchImpl: typeof fetch = globalThis.fetch, +): Promise { + const [modelsDev, openRouter, liteLlm] = await Promise.all([ + loadCatalog("models-dev", MODELS_DEV_URL, fetchImpl), + loadCatalog("openrouter", OPENROUTER_URL, fetchImpl), + loadCatalog("litellm", LITELLM_URL, fetchImpl), + ]); + let info: CatalogModelInfo | undefined; + const modelsDevInfo = modelsDev ? findModelsDevInfo(modelsDev, modelId) : undefined; + const openRouterInfo = openRouter ? findOpenRouterInfo(openRouter, modelId) : undefined; + const liteLlmInfo = liteLlm ? findLiteLlmInfo(liteLlm, modelId) : undefined; + if (modelsDevInfo) info = mergeCatalogModelInfo(info, modelsDevInfo); + if (openRouterInfo) info = mergeCatalogModelInfo(info, openRouterInfo); + if (liteLlmInfo) info = mergeCatalogModelInfo(info, liteLlmInfo); + return info; +} + +export async function enrichProviderModelInfo( + providerInfo: Readonly>, + modelIds: readonly string[], + fetchImpl?: typeof fetch, +): Promise> { + const result: Record = { ...providerInfo }; + await Promise.all( + modelIds.map(async (modelId) => { + const publicInfo = await lookupPublicModelInfo(modelId, fetchImpl); + if (!publicInfo && !providerInfo[modelId]) return; + result[modelId] = publicInfo + ? mergeCatalogModelInfo(providerInfo[modelId], publicInfo) + : providerInfo[modelId]!; + }), + ); + return result; +} + +async function loadCatalog( + kind: CatalogKind, + url: string, + fetchImpl: typeof fetch, +): Promise { + if (catalogCache.has(kind)) return catalogCache.get(kind); + if (catalogMisses.has(kind)) return undefined; + const pending = catalogInflight.get(kind); + if (pending) return pending; + const request = fetchCatalog(url, fetchImpl); + catalogInflight.set(kind, request); + try { + const payload = await request; + if (payload === undefined) catalogMisses.add(kind); + else catalogCache.set(kind, payload); + return payload; + } finally { + catalogInflight.delete(kind); + } +} + +async function fetchCatalog(url: string, fetchImpl: typeof fetch): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CATALOG_TIMEOUT_MS); + try { + const response = await fetchImpl(url, { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + if (!response.ok) return undefined; + return await response.json(); + } catch { + return undefined; + } finally { + clearTimeout(timer); + } +} + +function cacheKey(providerId: string, modelId: string): string { + return `${providerId}\u0000${modelId}`; +} diff --git a/packages/services/src/model-provider/modelInfoCatalogParse.ts b/packages/services/src/model-provider/modelInfoCatalogParse.ts new file mode 100644 index 0000000..f143024 --- /dev/null +++ b/packages/services/src/model-provider/modelInfoCatalogParse.ts @@ -0,0 +1,223 @@ +import type { CatalogModelInfo } from "./modelInfoCatalog.js"; + +const KCODE_REASONING_LEVELS = [ + "disabled", + "enabled", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", +] as const; + +export function modalityFlags( + modalities: readonly string[] | undefined, +): Pick { + if (!modalities) return {}; + const set = new Set(modalities.map((item) => item.toLowerCase())); + return { + supportsImage: set.has("image"), + supportsVideo: set.has("video"), + supportsAudio: set.has("audio"), + supportsPdf: set.has("pdf"), + }; +} + +export function compactInfo(info: CatalogModelInfo): CatalogModelInfo | undefined { + const entries = Object.entries(info).filter(([, value]) => { + if (value == null) return false; + return !Array.isArray(value) || value.length > 0; + }); + if (entries.length === 0) return undefined; + return Object.fromEntries(entries) as CatalogModelInfo; +} + +export function bestMatch( + modelId: string, + candidates: () => Generator<{ remoteId: string; info: CatalogModelInfo }>, +): CatalogModelInfo | undefined { + const local = modelIdCandidates(modelId); + let bestScore = 0; + let best: CatalogModelInfo | undefined; + for (const candidate of candidates()) { + const score = matchScore(local, candidate.remoteId); + if (score > bestScore) { + bestScore = score; + best = candidate.info; + } + } + return best; +} + +function modelIdCandidates(modelId: string): string[] { + const trimmed = modelId.trim().toLowerCase(); + const candidates = new Set([trimmed]); + const slash = trimmed.indexOf("/"); + if (slash >= 0 && slash < trimmed.length - 1) candidates.add(trimmed.slice(slash + 1)); + const colon = trimmed.lastIndexOf(":"); + if (colon > 0) candidates.add(trimmed.slice(0, colon)); + return [...candidates]; +} + +function matchScore(candidates: readonly string[], remote: string): number { + const normalized = remote.trim().toLowerCase(); + if (!normalized) return 0; + const bare = normalized.includes("/") + ? normalized.slice(normalized.lastIndexOf("/") + 1) + : normalized; + let best = 0; + for (const candidate of candidates) { + if (candidate === normalized) best = Math.max(best, 300); + else if (candidate === bare) best = Math.max(best, 250); + else if (normalized.endsWith(`/${candidate}`)) best = Math.max(best, 220); + else if (candidate.endsWith(`/${bare}`)) best = Math.max(best, 200); + else if ( + candidate.length >= 8 && + bare.length >= 8 && + (bare.startsWith(candidate) || candidate.startsWith(bare)) + ) { + best = Math.max(best, 120); + } + } + return best; +} + +export function reasoningLevelsFromOptions(value: unknown): readonly string[] | undefined { + if (!Array.isArray(value)) return undefined; + const levels: string[] = []; + for (const item of value) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + if (record.type === "budget_tokens") return undefined; + if (record.type === "toggle") { + pushLevel(levels, "disabled"); + continue; + } + for (const level of mapReasoningLevels(arrayOfStrings(record.values)) ?? []) + pushLevel(levels, level); + } + return levels.length > 0 ? levels : undefined; +} + +export function reasoningLevelsFromOpenRouter(value: unknown): readonly string[] | undefined { + if (!value || typeof value !== "object") return undefined; + return mapReasoningLevels( + arrayOfStrings((value as { supported_efforts?: unknown }).supported_efforts), + ); +} + +export function mapReasoningLevels(values: readonly string[]): readonly string[] | undefined { + const levels: string[] = []; + for (const value of values) { + const mapped = mapReasoningLevel(value); + if (mapped) pushLevel(levels, mapped); + } + return levels.length > 0 ? levels : undefined; +} + +function mapReasoningLevel(value: string): string | undefined { + switch (value.trim().toLowerCase()) { + case "none": + case "off": + case "disabled": + return "disabled"; + case "minimal": + return "minimal"; + case "low": + case "medium": + case "high": + case "max": + case "ultra": + case "enabled": + return value.trim().toLowerCase(); + case "xhigh": + case "very_high": + case "veryhigh": + return "xhigh"; + case "maximum": + return "max"; + default: + return KCODE_REASONING_LEVELS.includes( + value.trim().toLowerCase() as (typeof KCODE_REASONING_LEVELS)[number], + ) + ? value.trim().toLowerCase() + : undefined; + } +} + +function pushLevel(levels: string[], level: string) { + if (!levels.includes(level)) levels.push(level); +} + +export function modelEntries(payload: unknown): unknown[] { + if (Array.isArray(payload)) return payload; + if (!payload || typeof payload !== "object") return []; + const record = payload as Record; + if (Array.isArray(record.data)) return record.data; + if (Array.isArray(record.models)) return record.models; + return []; +} + +export function readId(entry: unknown): string | undefined { + if (typeof entry === "string") { + const modelId = entry.trim(); + return modelId || undefined; + } + if (!entry || typeof entry !== "object" || !("id" in entry)) return undefined; + return typeof entry.id === "string" && entry.id.trim() ? entry.id.trim() : undefined; +} + +export function inputModalities(value: unknown): string[] | undefined { + const items = arrayOfStrings(nested(value, "input")); + return items.length > 0 ? items : undefined; +} + +export function modalityWords(value: string | undefined): string[] { + if (!value) return []; + return value + .toLowerCase() + .split(/[^a-z]+/) + .filter( + (item) => item === "image" || item === "video" || item === "audio" || item === "pdf", + ); +} + +export function hasParameter(value: unknown, names: readonly string[]): boolean { + const parameters = arrayOfStrings(value); + return parameters.some((item) => names.includes(item)); +} + +export function nested(value: unknown, key: string): unknown { + if (!value || typeof value !== "object") return undefined; + return (value as Record)[key]; +} + +export function nestedString(value: unknown, key: string): string | undefined { + const child = nested(value, key); + return typeof child === "string" ? child : undefined; +} + +export function nestedNumber(value: unknown, key: string): number | undefined { + return numberValue(nested(value, key)); +} + +export function nestedArray(value: unknown, key: string): unknown[] | undefined { + const child = nested(value, key); + return Array.isArray(child) ? child : undefined; +} + +export function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export function positiveInt(value: number | undefined): number | undefined { + if (value == null || !Number.isInteger(value) || value <= 0) return undefined; + return value; +} + +export function arrayOfStrings(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} diff --git a/packages/services/src/model-provider/providerFacadeServices.ts b/packages/services/src/model-provider/providerFacadeServices.ts index 74fb88b..66d3716 100644 --- a/packages/services/src/model-provider/providerFacadeServices.ts +++ b/packages/services/src/model-provider/providerFacadeServices.ts @@ -1,6 +1,7 @@ import type { Event } from "@kcode/rpc"; import { ServiceChannels } from "@kcode/shared"; import { + ModelConfig, type ModelConfigObject, type ModelId, type ModelSelection, @@ -18,9 +19,15 @@ import { isApiKeyAccess, } from "@kcode/provider"; import { - fetchProviderRemoteModelIds, + fetchProviderRemoteModels, ProviderRemoteModelsError, } from "./providerRemoteModels.js"; +import { mergeCatalogModelInfo, modelConfigFromCatalog } from "./modelInfoCatalog.js"; +import { + cachedProviderModelInfo, + lookupPublicModelInfo, + rememberProviderModelInfo, +} from "./modelInfoCatalogFetch.js"; import { createServiceDescriptor } from "../descriptors.js"; import type { ModelConnectivityResult } from "@kcode/shared"; import { createServiceLogger } from "../logger/serviceLogger.js"; @@ -148,7 +155,17 @@ export function createProviderSettingsService( }, resolveModelConfig: async (input) => { await ensureReady(); - return facade.resolveModelConfig(input); + const resolution = facade.resolveModelConfig(input); + const patch = await catalogPatchForModel(facade, input.providerId, input.modelId); + if (!patch) return resolution; + const inheritedConfig = ModelConfig.fromData(resolution.inheritedConfig).overlay(patch).toJSON(); + const effectiveConfig = + "personalConfig" in input + ? ModelConfig.fromData(inheritedConfig) + .overlay(ModelConfig.fromData(input.personalConfig)) + .toJSON() + : ModelConfig.fromData(resolution.effectiveConfig).overlay(patch).toJSON(); + return { ...resolution, inheritedConfig, effectiveConfig }; }, savePersonalProviderOverlay: async (providerId, config, metadata) => { await ensureReady(); @@ -195,18 +212,19 @@ export function createProviderSettingsService( "请先填写 Base URL 和 API Key", ); } - return { - modelIds: await fetchProviderRemoteModelIds({ - apiType: api.type ?? undefined, - baseUrl: api.baseUrl, - apiKey: access.apiKey, - headers: api.headers, - }), - }; + const listed = await fetchProviderRemoteModels({ + apiType: api.type ?? undefined, + baseUrl: api.baseUrl, + apiKey: access.apiKey, + headers: api.headers, + }); + rememberProviderModelInfo(providerId, listed.info); + return { modelIds: listed.modelIds }; }, importRemoteProviderModels: async (providerId, modelIds) => { await ensureReady(); - const imported = await facade.importRemoteModels(providerId, modelIds); + const modelConfigs = await catalogConfigsForModels(facade, providerId, modelIds); + const imported = await facade.importRemoteModels(providerId, modelIds, modelConfigs); return { view: imported.view, addedCount: imported.addedModelIds.length, @@ -219,7 +237,11 @@ export function createProviderSettingsService( }, savePersonalModelDraft: async (input) => { await ensureReady(); - return facade.savePersonalModelDraft(input); + const patch = await catalogPatchForModel(facade, input.providerId, input.nextModelId); + const personalConfig = patch + ? patch.overlay(ModelConfig.fromData(input.personalConfig)).toJSON() + : input.personalConfig; + return facade.savePersonalModelDraft({ ...input, personalConfig }); }, setPersonalModelEnabled: async (providerId, modelId, enabled) => { await ensureReady(); @@ -329,3 +351,35 @@ function toEvent(subscribe: (listener: (event: T) => void) => () => void): Ev return { dispose }; }; } + +async function catalogConfigsForModels( + facade: ProviderSettingsFacade, + providerId: string, + modelIds: readonly string[], +): Promise> { + const configs: Record = {}; + await Promise.all( + modelIds.map(async (modelId) => { + const patch = await catalogPatchForModel(facade, providerId, modelId); + if (patch) configs[modelId] = patch; + }), + ); + return configs; +} + +async function catalogPatchForModel( + facade: ProviderSettingsFacade, + providerId: string, + modelId: string, +): Promise { + const trimmed = modelId.trim(); + if (!trimmed) return undefined; + const specific = facade.resolveSpecificBuiltinModelConfig({ + providerId, + modelId: trimmed, + }); + const providerInfo = cachedProviderModelInfo(providerId, trimmed); + const publicInfo = await lookupPublicModelInfo(trimmed); + const info = publicInfo ? mergeCatalogModelInfo(providerInfo, publicInfo) : providerInfo; + return modelConfigFromCatalog(specific, info); +} diff --git a/packages/services/src/model-provider/providerRemoteModels.ts b/packages/services/src/model-provider/providerRemoteModels.ts index 442c3bb..12f2c27 100644 --- a/packages/services/src/model-provider/providerRemoteModels.ts +++ b/packages/services/src/model-provider/providerRemoteModels.ts @@ -1,5 +1,6 @@ import type { ModelId, ProviderApiType } from "@kcode/provider"; import { normalizeApiKeyForHeader } from "../providers/api/apiKeyHeaders.js"; +import { parseProviderModelCatalog, type CatalogModelInfo } from "./modelInfoCatalog.js"; const ANTHROPIC_VERSION = "2023-06-01"; @@ -26,38 +27,14 @@ export function resolveProviderModelsUrl(apiType: ProviderApiType | undefined, b } export function parseProviderRemoteModelIds(payload: unknown): ModelId[] { - const ids: string[] = []; - const seen = new Set(); - const push = (value: unknown) => { - if (typeof value !== "string") return; - const modelId = value.trim(); - if (!modelId || seen.has(modelId)) return; - seen.add(modelId); - ids.push(modelId); - }; - - if (Array.isArray(payload)) { - for (const item of payload) { - if (typeof item === "string") push(item); - else if (item && typeof item === "object" && "id" in item) push(item.id); - } - return ids; - } + return parseProviderModelCatalog(payload).modelIds; +} - if (!payload || typeof payload !== "object") return ids; - const record = payload as Record; - if (Array.isArray(record.data)) { - for (const item of record.data) { - if (item && typeof item === "object" && "id" in item) push(item.id); - } - } - if (ids.length === 0 && Array.isArray(record.models)) { - for (const item of record.models) { - if (typeof item === "string") push(item); - else if (item && typeof item === "object" && "id" in item) push(item.id); - } - } - return ids; +export function parseProviderRemoteModelCatalog(payload: unknown): { + readonly modelIds: readonly ModelId[]; + readonly info: Readonly>; +} { + return parseProviderModelCatalog(payload); } export function buildProviderModelsRequestHeaders(input: { @@ -80,14 +57,17 @@ export function buildProviderModelsRequestHeaders(input: { return headers; } -export async function fetchProviderRemoteModelIds(input: { +export async function fetchProviderRemoteModels(input: { readonly apiType?: ProviderApiType; readonly baseUrl: string; readonly apiKey: string; readonly headers?: Readonly> | null; readonly fetchImpl?: typeof fetch; readonly signal?: AbortSignal; -}): Promise { +}): Promise<{ + readonly modelIds: readonly ModelId[]; + readonly info: Readonly>; +}> { const url = resolveProviderModelsUrl(input.apiType, input.baseUrl); const headers = buildProviderModelsRequestHeaders(input); const fetchImpl = input.fetchImpl ?? globalThis.fetch; @@ -117,9 +97,15 @@ export async function fetchProviderRemoteModelIds(input: { } catch { throw new ProviderRemoteModelsError("invalid-response", "远端返回的模型列表不是 JSON"); } - const modelIds = parseProviderRemoteModelIds(payload); - if (modelIds.length === 0) { + const catalog = parseProviderRemoteModelCatalog(payload); + if (catalog.modelIds.length === 0) { throw new ProviderRemoteModelsError("invalid-response", "远端返回的模型列表为空或格式无法识别"); } - return modelIds; + return catalog; +} + +export async function fetchProviderRemoteModelIds( + input: Parameters[0], +): Promise { + return (await fetchProviderRemoteModels(input)).modelIds; } diff --git a/packages/services/src/model-provider/providerRuntime.ts b/packages/services/src/model-provider/providerRuntime.ts index 113ce2b..47780de 100644 --- a/packages/services/src/model-provider/providerRuntime.ts +++ b/packages/services/src/model-provider/providerRuntime.ts @@ -157,8 +157,8 @@ function createSettingsMutationTarget( configService.renamePersonalModel(providerId, currentModelId, nextModelId, membership), deletePersonalModel: (providerId, modelId, membership) => configService.deletePersonalModel(providerId, modelId, membership), - importRemoteModels: (providerId, remoteModelIds, membership) => - configService.importRemoteModels(providerId, remoteModelIds, membership), + importRemoteModels: (providerId, remoteModelIds, membership, modelConfigs) => + configService.importRemoteModels(providerId, remoteModelIds, membership, modelConfigs), clearVisibleModels: (providerId, membership) => configService.clearVisibleModels(providerId, membership), setPersonalModelEnabled: (providerId, modelId, enabled, membership) => diff --git a/packages/services/test/modelInfoCatalog.test.ts b/packages/services/test/modelInfoCatalog.test.ts new file mode 100644 index 0000000..65ed7ac --- /dev/null +++ b/packages/services/test/modelInfoCatalog.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ModelConfig } from "@kcode/provider"; +import { + findModelsDevInfo, + mergeCatalogModelInfo, + modelConfigFromCatalog, + parseProviderModelCatalog, +} from "../src/model-provider/modelInfoCatalog.js"; + +test("供应商模型响应里的上下文和视觉能力会被保留", () => { + const catalog = parseProviderModelCatalog({ + data: [ + { + id: "model-b", + context_window: 128000, + max_completion_tokens: 32768, + capabilities: ["reasoning", "tools"], + architecture: { input_modalities: ["text", "image"] }, + }, + { id: "model-a", max_output_tokens: 8192 }, + ], + }); + + assert.deepEqual(catalog.modelIds, ["model-b", "model-a"]); + assert.equal(catalog.info["model-b"]?.contextWindow, 128000); + assert.equal(catalog.info["model-b"]?.maxOutputTokens, 32768); + assert.equal(catalog.info["model-b"]?.supportsImage, true); + assert.equal(catalog.info["model-b"]?.supportsToolCall, true); + assert.equal(catalog.info["model-a"]?.maxOutputTokens, 8192); +}); + +test("公开目录只补供应商没给的字段", () => { + const merged = mergeCatalogModelInfo( + { contextWindow: 128000, supportsToolCall: true }, + { contextWindow: 64000, maxOutputTokens: 16384, supportsImage: true }, + ); + assert.equal(merged.contextWindow, 128000); + assert.equal(merged.maxOutputTokens, 16384); + assert.equal(merged.supportsImage, true); + assert.equal(merged.supportsToolCall, true); +}); + +test("models.dev 按模型 ID 匹配上下文,并跳过已有具体规则的字段", () => { + const info = findModelsDevInfo( + { + openai: { + models: { + "gpt-test": { + id: "gpt-test", + limit: { context: 200000, output: 32000 }, + tool_call: true, + modalities: { input: ["text", "image"] }, + }, + }, + }, + }, + "openai/gpt-test", + ); + assert.equal(info?.contextWindow, 200000); + assert.equal(info?.supportsImage, true); + + const patch = modelConfigFromCatalog( + { + properties: { contextWindow: 1000000 }, + optionSpecs: { + maxOutputTokens: { max: 32000, map: "{'max_tokens': maxOutputTokens}" }, + }, + }, + info, + ); + const json = patch?.toJSON(); + assert.equal(json?.properties?.contextWindow, undefined); + assert.equal(json?.properties?.inputFormat?.supportsImage, true); + assert.equal(json?.optionSpecs?.maxOutputTokens, undefined); + assert.ok(patch instanceof ModelConfig); +}); diff --git a/packages/ui/src/settings/model-provider-section/spec.md b/packages/ui/src/settings/model-provider-section/spec.md index 11202ab..a63144a 100644 --- a/packages/ui/src/settings/model-provider-section/spec.md +++ b/packages/ui/src/settings/model-provider-section/spec.md @@ -35,6 +35,9 @@ KCode 不再提供智谱 / Z.ai / BigModel 官方账号、OAuth、编程套餐 → Host GET 远端目录(不经 Agent runtime)→ 此时不改成员 → 弹窗勾选要写入的模型(已在实例中的只展示、不可改;新模型默认不勾选) → 确认后一次事务导入勾选项;取消或全不选不写盘 + → 新模型用供应商 /models 返回的上下文、输出上限和能力填充 + → 供应商没给的字段再按 models.dev、OpenRouter、LiteLLM 补,已有具体规则或个人配置不覆盖 +智能配置解析模型 ID 时同样只补通用兜底没覆盖的字段;目录请求失败则保持原配置 清空模型 → 确认后隐藏全部模板模型并清空个人模型;连接配置保留 失败 → 不改成员;UI 只报本次错误 ``` @@ -67,6 +70,8 @@ KCode 不再提供智谱 / Z.ai / BigModel 官方账号、OAuth、编程套餐 - 删除个人添加的模型后,该模型从本实例消失 - 保存 Base URL / API Key 不会把已删除的模板模型重新加回来 - 「获取模型列表」先弹出远端目录供勾选,确认后才写入;取消不改成员 +- 新导入的模型写入供应商或公开目录给出的上下文窗口、最大输出和输入能力;具体内置规则与已有个人配置保持不变 +- 添加或编辑模型的智能配置会用同一目录补全通用兜底,目录不可用时仍显示原推荐值 - 新拉到的远端模型默认不勾选;已在实例中的只展示为已添加 - 「清空」确认后当前实例模型列表为空,Base URL / API Key 仍在 - 获取失败或缺少连接配置时,成员保持原样并提示原因