From e20f7afe56af47b8e04544fff8a437718f608c8c Mon Sep 17 00:00:00 2001 From: KCode Date: Fri, 25 Sep 2026 10:25:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E4=BB=8E=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E8=87=AA=E5=8A=A8=E5=A1=AB=E5=85=85=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新导入和智能配置只补通用兜底没覆盖的上下文、输出上限和输入能力,供应商返回优先于公开目录。 --- packages/provider/src/config-service.ts | 1544 +++++++++-------- packages/provider/src/config/model-config.ts | 1015 +++++------ packages/provider/src/facades.ts | 1297 +++++++------- .../src/model-provider/modelInfoCatalog.ts | 251 +++ .../model-provider/modelInfoCatalogFetch.ts | 117 ++ .../model-provider/modelInfoCatalogParse.ts | 223 +++ .../model-provider/providerFacadeServices.ts | 612 ++++--- .../model-provider/providerRemoteModels.ts | 200 +-- .../src/model-provider/providerRuntime.ts | 356 ++-- .../services/test/modelInfoCatalog.test.ts | 77 + .../settings/model-provider-section/spec.md | 5 + 11 files changed, 3274 insertions(+), 2423 deletions(-) create mode 100644 packages/services/src/model-provider/modelInfoCatalog.ts create mode 100644 packages/services/src/model-provider/modelInfoCatalogFetch.ts create mode 100644 packages/services/src/model-provider/modelInfoCatalogParse.ts create mode 100644 packages/services/test/modelInfoCatalog.test.ts diff --git a/packages/provider/src/config-service.ts b/packages/provider/src/config-service.ts index 8467cb7e..6bc29075 100644 --- a/packages/provider/src/config-service.ts +++ b/packages/provider/src/config-service.ts @@ -1,866 +1,906 @@ /* oxlint-disable eslint(max-lines) -- Provider/Model 的原子配置生命周期共享一次 Repository 更新边界,拆开会重复顺序与规范化逻辑。 */ import type { - ModelConfigRules, - ModelId, - ProviderConfig, - ProviderId, - ProviderTemplateId, - ProviderConfigRule, - ProviderTemplateLocale, + ModelConfigRules, + ModelId, + ProviderConfig, + ProviderId, + ProviderTemplateId, + ProviderConfigRule, + ProviderTemplateLocale, } from "./config/index.js"; import { - ApiKeyAccessConfig, - ModelConfig, - ProviderConfigMap, - ProviderConfig as ProviderConfigValue, - ProviderTemplateMap, - resolveProviderTemplateName, + ApiKeyAccessConfig, + ModelConfig, + ProviderConfigMap, + ProviderConfig as ProviderConfigValue, + ProviderTemplateMap, + resolveProviderTemplateName, } from "./config/index.js"; import { resolveOwnedOrder } from "./owned-order.js"; import { - normalizeHiddenInheritedModelIds, - resolvePersonalModelDeletionPlan, - resolveRemoteModelImportPlan, - type RemoteModelImportPlan, + normalizeHiddenInheritedModelIds, + resolvePersonalModelDeletionPlan, + resolveRemoteModelImportPlan, + type RemoteModelImportPlan, } from "./personal-model-membership.js"; import type { ModelSelection } from "@kcode/shared/model-selection"; import type { ProviderConfigSnapshot, ProviderSource } from "./sources.js"; import { - omitRetiredOfficialProviders, - omitRetiredOfficialTemplates, + omitRetiredOfficialProviders, + omitRetiredOfficialTemplates, } from "./retiredOfficialProviders.js"; export interface ProviderConfigLayerSnapshot { - readonly revision: string; - readonly providers: ProviderConfigMap; - readonly providerTemplates?: ProviderTemplateMap; - readonly models: ModelConfigRules; - readonly providerOrder?: readonly ProviderId[]; - readonly defaultModelSelection?: ModelSelection; + readonly revision: string; + readonly providers: ProviderConfigMap; + readonly providerTemplates?: ProviderTemplateMap; + readonly models: ModelConfigRules; + readonly providerOrder?: readonly ProviderId[]; + readonly defaultModelSelection?: ModelSelection; } export interface ProviderConfigLayerUpdate { - readonly providers: ProviderConfigMap; - readonly providerTemplates?: ProviderTemplateMap; - readonly models: ModelConfigRules; - readonly providerOrder?: readonly ProviderId[]; - readonly defaultModelSelection?: ModelSelection; + readonly providers: ProviderConfigMap; + readonly providerTemplates?: ProviderTemplateMap; + readonly models: ModelConfigRules; + readonly providerOrder?: readonly ProviderId[]; + readonly defaultModelSelection?: ModelSelection; } export interface PersonalProviderConfigRepository extends ProviderSource { - update( - transform: (current: ProviderConfigLayerSnapshot) => ProviderConfigLayerUpdate, - ): Promise; + update( + transform: (current: ProviderConfigLayerSnapshot) => ProviderConfigLayerUpdate, + ): Promise; } export interface ProviderConfigServiceDependencies { - readonly kcodeBuiltinSource: ProviderSource; - readonly personalRepository: PersonalProviderConfigRepository; + readonly kcodeBuiltinSource: ProviderSource; + readonly personalRepository: PersonalProviderConfigRepository; } export interface PersonalProviderCreation { - readonly providerId: ProviderId; + readonly providerId: ProviderId; } export interface CreatePersonalProviderInput { - readonly templateId?: ProviderTemplateId; - readonly providerName?: string; - readonly locale?: ProviderTemplateLocale; - readonly initialConfig?: ProviderConfig; + readonly templateId?: ProviderTemplateId; + readonly providerName?: string; + readonly locale?: ProviderTemplateLocale; + readonly initialConfig?: ProviderConfig; } /** Facade 提供的 Host 内部成员事实;不得接受 Renderer 自报的模型名单。 */ export interface ProviderModelMembership { - readonly providerId: ProviderId; - readonly inheritedModelIds: readonly ModelId[]; - readonly personalRevision: string; - /** 在 Personal 事务内检查发布快照未过期,不触发网络请求。 */ - readonly assertCurrent: () => void; + readonly providerId: ProviderId; + readonly inheritedModelIds: readonly ModelId[]; + readonly personalRevision: string; + /** 在 Personal 事务内检查发布快照未过期,不触发网络请求。 */ + readonly assertCurrent: () => void; } function assertMembershipCurrent( - membership: ProviderModelMembership | undefined, - providerId: ProviderId, - current: ProviderConfigLayerSnapshot, + membership: ProviderModelMembership | undefined, + providerId: ProviderId, + current: ProviderConfigLayerSnapshot, ): void { - if (!membership) return; - membership.assertCurrent(); - if (membership.providerId !== providerId || membership.personalRevision !== current.revision) { - throw new Error("Provider Settings membership revision conflict"); - } + if (!membership) return; + membership.assertCurrent(); + if (membership.providerId !== providerId || membership.personalRevision !== current.revision) { + throw new Error("Provider Settings membership revision conflict"); + } } /** Personal 根记录只是覆盖层,不是 Provider 存在性的依据;仅继承 Provider 可按需创建覆盖。 */ function writableProviderOverlay( - builtin: ProviderConfigLayerSnapshot, - current: ProviderConfigLayerSnapshot, - providerId: ProviderId, + builtin: ProviderConfigLayerSnapshot, + current: ProviderConfigLayerSnapshot, + providerId: ProviderId, ): ProviderConfig { - const provider = current.providers.get(providerId); - if (provider) return provider; - if (builtin.providers.has(providerId)) return new ProviderConfigValue({}); - // 已删除的自定义/模板实例没有继承 Provider 身份,迟到操作不能把它复活。 - throw new Error(`Provider 不存在: ${providerId}`); + const provider = current.providers.get(providerId); + if (provider) return provider; + if (builtin.providers.has(providerId)) return new ProviderConfigValue({}); + // 已删除的自定义/模板实例没有继承 Provider 身份,迟到操作不能把它复活。 + throw new Error(`Provider 不存在: ${providerId}`); } export class ProviderConfigService implements ProviderSource { - readonly #kcodeBuiltinSource: ProviderSource; - readonly #personalRepository: PersonalProviderConfigRepository; - readonly #listeners = new Set<(reason: string) => void>(); - readonly #sourceDisposers: Array<() => void>; - #disposed = false; - - constructor(dependencies: ProviderConfigServiceDependencies) { - this.#kcodeBuiltinSource = dependencies.kcodeBuiltinSource; - this.#personalRepository = dependencies.personalRepository; - this.#sourceDisposers = [ - this.#kcodeBuiltinSource.onDidChange((reason) => this.#emit(`kcodeBuiltin:${reason}`)), - this.#personalRepository.onDidChange((reason) => this.#emit(`personal:${reason}`)), - ]; - } - - async read(): Promise { - this.#assertNotDisposed(); - const [kcodeBuiltin, personal] = await Promise.all([ - this.#kcodeBuiltinSource.read(), - this.#personalRepository.read(), - ]); - return Object.freeze({ - revision: JSON.stringify([kcodeBuiltin.revision, personal.revision]), - kcodeBuiltinRevision: kcodeBuiltin.revision, - personalRevision: personal.revision, - kcodeBuiltinProviders: omitRetiredOfficialProviders(kcodeBuiltin.providers), - kcodeBuiltinProviderTemplates: omitRetiredOfficialTemplates( - kcodeBuiltin.providerTemplates ?? ProviderTemplateMap.empty(), - ), - personalProviders: omitRetiredOfficialProviders(personal.providers), - kcodeBuiltinModelRules: kcodeBuiltin.models, - personalModels: personal.models, - personalProviderOrder: personal.providerOrder ?? [], - }); - } - - onDidChange(listener: (reason: string) => void): () => void { - this.#assertNotDisposed(); - this.#listeners.add(listener); - return () => this.#listeners.delete(listener); - } - - /** 仅供版本迁移或事实源 cutover 原子替换完整 Personal Overlay。 */ - replacePersonalConfig(config: ProviderConfigLayerUpdate): Promise { - this.#assertNotDisposed(); - // 全量导入/分发与字段编辑不同;新信封未带默认选择时必须清除,不能继承接收端旧值。 - return this.#personalRepository.update(() => config); - } - - async savePersonalProviderOverlay( - providerId: ProviderId, - config: ProviderConfig, - membership?: ProviderModelMembership, - metadata?: Pick, - ): Promise { - assertNonEmptyId("providerId", providerId); - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, providerId, current); - const builtin = kcodeBuiltin.providers.get(providerId); - const currentPersonal = current.providers.get(providerId); - const currentEffectiveProviders = kcodeBuiltin.providers.overlay(current.providers); - // 账号总禁用已撤销;在公共写入边界拒绝新操作,避免隐藏 UI 后仍能写出无效状态。 - if (builtin?.access?.type === "zhipu-account" && metadata?.enabled === false) { - throw new Error(`Account Provider 不允许禁用: ${providerId}`); - } - if (builtin?.access?.type === "zhipu-account" && config.access !== undefined) { - // 通用保存入口只解析 ProviderConfig,曾绕过 Personal Source Schema, - // 允许固定 Account Provider 的 access 被写盘,直到下次读取才整份拒绝。 - throw new Error( - `固定 Account Provider 的 Access 只能由 KCode Built-in Config 声明: ${providerId}`, - ); - } - // 普通保存曾同时承担创建语义,删除后的迟到保存可以凭空复活 Overlay。 - // 创建已经是明确的领域操作,普通保存只更新现有配置,不存在即拒绝。 - if (!currentPersonal && !builtin) { - throw new Error(`Personal Provider 尚未创建: ${providerId}`); - } - let normalized = config; - if (builtin) { - if (normalized.group != null && normalized.group !== builtin.group) { - throw new Error(`Personal Overlay 不能改写 Built-in Provider group: ${providerId}`); + readonly #kcodeBuiltinSource: ProviderSource; + readonly #personalRepository: PersonalProviderConfigRepository; + readonly #listeners = new Set<(reason: string) => void>(); + readonly #sourceDisposers: Array<() => void>; + #disposed = false; + + constructor(dependencies: ProviderConfigServiceDependencies) { + this.#kcodeBuiltinSource = dependencies.kcodeBuiltinSource; + this.#personalRepository = dependencies.personalRepository; + this.#sourceDisposers = [ + this.#kcodeBuiltinSource.onDidChange((reason) => this.#emit(`kcodeBuiltin:${reason}`)), + this.#personalRepository.onDidChange((reason) => this.#emit(`personal:${reason}`)), + ]; + } + + async read(): Promise { + this.#assertNotDisposed(); + const [kcodeBuiltin, personal] = await Promise.all([ + this.#kcodeBuiltinSource.read(), + this.#personalRepository.read(), + ]); + return Object.freeze({ + revision: JSON.stringify([kcodeBuiltin.revision, personal.revision]), + kcodeBuiltinRevision: kcodeBuiltin.revision, + personalRevision: personal.revision, + kcodeBuiltinProviders: omitRetiredOfficialProviders(kcodeBuiltin.providers), + kcodeBuiltinProviderTemplates: omitRetiredOfficialTemplates( + kcodeBuiltin.providerTemplates ?? ProviderTemplateMap.empty(), + ), + personalProviders: omitRetiredOfficialProviders(personal.providers), + kcodeBuiltinModelRules: kcodeBuiltin.models, + personalModels: personal.models, + personalProviderOrder: personal.providerOrder ?? [], + }); + } + + onDidChange(listener: (reason: string) => void): () => void { + this.#assertNotDisposed(); + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + /** 仅供版本迁移或事实源 cutover 原子替换完整 Personal Overlay。 */ + replacePersonalConfig(config: ProviderConfigLayerUpdate): Promise { + this.#assertNotDisposed(); + // 全量导入/分发与字段编辑不同;新信封未带默认选择时必须清除,不能继承接收端旧值。 + return this.#personalRepository.update(() => config); + } + + async savePersonalProviderOverlay( + providerId: ProviderId, + config: ProviderConfig, + membership?: ProviderModelMembership, + metadata?: Pick, + ): Promise { + assertNonEmptyId("providerId", providerId); + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, providerId, current); + const builtin = kcodeBuiltin.providers.get(providerId); + const currentPersonal = current.providers.get(providerId); + const currentEffectiveProviders = kcodeBuiltin.providers.overlay(current.providers); + // 账号总禁用已撤销;在公共写入边界拒绝新操作,避免隐藏 UI 后仍能写出无效状态。 + if (builtin?.access?.type === "zhipu-account" && metadata?.enabled === false) { + throw new Error(`Account Provider 不允许禁用: ${providerId}`); + } + if (builtin?.access?.type === "zhipu-account" && config.access !== undefined) { + // 通用保存入口只解析 ProviderConfig,曾绕过 Personal Source Schema, + // 允许固定 Account Provider 的 access 被写盘,直到下次读取才整份拒绝。 + throw new Error( + `固定 Account Provider 的 Access 只能由 KCode Built-in Config 声明: ${providerId}`, + ); + } + // 普通保存曾同时承担创建语义,删除后的迟到保存可以凭空复活 Overlay。 + // 创建已经是明确的领域操作,普通保存只更新现有配置,不存在即拒绝。 + if (!currentPersonal && !builtin) { + throw new Error(`Personal Provider 尚未创建: ${providerId}`); + } + let normalized = config; + if (builtin) { + if (normalized.group != null && normalized.group !== builtin.group) { + throw new Error( + `Personal Overlay 不能改写 Built-in Provider group: ${providerId}`, + ); + } + normalized = normalized.withoutGroup(); + } else { + const group = normalized.group ?? currentPersonal?.group; + if (group !== "standard-personal") { + throw new Error( + `Personal-only Provider 必须使用 standard-personal group: ${providerId}`, + ); + } + normalized = normalized.overlay(new ProviderConfigValue({ group })); + } + const membershipBaseline = + builtin ?? resolveTemplateBaseline(kcodeBuiltin, current.providers, providerId); + const next = normalized.withModelMembershipFrom( + // 普通 Provider 保存也保留动态成员顺序;不能改名称时又按静态名单删掉已保存的调序。 + normalizePersonalProviderMembership( + currentPersonal, + membershipBaseline, + membership?.inheritedModelIds, + ), + ); + const currentRule = current.providers.getRule(providerId); + const providers = current.providers.setRule({ + ...currentRule, + providerId, + ...(metadata?.templateId === undefined ? {} : { templateId: metadata.templateId }), + ...(metadata?.enabled === undefined ? {} : { enabled: metadata.enabled }), + ...(metadata?.providerName === undefined + ? {} + : { providerName: metadata.providerName?.trim() || null }), + config: next, + }); + const nextEffectiveProviders = kcodeBuiltin.providers.overlay(providers); + // 旧版本可能已经留下重名 Provider。全量校验会让这些历史问题阻断 + // 任意无关 Provider 的保存;这里仅禁止本次名称变更新引入重名。 + assertProviderLabelMutationIsUnique( + providerId, + currentEffectiveProviders, + nextEffectiveProviders, + ); + return { + providers, + models: current.models, + providerOrder: current.providerOrder, + }; + }); + } + + async createPersonalProvider( + input: CreatePersonalProviderInput = {}, + ): Promise { + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + const templateId = input.templateId?.trim(); + const template = templateId ? kcodeBuiltin.providerTemplates?.get(templateId) : undefined; + if (templateId && !template) throw new Error(`Provider Template 不存在: ${templateId}`); + if (input.initialConfig?.group !== undefined) { + throw new Error("initialConfig 不能包含 group"); } - normalized = normalized.withoutGroup(); - } else { - const group = normalized.group ?? currentPersonal?.group; - if (group !== "standard-personal") { - throw new Error(`Personal-only Provider 必须使用 standard-personal group: ${providerId}`); + if (input.initialConfig?.builtinModelIds !== undefined) { + throw new Error("initialConfig 不能包含 builtinModelIds"); } - normalized = normalized.overlay(new ProviderConfigValue({ group })); - } - const membershipBaseline = - builtin ?? resolveTemplateBaseline(kcodeBuiltin, current.providers, providerId); - const next = normalized.withModelMembershipFrom( - // 普通 Provider 保存也保留动态成员顺序;不能改名称时又按静态名单删掉已保存的调序。 - normalizePersonalProviderMembership( - currentPersonal, - membershipBaseline, - membership?.inheritedModelIds, - ), - ); - const currentRule = current.providers.getRule(providerId); - const providers = current.providers.setRule({ - ...currentRule, - providerId, - ...(metadata?.templateId === undefined ? {} : { templateId: metadata.templateId }), - ...(metadata?.enabled === undefined ? {} : { enabled: metadata.enabled }), - ...(metadata?.providerName === undefined - ? {} - : { providerName: metadata.providerName?.trim() || null }), - config: next, - }); - const nextEffectiveProviders = kcodeBuiltin.providers.overlay(providers); - // 旧版本可能已经留下重名 Provider。全量校验会让这些历史问题阻断 - // 任意无关 Provider 的保存;这里仅禁止本次名称变更新引入重名。 - assertProviderLabelMutationIsUnique( - providerId, - currentEffectiveProviders, - nextEffectiveProviders, - ); - return { - providers, - models: current.models, - providerOrder: current.providerOrder, - }; - }); - } - - async createPersonalProvider( - input: CreatePersonalProviderInput = {}, - ): Promise { - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - const templateId = input.templateId?.trim(); - const template = templateId ? kcodeBuiltin.providerTemplates?.get(templateId) : undefined; - if (templateId && !template) throw new Error(`Provider Template 不存在: ${templateId}`); - if (input.initialConfig?.group !== undefined) { - throw new Error("initialConfig 不能包含 group"); + let createdProviderId: ProviderId | undefined; + await this.#updatePersonal((current) => { + const occupied = new Set([ + ...kcodeBuiltin.providers.keys(), + ...current.providers.keys(), + ]); + const providerId = nextPersonalProviderId(occupied, templateId); + createdProviderId = providerId; + const effectiveProviders = resolvePersonalProviderBaselines( + kcodeBuiltin, + current.providers, + ); + const label = nextPersonalProviderLabel( + input.providerName ?? + (template && templateId + ? resolveProviderTemplateName(templateId, template, input.locale ?? "en-US") + : "new-provider"), + effectiveProviders, + ); + const initial = input.initialConfig ?? new ProviderConfigValue(); + const providers = current.providers.setRule({ + providerId, + ...(templateId ? { templateId } : {}), + providerName: label, + config: new ProviderConfigValue({ + group: "standard-personal", + access: templateId ? undefined : new ApiKeyAccessConfig(), + personalModelIds: [], + modelOrder: [], + }).overlay(initial), + }); + return { + providers, + models: current.models, + providerOrder: appendCurrentProviderOrder( + kcodeBuiltin.providers, + providers, + current.providerOrder, + providerId, + ), + }; + }); + if (!createdProviderId) throw new Error("Personal Provider 创建失败"); + return Object.freeze({ providerId: createdProviderId }); } - if (input.initialConfig?.builtinModelIds !== undefined) { - throw new Error("initialConfig 不能包含 builtinModelIds"); + + deletePersonalProvider(providerId: ProviderId): Promise { + assertNonEmptyId("providerId", providerId); + return this.#updatePersonal((current) => ({ + providers: current.providers.delete(providerId), + models: current.models.deleteExactForProvider(providerId), + providerOrder: current.providerOrder?.filter((candidate) => candidate !== providerId), + })); } - let createdProviderId: ProviderId | undefined; - await this.#updatePersonal((current) => { - const occupied = new Set([...kcodeBuiltin.providers.keys(), ...current.providers.keys()]); - const providerId = nextPersonalProviderId(occupied, templateId); - createdProviderId = providerId; - const effectiveProviders = resolvePersonalProviderBaselines(kcodeBuiltin, current.providers); - const label = nextPersonalProviderLabel( - input.providerName ?? - (template && templateId - ? resolveProviderTemplateName(templateId, template, input.locale ?? "en-US") - : "new-provider"), - effectiveProviders, - ); - const initial = input.initialConfig ?? new ProviderConfigValue(); - const providers = current.providers.setRule({ - providerId, - ...(templateId ? { templateId } : {}), - providerName: label, - config: new ProviderConfigValue({ - group: "standard-personal", - access: templateId ? undefined : new ApiKeyAccessConfig(), - personalModelIds: [], - modelOrder: [], - }).overlay(initial), - }); - return { - providers, - models: current.models, - providerOrder: appendCurrentProviderOrder( - kcodeBuiltin.providers, - providers, - current.providerOrder, - providerId, - ), - }; - }); - if (!createdProviderId) throw new Error("Personal Provider 创建失败"); - return Object.freeze({ providerId: createdProviderId }); - } - - deletePersonalProvider(providerId: ProviderId): Promise { - assertNonEmptyId("providerId", providerId); - return this.#updatePersonal((current) => ({ - providers: current.providers.delete(providerId), - models: current.models.deleteExactForProvider(providerId), - providerOrder: current.providerOrder?.filter((candidate) => candidate !== providerId), - })); - } - - async reorderPersonalProviders( - providerIds: readonly ProviderId[], - ): Promise { - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => ({ - providers: current.providers, - models: current.models, - providerOrder: normalizeProviderOrder(kcodeBuiltin.providers, current.providers, providerIds), - })); - } - - async reorderPersonalModels( - providerId: ProviderId, - modelIds: readonly ModelId[], - membership?: ProviderModelMembership, - ): Promise { - assertNonEmptyId("providerId", providerId); - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, providerId, current); - const builtinProvider = - kcodeBuiltin.providers.get(providerId) ?? - resolveTemplateBaseline(kcodeBuiltin, current.providers, providerId); - const provider = writableProviderOverlay(kcodeBuiltin, current, providerId); - const modelOrder = normalizeModelOrder( - membership?.inheritedModelIds ?? builtinProvider?.builtinModelIds ?? [], - provider.personalModelIds ?? [], - modelIds, - ); - return { - providers: current.providers.set(providerId, provider.withModelOrder(modelOrder)), - models: current.models, - providerOrder: current.providerOrder, - }; - }); - } - async addPersonalModel( - providerId: ProviderId, - modelId: ModelId, - config: ModelConfig, - membership?: ProviderModelMembership, - useRecommendedConfig?: boolean, - ): Promise { - const normalizedProviderId = normalizeId("providerId", providerId); - const normalizedModelId = normalizeId("modelId", modelId); - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, normalizedProviderId, current); - const provider = writableProviderOverlay(kcodeBuiltin, current, normalizedProviderId); - const builtinModelIds = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds(kcodeBuiltin, current.providers, normalizedProviderId); - if (builtinModelIds.includes(normalizedModelId)) { - throw new Error(`Model 已存在: ${normalizedProviderId}/${normalizedModelId}`); - } - const currentModelIds = provider.personalModelIds ?? []; - if (currentModelIds.includes(normalizedModelId)) { - throw new Error(`Model 已存在: ${normalizedProviderId}/${normalizedModelId}`); - } - return { - providers: current.providers.set( - normalizedProviderId, - provider.withPersonalModelIds([...currentModelIds, normalizedModelId]).withModelOrder( - // 添加不能重新按成员名单排序,否则会丢掉用户已经保存的顺序。 - normalizeModelOrder( - builtinModelIds, - [...currentModelIds, normalizedModelId], - provider.modelOrder ?? [], + async reorderPersonalProviders( + providerIds: readonly ProviderId[], + ): Promise { + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => ({ + providers: current.providers, + models: current.models, + providerOrder: normalizeProviderOrder( + kcodeBuiltin.providers, + current.providers, + providerIds, ), - ), - ), - models: current.models.setExact( - normalizedProviderId, - normalizedModelId, - config.overlay(new ModelConfig({ enabled: true })), - useRecommendedConfig, - ), - providerOrder: current.providerOrder, - }; - }); - } + })); + } - async renamePersonalModel( - providerId: ProviderId, - currentModelId: ModelId, - nextModelId: ModelId, - membership?: ProviderModelMembership, - ): Promise { - const normalizedProviderId = normalizeId("providerId", providerId); - const currentId = normalizeId("modelId", currentModelId); - const nextId = normalizeId("modelId", nextModelId); - if (currentId === nextId) return this.#personalRepository.read(); - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, normalizedProviderId, current); - const provider = current.providers.get(normalizedProviderId); - const builtinModelIds = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds(kcodeBuiltin, current.providers, normalizedProviderId); - // 继承归属保护适用于 Facade 和底层直接调用,不能只在有动态上下文时检查。 - if (builtinModelIds.includes(currentId)) - throw new Error(`Built-in Model 不能重命名: ${normalizedProviderId}/${currentId}`); - if (!provider?.personalModelIds?.includes(currentId)) { - throw new Error(`Personal Model 不存在: ${normalizedProviderId}/${currentId}`); - } - if (provider.personalModelIds.includes(nextId)) { - throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); - } - if (builtinModelIds.includes(nextId)) { - throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); - } - const modelIds = provider.personalModelIds.map((modelId) => - modelId === currentId ? nextId : modelId, - ); - const requestedOrder = (provider.modelOrder ?? []).map((modelId) => - modelId === currentId ? nextId : modelId, - ); - return { - providers: current.providers.set( - normalizedProviderId, - provider - .withPersonalModelIds(modelIds) - .withModelOrder(normalizeModelOrder(builtinModelIds, modelIds, requestedOrder)), - ), - models: current.models.renameExactModel(normalizedProviderId, currentId, nextId), - providerOrder: current.providerOrder, - }; - }); - } + async reorderPersonalModels( + providerId: ProviderId, + modelIds: readonly ModelId[], + membership?: ProviderModelMembership, + ): Promise { + assertNonEmptyId("providerId", providerId); + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, providerId, current); + const builtinProvider = + kcodeBuiltin.providers.get(providerId) ?? + resolveTemplateBaseline(kcodeBuiltin, current.providers, providerId); + const provider = writableProviderOverlay(kcodeBuiltin, current, providerId); + const modelOrder = normalizeModelOrder( + membership?.inheritedModelIds ?? builtinProvider?.builtinModelIds ?? [], + provider.personalModelIds ?? [], + modelIds, + ); + return { + providers: current.providers.set(providerId, provider.withModelOrder(modelOrder)), + models: current.models, + providerOrder: current.providerOrder, + }; + }); + } - async setPersonalModelEnabled( - providerId: ProviderId, - modelId: ModelId, - enabled: boolean, - membership?: ProviderModelMembership, - ): Promise { - const id = normalizeId("providerId", providerId); - const model = normalizeId("modelId", modelId); - if (typeof enabled !== "boolean") throw new Error("Model enabled 必须是 boolean"); - const builtin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, id, current); - const provider = current.providers.get(id); - const inherited = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds(builtin, current.providers, id); - if (!inherited.includes(model) && !provider?.personalModelIds?.includes(model)) { - throw new Error(`Model 不存在: ${id}/${model}`); - } - // 启停曾复用完整草稿保存,可能覆盖其他编辑或被固定配置完整性阻挡。 - // 在事务内只修改最新 enabled;不改变模式、成员和其他模型字段。 - const config = (current.models.getExact(id, model) ?? new ModelConfig({})).overlay( - new ModelConfig({ enabled }), - ); - return { - providers: current.providers, - models: current.models.setExact(id, model, config), - providerOrder: current.providerOrder, - }; - }); - } + async addPersonalModel( + providerId: ProviderId, + modelId: ModelId, + config: ModelConfig, + membership?: ProviderModelMembership, + useRecommendedConfig?: boolean, + ): Promise { + const normalizedProviderId = normalizeId("providerId", providerId); + const normalizedModelId = normalizeId("modelId", modelId); + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, normalizedProviderId, current); + const provider = writableProviderOverlay(kcodeBuiltin, current, normalizedProviderId); + const builtinModelIds = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds( + kcodeBuiltin, + current.providers, + normalizedProviderId, + ); + if (builtinModelIds.includes(normalizedModelId)) { + throw new Error(`Model 已存在: ${normalizedProviderId}/${normalizedModelId}`); + } + const currentModelIds = provider.personalModelIds ?? []; + if (currentModelIds.includes(normalizedModelId)) { + throw new Error(`Model 已存在: ${normalizedProviderId}/${normalizedModelId}`); + } + return { + providers: current.providers.set( + normalizedProviderId, + provider + .withPersonalModelIds([...currentModelIds, normalizedModelId]) + .withModelOrder( + // 添加不能重新按成员名单排序,否则会丢掉用户已经保存的顺序。 + normalizeModelOrder( + builtinModelIds, + [...currentModelIds, normalizedModelId], + provider.modelOrder ?? [], + ), + ), + ), + models: current.models.setExact( + normalizedProviderId, + normalizedModelId, + config.overlay(new ModelConfig({ enabled: true })), + useRecommendedConfig, + ), + providerOrder: current.providerOrder, + }; + }); + } - /** Model 编辑弹窗的唯一写入边界:成员、顺序、精确 Rule 在同一次 Repository update 中提交。 */ - async savePersonalModelDraft( - providerId: ProviderId, - originalModelId: ModelId, - nextModelId: ModelId, - config: ModelConfig, - expectedPersonalRevision: string, - useRecommendedConfig?: boolean, - membership?: ProviderModelMembership, - ): Promise { - const normalizedProviderId = normalizeId("providerId", providerId); - const originalId = normalizeId("modelId", originalModelId); - const nextId = normalizeId("modelId", nextModelId); - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, normalizedProviderId, current); - if (current.revision !== expectedPersonalRevision) { - throw new Error( - `Personal Provider Config revision conflict: expected ${expectedPersonalRevision}, current ${current.revision}`, - ); - } - const recommended = - useRecommendedConfig ?? - current.models.getExactRule(normalizedProviderId, originalId)?.type !== - "manual-provider-model"; - const provider = current.providers.get(normalizedProviderId); - const builtinModelIds = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds(kcodeBuiltin, current.providers, normalizedProviderId); - const builtinSet = new Set(builtinModelIds); - if (originalId !== nextId && builtinSet.has(originalId)) { - throw new Error(`Built-in Model 不能重命名: ${normalizedProviderId}/${originalId}`); - } - if (originalId !== nextId && builtinSet.has(nextId)) { - throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); - } - const personalModelIds = provider?.personalModelIds ?? []; - const originalExists = builtinSet.has(originalId) || personalModelIds.includes(originalId); - if (!originalExists) { - throw new Error(`Model 不存在: ${normalizedProviderId}/${originalId}`); - } - if (originalId !== nextId && (personalModelIds.includes(nextId) || builtinSet.has(nextId))) { - throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); - } - - let providers = current.providers; - let models = current.models; - if (originalId !== nextId) { - if (!provider?.personalModelIds?.includes(originalId)) { - throw new Error(`Personal Model 不存在: ${normalizedProviderId}/${originalId}`); - } - const modelIds = provider.personalModelIds.map((modelId) => - modelId === originalId ? nextId : modelId, - ); - const requestedOrder = (provider.modelOrder ?? []).map((modelId) => - modelId === originalId ? nextId : modelId, - ); - providers = providers.set( - normalizedProviderId, - provider - .withPersonalModelIds(modelIds) - .withModelOrder(normalizeModelOrder(builtinModelIds, modelIds, requestedOrder)), - ); - models = models.renameExactModel(normalizedProviderId, originalId, nextId); - } - models = - recommended && isStructurallyEmpty(config.toJSON()) - ? models.deleteExact(normalizedProviderId, nextId) - : models.setExact(normalizedProviderId, nextId, config, recommended); - return { providers, models, providerOrder: current.providerOrder }; - }); - } + async renamePersonalModel( + providerId: ProviderId, + currentModelId: ModelId, + nextModelId: ModelId, + membership?: ProviderModelMembership, + ): Promise { + const normalizedProviderId = normalizeId("providerId", providerId); + const currentId = normalizeId("modelId", currentModelId); + const nextId = normalizeId("modelId", nextModelId); + if (currentId === nextId) return this.#personalRepository.read(); + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, normalizedProviderId, current); + const provider = current.providers.get(normalizedProviderId); + const builtinModelIds = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds( + kcodeBuiltin, + current.providers, + normalizedProviderId, + ); + // 继承归属保护适用于 Facade 和底层直接调用,不能只在有动态上下文时检查。 + if (builtinModelIds.includes(currentId)) + throw new Error(`Built-in Model 不能重命名: ${normalizedProviderId}/${currentId}`); + if (!provider?.personalModelIds?.includes(currentId)) { + throw new Error(`Personal Model 不存在: ${normalizedProviderId}/${currentId}`); + } + if (provider.personalModelIds.includes(nextId)) { + throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); + } + if (builtinModelIds.includes(nextId)) { + throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); + } + const modelIds = provider.personalModelIds.map((modelId) => + modelId === currentId ? nextId : modelId, + ); + const requestedOrder = (provider.modelOrder ?? []).map((modelId) => + modelId === currentId ? nextId : modelId, + ); + return { + providers: current.providers.set( + normalizedProviderId, + provider + .withPersonalModelIds(modelIds) + .withModelOrder( + normalizeModelOrder(builtinModelIds, modelIds, requestedOrder), + ), + ), + models: current.models.renameExactModel(normalizedProviderId, currentId, nextId), + providerOrder: current.providerOrder, + }; + }); + } - async deletePersonalModel( - providerId: ProviderId, - modelId: ModelId, - membership?: ProviderModelMembership, - ): Promise { - const normalizedProviderId = normalizeId("providerId", providerId); - const normalizedModelId = normalizeId("modelId", modelId); - const builtin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, normalizedProviderId, current); - const provider = writableProviderOverlay(builtin, current, normalizedProviderId); - const inherited = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds(builtin, current.providers, normalizedProviderId); - const plan = resolvePersonalModelDeletionPlan({ - modelId: normalizedModelId, - inheritedModelIds: inherited, - personalModelIds: provider.personalModelIds ?? [], - hiddenInheritedModelIds: provider.hiddenInheritedModelIds, - }); - if (plan.kind === "inherited") { - return { - providers: current.providers.set( - normalizedProviderId, - provider.withHiddenInheritedModelIds(plan.nextHiddenInheritedModelIds), - ), - models: current.models, - providerOrder: current.providerOrder, - }; - } - return { - providers: current.providers.set( - normalizedProviderId, - provider - .withPersonalModelIds(plan.nextPersonalModelIds) - .withModelOrder( - normalizeModelOrder(inherited, plan.nextPersonalModelIds, provider.modelOrder ?? []), - ), - ), - models: current.models.deleteExact(normalizedProviderId, normalizedModelId), - providerOrder: current.providerOrder, - }; - }); - } + async setPersonalModelEnabled( + providerId: ProviderId, + modelId: ModelId, + enabled: boolean, + membership?: ProviderModelMembership, + ): Promise { + const id = normalizeId("providerId", providerId); + const model = normalizeId("modelId", modelId); + if (typeof enabled !== "boolean") throw new Error("Model enabled 必须是 boolean"); + const builtin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, id, current); + const provider = current.providers.get(id); + const inherited = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds(builtin, current.providers, id); + if (!inherited.includes(model) && !provider?.personalModelIds?.includes(model)) { + throw new Error(`Model 不存在: ${id}/${model}`); + } + // 启停曾复用完整草稿保存,可能覆盖其他编辑或被固定配置完整性阻挡。 + // 在事务内只修改最新 enabled;不改变模式、成员和其他模型字段。 + const config = (current.models.getExact(id, model) ?? new ModelConfig({})).overlay( + new ModelConfig({ enabled }), + ); + return { + providers: current.providers, + models: current.models.setExact(id, model, config), + providerOrder: current.providerOrder, + }; + }); + } - async importRemoteModels( - providerId: ProviderId, - remoteModelIds: readonly ModelId[], - membership?: ProviderModelMembership, - ): Promise { - const normalizedProviderId = normalizeId("providerId", providerId); - const builtin = await this.#kcodeBuiltinSource.read(); - let resolvedPlan: RemoteModelImportPlan | undefined; - await this.#updatePersonal((current) => { - assertMembershipCurrent(membership, normalizedProviderId, current); - const provider = writableProviderOverlay(builtin, current, normalizedProviderId); - const inherited = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds(builtin, current.providers, normalizedProviderId); - const plan = resolveRemoteModelImportPlan({ - remoteModelIds, - inheritedModelIds: inherited, - personalModelIds: provider.personalModelIds ?? [], - hiddenInheritedModelIds: provider.hiddenInheritedModelIds, - }); - resolvedPlan = plan; - let models = current.models; - for (const modelId of plan.addedModelIds) { - models = models.setExact( - normalizedProviderId, - modelId, - new ModelConfig({ enabled: true }), - true, - ); - } - return { - providers: current.providers.set( - normalizedProviderId, - provider - .withPersonalModelIds(plan.nextPersonalModelIds) - .withHiddenInheritedModelIds(plan.nextHiddenInheritedModelIds) - .withModelOrder( - normalizeModelOrder( - inherited, - plan.nextPersonalModelIds, - [ - ...(provider.modelOrder ?? []), - ...plan.addedModelIds, - ...plan.restoredInheritedModelIds, - ], - ), - ), - ), - models, - providerOrder: current.providerOrder, - }; - }); - if (!resolvedPlan) throw new Error(`导入远端模型失败: ${normalizedProviderId}`); - return resolvedPlan; - } + /** Model 编辑弹窗的唯一写入边界:成员、顺序、精确 Rule 在同一次 Repository update 中提交。 */ + async savePersonalModelDraft( + providerId: ProviderId, + originalModelId: ModelId, + nextModelId: ModelId, + config: ModelConfig, + expectedPersonalRevision: string, + useRecommendedConfig?: boolean, + membership?: ProviderModelMembership, + ): Promise { + const normalizedProviderId = normalizeId("providerId", providerId); + const originalId = normalizeId("modelId", originalModelId); + const nextId = normalizeId("modelId", nextModelId); + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, normalizedProviderId, current); + if (current.revision !== expectedPersonalRevision) { + throw new Error( + `Personal Provider Config revision conflict: expected ${expectedPersonalRevision}, current ${current.revision}`, + ); + } + const recommended = + useRecommendedConfig ?? + current.models.getExactRule(normalizedProviderId, originalId)?.type !== + "manual-provider-model"; + const provider = current.providers.get(normalizedProviderId); + const builtinModelIds = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds( + kcodeBuiltin, + current.providers, + normalizedProviderId, + ); + const builtinSet = new Set(builtinModelIds); + if (originalId !== nextId && builtinSet.has(originalId)) { + throw new Error(`Built-in Model 不能重命名: ${normalizedProviderId}/${originalId}`); + } + if (originalId !== nextId && builtinSet.has(nextId)) { + throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); + } + const personalModelIds = provider?.personalModelIds ?? []; + const originalExists = + builtinSet.has(originalId) || personalModelIds.includes(originalId); + if (!originalExists) { + throw new Error(`Model 不存在: ${normalizedProviderId}/${originalId}`); + } + if ( + originalId !== nextId && + (personalModelIds.includes(nextId) || builtinSet.has(nextId)) + ) { + throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); + } + + let providers = current.providers; + let models = current.models; + if (originalId !== nextId) { + if (!provider?.personalModelIds?.includes(originalId)) { + throw new Error(`Personal Model 不存在: ${normalizedProviderId}/${originalId}`); + } + const modelIds = provider.personalModelIds.map((modelId) => + modelId === originalId ? nextId : modelId, + ); + const requestedOrder = (provider.modelOrder ?? []).map((modelId) => + modelId === originalId ? nextId : modelId, + ); + providers = providers.set( + normalizedProviderId, + provider + .withPersonalModelIds(modelIds) + .withModelOrder( + normalizeModelOrder(builtinModelIds, modelIds, requestedOrder), + ), + ); + models = models.renameExactModel(normalizedProviderId, originalId, nextId); + } + models = + recommended && isStructurallyEmpty(config.toJSON()) + ? models.deleteExact(normalizedProviderId, nextId) + : models.setExact(normalizedProviderId, nextId, config, recommended); + return { providers, models, providerOrder: current.providerOrder }; + }); + } - async clearVisibleModels( - providerId: ProviderId, - membership?: ProviderModelMembership, - ): Promise { - const normalizedProviderId = normalizeId("providerId", providerId); - const builtin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, normalizedProviderId, current); - const provider = writableProviderOverlay(builtin, current, normalizedProviderId); - const inherited = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds(builtin, current.providers, normalizedProviderId); - let models = current.models; - for (const modelId of provider.personalModelIds ?? []) { - models = models.deleteExact(normalizedProviderId, modelId); - } - return { - providers: current.providers.set( - normalizedProviderId, - provider - .withPersonalModelIds([]) - .withHiddenInheritedModelIds( - normalizeHiddenInheritedModelIds(inherited, inherited), - ) - .withModelOrder([]), - ), - models, - providerOrder: current.providerOrder, - }; - }); - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const dispose of this.#sourceDisposers.splice(0)) dispose(); - this.#listeners.clear(); - } - - #updatePersonal( - transform: (current: ProviderConfigLayerSnapshot) => ProviderConfigLayerUpdate, - ): Promise { - this.#assertNotDisposed(); - return this.#personalRepository.update((current) => ({ - // Provider/Model/排序只修改自己的成员,不能因共用文件清掉默认选择。 - defaultModelSelection: current.defaultModelSelection, - ...transform(current), - })); - } - - #emit(reason: string): void { - if (this.#disposed) return; - for (const listener of this.#listeners) listener(reason || "changed"); - } - - #assertNotDisposed(): void { - if (this.#disposed) throw new Error("ProviderConfigService 已 dispose"); - } + async deletePersonalModel( + providerId: ProviderId, + modelId: ModelId, + membership?: ProviderModelMembership, + ): Promise { + const normalizedProviderId = normalizeId("providerId", providerId); + const normalizedModelId = normalizeId("modelId", modelId); + const builtin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, normalizedProviderId, current); + const provider = writableProviderOverlay(builtin, current, normalizedProviderId); + const inherited = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds(builtin, current.providers, normalizedProviderId); + const plan = resolvePersonalModelDeletionPlan({ + modelId: normalizedModelId, + inheritedModelIds: inherited, + personalModelIds: provider.personalModelIds ?? [], + hiddenInheritedModelIds: provider.hiddenInheritedModelIds, + }); + if (plan.kind === "inherited") { + return { + providers: current.providers.set( + normalizedProviderId, + provider.withHiddenInheritedModelIds(plan.nextHiddenInheritedModelIds), + ), + models: current.models, + providerOrder: current.providerOrder, + }; + } + return { + providers: current.providers.set( + normalizedProviderId, + provider + .withPersonalModelIds(plan.nextPersonalModelIds) + .withModelOrder( + normalizeModelOrder( + inherited, + plan.nextPersonalModelIds, + provider.modelOrder ?? [], + ), + ), + ), + models: current.models.deleteExact(normalizedProviderId, normalizedModelId), + providerOrder: current.providerOrder, + }; + }); + } + + async importRemoteModels( + providerId: ProviderId, + remoteModelIds: readonly ModelId[], + membership?: ProviderModelMembership, + modelConfigs?: Readonly>, + ): Promise { + const normalizedProviderId = normalizeId("providerId", providerId); + const builtin = await this.#kcodeBuiltinSource.read(); + let resolvedPlan: RemoteModelImportPlan | undefined; + await this.#updatePersonal((current) => { + assertMembershipCurrent(membership, normalizedProviderId, current); + const provider = writableProviderOverlay(builtin, current, normalizedProviderId); + const inherited = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds(builtin, current.providers, normalizedProviderId); + const plan = resolveRemoteModelImportPlan({ + remoteModelIds, + inheritedModelIds: inherited, + personalModelIds: provider.personalModelIds ?? [], + hiddenInheritedModelIds: provider.hiddenInheritedModelIds, + }); + resolvedPlan = plan; + let models = current.models; + for (const modelId of plan.addedModelIds) { + const provided = modelConfigs?.[modelId]; + models = models.setExact( + normalizedProviderId, + modelId, + provided + ? new ModelConfig({ enabled: true }).overlay(provided) + : new ModelConfig({ enabled: true }), + true, + ); + } + return { + providers: current.providers.set( + normalizedProviderId, + provider + .withPersonalModelIds(plan.nextPersonalModelIds) + .withHiddenInheritedModelIds(plan.nextHiddenInheritedModelIds) + .withModelOrder( + normalizeModelOrder(inherited, plan.nextPersonalModelIds, [ + ...(provider.modelOrder ?? []), + ...plan.addedModelIds, + ...plan.restoredInheritedModelIds, + ]), + ), + ), + models, + providerOrder: current.providerOrder, + }; + }); + if (!resolvedPlan) throw new Error(`导入远端模型失败: ${normalizedProviderId}`); + return resolvedPlan; + } + + async clearVisibleModels( + providerId: ProviderId, + membership?: ProviderModelMembership, + ): Promise { + const normalizedProviderId = normalizeId("providerId", providerId); + const builtin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, normalizedProviderId, current); + const provider = writableProviderOverlay(builtin, current, normalizedProviderId); + const inherited = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds(builtin, current.providers, normalizedProviderId); + let models = current.models; + for (const modelId of provider.personalModelIds ?? []) { + models = models.deleteExact(normalizedProviderId, modelId); + } + return { + providers: current.providers.set( + normalizedProviderId, + provider + .withPersonalModelIds([]) + .withHiddenInheritedModelIds( + normalizeHiddenInheritedModelIds(inherited, inherited), + ) + .withModelOrder([]), + ), + models, + providerOrder: current.providerOrder, + }; + }); + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + for (const dispose of this.#sourceDisposers.splice(0)) dispose(); + this.#listeners.clear(); + } + + #updatePersonal( + transform: (current: ProviderConfigLayerSnapshot) => ProviderConfigLayerUpdate, + ): Promise { + this.#assertNotDisposed(); + return this.#personalRepository.update((current) => ({ + // Provider/Model/排序只修改自己的成员,不能因共用文件清掉默认选择。 + defaultModelSelection: current.defaultModelSelection, + ...transform(current), + })); + } + + #emit(reason: string): void { + if (this.#disposed) return; + for (const listener of this.#listeners) listener(reason || "changed"); + } + + #assertNotDisposed(): void { + if (this.#disposed) throw new Error("ProviderConfigService 已 dispose"); + } } function isStructurallyEmpty(value: unknown): boolean { - if (value === undefined) return true; - if (value === null || typeof value !== "object") return false; - if (Array.isArray(value)) return value.length === 0; - return Object.values(value).every(isStructurallyEmpty); + if (value === undefined) return true; + if (value === null || typeof value !== "object") return false; + if (Array.isArray(value)) return value.length === 0; + return Object.values(value).every(isStructurallyEmpty); } function assertProviderLabelMutationIsUnique( - providerId: ProviderId, - currentProviders: ProviderConfigMap, - nextProviders: ProviderConfigMap, + providerId: ProviderId, + currentProviders: ProviderConfigMap, + nextProviders: ProviderConfigMap, ): void { - const currentLabel = currentProviders.getRule(providerId)?.providerName?.trim(); - const nextLabel = nextProviders.getRule(providerId)?.providerName?.trim(); - const currentKey = currentLabel?.toLocaleLowerCase(); - const nextKey = nextLabel?.toLocaleLowerCase(); - if (!nextKey || nextKey === currentKey) return; - for (const candidate of nextProviders.rules()) { - const candidateId = candidate.providerId; - if (candidateId === providerId) continue; - if (candidate.providerName?.trim().toLocaleLowerCase() === nextKey) { - throw new Error(`Provider 名称已存在: ${nextLabel}`); + const currentLabel = currentProviders.getRule(providerId)?.providerName?.trim(); + const nextLabel = nextProviders.getRule(providerId)?.providerName?.trim(); + const currentKey = currentLabel?.toLocaleLowerCase(); + const nextKey = nextLabel?.toLocaleLowerCase(); + if (!nextKey || nextKey === currentKey) return; + for (const candidate of nextProviders.rules()) { + const candidateId = candidate.providerId; + if (candidateId === providerId) continue; + if (candidate.providerName?.trim().toLocaleLowerCase() === nextKey) { + throw new Error(`Provider 名称已存在: ${nextLabel}`); + } } - } } function normalizePersonalProviderMembership( - personal: ProviderConfig | undefined, - builtin: ProviderConfig | undefined, - inheritedModelIds?: readonly ModelId[], + personal: ProviderConfig | undefined, + builtin: ProviderConfig | undefined, + inheritedModelIds?: readonly ModelId[], ): ProviderConfig | undefined { - if (!personal) return undefined; - const builtinModelIds = uniqueInOrder(inheritedModelIds ?? builtin?.builtinModelIds ?? []); - const builtinSet = new Set(builtinModelIds); - const personalModelIds = uniqueInOrder(personal.personalModelIds ?? []).filter( - (modelId) => !builtinSet.has(modelId), - ); - let normalized = personal - .withPersonalModelIds(personalModelIds) - .withHiddenInheritedModelIds( - normalizeHiddenInheritedModelIds(builtinModelIds, personal.hiddenInheritedModelIds), - ); - if (personal.modelOrder !== undefined && personal.modelOrder !== null) { - normalized = normalized.withModelOrder( - normalizeModelOrder(builtinModelIds, personalModelIds, personal.modelOrder), + if (!personal) return undefined; + const builtinModelIds = uniqueInOrder(inheritedModelIds ?? builtin?.builtinModelIds ?? []); + const builtinSet = new Set(builtinModelIds); + const personalModelIds = uniqueInOrder(personal.personalModelIds ?? []).filter( + (modelId) => !builtinSet.has(modelId), ); - } - return normalized; + let normalized = personal + .withPersonalModelIds(personalModelIds) + .withHiddenInheritedModelIds( + normalizeHiddenInheritedModelIds(builtinModelIds, personal.hiddenInheritedModelIds), + ); + if (personal.modelOrder !== undefined && personal.modelOrder !== null) { + normalized = normalized.withModelOrder( + normalizeModelOrder(builtinModelIds, personalModelIds, personal.modelOrder), + ); + } + return normalized; } function assertNonEmptyId(label: string, value: string): void { - if (!value.trim()) throw new Error(`${label} 不能为空`); + if (!value.trim()) throw new Error(`${label} 不能为空`); } function normalizeId(label: string, value: string): string { - const normalized = value.trim(); - if (!normalized) throw new Error(`${label} 不能为空`); - return normalized; + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} 不能为空`); + return normalized; } function uniqueInOrder(values: readonly T[]): T[] { - const result: T[] = []; - for (const value of values) if (!result.includes(value)) result.push(value); - return result; + const result: T[] = []; + for (const value of values) if (!result.includes(value)) result.push(value); + return result; } function normalizeProviderOrder( - _kcodeBuiltinProviders: ProviderConfigMap, - personalProviders: ProviderConfigMap, - requested: readonly ProviderId[], + _kcodeBuiltinProviders: ProviderConfigMap, + personalProviders: ProviderConfigMap, + requested: readonly ProviderId[], ): ProviderId[] { - const personalIds = personalProviders - .entries() - .filter(([, config]) => config.group === "standard-personal") - .map(([providerId]) => providerId); - return [...resolveOwnedOrder([], personalIds, requested)]; + const personalIds = personalProviders + .entries() + .filter(([, config]) => config.group === "standard-personal") + .map(([providerId]) => providerId); + return [...resolveOwnedOrder([], personalIds, requested)]; } function appendCurrentProviderOrder( - kcodeBuiltinProviders: ProviderConfigMap, - personalProviders: ProviderConfigMap, - currentOrder: readonly ProviderId[] | undefined, - addedProviderId: ProviderId, + kcodeBuiltinProviders: ProviderConfigMap, + personalProviders: ProviderConfigMap, + currentOrder: readonly ProviderId[] | undefined, + addedProviderId: ProviderId, ): ProviderId[] { - const current = normalizeProviderOrder( - kcodeBuiltinProviders, - personalProviders, - currentOrder ?? [], - ); - return normalizeProviderOrder(kcodeBuiltinProviders, personalProviders, [ - ...current.filter((providerId) => providerId !== addedProviderId), - addedProviderId, - ]); + const current = normalizeProviderOrder( + kcodeBuiltinProviders, + personalProviders, + currentOrder ?? [], + ); + return normalizeProviderOrder(kcodeBuiltinProviders, personalProviders, [ + ...current.filter((providerId) => providerId !== addedProviderId), + addedProviderId, + ]); } function normalizeModelOrder( - builtinModelIds: readonly ModelId[], - personalModelIds: readonly ModelId[], - requested: readonly ModelId[], + builtinModelIds: readonly ModelId[], + personalModelIds: readonly ModelId[], + requested: readonly ModelId[], ): ModelId[] { - return [...resolveOwnedOrder(builtinModelIds, personalModelIds, requested)]; + return [...resolveOwnedOrder(builtinModelIds, personalModelIds, requested)]; } function nextPersonalProviderId( - occupied: ReadonlySet, - templateId?: ProviderTemplateId, + occupied: ReadonlySet, + templateId?: ProviderTemplateId, ): ProviderId { - const base = templateId ? normalizeProviderIdSeed(templateId) : "new-provider"; - if (!occupied.has(base)) return base; - for (let suffix = 2; ; suffix += 1) { - const candidate = `${base}-${suffix}`; - if (!occupied.has(candidate)) return candidate; - } + const base = templateId ? normalizeProviderIdSeed(templateId) : "new-provider"; + if (!occupied.has(base)) return base; + for (let suffix = 2; ; suffix += 1) { + const candidate = `${base}-${suffix}`; + if (!occupied.has(candidate)) return candidate; + } } function normalizeProviderIdSeed(value: string): string { - return ( - value - .trim() - .toLocaleLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "new-provider" - ); + return ( + value + .trim() + .toLocaleLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "new-provider" + ); } function resolvePersonalProviderBaselines( - kcodeBuiltin: ProviderConfigLayerSnapshot, - personalProviders: ProviderConfigMap, + kcodeBuiltin: ProviderConfigLayerSnapshot, + personalProviders: ProviderConfigMap, ): ProviderConfigMap { - const personal = personalProviders.mapConfigs((config, _id, rule) => { - const template = rule.templateId - ? kcodeBuiltin.providerTemplates?.get(rule.templateId) - : undefined; - return template ? template.config.overlay(config) : config; - }); - return kcodeBuiltin.providers.overlay(personal); + const personal = personalProviders.mapConfigs((config, _id, rule) => { + const template = rule.templateId + ? kcodeBuiltin.providerTemplates?.get(rule.templateId) + : undefined; + return template ? template.config.overlay(config) : config; + }); + return kcodeBuiltin.providers.overlay(personal); } function resolveTemplateBaseline( - builtin: ProviderConfigLayerSnapshot, - providers: ProviderConfigMap, - providerId: ProviderId, + builtin: ProviderConfigLayerSnapshot, + providers: ProviderConfigMap, + providerId: ProviderId, ): ProviderConfig | undefined { - const templateId = providers.getRule(providerId)?.templateId; - return templateId ? builtin.providerTemplates?.get(templateId)?.config : undefined; + const templateId = providers.getRule(providerId)?.templateId; + return templateId ? builtin.providerTemplates?.get(templateId)?.config : undefined; } function resolveProviderBuiltinModelIds( - builtin: ProviderConfigLayerSnapshot, - personalProviders: ProviderConfigMap, - providerId: ProviderId, + builtin: ProviderConfigLayerSnapshot, + personalProviders: ProviderConfigMap, + providerId: ProviderId, ): readonly ModelId[] { - return ( - builtin.providers.get(providerId)?.builtinModelIds ?? - resolveTemplateBaseline(builtin, personalProviders, providerId)?.builtinModelIds ?? - [] - ); + return ( + builtin.providers.get(providerId)?.builtinModelIds ?? + resolveTemplateBaseline(builtin, personalProviders, providerId)?.builtinModelIds ?? + [] + ); } function nextPersonalProviderLabel(seed: string, providers: ProviderConfigMap): string { - const base = seed.trim() || "new-provider"; - const labels = new Set( - providers - .rules() - .map((provider) => provider.providerName?.trim().toLocaleLowerCase()) - .filter((label): label is string => Boolean(label)), - ); - if (!labels.has(base.toLocaleLowerCase())) return base; - for (let suffix = 2; ; suffix += 1) { - const candidate = `${base} ${suffix}`; - if (!labels.has(candidate.toLocaleLowerCase())) return candidate; - } + const base = seed.trim() || "new-provider"; + const labels = new Set( + providers + .rules() + .map((provider) => provider.providerName?.trim().toLocaleLowerCase()) + .filter((label): label is string => Boolean(label)), + ); + if (!labels.has(base.toLocaleLowerCase())) return base; + for (let suffix = 2; ; suffix += 1) { + const candidate = `${base} ${suffix}`; + if (!labels.has(candidate.toLocaleLowerCase())) return candidate; + } } diff --git a/packages/provider/src/config/model-config.ts b/packages/provider/src/config/model-config.ts index 6c017893..6cfb3e20 100644 --- a/packages/provider/src/config/model-config.ts +++ b/packages/provider/src/config/model-config.ts @@ -2,36 +2,36 @@ import { ConfigOverlay, type ConfigValidationIssue } from "../config-overlay.js"; import type { z } from "zod"; import { - completeEnumOptionSpecDataSchema, - completeLimitOptionSpecDataSchema, - completeModelInputFormatDataSchema, - completeModelOutputFormatDataSchema, - completeModelPropertiesDataSchema, - completeModelOptionSpecsDataSchema, - completeModelConfigDataSchema, - type enumOptionSpecDataSchema, - type limitOptionSpecDataSchema, - type modelInputFormatDataSchema, - type modelOutputFormatDataSchema, - type modelPropertiesDataSchema, - type modelOptionSpecsDataSchema, - type modelConfigDataSchema, + completeEnumOptionSpecDataSchema, + completeLimitOptionSpecDataSchema, + completeModelInputFormatDataSchema, + completeModelOutputFormatDataSchema, + completeModelPropertiesDataSchema, + completeModelOptionSpecsDataSchema, + completeModelConfigDataSchema, + type enumOptionSpecDataSchema, + type limitOptionSpecDataSchema, + type modelInputFormatDataSchema, + type modelOutputFormatDataSchema, + type modelPropertiesDataSchema, + type modelOptionSpecsDataSchema, + type modelConfigDataSchema, } from "@kcode/shared/model-config"; import { validateConfigSchema } from "./schema-validation.js"; import { clearManualModelConfig } from "./manual-model-config.js"; import { - providerModelConfigRuleSchema, - manualProviderModelConfigRuleSchema, - personalModelConfigRulesSchema, - builtinModelConfigRulesSchema, - type ProviderModelConfigRuleData, - type ManualProviderModelConfigRuleData, - type TemplateModelConfigRuleData, - type ModelMatchConfigRuleData, - type ModelApiMatchConfigRuleData, - type ProviderSiteMatchConfigRuleData, - type BuiltinModelConfigRulesData, - type PersonalModelConfigRulesData, + providerModelConfigRuleSchema, + manualProviderModelConfigRuleSchema, + personalModelConfigRulesSchema, + builtinModelConfigRulesSchema, + type ProviderModelConfigRuleData, + type ManualProviderModelConfigRuleData, + type TemplateModelConfigRuleData, + type ModelMatchConfigRuleData, + type ModelApiMatchConfigRuleData, + type ProviderSiteMatchConfigRuleData, + type BuiltinModelConfigRulesData, + type PersonalModelConfigRulesData, } from "./rule-data-schema.js"; export type EnumOptionSpec = Readonly>; @@ -41,69 +41,69 @@ export type LimitOptionSpec = Readonly>; export class EnumOptionSpecConfig extends ConfigOverlay { - readonly values?: EnumOptionSpecConfigInput["values"]; - readonly map?: EnumOptionSpecConfigInput["map"]; - - constructor(input: EnumOptionSpecConfigInput = {}) { - super(); - this.values = - input.values === undefined - ? undefined - : input.values === null - ? null - : Object.freeze([...input.values]); - this.map = input.map; - Object.freeze(this); - } - - overlay(next: EnumOptionSpecConfig): EnumOptionSpecConfig { - return new EnumOptionSpecConfig({ - values: this.overlayValue(this.values, next.values), - map: this.overlayValue(this.map, next.map), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeEnumOptionSpecDataSchema, this.toJSON(), path, true); - } - - toJSON(): EnumOptionSpecConfigInput { - return objectWithoutUndefined({ - values: this.values, - map: this.map, - }); - } + readonly values?: EnumOptionSpecConfigInput["values"]; + readonly map?: EnumOptionSpecConfigInput["map"]; + + constructor(input: EnumOptionSpecConfigInput = {}) { + super(); + this.values = + input.values === undefined + ? undefined + : input.values === null + ? null + : Object.freeze([...input.values]); + this.map = input.map; + Object.freeze(this); + } + + overlay(next: EnumOptionSpecConfig): EnumOptionSpecConfig { + return new EnumOptionSpecConfig({ + values: this.overlayValue(this.values, next.values), + map: this.overlayValue(this.map, next.map), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeEnumOptionSpecDataSchema, this.toJSON(), path, true); + } + + toJSON(): EnumOptionSpecConfigInput { + return objectWithoutUndefined({ + values: this.values, + map: this.map, + }); + } } export type LimitOptionSpecConfigInput = Readonly>; export class LimitOptionSpecConfig extends ConfigOverlay { - readonly max?: LimitOptionSpecConfigInput["max"]; - readonly map?: LimitOptionSpecConfigInput["map"]; - - constructor(input: LimitOptionSpecConfigInput = {}) { - super(); - Object.assign(this, input); - Object.freeze(this); - } - - overlay(next: LimitOptionSpecConfig): LimitOptionSpecConfig { - return new LimitOptionSpecConfig({ - max: this.overlayValue(this.max, next.max), - map: this.overlayValue(this.map, next.map), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeLimitOptionSpecDataSchema, this.toJSON(), path, true); - } - - toJSON(): LimitOptionSpecConfigInput { - return objectWithoutUndefined({ - max: this.max, - map: this.map, - }); - } + readonly max?: LimitOptionSpecConfigInput["max"]; + readonly map?: LimitOptionSpecConfigInput["map"]; + + constructor(input: LimitOptionSpecConfigInput = {}) { + super(); + Object.assign(this, input); + Object.freeze(this); + } + + overlay(next: LimitOptionSpecConfig): LimitOptionSpecConfig { + return new LimitOptionSpecConfig({ + max: this.overlayValue(this.max, next.max), + map: this.overlayValue(this.map, next.map), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeLimitOptionSpecDataSchema, this.toJSON(), path, true); + } + + toJSON(): LimitOptionSpecConfigInput { + return objectWithoutUndefined({ + max: this.max, + map: this.map, + }); + } } export type ModelPropertiesConfigInput = Readonly>; @@ -111,227 +111,227 @@ export type ModelPropertiesConfigInput = Readonly>; export class ModelInputFormatConfig extends ConfigOverlay { - readonly supportsText?: ModelInputFormatConfigInput["supportsText"]; - readonly supportsImage?: ModelInputFormatConfigInput["supportsImage"]; - readonly supportsVideo?: ModelInputFormatConfigInput["supportsVideo"]; - readonly supportsAudio?: ModelInputFormatConfigInput["supportsAudio"]; - readonly supportsPdf?: ModelInputFormatConfigInput["supportsPdf"]; - - constructor(input: ModelInputFormatConfigInput = {}) { - super(); - Object.assign(this, input); - Object.freeze(this); - } - - overlay(next: ModelInputFormatConfig): ModelInputFormatConfig { - return new ModelInputFormatConfig({ - supportsText: this.overlayValue(this.supportsText, next.supportsText), - supportsImage: this.overlayValue(this.supportsImage, next.supportsImage), - supportsVideo: this.overlayValue(this.supportsVideo, next.supportsVideo), - supportsAudio: this.overlayValue(this.supportsAudio, next.supportsAudio), - supportsPdf: this.overlayValue(this.supportsPdf, next.supportsPdf), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeModelInputFormatDataSchema, this.toJSON(), path); - } - - toJSON(): ModelInputFormatConfigInput { - return objectWithoutUndefined({ - supportsText: this.supportsText, - supportsImage: this.supportsImage, - supportsVideo: this.supportsVideo, - supportsAudio: this.supportsAudio, - supportsPdf: this.supportsPdf, - }); - } + readonly supportsText?: ModelInputFormatConfigInput["supportsText"]; + readonly supportsImage?: ModelInputFormatConfigInput["supportsImage"]; + readonly supportsVideo?: ModelInputFormatConfigInput["supportsVideo"]; + readonly supportsAudio?: ModelInputFormatConfigInput["supportsAudio"]; + readonly supportsPdf?: ModelInputFormatConfigInput["supportsPdf"]; + + constructor(input: ModelInputFormatConfigInput = {}) { + super(); + Object.assign(this, input); + Object.freeze(this); + } + + overlay(next: ModelInputFormatConfig): ModelInputFormatConfig { + return new ModelInputFormatConfig({ + supportsText: this.overlayValue(this.supportsText, next.supportsText), + supportsImage: this.overlayValue(this.supportsImage, next.supportsImage), + supportsVideo: this.overlayValue(this.supportsVideo, next.supportsVideo), + supportsAudio: this.overlayValue(this.supportsAudio, next.supportsAudio), + supportsPdf: this.overlayValue(this.supportsPdf, next.supportsPdf), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeModelInputFormatDataSchema, this.toJSON(), path); + } + + toJSON(): ModelInputFormatConfigInput { + return objectWithoutUndefined({ + supportsText: this.supportsText, + supportsImage: this.supportsImage, + supportsVideo: this.supportsVideo, + supportsAudio: this.supportsAudio, + supportsPdf: this.supportsPdf, + }); + } } export type ModelOutputFormatConfigInput = Readonly>; export class ModelOutputFormatConfig extends ConfigOverlay { - readonly supportsText?: ModelOutputFormatConfigInput["supportsText"]; - - constructor(input: ModelOutputFormatConfigInput = {}) { - super(); - Object.assign(this, input); - Object.freeze(this); - } - - overlay(next: ModelOutputFormatConfig): ModelOutputFormatConfig { - return new ModelOutputFormatConfig({ - supportsText: this.overlayValue(this.supportsText, next.supportsText), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeModelOutputFormatDataSchema, this.toJSON(), path); - } - - toJSON(): ModelOutputFormatConfigInput { - return objectWithoutUndefined({ supportsText: this.supportsText }); - } + readonly supportsText?: ModelOutputFormatConfigInput["supportsText"]; + + constructor(input: ModelOutputFormatConfigInput = {}) { + super(); + Object.assign(this, input); + Object.freeze(this); + } + + overlay(next: ModelOutputFormatConfig): ModelOutputFormatConfig { + return new ModelOutputFormatConfig({ + supportsText: this.overlayValue(this.supportsText, next.supportsText), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeModelOutputFormatDataSchema, this.toJSON(), path); + } + + toJSON(): ModelOutputFormatConfigInput { + return objectWithoutUndefined({ supportsText: this.supportsText }); + } } export class ModelPropertiesConfig extends ConfigOverlay { - readonly requiresMfjsToolSchema?: ModelPropertiesConfigInput["requiresMfjsToolSchema"]; - readonly contextWindow?: ModelPropertiesConfigInput["contextWindow"]; - readonly inputFormat?: ModelInputFormatConfig | null; - readonly outputFormat?: ModelOutputFormatConfig | null; - readonly supportsToolCall?: ModelPropertiesConfigInput["supportsToolCall"]; - readonly supportsJsonSchemaOutput?: ModelPropertiesConfigInput["supportsJsonSchemaOutput"]; - readonly supportsNativeWebSearch?: ModelPropertiesConfigInput["supportsNativeWebSearch"]; - readonly supportsMidConversationSystem?: ModelPropertiesConfigInput["supportsMidConversationSystem"]; - - constructor(input: ModelPropertiesConfigInput = {}) { - super(); - Object.assign(this, { - ...input, - inputFormat: - input.inputFormat instanceof ModelInputFormatConfig || input.inputFormat == null - ? input.inputFormat - : new ModelInputFormatConfig(input.inputFormat), - outputFormat: - input.outputFormat instanceof ModelOutputFormatConfig || input.outputFormat == null - ? input.outputFormat - : new ModelOutputFormatConfig(input.outputFormat), - }); - Object.freeze(this); - } - - overlay(next: ModelPropertiesConfig): ModelPropertiesConfig { - return new ModelPropertiesConfig({ - requiresMfjsToolSchema: this.overlayValue( - this.requiresMfjsToolSchema, - next.requiresMfjsToolSchema, - ), - contextWindow: this.overlayValue(this.contextWindow, next.contextWindow), - inputFormat: this.overlayConfig(this.inputFormat, next.inputFormat), - outputFormat: this.overlayConfig(this.outputFormat, next.outputFormat), - supportsToolCall: this.overlayValue(this.supportsToolCall, next.supportsToolCall), - supportsJsonSchemaOutput: this.overlayValue( - this.supportsJsonSchemaOutput, - next.supportsJsonSchemaOutput, - ), - supportsNativeWebSearch: this.overlayValue( - this.supportsNativeWebSearch, - next.supportsNativeWebSearch, - ), - supportsMidConversationSystem: this.overlayValue( - this.supportsMidConversationSystem, - next.supportsMidConversationSystem, - ), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeModelPropertiesDataSchema, this.toJSON(), path); - } - - toJSON(): ModelPropertiesConfigInput { - return objectWithoutUndefined({ - requiresMfjsToolSchema: this.requiresMfjsToolSchema, - contextWindow: this.contextWindow, - inputFormat: this.inputFormat?.toJSON() ?? this.inputFormat, - outputFormat: this.outputFormat?.toJSON() ?? this.outputFormat, - supportsToolCall: this.supportsToolCall, - supportsJsonSchemaOutput: this.supportsJsonSchemaOutput, - supportsNativeWebSearch: this.supportsNativeWebSearch, - supportsMidConversationSystem: this.supportsMidConversationSystem, - }); - } + readonly requiresMfjsToolSchema?: ModelPropertiesConfigInput["requiresMfjsToolSchema"]; + readonly contextWindow?: ModelPropertiesConfigInput["contextWindow"]; + readonly inputFormat?: ModelInputFormatConfig | null; + readonly outputFormat?: ModelOutputFormatConfig | null; + readonly supportsToolCall?: ModelPropertiesConfigInput["supportsToolCall"]; + readonly supportsJsonSchemaOutput?: ModelPropertiesConfigInput["supportsJsonSchemaOutput"]; + readonly supportsNativeWebSearch?: ModelPropertiesConfigInput["supportsNativeWebSearch"]; + readonly supportsMidConversationSystem?: ModelPropertiesConfigInput["supportsMidConversationSystem"]; + + constructor(input: ModelPropertiesConfigInput = {}) { + super(); + Object.assign(this, { + ...input, + inputFormat: + input.inputFormat instanceof ModelInputFormatConfig || input.inputFormat == null + ? input.inputFormat + : new ModelInputFormatConfig(input.inputFormat), + outputFormat: + input.outputFormat instanceof ModelOutputFormatConfig || input.outputFormat == null + ? input.outputFormat + : new ModelOutputFormatConfig(input.outputFormat), + }); + Object.freeze(this); + } + + overlay(next: ModelPropertiesConfig): ModelPropertiesConfig { + return new ModelPropertiesConfig({ + requiresMfjsToolSchema: this.overlayValue( + this.requiresMfjsToolSchema, + next.requiresMfjsToolSchema, + ), + contextWindow: this.overlayValue(this.contextWindow, next.contextWindow), + inputFormat: this.overlayConfig(this.inputFormat, next.inputFormat), + outputFormat: this.overlayConfig(this.outputFormat, next.outputFormat), + supportsToolCall: this.overlayValue(this.supportsToolCall, next.supportsToolCall), + supportsJsonSchemaOutput: this.overlayValue( + this.supportsJsonSchemaOutput, + next.supportsJsonSchemaOutput, + ), + supportsNativeWebSearch: this.overlayValue( + this.supportsNativeWebSearch, + next.supportsNativeWebSearch, + ), + supportsMidConversationSystem: this.overlayValue( + this.supportsMidConversationSystem, + next.supportsMidConversationSystem, + ), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeModelPropertiesDataSchema, this.toJSON(), path); + } + + toJSON(): ModelPropertiesConfigInput { + return objectWithoutUndefined({ + requiresMfjsToolSchema: this.requiresMfjsToolSchema, + contextWindow: this.contextWindow, + inputFormat: this.inputFormat?.toJSON() ?? this.inputFormat, + outputFormat: this.outputFormat?.toJSON() ?? this.outputFormat, + supportsToolCall: this.supportsToolCall, + supportsJsonSchemaOutput: this.supportsJsonSchemaOutput, + supportsNativeWebSearch: this.supportsNativeWebSearch, + supportsMidConversationSystem: this.supportsMidConversationSystem, + }); + } } export type ModelOptionSpecsConfigInput = Readonly>; export class ModelOptionSpecsConfig extends ConfigOverlay { - readonly reasoningLevel?: EnumOptionSpecConfig | null; - readonly maxOutputTokens?: LimitOptionSpecConfig | null; - - constructor(input: ModelOptionSpecsConfigInput = {}) { - super(); - this.reasoningLevel = toEnumOptionSpecConfig(input.reasoningLevel); - this.maxOutputTokens = toLimitOptionSpecConfig(input.maxOutputTokens); - Object.freeze(this); - } - - overlay(next: ModelOptionSpecsConfig): ModelOptionSpecsConfig { - return new ModelOptionSpecsConfig({ - reasoningLevel: this.overlayConfig(this.reasoningLevel, next.reasoningLevel), - maxOutputTokens: this.overlayConfig(this.maxOutputTokens, next.maxOutputTokens), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeModelOptionSpecsDataSchema, this.toJSON(), path, true); - } - - toJSON(): ModelOptionSpecsConfigInput { - return objectWithoutUndefined({ - reasoningLevel: this.reasoningLevel?.toJSON() ?? this.reasoningLevel, - maxOutputTokens: this.maxOutputTokens?.toJSON() ?? this.maxOutputTokens, - }); - } + readonly reasoningLevel?: EnumOptionSpecConfig | null; + readonly maxOutputTokens?: LimitOptionSpecConfig | null; + + constructor(input: ModelOptionSpecsConfigInput = {}) { + super(); + this.reasoningLevel = toEnumOptionSpecConfig(input.reasoningLevel); + this.maxOutputTokens = toLimitOptionSpecConfig(input.maxOutputTokens); + Object.freeze(this); + } + + overlay(next: ModelOptionSpecsConfig): ModelOptionSpecsConfig { + return new ModelOptionSpecsConfig({ + reasoningLevel: this.overlayConfig(this.reasoningLevel, next.reasoningLevel), + maxOutputTokens: this.overlayConfig(this.maxOutputTokens, next.maxOutputTokens), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeModelOptionSpecsDataSchema, this.toJSON(), path, true); + } + + toJSON(): ModelOptionSpecsConfigInput { + return objectWithoutUndefined({ + reasoningLevel: this.reasoningLevel?.toJSON() ?? this.reasoningLevel, + maxOutputTokens: this.maxOutputTokens?.toJSON() ?? this.maxOutputTokens, + }); + } } export type ModelConfigInput = Omit & { - readonly properties?: ModelPropertiesConfig | null; - readonly optionSpecs?: ModelOptionSpecsConfig | null; + readonly properties?: ModelPropertiesConfig | null; + readonly optionSpecs?: ModelOptionSpecsConfig | null; }; export type ModelConfigObject = Readonly>; export class ModelConfig extends ConfigOverlay { - readonly enabled?: ModelConfigObject["enabled"]; - readonly properties?: ModelPropertiesConfig | null; - readonly optionSpecs?: ModelOptionSpecsConfig | null; - - constructor(input: ModelConfigInput = {}) { - super(); - this.enabled = input.enabled; - this.properties = input.properties; - this.optionSpecs = input.optionSpecs; - Object.freeze(this); - } - - static empty(): ModelConfig { - return new ModelConfig(); - } - - static fromData(config: ModelConfigObject): ModelConfig { - return new ModelConfig({ - enabled: config.enabled, - properties: - config.properties == null - ? config.properties - : new ModelPropertiesConfig(config.properties), - optionSpecs: - config.optionSpecs == null - ? config.optionSpecs - : new ModelOptionSpecsConfig(config.optionSpecs), - }); - } - - overlay(next: ModelConfig): ModelConfig { - return new ModelConfig({ - enabled: this.overlayValue(this.enabled, next.enabled), - properties: this.overlayConfig(this.properties, next.properties), - optionSpecs: this.overlayConfig(this.optionSpecs, next.optionSpecs), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeModelConfigDataSchema, this.toJSON(), path); - } - - toJSON(): ModelConfigObject { - return objectWithoutUndefined({ - enabled: this.enabled, - properties: this.properties?.toJSON() ?? this.properties, - optionSpecs: this.optionSpecs?.toJSON() ?? this.optionSpecs, - }); - } + readonly enabled?: ModelConfigObject["enabled"]; + readonly properties?: ModelPropertiesConfig | null; + readonly optionSpecs?: ModelOptionSpecsConfig | null; + + constructor(input: ModelConfigInput = {}) { + super(); + this.enabled = input.enabled; + this.properties = input.properties; + this.optionSpecs = input.optionSpecs; + Object.freeze(this); + } + + static empty(): ModelConfig { + return new ModelConfig(); + } + + static fromData(config: ModelConfigObject): ModelConfig { + return new ModelConfig({ + enabled: config.enabled, + properties: + config.properties == null + ? config.properties + : new ModelPropertiesConfig(config.properties), + optionSpecs: + config.optionSpecs == null + ? config.optionSpecs + : new ModelOptionSpecsConfig(config.optionSpecs), + }); + } + + overlay(next: ModelConfig): ModelConfig { + return new ModelConfig({ + enabled: this.overlayValue(this.enabled, next.enabled), + properties: this.overlayConfig(this.properties, next.properties), + optionSpecs: this.overlayConfig(this.optionSpecs, next.optionSpecs), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeModelConfigDataSchema, this.toJSON(), path); + } + + toJSON(): ModelConfigObject { + return objectWithoutUndefined({ + enabled: this.enabled, + properties: this.properties?.toJSON() ?? this.properties, + optionSpecs: this.optionSpecs?.toJSON() ?? this.optionSpecs, + }); + } } type RuleWithConfig = Readonly & { config: ModelConfig }>; @@ -344,238 +344,273 @@ export type ProviderSiteMatchConfigRule = RuleWithConfig; export interface ModelConfigRuleResolutionInput { - readonly providerId: string; - readonly templateId?: string | null; - readonly modelId: string; - readonly apiType?: string | null; - readonly baseUrl?: string | null; + readonly providerId: string; + readonly templateId?: string | null; + readonly modelId: string; + readonly apiType?: string | null; + readonly baseUrl?: string | null; } export class ModelConfigRules { - readonly #rules: readonly ModelConfigRule[]; - - constructor(rules: readonly ModelConfigRule[] = []) { - this.#rules = Object.freeze(rules.map((rule) => Object.freeze({ ...rule }))); - Object.freeze(this); - } - - static empty(): ModelConfigRules { - return new ModelConfigRules(); - } - - /** Built-in 保留原层次和组内顺序;个人只包含普通/手动精确规则。 */ - static composeEffective(builtin: ModelConfigRules, personal: ModelConfigRules): ModelConfigRules { - return new ModelConfigRules([...builtin.rules(), ...personal.rules().filter(isExactModelRule)]); - } - - rules(): readonly ModelConfigRule[] { - return this.#rules; - } - - resolve(input: ModelConfigRuleResolutionInput): ModelConfig { - let result = ModelConfig.empty(); - const baseUrl = input.baseUrl == null ? undefined : normalizeBaseURLForRuleMatch(input.baseUrl); - for (const rule of this.#rules) { - if (isExactModelRule(rule)) { - if (rule.providerId !== input.providerId || rule.modelId !== input.modelId) continue; - // 手动规则要求所有可编辑叶子齐全,因此可直接覆盖;系统叶子继续来自当前身份的规则。 - // 清空整份基线会既丢失系统映射,也迫使 UI 把旧模型的隐藏配置复制进个人规则。 - result = ( - rule.type === "manual-provider-model" - ? ModelConfig.fromData(clearManualModelConfig(result.toJSON())) - : result - ).overlay(rule.config); - continue; - } - if (rule.type === "template-model") { - if (rule.templateId === input.templateId && rule.modelId === input.modelId) - result = result.overlay(rule.config); - continue; - } - // 只放宽推荐规则匹配,不改真实请求里的模型 ID。 - if (!matchesRule(rule.modelMatch, input.modelId, true)) continue; - if ( - (rule.type === "model-api" || rule.type === "provider-site") && - rule.apiTypeMatch !== undefined - ) { - if (input.apiType == null || !matchesRule(rule.apiTypeMatch, input.apiType)) continue; - } - if ( - rule.type === "provider-site" && - (baseUrl === undefined || !matchesRule(rule.baseUrlMatch, baseUrl)) - ) - continue; - result = result.overlay(rule.config); - } - return result; - } - - setExact( - providerId: string, - modelId: string, - config: ModelConfig, - useRecommendedConfig?: boolean, - ): ModelConfigRules { - const previous = this.getExactRule(providerId, modelId); - const manual = - useRecommendedConfig === undefined - ? previous?.type === "manual-provider-model" - : !useRecommendedConfig; - // 保存入口复用整条规则 schema,不再另写一份“完整但忽略 enabled”的校验。 - const data = { providerId, modelId, config: config.toJSON() }; - (manual ? manualProviderModelConfigRuleSchema : providerModelConfigRuleSchema).parse(data); - const replacement: ExactModelConfigRule = { - type: manual ? "manual-provider-model" : "provider-model", - providerId, - modelId, - config, - }; - const result: ModelConfigRule[] = []; - let replaced = false; - for (const rule of this.#rules) { - if (isExactModelRule(rule) && rule.providerId === providerId && rule.modelId === modelId) { + readonly #rules: readonly ModelConfigRule[]; + + constructor(rules: readonly ModelConfigRule[] = []) { + this.#rules = Object.freeze(rules.map((rule) => Object.freeze({ ...rule }))); + Object.freeze(this); + } + + static empty(): ModelConfigRules { + return new ModelConfigRules(); + } + + /** Built-in 保留原层次和组内顺序;个人只包含普通/手动精确规则。 */ + static composeEffective( + builtin: ModelConfigRules, + personal: ModelConfigRules, + ): ModelConfigRules { + return new ModelConfigRules([ + ...builtin.rules(), + ...personal.rules().filter(isExactModelRule), + ]); + } + + rules(): readonly ModelConfigRule[] { + return this.#rules; + } + + /** 去掉通用 `.*` 模型兜底,用来判断某字段是否已有更具体的内置规则。 */ + withoutGenericModelFallback(): ModelConfigRules { + return new ModelConfigRules( + this.#rules.filter((rule) => !(rule.type === "model" && rule.modelMatch === ".*")), + ); + } + + resolve(input: ModelConfigRuleResolutionInput): ModelConfig { + let result = ModelConfig.empty(); + const baseUrl = + input.baseUrl == null ? undefined : normalizeBaseURLForRuleMatch(input.baseUrl); + for (const rule of this.#rules) { + if (isExactModelRule(rule)) { + if (rule.providerId !== input.providerId || rule.modelId !== input.modelId) + continue; + // 手动规则要求所有可编辑叶子齐全,因此可直接覆盖;系统叶子继续来自当前身份的规则。 + // 清空整份基线会既丢失系统映射,也迫使 UI 把旧模型的隐藏配置复制进个人规则。 + result = ( + rule.type === "manual-provider-model" + ? ModelConfig.fromData(clearManualModelConfig(result.toJSON())) + : result + ).overlay(rule.config); + continue; + } + if (rule.type === "template-model") { + if (rule.templateId === input.templateId && rule.modelId === input.modelId) + result = result.overlay(rule.config); + continue; + } + // 只放宽推荐规则匹配,不改真实请求里的模型 ID。 + if (!matchesRule(rule.modelMatch, input.modelId, true)) continue; + if ( + (rule.type === "model-api" || rule.type === "provider-site") && + rule.apiTypeMatch !== undefined + ) { + if (input.apiType == null || !matchesRule(rule.apiTypeMatch, input.apiType)) + continue; + } + if ( + rule.type === "provider-site" && + (baseUrl === undefined || !matchesRule(rule.baseUrlMatch, baseUrl)) + ) + continue; + result = result.overlay(rule.config); + } + return result; + } + + setExact( + providerId: string, + modelId: string, + config: ModelConfig, + useRecommendedConfig?: boolean, + ): ModelConfigRules { + const previous = this.getExactRule(providerId, modelId); + const manual = + useRecommendedConfig === undefined + ? previous?.type === "manual-provider-model" + : !useRecommendedConfig; + // 保存入口复用整条规则 schema,不再另写一份“完整但忽略 enabled”的校验。 + const data = { providerId, modelId, config: config.toJSON() }; + (manual ? manualProviderModelConfigRuleSchema : providerModelConfigRuleSchema).parse(data); + const replacement: ExactModelConfigRule = { + type: manual ? "manual-provider-model" : "provider-model", + providerId, + modelId, + config, + }; + const result: ModelConfigRule[] = []; + let replaced = false; + for (const rule of this.#rules) { + if ( + isExactModelRule(rule) && + rule.providerId === providerId && + rule.modelId === modelId + ) { + if (!replaced) result.push(replacement); + replaced = true; + } else { + result.push(rule); + } + } if (!replaced) result.push(replacement); - replaced = true; - } else { - result.push(rule); - } - } - if (!replaced) result.push(replacement); - return new ModelConfigRules(result); - } - - deleteExact(providerId: string, modelId: string): ModelConfigRules { - return new ModelConfigRules( - this.#rules.filter( - (rule) => - !isExactModelRule(rule) || rule.providerId !== providerId || rule.modelId !== modelId, - ), - ); - } - - renameExactModel( - providerId: string, - currentModelId: string, - nextModelId: string, - ): ModelConfigRules { - if (currentModelId === nextModelId) return this; - return new ModelConfigRules( - this.#rules.map((rule) => - isExactModelRule(rule) && rule.providerId === providerId && rule.modelId === currentModelId - ? { ...rule, modelId: nextModelId } - : rule, - ), - ); - } - - deleteExactForProvider(providerId: string): ModelConfigRules { - return new ModelConfigRules( - this.#rules.filter((rule) => !isExactModelRule(rule) || rule.providerId !== providerId), - ); - } - - getExact(providerId: string, modelId: string): ModelConfig | undefined { - let result: ModelConfig | undefined; - for (const rule of this.#rules) { - if (!isExactModelRule(rule) || rule.providerId !== providerId || rule.modelId !== modelId) - continue; - result = result ? result.overlay(rule.config) : rule.config; - } - return result; - } - - getExactRule(providerId: string, modelId: string): ExactModelConfigRule | undefined { - for (let index = this.#rules.length - 1; index >= 0; index -= 1) { - const rule = this.#rules[index]!; - if (isExactModelRule(rule) && rule.providerId === providerId && rule.modelId === modelId) - return rule; - } - return undefined; - } - - toKCodeBuiltinJSON(): BuiltinModelConfigRulesData { - return builtinModelConfigRulesSchema.parse({ - modelRules: this.#collect("model"), - modelApiRules: this.#collect("model-api"), - providerSiteRules: this.#collect("provider-site"), - templateModelRules: this.#collect("template-model"), - builtinProviderModelRules: this.#collect("provider-model"), - }); - } - - toPersonalJSON(): PersonalModelConfigRulesData { - // 完整规则必须在编码边界再校验,不能把直接构造的不完整手动值写入文件。 - return personalModelConfigRulesSchema.parse({ - providerModelRules: this.#collect("provider-model"), - manualProviderModelRules: this.#collect("manual-provider-model"), - }); - } - - toJSON() { - return { - ...this.toKCodeBuiltinJSON(), - manualProviderModelRules: this.#collect("manual-provider-model"), - }; - } - - #collect(type: T) { - return this.#rules - .filter((rule): rule is Extract => rule.type === type) - .map(({ type: _type, config, ...identity }) => ({ ...identity, config: config.toJSON() })); - } + return new ModelConfigRules(result); + } + + deleteExact(providerId: string, modelId: string): ModelConfigRules { + return new ModelConfigRules( + this.#rules.filter( + (rule) => + !isExactModelRule(rule) || + rule.providerId !== providerId || + rule.modelId !== modelId, + ), + ); + } + + renameExactModel( + providerId: string, + currentModelId: string, + nextModelId: string, + ): ModelConfigRules { + if (currentModelId === nextModelId) return this; + return new ModelConfigRules( + this.#rules.map((rule) => + isExactModelRule(rule) && + rule.providerId === providerId && + rule.modelId === currentModelId + ? { ...rule, modelId: nextModelId } + : rule, + ), + ); + } + + deleteExactForProvider(providerId: string): ModelConfigRules { + return new ModelConfigRules( + this.#rules.filter((rule) => !isExactModelRule(rule) || rule.providerId !== providerId), + ); + } + + getExact(providerId: string, modelId: string): ModelConfig | undefined { + let result: ModelConfig | undefined; + for (const rule of this.#rules) { + if ( + !isExactModelRule(rule) || + rule.providerId !== providerId || + rule.modelId !== modelId + ) + continue; + result = result ? result.overlay(rule.config) : rule.config; + } + return result; + } + + getExactRule(providerId: string, modelId: string): ExactModelConfigRule | undefined { + for (let index = this.#rules.length - 1; index >= 0; index -= 1) { + const rule = this.#rules[index]!; + if ( + isExactModelRule(rule) && + rule.providerId === providerId && + rule.modelId === modelId + ) + return rule; + } + return undefined; + } + + toKCodeBuiltinJSON(): BuiltinModelConfigRulesData { + return builtinModelConfigRulesSchema.parse({ + modelRules: this.#collect("model"), + modelApiRules: this.#collect("model-api"), + providerSiteRules: this.#collect("provider-site"), + templateModelRules: this.#collect("template-model"), + builtinProviderModelRules: this.#collect("provider-model"), + }); + } + + toPersonalJSON(): PersonalModelConfigRulesData { + // 完整规则必须在编码边界再校验,不能把直接构造的不完整手动值写入文件。 + return personalModelConfigRulesSchema.parse({ + providerModelRules: this.#collect("provider-model"), + manualProviderModelRules: this.#collect("manual-provider-model"), + }); + } + + toJSON() { + return { + ...this.toKCodeBuiltinJSON(), + manualProviderModelRules: this.#collect("manual-provider-model"), + }; + } + + #collect(type: T) { + return this.#rules + .filter((rule): rule is Extract => rule.type === type) + .map(({ type: _type, config, ...identity }) => ({ + ...identity, + config: config.toJSON(), + })); + } } function isExactModelRule(rule: ModelConfigRule): rule is ExactModelConfigRule { - return rule.type === "provider-model" || rule.type === "manual-provider-model"; + return rule.type === "provider-model" || rule.type === "manual-provider-model"; } function matchesRule(pattern: string, value: string, ignoreCase = false): boolean { - return new RegExp(`^(?:${pattern})$`, ignoreCase ? "i" : undefined).test(value); + return new RegExp(`^(?:${pattern})$`, ignoreCase ? "i" : undefined).test(value); } function normalizeBaseURLForRuleMatch(value: string): string | undefined { - try { - // Host 大小写、默认端口和尾部斜杠不应改变配置命中;URL parser 会保留 path/query 大小写。 - const parsed = new URL(value); - const suffix = `${parsed.search}${parsed.hash}`; - const serialized = parsed.toString(); - const endpoint = suffix.length === 0 ? serialized : serialized.slice(0, -suffix.length); - return `${endpoint.replace(/\/+$/, "")}${suffix}`; - } catch { - return undefined; - } + try { + // Host 大小写、默认端口和尾部斜杠不应改变配置命中;URL parser 会保留 path/query 大小写。 + const parsed = new URL(value); + const suffix = `${parsed.search}${parsed.hash}`; + const serialized = parsed.toString(); + const endpoint = suffix.length === 0 ? serialized : serialized.slice(0, -suffix.length); + return `${endpoint.replace(/\/+$/, "")}${suffix}`; + } catch { + return undefined; + } } function toEnumOptionSpecConfig( - spec: EnumOptionSpecConfig | EnumOptionSpecConfigInput | null | undefined, + spec: EnumOptionSpecConfig | EnumOptionSpecConfigInput | null | undefined, ): EnumOptionSpecConfig | null | undefined { - return spec instanceof EnumOptionSpecConfig || spec == null - ? spec - : new EnumOptionSpecConfig(spec); + return spec instanceof EnumOptionSpecConfig || spec == null + ? spec + : new EnumOptionSpecConfig(spec); } function toLimitOptionSpecConfig( - spec: LimitOptionSpecConfig | LimitOptionSpecConfigInput | null | undefined, + spec: LimitOptionSpecConfig | LimitOptionSpecConfigInput | null | undefined, ): LimitOptionSpecConfig | null | undefined { - return spec instanceof LimitOptionSpecConfig || spec == null - ? spec - : new LimitOptionSpecConfig(spec); + return spec instanceof LimitOptionSpecConfig || spec == null + ? spec + : new LimitOptionSpecConfig(spec); } function objectWithoutUndefined(value: T): T { - return Object.fromEntries( - Object.entries(value).filter(([, fieldValue]) => fieldValue !== undefined), - ) as T; + return Object.fromEntries( + Object.entries(value).filter(([, fieldValue]) => fieldValue !== undefined), + ) as T; } diff --git a/packages/provider/src/facades.ts b/packages/provider/src/facades.ts index e2628667..9928ac35 100644 --- a/packages/provider/src/facades.ts +++ b/packages/provider/src/facades.ts @@ -3,729 +3,778 @@ import type { ConfigValidationIssue } from "./config-overlay.js"; import type { ProviderModelMembership } from "./config-service.js"; import type { RemoteModelImportPlan } from "./personal-model-membership.js"; import type { - ModelConfig, - ModelConfigObject, - ModelId, - ProviderConfig, - ProviderConfigObject, - ProviderId, - ProviderTemplateId, + ModelConfig, + ModelConfigObject, + ModelId, + ProviderConfig, + ProviderConfigObject, + ProviderId, + ProviderTemplateId, } from "./config/index.js"; import { - ModelConfigRules, - ProviderTemplateMap, - type ProviderConfigRule, - type ProviderTemplateNameMap, - type ProviderTemplateLocale, - parseModelConfig, - parseProviderConfig, + ModelConfigRules, + ProviderTemplateMap, + type ProviderConfigRule, + type ProviderTemplateNameMap, + type ProviderTemplateLocale, + parseModelConfig, + parseProviderConfig, } from "./config/index.js"; import type { ProviderRegistryView } from "./registry.js"; import { - serializeRegistryModelConfig, - serializeRegistryProviderConfig, - type ProviderConfigResolution, - type RegistryModelConfigObject, + serializeRegistryModelConfig, + serializeRegistryProviderConfig, + type ProviderConfigResolution, + type RegistryModelConfigObject, } from "./resolver.js"; import { resolveInitialModelSelection } from "./model-selection-config.js"; import { - resolveEffectiveModelSelection, - type EffectiveModelSelectionResult, - type ModelSelectionProviderClassifier, + resolveEffectiveModelSelection, + type EffectiveModelSelectionResult, + type ModelSelectionProviderClassifier, } from "./effective-model-selection.js"; import type { ModelSelection } from "./registry.js"; import type { - ProviderRegistryServiceChangedEvent, - ProviderRegistryServiceSnapshot, + ProviderRegistryServiceChangedEvent, + ProviderRegistryServiceSnapshot, } from "./registry-service.js"; export interface ProviderRegistryFacadeSource { - getSnapshot(): ProviderRegistryServiceSnapshot | null; - getView(): ProviderRegistryView; - refresh(reason: string): Promise; - onDidChange(listener: (event: ProviderRegistryServiceChangedEvent) => void): () => void; + getSnapshot(): ProviderRegistryServiceSnapshot | null; + getView(): ProviderRegistryView; + refresh(reason: string): Promise; + onDidChange(listener: (event: ProviderRegistryServiceChangedEvent) => void): () => void; } export interface ProviderSettingsMutationTarget { - createPersonalProvider(input?: { - readonly templateId?: ProviderTemplateId; - readonly providerName?: string; - readonly locale?: ProviderTemplateLocale; - readonly initialConfig?: ProviderConfig; - }): Promise<{ readonly providerId: ProviderId }>; - savePersonalProviderOverlay( - providerId: ProviderId, - config: ProviderConfig, - membership?: ProviderModelMembership, - metadata?: Pick, - ): Promise; - deletePersonalProvider(providerId: ProviderId): Promise; - reorderPersonalProviders(providerIds: readonly ProviderId[]): Promise; - reorderPersonalModels( - providerId: ProviderId, - modelIds: readonly ModelId[], - membership?: ProviderModelMembership, - ): Promise; - addPersonalModel( - providerId: ProviderId, - modelId: ModelId, - config: ModelConfig, - membership?: ProviderModelMembership, - useRecommendedConfig?: boolean, - ): Promise; - renamePersonalModel( - providerId: ProviderId, - currentModelId: ModelId, - nextModelId: ModelId, - membership?: ProviderModelMembership, - ): Promise; - deletePersonalModel( - providerId: ProviderId, - modelId: ModelId, - membership?: ProviderModelMembership, - ): Promise; - importRemoteModels( - providerId: ProviderId, - remoteModelIds: readonly ModelId[], - membership?: ProviderModelMembership, - ): Promise; - clearVisibleModels( - providerId: ProviderId, - membership?: ProviderModelMembership, - ): Promise; - savePersonalModelDraft( - providerId: ProviderId, - originalModelId: ModelId, - nextModelId: ModelId, - config: ModelConfig, - expectedPersonalRevision: string, - useRecommendedConfig?: boolean, - membership?: ProviderModelMembership, - ): Promise; - setPersonalModelEnabled( - providerId: ProviderId, - modelId: ModelId, - enabled: boolean, - membership?: ProviderModelMembership, - ): Promise; - refresh(reason: string): Promise; - /** 主动重读上游事实后再刷新 Registry。 */ - refreshSources?(reason: string): Promise; + createPersonalProvider(input?: { + readonly templateId?: ProviderTemplateId; + readonly providerName?: string; + readonly locale?: ProviderTemplateLocale; + readonly initialConfig?: ProviderConfig; + }): Promise<{ readonly providerId: ProviderId }>; + savePersonalProviderOverlay( + providerId: ProviderId, + config: ProviderConfig, + membership?: ProviderModelMembership, + metadata?: Pick, + ): Promise; + deletePersonalProvider(providerId: ProviderId): Promise; + reorderPersonalProviders(providerIds: readonly ProviderId[]): Promise; + reorderPersonalModels( + providerId: ProviderId, + modelIds: readonly ModelId[], + membership?: ProviderModelMembership, + ): Promise; + addPersonalModel( + providerId: ProviderId, + modelId: ModelId, + config: ModelConfig, + membership?: ProviderModelMembership, + useRecommendedConfig?: boolean, + ): Promise; + renamePersonalModel( + providerId: ProviderId, + currentModelId: ModelId, + nextModelId: ModelId, + membership?: ProviderModelMembership, + ): Promise; + deletePersonalModel( + providerId: ProviderId, + modelId: ModelId, + membership?: ProviderModelMembership, + ): Promise; + importRemoteModels( + providerId: ProviderId, + remoteModelIds: readonly ModelId[], + membership?: ProviderModelMembership, + modelConfigs?: Readonly>, + ): Promise; + clearVisibleModels( + providerId: ProviderId, + membership?: ProviderModelMembership, + ): Promise; + savePersonalModelDraft( + providerId: ProviderId, + originalModelId: ModelId, + nextModelId: ModelId, + config: ModelConfig, + expectedPersonalRevision: string, + useRecommendedConfig?: boolean, + membership?: ProviderModelMembership, + ): Promise; + setPersonalModelEnabled( + providerId: ProviderId, + modelId: ModelId, + enabled: boolean, + membership?: ProviderModelMembership, + ): Promise; + refresh(reason: string): Promise; + /** 主动重读上游事实后再刷新 Registry。 */ + refreshSources?(reason: string): Promise; } export interface ProviderSettingsModelCandidateView { - readonly kind: "candidate"; - readonly modelId: ModelId; - readonly builtin: boolean; - readonly effectiveBuiltinConfig: ModelConfigObject; - readonly personalExactConfig?: ModelConfigObject; - readonly useRecommendedConfig?: boolean; - readonly effectiveConfig: RegistryModelConfigObject | ModelConfigObject; - readonly enabled: boolean; - readonly executable: boolean; - readonly selectable: boolean; - readonly issues: readonly ConfigValidationIssue[]; + readonly kind: "candidate"; + readonly modelId: ModelId; + readonly builtin: boolean; + readonly effectiveBuiltinConfig: ModelConfigObject; + readonly personalExactConfig?: ModelConfigObject; + readonly useRecommendedConfig?: boolean; + readonly effectiveConfig: RegistryModelConfigObject | ModelConfigObject; + readonly enabled: boolean; + readonly executable: boolean; + readonly selectable: boolean; + readonly issues: readonly ConfigValidationIssue[]; } export type ProviderSettingsModelView = ProviderSettingsModelCandidateView; export interface ModelConfigResolution { - readonly inheritedConfig: ModelConfigObject; - readonly effectiveConfig: ModelConfigObject; - readonly issues: readonly ConfigValidationIssue[]; + readonly inheritedConfig: ModelConfigObject; + readonly effectiveConfig: ModelConfigObject; + readonly issues: readonly ConfigValidationIssue[]; } export type ResolveModelConfigInput = - | { - readonly providerId: ProviderId; - readonly modelId: ModelId; - } - | { - readonly providerId: ProviderId; - readonly originalModelId: ModelId; - readonly modelId: ModelId; - readonly personalConfig: ModelConfigObject; - }; + | { + readonly providerId: ProviderId; + readonly modelId: ModelId; + } + | { + readonly providerId: ProviderId; + readonly originalModelId: ModelId; + readonly modelId: ModelId; + readonly personalConfig: ModelConfigObject; + }; export interface SavePersonalModelDraftInput { - readonly providerId: ProviderId; - readonly originalModelId: ModelId; - readonly nextModelId: ModelId; - readonly personalConfig: ModelConfigObject; - readonly useRecommendedConfig?: boolean; - readonly basedOnRevision: number; + readonly providerId: ProviderId; + readonly originalModelId: ModelId; + readonly nextModelId: ModelId; + readonly personalConfig: ModelConfigObject; + readonly useRecommendedConfig?: boolean; + readonly basedOnRevision: number; } export interface ProviderSettingsProviderView extends Pick< - ProviderConfigRule, - "providerName" | "templateId" + ProviderConfigRule, + "providerName" | "templateId" > { - readonly enabled: boolean; - readonly accountState?: import("./account-provider-state.js").AccountProviderState; - readonly providerId: ProviderId; - /** 当前 Effective Config 是否已经进入 Registry,可用于模型选择和创建。 */ - readonly executable: boolean; - readonly effectiveBuiltinConfig?: ProviderConfigObject; - readonly personalConfig?: ProviderConfigObject; - readonly effectiveConfig: ProviderConfigObject; - readonly issues: readonly ConfigValidationIssue[]; - readonly models: readonly ProviderSettingsModelView[]; + readonly enabled: boolean; + readonly accountState?: import("./account-provider-state.js").AccountProviderState; + readonly providerId: ProviderId; + /** 当前 Effective Config 是否已经进入 Registry,可用于模型选择和创建。 */ + readonly executable: boolean; + readonly effectiveBuiltinConfig?: ProviderConfigObject; + readonly personalConfig?: ProviderConfigObject; + readonly effectiveConfig: ProviderConfigObject; + readonly issues: readonly ConfigValidationIssue[]; + readonly models: readonly ProviderSettingsModelView[]; } export interface ProviderSettingsTemplateView { - readonly templateId: ProviderTemplateId; - readonly templateNameMap: ProviderTemplateNameMap; - readonly config: ProviderConfigObject; + readonly templateId: ProviderTemplateId; + readonly templateNameMap: ProviderTemplateNameMap; + readonly config: ProviderConfigObject; } export interface ProviderSettingsView { - readonly revision: number; - readonly providerTemplates: readonly ProviderSettingsTemplateView[]; - readonly providerOrder: readonly ProviderId[]; - readonly providers: readonly ProviderSettingsProviderView[]; + readonly revision: number; + readonly providerTemplates: readonly ProviderSettingsTemplateView[]; + readonly providerOrder: readonly ProviderId[]; + readonly providers: readonly ProviderSettingsProviderView[]; } export interface ProviderSettingsRemoteModelsImportResult { - readonly view: ProviderSettingsView; - readonly addedModelIds: readonly ModelId[]; - readonly restoredInheritedModelIds: readonly ModelId[]; - readonly skippedModelIds: readonly ModelId[]; + readonly view: ProviderSettingsView; + readonly addedModelIds: readonly ModelId[]; + readonly restoredInheritedModelIds: readonly ModelId[]; + readonly skippedModelIds: readonly ModelId[]; } export interface ProviderSettingsCreationResult { - readonly providerId: ProviderId; - readonly view: ProviderSettingsView; + readonly providerId: ProviderId; + readonly view: ProviderSettingsView; } export interface ModelSelectionModelView { - readonly modelId: ModelId; - readonly config: RegistryModelConfigObject; + readonly modelId: ModelId; + readonly config: RegistryModelConfigObject; } export interface ModelSelectionProviderView extends Pick< - ProviderConfigRule, - "providerName" | "templateId" + ProviderConfigRule, + "providerName" | "templateId" > { - readonly providerId: ProviderId; - readonly config: ProviderConfigObject; - readonly models: readonly ModelSelectionModelView[]; + readonly providerId: ProviderId; + readonly config: ProviderConfigObject; + readonly models: readonly ModelSelectionModelView[]; } export interface ModelSelectionViewInput { - readonly selection: ModelSelection | null; + readonly selection: ModelSelection | null; } export interface ModelSelectionView extends Partial { - readonly revision: number; - readonly providers: readonly ModelSelectionProviderView[]; - readonly preferredSelection?: ModelSelection; + readonly revision: number; + readonly providers: readonly ModelSelectionProviderView[]; + readonly preferredSelection?: ModelSelection; } export class ProviderSettingsFacade { - readonly #source: ProviderRegistryFacadeSource; - readonly #mutations?: ProviderSettingsMutationTarget; - readonly #providerMutationTails = new Map>(); - - constructor(source: ProviderRegistryFacadeSource, mutations?: ProviderSettingsMutationTarget) { - this.#source = source; - this.#mutations = mutations; - } - - getView(): ProviderSettingsView { - const snapshot = requireSnapshot(this.#source); - return createProviderSettingsView({ - revision: snapshot.registry.revision, - kcodeBuiltinProviders: snapshot.config.kcodeBuiltinProviders, - kcodeBuiltinProviderTemplates: snapshot.config.kcodeBuiltinProviderTemplates, - personalProviders: snapshot.config.personalProviders, - personalModels: snapshot.config.personalModels, - resolution: snapshot.resolution, - accountStates: snapshot.account.states, - }); - } + readonly #source: ProviderRegistryFacadeSource; + readonly #mutations?: ProviderSettingsMutationTarget; + readonly #providerMutationTails = new Map>(); - async refresh(reason: string): Promise { - if (this.#mutations?.refreshSources) { - await this.#mutations.refreshSources(`settings:${reason}`); - } else { - await this.#source.refresh(reason); + constructor(source: ProviderRegistryFacadeSource, mutations?: ProviderSettingsMutationTarget) { + this.#source = source; + this.#mutations = mutations; } - return this.getView(); - } - - resolveModelConfig(input: ResolveModelConfigInput): ModelConfigResolution { - const snapshot = requireSnapshot(this.#source); - const provider = requireEffectiveProvider(snapshot, input.providerId); - if (!("personalConfig" in input)) { - const modelRules = ModelConfigRules.composeEffective( - snapshot.config.kcodeBuiltinModelRules, - snapshot.config.personalModels, - ); - const config = modelRules.resolve({ - providerId: input.providerId, - templateId: provider.templateId, - modelId: input.modelId, - apiType: provider.config.api?.type, - baseUrl: provider.config.api?.baseUrl, - }); - return Object.freeze({ - inheritedConfig: config.toJSON(), - effectiveConfig: config.toJSON(), - issues: Object.freeze([ - ...config.validateComplete(["providers", input.providerId, "models", input.modelId]), - ]), - }); + + getView(): ProviderSettingsView { + const snapshot = requireSnapshot(this.#source); + return createProviderSettingsView({ + revision: snapshot.registry.revision, + kcodeBuiltinProviders: snapshot.config.kcodeBuiltinProviders, + kcodeBuiltinProviderTemplates: snapshot.config.kcodeBuiltinProviderTemplates, + personalProviders: snapshot.config.personalProviders, + personalModels: snapshot.config.personalModels, + resolution: snapshot.resolution, + accountStates: snapshot.account.states, + }); } - const personalConfig = parseModelConfig(input.personalConfig); - const inheritedConfig = snapshot.config.kcodeBuiltinModelRules.resolve({ - providerId: input.providerId, - templateId: provider.templateId, - modelId: input.modelId, - apiType: provider.config.api?.type, - baseUrl: provider.config.api?.baseUrl, - }); - let personalRules = snapshot.config.personalModels; - if (input.originalModelId !== input.modelId) { - personalRules = personalRules.renameExactModel( - input.providerId, - input.originalModelId, - input.modelId, - ); + async refresh(reason: string): Promise { + if (this.#mutations?.refreshSources) { + await this.#mutations.refreshSources(`settings:${reason}`); + } else { + await this.#source.refresh(reason); + } + return this.getView(); } - // 此入口预览智能配置草稿;固定模式不请求推荐,重新开启时不能沿用旧固定标记。 - personalRules = personalRules.setExact(input.providerId, input.modelId, personalConfig, true); - const config = ModelConfigRules.composeEffective( - snapshot.config.kcodeBuiltinModelRules, - personalRules, - ).resolve({ - providerId: input.providerId, - templateId: provider.templateId, - modelId: input.modelId, - apiType: provider.config.api?.type, - baseUrl: provider.config.api?.baseUrl, - }); - return Object.freeze({ - inheritedConfig: inheritedConfig.toJSON(), - effectiveConfig: config.toJSON(), - issues: Object.freeze([ - ...config.validateComplete(["providers", input.providerId, "models", input.modelId]), - ]), - }); - } - - onDidChange(listener: (view: ProviderSettingsView) => void): () => void { - return this.#source.onDidChange(() => listener(this.getView())); - } - - createPersonalProvider(input?: { - readonly templateId?: ProviderTemplateId; - readonly providerName?: string; - readonly locale?: ProviderTemplateLocale; - readonly initialConfig?: ProviderConfigObject; - }): Promise { - return this.#mutateWithResult("create-provider", (target) => - target.createPersonalProvider({ - ...(input?.templateId ? { templateId: input.templateId } : {}), - ...(input?.providerName ? { providerName: input.providerName } : {}), - ...(input?.locale ? { locale: input.locale } : {}), - ...(input?.initialConfig - ? { initialConfig: parseProviderConfig(input.initialConfig) } - : {}), - }), - ).then(({ result, view }) => ({ providerId: result.providerId, view })); - } - - savePersonalProviderOverlay( - providerId: ProviderId, - config: ProviderConfigObject, - metadata?: Pick, - ): Promise { - return this.#mutateProvider(providerId, "save-provider", (target) => - target.savePersonalProviderOverlay( - providerId, - parseProviderConfig(config), - this.#modelMembership(providerId), - metadata, - ), - ); - } - deletePersonalProvider(providerId: ProviderId): Promise { - return this.#mutateProvider(providerId, "delete-provider", (target) => - target.deletePersonalProvider(providerId), - ); - } + resolveModelConfig(input: ResolveModelConfigInput): ModelConfigResolution { + const snapshot = requireSnapshot(this.#source); + const provider = requireEffectiveProvider(snapshot, input.providerId); + if (!("personalConfig" in input)) { + const modelRules = ModelConfigRules.composeEffective( + snapshot.config.kcodeBuiltinModelRules, + snapshot.config.personalModels, + ); + const config = modelRules.resolve({ + providerId: input.providerId, + templateId: provider.templateId, + modelId: input.modelId, + apiType: provider.config.api?.type, + baseUrl: provider.config.api?.baseUrl, + }); + return Object.freeze({ + inheritedConfig: config.toJSON(), + effectiveConfig: config.toJSON(), + issues: Object.freeze([ + ...config.validateComplete([ + "providers", + input.providerId, + "models", + input.modelId, + ]), + ]), + }); + } + + const personalConfig = parseModelConfig(input.personalConfig); + const inheritedConfig = snapshot.config.kcodeBuiltinModelRules.resolve({ + providerId: input.providerId, + templateId: provider.templateId, + modelId: input.modelId, + apiType: provider.config.api?.type, + baseUrl: provider.config.api?.baseUrl, + }); + let personalRules = snapshot.config.personalModels; + if (input.originalModelId !== input.modelId) { + personalRules = personalRules.renameExactModel( + input.providerId, + input.originalModelId, + input.modelId, + ); + } + // 此入口预览智能配置草稿;固定模式不请求推荐,重新开启时不能沿用旧固定标记。 + personalRules = personalRules.setExact( + input.providerId, + input.modelId, + personalConfig, + true, + ); + const config = ModelConfigRules.composeEffective( + snapshot.config.kcodeBuiltinModelRules, + personalRules, + ).resolve({ + providerId: input.providerId, + templateId: provider.templateId, + modelId: input.modelId, + apiType: provider.config.api?.type, + baseUrl: provider.config.api?.baseUrl, + }); + return Object.freeze({ + inheritedConfig: inheritedConfig.toJSON(), + effectiveConfig: config.toJSON(), + issues: Object.freeze([ + ...config.validateComplete([ + "providers", + input.providerId, + "models", + input.modelId, + ]), + ]), + }); + } - reorderPersonalProviders(providerIds: readonly ProviderId[]): Promise { - return this.#mutate("reorder-providers", (target) => - target.reorderPersonalProviders(providerIds), - ); - } + /** 不含通用 `.*` 模型兜底,供目录信息只填补真正空缺的字段。 */ + 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(); + } - reorderPersonalModels( - providerId: ProviderId, - modelIds: readonly ModelId[], - ): Promise { - return this.#mutateProvider(providerId, "reorder-models", (target) => - target.reorderPersonalModels(providerId, modelIds, this.#modelMembership(providerId)), - ); - } + onDidChange(listener: (view: ProviderSettingsView) => void): () => void { + return this.#source.onDidChange(() => listener(this.getView())); + } - addPersonalModel( - providerId: ProviderId, - modelId: ModelId, - config: ModelConfigObject, - useRecommendedConfig?: boolean, - ): Promise { - return this.#mutateProvider(providerId, "add-model", (target) => - target.addPersonalModel( - providerId, - modelId, - parseModelConfig(config), - this.#modelMembership(providerId), - useRecommendedConfig, - ), - ); - } + createPersonalProvider(input?: { + readonly templateId?: ProviderTemplateId; + readonly providerName?: string; + readonly locale?: ProviderTemplateLocale; + readonly initialConfig?: ProviderConfigObject; + }): Promise { + return this.#mutateWithResult("create-provider", (target) => + target.createPersonalProvider({ + ...(input?.templateId ? { templateId: input.templateId } : {}), + ...(input?.providerName ? { providerName: input.providerName } : {}), + ...(input?.locale ? { locale: input.locale } : {}), + ...(input?.initialConfig + ? { initialConfig: parseProviderConfig(input.initialConfig) } + : {}), + }), + ).then(({ result, view }) => ({ providerId: result.providerId, view })); + } - renamePersonalModel( - providerId: ProviderId, - currentModelId: ModelId, - nextModelId: ModelId, - ): Promise { - return this.#mutateProvider(providerId, "rename-model", (target) => - target.renamePersonalModel( - providerId, - currentModelId, - nextModelId, - this.#modelMembership(providerId), - ), - ); - } + savePersonalProviderOverlay( + providerId: ProviderId, + config: ProviderConfigObject, + metadata?: Pick, + ): Promise { + return this.#mutateProvider(providerId, "save-provider", (target) => + target.savePersonalProviderOverlay( + providerId, + parseProviderConfig(config), + this.#modelMembership(providerId), + metadata, + ), + ); + } - deletePersonalModel(providerId: ProviderId, modelId: ModelId): Promise { - return this.#mutateProvider(providerId, "delete-personal-model", (target) => - target.deletePersonalModel(providerId, modelId, this.#modelMembership(providerId)), - ); - } + deletePersonalProvider(providerId: ProviderId): Promise { + return this.#mutateProvider(providerId, "delete-provider", (target) => + target.deletePersonalProvider(providerId), + ); + } - async importRemoteModels( - providerId: ProviderId, - remoteModelIds: readonly ModelId[], - ): Promise { - let plan: RemoteModelImportPlan | undefined; - const view = await this.#mutateProvider(providerId, "import-remote-models", async (target) => { - plan = await target.importRemoteModels( - providerId, - remoteModelIds, - this.#modelMembership(providerId), - ); - }); - if (!plan) throw new Error(`导入远端模型失败: ${providerId}`); - return { - view, - addedModelIds: plan.addedModelIds, - restoredInheritedModelIds: plan.restoredInheritedModelIds, - skippedModelIds: plan.skippedModelIds, - }; - } + reorderPersonalProviders(providerIds: readonly ProviderId[]): Promise { + return this.#mutate("reorder-providers", (target) => + target.reorderPersonalProviders(providerIds), + ); + } - clearVisibleModels(providerId: ProviderId): Promise { - return this.#mutateProvider(providerId, "clear-visible-models", (target) => - target.clearVisibleModels(providerId, this.#modelMembership(providerId)), - ); - } - - savePersonalModelDraft(input: SavePersonalModelDraftInput): Promise { - return this.#mutateProvider(input.providerId, "save-model-draft", async (target) => { - const snapshot = requireSnapshot(this.#source); - if (snapshot.registry.revision !== input.basedOnRevision) { - throw new Error( - `Provider Settings revision conflict: expected ${input.basedOnRevision}, current ${snapshot.registry.revision}`, + reorderPersonalModels( + providerId: ProviderId, + modelIds: readonly ModelId[], + ): Promise { + return this.#mutateProvider(providerId, "reorder-models", (target) => + target.reorderPersonalModels(providerId, modelIds, this.#modelMembership(providerId)), ); - } - const parsedConfig = parseModelConfig(input.personalConfig); - await target.savePersonalModelDraft( - input.providerId, - input.originalModelId, - input.nextModelId, - parsedConfig, - snapshot.config.personalRevision, - input.useRecommendedConfig, - this.#modelMembership(input.providerId, snapshot), - ); - }); - } + } - setPersonalModelEnabled( - providerId: ProviderId, - modelId: ModelId, - enabled: boolean, - ): Promise { - return this.#mutateProvider(providerId, "set-model-enabled", (target) => - target.setPersonalModelEnabled( - providerId, - modelId, - enabled, - this.#modelMembership(providerId), - ), - ); - } + addPersonalModel( + providerId: ProviderId, + modelId: ModelId, + config: ModelConfigObject, + useRecommendedConfig?: boolean, + ): Promise { + return this.#mutateProvider(providerId, "add-model", (target) => + target.addPersonalModel( + providerId, + modelId, + parseModelConfig(config), + this.#modelMembership(providerId), + useRecommendedConfig, + ), + ); + } - #modelMembership( - providerId: ProviderId, - snapshot = requireSnapshot(this.#source), - ): ProviderModelMembership { - const provider = snapshot.resolution.resolvedProviders.find( - (item) => item.providerId === providerId, - ); - if (!provider) throw new Error(`Provider 不存在: ${providerId}`); - // 配置成员与可执行/可见模型不是同一名单:禁用、无权益、不完整和已隐藏的 - // 继承模型仍属于该实例,不能只按当前 Settings 列表回推。 - // Account 的空/替换名单以 overlay 后的 builtinModelIds 为准,不再与静态 Built-in 取并集。 - return Object.freeze({ - providerId, - inheritedModelIds: Object.freeze([...(provider.config.builtinModelIds ?? [])]), - personalRevision: snapshot.config.personalRevision, - assertCurrent: () => { - if (this.#source.getSnapshot() !== snapshot) - throw new Error("Provider Settings snapshot revision conflict"); - }, - }); - } - - async #mutate( - reason: string, - operation: (target: ProviderSettingsMutationTarget) => Promise, - ): Promise { - if (!this.#mutations) throw new Error("ProviderSettingsFacade 未配置写入目标"); - await operation(this.#mutations); - await this.#mutations.refresh(`settings:${reason}`); - return this.getView(); - } - - #mutateProvider( - providerId: ProviderId, - reason: string, - operation: (target: ProviderSettingsMutationTarget) => Promise, - ): Promise { - const previous = this.#providerMutationTails.get(providerId) ?? Promise.resolve(); - const waitForPrevious = previous.catch(() => undefined); - const result = waitForPrevious.then(() => this.#mutate(reason, operation)); - this.#providerMutationTails.set(providerId, result); - const cleanup = () => { - if (this.#providerMutationTails.get(providerId) === result) { - this.#providerMutationTails.delete(providerId); - } - }; - void result.then(cleanup, cleanup); - return result; - } - - async waitForProviderOperations(providerId: ProviderId): Promise { - await this.#providerMutationTails.get(providerId); - } - - async #mutateWithResult( - reason: string, - operation: (target: ProviderSettingsMutationTarget) => Promise, - ): Promise<{ readonly result: TResult; readonly view: ProviderSettingsView }> { - if (!this.#mutations) throw new Error("ProviderSettingsFacade 未配置写入目标"); - const result = await operation(this.#mutations); - await this.#mutations.refresh(`settings:${reason}`); - return { result, view: this.getView() }; - } + renamePersonalModel( + providerId: ProviderId, + currentModelId: ModelId, + nextModelId: ModelId, + ): Promise { + return this.#mutateProvider(providerId, "rename-model", (target) => + target.renamePersonalModel( + providerId, + currentModelId, + nextModelId, + this.#modelMembership(providerId), + ), + ); + } + + deletePersonalModel(providerId: ProviderId, modelId: ModelId): Promise { + return this.#mutateProvider(providerId, "delete-personal-model", (target) => + target.deletePersonalModel(providerId, modelId, this.#modelMembership(providerId)), + ); + } + + 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) => { + plan = await target.importRemoteModels( + providerId, + remoteModelIds, + this.#modelMembership(providerId), + modelConfigs, + ); + }, + ); + if (!plan) throw new Error(`导入远端模型失败: ${providerId}`); + return { + view, + addedModelIds: plan.addedModelIds, + restoredInheritedModelIds: plan.restoredInheritedModelIds, + skippedModelIds: plan.skippedModelIds, + }; + } + + clearVisibleModels(providerId: ProviderId): Promise { + return this.#mutateProvider(providerId, "clear-visible-models", (target) => + target.clearVisibleModels(providerId, this.#modelMembership(providerId)), + ); + } + + savePersonalModelDraft(input: SavePersonalModelDraftInput): Promise { + return this.#mutateProvider(input.providerId, "save-model-draft", async (target) => { + const snapshot = requireSnapshot(this.#source); + if (snapshot.registry.revision !== input.basedOnRevision) { + throw new Error( + `Provider Settings revision conflict: expected ${input.basedOnRevision}, current ${snapshot.registry.revision}`, + ); + } + const parsedConfig = parseModelConfig(input.personalConfig); + await target.savePersonalModelDraft( + input.providerId, + input.originalModelId, + input.nextModelId, + parsedConfig, + snapshot.config.personalRevision, + input.useRecommendedConfig, + this.#modelMembership(input.providerId, snapshot), + ); + }); + } + + setPersonalModelEnabled( + providerId: ProviderId, + modelId: ModelId, + enabled: boolean, + ): Promise { + return this.#mutateProvider(providerId, "set-model-enabled", (target) => + target.setPersonalModelEnabled( + providerId, + modelId, + enabled, + this.#modelMembership(providerId), + ), + ); + } + + #modelMembership( + providerId: ProviderId, + snapshot = requireSnapshot(this.#source), + ): ProviderModelMembership { + const provider = snapshot.resolution.resolvedProviders.find( + (item) => item.providerId === providerId, + ); + if (!provider) throw new Error(`Provider 不存在: ${providerId}`); + // 配置成员与可执行/可见模型不是同一名单:禁用、无权益、不完整和已隐藏的 + // 继承模型仍属于该实例,不能只按当前 Settings 列表回推。 + // Account 的空/替换名单以 overlay 后的 builtinModelIds 为准,不再与静态 Built-in 取并集。 + return Object.freeze({ + providerId, + inheritedModelIds: Object.freeze([...(provider.config.builtinModelIds ?? [])]), + personalRevision: snapshot.config.personalRevision, + assertCurrent: () => { + if (this.#source.getSnapshot() !== snapshot) + throw new Error("Provider Settings snapshot revision conflict"); + }, + }); + } + + async #mutate( + reason: string, + operation: (target: ProviderSettingsMutationTarget) => Promise, + ): Promise { + if (!this.#mutations) throw new Error("ProviderSettingsFacade 未配置写入目标"); + await operation(this.#mutations); + await this.#mutations.refresh(`settings:${reason}`); + return this.getView(); + } + + #mutateProvider( + providerId: ProviderId, + reason: string, + operation: (target: ProviderSettingsMutationTarget) => Promise, + ): Promise { + const previous = this.#providerMutationTails.get(providerId) ?? Promise.resolve(); + const waitForPrevious = previous.catch(() => undefined); + const result = waitForPrevious.then(() => this.#mutate(reason, operation)); + this.#providerMutationTails.set(providerId, result); + const cleanup = () => { + if (this.#providerMutationTails.get(providerId) === result) { + this.#providerMutationTails.delete(providerId); + } + }; + void result.then(cleanup, cleanup); + return result; + } + + async waitForProviderOperations(providerId: ProviderId): Promise { + await this.#providerMutationTails.get(providerId); + } + + async #mutateWithResult( + reason: string, + operation: (target: ProviderSettingsMutationTarget) => Promise, + ): Promise<{ readonly result: TResult; readonly view: ProviderSettingsView }> { + if (!this.#mutations) throw new Error("ProviderSettingsFacade 未配置写入目标"); + const result = await operation(this.#mutations); + await this.#mutations.refresh(`settings:${reason}`); + return { result, view: this.getView() }; + } } export class ModelSelectionFacade { - readonly #source: ProviderRegistryFacadeSource; - readonly #classifyProvider: ModelSelectionProviderClassifier; - readonly #resolveLegacyReasoningLevel?: ( - snapshot: ProviderRegistryServiceSnapshot, - selection: ModelSelection, - ) => string | undefined; - - constructor( - source: ProviderRegistryFacadeSource, - classifyProvider: ModelSelectionProviderClassifier = () => "ordinary", - resolveLegacyReasoningLevel?: ( - snapshot: ProviderRegistryServiceSnapshot, - selection: ModelSelection, - ) => string | undefined, - ) { - this.#source = source; - this.#classifyProvider = classifyProvider; - this.#resolveLegacyReasoningLevel = resolveLegacyReasoningLevel; - } - - getView( - configuredDefault?: ModelSelection, - revision?: number, - input?: ModelSelectionViewInput, - ): ModelSelectionView { - // 账号事实与候选从同一已应用快照读取;不把最新 Settings 配给旧 Registry。 - const snapshot = input ? requireSnapshot(this.#source) : this.#source.getSnapshot(); - const registry = snapshot?.registry ?? this.#source.getView(); - const resolveLegacyReasoningLevel = - snapshot && this.#resolveLegacyReasoningLevel - ? (selection: ModelSelection) => this.#resolveLegacyReasoningLevel!(snapshot, selection) - : undefined; - // 默认偏好只归一化档位,不借此改写账号身份或保存配置。 - const normalizedDefault = - configuredDefault && resolveLegacyReasoningLevel - ? (resolveEffectiveModelSelection({ - selection: configuredDefault, - registry, - classifyProvider: () => "ordinary", - resolveLegacyReasoningLevel, - }).effectiveSelection ?? undefined) - : configuredDefault; - const initial = resolveInitialModelSelection({ - registry, - configuredDefault: normalizedDefault, - }); - return Object.freeze({ - revision: revision ?? registry.revision, - providers: Object.freeze( - registry.providers - .filter((provider) => provider.config.visibility !== "hidden") - .map(projectModelSelectionProviderView), - ), - ...(initial.source === "none" ? {} : { preferredSelection: initial.selection }), - ...(input - ? resolveEffectiveModelSelection({ - selection: input.selection, + readonly #source: ProviderRegistryFacadeSource; + readonly #classifyProvider: ModelSelectionProviderClassifier; + readonly #resolveLegacyReasoningLevel?: ( + snapshot: ProviderRegistryServiceSnapshot, + selection: ModelSelection, + ) => string | undefined; + + constructor( + source: ProviderRegistryFacadeSource, + classifyProvider: ModelSelectionProviderClassifier = () => "ordinary", + resolveLegacyReasoningLevel?: ( + snapshot: ProviderRegistryServiceSnapshot, + selection: ModelSelection, + ) => string | undefined, + ) { + this.#source = source; + this.#classifyProvider = classifyProvider; + this.#resolveLegacyReasoningLevel = resolveLegacyReasoningLevel; + } + + getView( + configuredDefault?: ModelSelection, + revision?: number, + input?: ModelSelectionViewInput, + ): ModelSelectionView { + // 账号事实与候选从同一已应用快照读取;不把最新 Settings 配给旧 Registry。 + const snapshot = input ? requireSnapshot(this.#source) : this.#source.getSnapshot(); + const registry = snapshot?.registry ?? this.#source.getView(); + const resolveLegacyReasoningLevel = + snapshot && this.#resolveLegacyReasoningLevel + ? (selection: ModelSelection) => + this.#resolveLegacyReasoningLevel!(snapshot, selection) + : undefined; + // 默认偏好只归一化档位,不借此改写账号身份或保存配置。 + const normalizedDefault = + configuredDefault && resolveLegacyReasoningLevel + ? (resolveEffectiveModelSelection({ + selection: configuredDefault, + registry, + classifyProvider: () => "ordinary", + resolveLegacyReasoningLevel, + }).effectiveSelection ?? undefined) + : configuredDefault; + const initial = resolveInitialModelSelection({ registry, - accountStates: snapshot?.account.states, - classifyProvider: this.#classifyProvider, - resolveLegacyReasoningLevel, - }) - : {}), - }); - } + configuredDefault: normalizedDefault, + }); + return Object.freeze({ + revision: revision ?? registry.revision, + providers: Object.freeze( + registry.providers + .filter((provider) => provider.config.visibility !== "hidden") + .map(projectModelSelectionProviderView), + ), + ...(initial.source === "none" ? {} : { preferredSelection: initial.selection }), + ...(input + ? resolveEffectiveModelSelection({ + selection: input.selection, + registry, + accountStates: snapshot?.account.states, + classifyProvider: this.#classifyProvider, + resolveLegacyReasoningLevel, + }) + : {}), + }); + } - onDidChange(listener: (view: ModelSelectionView) => void): () => void { - return this.#source.onDidChange(() => listener(this.getView())); - } + onDidChange(listener: (view: ModelSelectionView) => void): () => void { + return this.#source.onDidChange(() => listener(this.getView())); + } } /** 把一个已进入 Registry 的 Provider 投影为标准模型选择候选。调用方负责产品作用域。 */ export function projectModelSelectionProviderView( - provider: ProviderRegistryView["providers"][number], + provider: ProviderRegistryView["providers"][number], ): ModelSelectionProviderView { - return Object.freeze({ - providerId: provider.providerId, - providerName: provider.providerName, - templateId: provider.templateId, - config: serializeRegistryProviderConfig(provider.config), - models: Object.freeze( - provider.models.map((model) => - Object.freeze({ - modelId: model.modelId, - config: serializeRegistryModelConfig(model.config), - }), - ), - ), - }); + return Object.freeze({ + providerId: provider.providerId, + providerName: provider.providerName, + templateId: provider.templateId, + config: serializeRegistryProviderConfig(provider.config), + models: Object.freeze( + provider.models.map((model) => + Object.freeze({ + modelId: model.modelId, + config: serializeRegistryModelConfig(model.config), + }), + ), + ), + }); } function requireSnapshot(source: ProviderRegistryFacadeSource): ProviderRegistryServiceSnapshot { - const snapshot = source.getSnapshot(); - if (!snapshot) throw new Error("ProviderRegistryService 尚未 start()"); - return snapshot; + const snapshot = source.getSnapshot(); + if (!snapshot) throw new Error("ProviderRegistryService 尚未 start()"); + return snapshot; } function requireEffectiveProvider( - snapshot: ProviderRegistryServiceSnapshot, - providerId: ProviderId, + snapshot: ProviderRegistryServiceSnapshot, + providerId: ProviderId, ): ProviderConfigRule { - const provider = snapshot.resolution.effectiveProviders.getRule(providerId); - if (!provider) throw new Error(`Provider 不存在: ${providerId}`); - return provider; + const provider = snapshot.resolution.effectiveProviders.getRule(providerId); + if (!provider) throw new Error(`Provider 不存在: ${providerId}`); + return provider; } function createProviderSettingsView(input: { - revision: number; - kcodeBuiltinProviders: ProviderRegistryServiceSnapshot["config"]["kcodeBuiltinProviders"]; - kcodeBuiltinProviderTemplates: ProviderRegistryServiceSnapshot["config"]["kcodeBuiltinProviderTemplates"]; - personalProviders: ProviderRegistryServiceSnapshot["config"]["personalProviders"]; - personalModels: ProviderRegistryServiceSnapshot["config"]["personalModels"]; - resolution: ProviderConfigResolution; - accountStates?: import("./account-provider-state.js").AccountProviderStates; + revision: number; + kcodeBuiltinProviders: ProviderRegistryServiceSnapshot["config"]["kcodeBuiltinProviders"]; + kcodeBuiltinProviderTemplates: ProviderRegistryServiceSnapshot["config"]["kcodeBuiltinProviderTemplates"]; + personalProviders: ProviderRegistryServiceSnapshot["config"]["personalProviders"]; + personalModels: ProviderRegistryServiceSnapshot["config"]["personalModels"]; + resolution: ProviderConfigResolution; + accountStates?: import("./account-provider-state.js").AccountProviderStates; }): ProviderSettingsView { - const executableProviderIds = new Set( - input.resolution.registryProviders.map((provider) => provider.providerId), - ); - const projectProvider = (provider: (typeof input.resolution.resolvedProviders)[number]) => { - const personalConfig = input.personalProviders.get(provider.providerId); + const executableProviderIds = new Set( + input.resolution.registryProviders.map((provider) => provider.providerId), + ); + const projectProvider = (provider: (typeof input.resolution.resolvedProviders)[number]) => { + const personalConfig = input.personalProviders.get(provider.providerId); + return Object.freeze({ + providerId: provider.providerId, + providerName: provider.providerName, + templateId: provider.templateId, + enabled: provider.enabled, + ...(input.accountStates?.[provider.providerId] + ? { accountState: input.accountStates[provider.providerId] } + : {}), + executable: executableProviderIds.has(provider.providerId), + ...(provider.templateConfig + ? { templateConfig: provider.templateConfig.toJSON() } + : {}), + ...(provider.effectiveBuiltinConfig + ? { effectiveBuiltinConfig: provider.effectiveBuiltinConfig.toJSON() } + : {}), + ...(personalConfig ? { personalConfig: personalConfig.toJSON() } : {}), + effectiveConfig: provider.config.toJSON(), + issues: provider.providerIssues, + models: Object.freeze( + provider.models.map((model) => { + const personalModelConfig = input.personalModels.getExact( + provider.providerId, + model.modelId, + ); + const personalModelRule = input.personalModels.getExactRule( + provider.providerId, + model.modelId, + ); + return Object.freeze({ + kind: model.kind, + modelId: model.modelId, + builtin: model.source === "builtin", + effectiveBuiltinConfig: model.effectiveBuiltinConfig.toJSON(), + ...(personalModelConfig + ? { personalExactConfig: personalModelConfig.toJSON() } + : {}), + ...(personalModelRule + ? { + useRecommendedConfig: + personalModelRule.type !== "manual-provider-model", + } + : {}), + effectiveConfig: model.config.toJSON(), + enabled: model.enabled, + executable: model.executable, + selectable: model.selectable, + issues: model.issues, + }); + }), + ), + }); + }; + const visibleProviders = input.resolution.resolvedProviders.filter( + (provider) => provider.config.visibility !== "hidden", + ); + const configuredProviders = visibleProviders; return Object.freeze({ - providerId: provider.providerId, - providerName: provider.providerName, - templateId: provider.templateId, - enabled: provider.enabled, - ...(input.accountStates?.[provider.providerId] - ? { accountState: input.accountStates[provider.providerId] } - : {}), - executable: executableProviderIds.has(provider.providerId), - ...(provider.templateConfig ? { templateConfig: provider.templateConfig.toJSON() } : {}), - ...(provider.effectiveBuiltinConfig - ? { effectiveBuiltinConfig: provider.effectiveBuiltinConfig.toJSON() } - : {}), - ...(personalConfig ? { personalConfig: personalConfig.toJSON() } : {}), - effectiveConfig: provider.config.toJSON(), - issues: provider.providerIssues, - models: Object.freeze( - provider.models.map((model) => { - const personalModelConfig = input.personalModels.getExact( - provider.providerId, - model.modelId, - ); - const personalModelRule = input.personalModels.getExactRule( - provider.providerId, - model.modelId, - ); - return Object.freeze({ - kind: model.kind, - modelId: model.modelId, - builtin: model.source === "builtin", - effectiveBuiltinConfig: model.effectiveBuiltinConfig.toJSON(), - ...(personalModelConfig ? { personalExactConfig: personalModelConfig.toJSON() } : {}), - ...(personalModelRule - ? { useRecommendedConfig: personalModelRule.type !== "manual-provider-model" } - : {}), - effectiveConfig: model.config.toJSON(), - enabled: model.enabled, - executable: model.executable, - selectable: model.selectable, - issues: model.issues, - }); - }), - ), - }); - }; - const visibleProviders = input.resolution.resolvedProviders.filter( - (provider) => provider.config.visibility !== "hidden", - ); - const configuredProviders = visibleProviders; - return Object.freeze({ - revision: input.revision, - providerTemplates: Object.freeze( - (input.kcodeBuiltinProviderTemplates ?? ProviderTemplateMap.empty()) - .entries() - .map(([templateId, template]) => - Object.freeze({ - templateId, - templateNameMap: template.templateNameMap, - config: template.config.toJSON(), - }), + revision: input.revision, + providerTemplates: Object.freeze( + (input.kcodeBuiltinProviderTemplates ?? ProviderTemplateMap.empty()) + .entries() + .map(([templateId, template]) => + Object.freeze({ + templateId, + templateNameMap: template.templateNameMap, + config: template.config.toJSON(), + }), + ), + ), + providerOrder: Object.freeze( + configuredProviders + .filter((provider) => provider.config.group === "standard-personal") + .map((provider) => provider.providerId), ), - ), - providerOrder: Object.freeze( - configuredProviders - .filter((provider) => provider.config.group === "standard-personal") - .map((provider) => provider.providerId), - ), - providers: Object.freeze(configuredProviders.map(projectProvider)), - }); + providers: Object.freeze(configuredProviders.map(projectProvider)), + }); } diff --git a/packages/services/src/model-provider/modelInfoCatalog.ts b/packages/services/src/model-provider/modelInfoCatalog.ts new file mode 100644 index 00000000..6fd6a590 --- /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 00000000..a84f9930 --- /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 00000000..a6ba35d6 --- /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 74fb88b0..33bec000 100644 --- a/packages/services/src/model-provider/providerFacadeServices.ts +++ b/packages/services/src/model-provider/providerFacadeServices.ts @@ -1,331 +1,389 @@ import type { Event } from "@kcode/rpc"; import { ServiceChannels } from "@kcode/shared"; import { - type ModelConfigObject, - type ModelId, - type ModelSelection, - type ModelSelectionFacade, - type ModelSelectionView, - type ModelSelectionViewInput, - type ProviderConfigObject, - type ProviderId, - type ProviderSettingsFacade, - type ProviderSettingsCreationResult, - type ModelConfigResolution, - type ProviderSettingsView, - type ResolveModelConfigInput, - type SavePersonalModelDraftInput, - isApiKeyAccess, + ModelConfig, + type ModelConfigObject, + type ModelId, + type ModelSelection, + type ModelSelectionFacade, + type ModelSelectionView, + type ModelSelectionViewInput, + type ProviderConfigObject, + type ProviderId, + type ProviderSettingsFacade, + type ProviderSettingsCreationResult, + type ModelConfigResolution, + type ProviderSettingsView, + type ResolveModelConfigInput, + type SavePersonalModelDraftInput, + isApiKeyAccess, } from "@kcode/provider"; +import { fetchProviderRemoteModels, ProviderRemoteModelsError } from "./providerRemoteModels.js"; +import { mergeCatalogModelInfo, modelConfigFromCatalog } from "./modelInfoCatalog.js"; import { - fetchProviderRemoteModelIds, - ProviderRemoteModelsError, -} from "./providerRemoteModels.js"; + cachedProviderModelInfo, + lookupPublicModelInfo, + rememberProviderModelInfo, +} from "./modelInfoCatalogFetch.js"; import { createServiceDescriptor } from "../descriptors.js"; import type { ModelConnectivityResult } from "@kcode/shared"; import { createServiceLogger } from "../logger/serviceLogger.js"; export type { - ProviderSettingsProviderView, - ModelSelectionView, - ModelSelectionViewInput, - ProviderSettingsView, + ProviderSettingsProviderView, + ModelSelectionView, + ModelSelectionViewInput, + ProviderSettingsView, } from "@kcode/provider"; export interface ProviderSettingsRemoteModelsResult { - readonly view: ProviderSettingsView; - readonly addedCount: number; - readonly restoredCount: number; + readonly view: ProviderSettingsView; + readonly addedCount: number; + readonly restoredCount: number; } export interface ProviderSettingsRemoteModelsListResult { - readonly modelIds: readonly string[]; + readonly modelIds: readonly string[]; } export interface IProviderSettingsService { - readonly onDidChange: Event; - getView(): Promise; - refresh(reason: string): Promise; - createPersonalProvider( - input?: Parameters[0], - ): Promise; - resolveModelConfig(input: ResolveModelConfigInput): Promise; - savePersonalProviderOverlay( - providerId: ProviderId, - config: ProviderConfigObject, - metadata?: Parameters[2], - ): Promise; - deletePersonalProvider(providerId: ProviderId): Promise; - reorderPersonalProviders(providerIds: readonly ProviderId[]): Promise; - reorderPersonalModels( - providerId: ProviderId, - modelIds: readonly ModelId[], - ): Promise; - addPersonalModel( - providerId: ProviderId, - modelId: ModelId, - config: ModelConfigObject, - useRecommendedConfig?: boolean, - ): Promise; - renamePersonalModel( - providerId: ProviderId, - currentModelId: ModelId, - nextModelId: ModelId, - ): Promise; - deletePersonalModel(providerId: ProviderId, modelId: ModelId): Promise; - listRemoteProviderModels(providerId: ProviderId): Promise; - importRemoteProviderModels( - providerId: ProviderId, - modelIds: readonly string[], - ): Promise; - clearProviderModels(providerId: ProviderId): Promise; - savePersonalModelDraft(input: SavePersonalModelDraftInput): Promise; - setPersonalModelEnabled( - providerId: ProviderId, - modelId: ModelId, - enabled: boolean, - ): Promise; - /** 测试已经保存并进入目标 Environment Registry 的正式 Model。 */ - testModelConnectivity( - input: ProviderSettingsConnectivityRequest, - ): Promise; + readonly onDidChange: Event; + getView(): Promise; + refresh(reason: string): Promise; + createPersonalProvider( + input?: Parameters[0], + ): Promise; + resolveModelConfig(input: ResolveModelConfigInput): Promise; + savePersonalProviderOverlay( + providerId: ProviderId, + config: ProviderConfigObject, + metadata?: Parameters[2], + ): Promise; + deletePersonalProvider(providerId: ProviderId): Promise; + reorderPersonalProviders(providerIds: readonly ProviderId[]): Promise; + reorderPersonalModels( + providerId: ProviderId, + modelIds: readonly ModelId[], + ): Promise; + addPersonalModel( + providerId: ProviderId, + modelId: ModelId, + config: ModelConfigObject, + useRecommendedConfig?: boolean, + ): Promise; + renamePersonalModel( + providerId: ProviderId, + currentModelId: ModelId, + nextModelId: ModelId, + ): Promise; + deletePersonalModel(providerId: ProviderId, modelId: ModelId): Promise; + listRemoteProviderModels( + providerId: ProviderId, + ): Promise; + importRemoteProviderModels( + providerId: ProviderId, + modelIds: readonly string[], + ): Promise; + clearProviderModels(providerId: ProviderId): Promise; + savePersonalModelDraft(input: SavePersonalModelDraftInput): Promise; + setPersonalModelEnabled( + providerId: ProviderId, + modelId: ModelId, + enabled: boolean, + ): Promise; + /** 测试已经保存并进入目标 Environment Registry 的正式 Model。 */ + testModelConnectivity( + input: ProviderSettingsConnectivityRequest, + ): Promise; } export const IProviderSettingsService = createServiceDescriptor( - ServiceChannels.ProviderSettings, + ServiceChannels.ProviderSettings, ); export interface ProviderSettingsConnectivityTestInput { - readonly workspacePath: string; - readonly workspaceIdentity?: string; - readonly providerId: ProviderId; - readonly modelId: ModelId; + readonly workspacePath: string; + readonly workspaceIdentity?: string; + readonly providerId: ProviderId; + readonly modelId: ModelId; } export interface ProviderSettingsConnectivityRequest { - readonly workspacePath: string; - readonly workspaceIdentity?: string; - readonly providerId: ProviderId; - readonly modelId: ModelId; + readonly workspacePath: string; + readonly workspaceIdentity?: string; + readonly providerId: ProviderId; + readonly modelId: ModelId; } export type ProviderSettingsConnectivityTester = ( - input: ProviderSettingsConnectivityTestInput, + input: ProviderSettingsConnectivityTestInput, ) => Promise; export interface IModelSelectionService { - readonly onDidChange: Event; - getView(input?: ModelSelectionViewInput): Promise; + readonly onDidChange: Event; + getView(input?: ModelSelectionViewInput): Promise; } export interface ModelSelectionConfiguredDefaultSource { - read(): Promise; - onDidChange?(listener: () => void): () => void; + read(): Promise; + onDidChange?(listener: () => void): () => void; } export const IModelSelectionService = createServiceDescriptor( - ServiceChannels.ModelSelection, + ServiceChannels.ModelSelection, ); export function createProviderSettingsService( - facade: ProviderSettingsFacade, - ensureReady: () => Promise = async () => {}, - testConnectivity?: ProviderSettingsConnectivityTester, + facade: ProviderSettingsFacade, + ensureReady: () => Promise = async () => {}, + testConnectivity?: ProviderSettingsConnectivityTester, ): IProviderSettingsService { - return { - onDidChange: toEvent((listener) => facade.onDidChange(listener)), - getView: async () => { - await ensureReady(); - return facade.getView(); - }, - refresh: async (reason) => { - await ensureReady(); - return facade.refresh(reason); - }, - createPersonalProvider: async (input) => { - await ensureReady(); - return facade.createPersonalProvider(input); - }, - resolveModelConfig: async (input) => { - await ensureReady(); - return facade.resolveModelConfig(input); - }, - savePersonalProviderOverlay: async (providerId, config, metadata) => { - await ensureReady(); - return facade.savePersonalProviderOverlay(providerId, config, metadata); - }, - deletePersonalProvider: async (providerId) => { - await ensureReady(); - return facade.deletePersonalProvider(providerId); - }, - reorderPersonalProviders: async (providerIds) => { - await ensureReady(); - return facade.reorderPersonalProviders(providerIds); - }, - reorderPersonalModels: async (providerId, modelIds) => { - await ensureReady(); - return facade.reorderPersonalModels(providerId, modelIds); - }, - addPersonalModel: async (providerId, modelId, config, useRecommendedConfig) => { - await ensureReady(); - return facade.addPersonalModel(providerId, modelId, config, useRecommendedConfig); - }, - renamePersonalModel: async (providerId, currentModelId, nextModelId) => { - await ensureReady(); - return facade.renamePersonalModel(providerId, currentModelId, nextModelId); - }, - deletePersonalModel: async (providerId, modelId) => { - await ensureReady(); - return facade.deletePersonalModel(providerId, modelId); - }, - listRemoteProviderModels: async (providerId) => { - await ensureReady(); - await facade.waitForProviderOperations(providerId); - const provider = facade - .getView() - .providers.find((item) => item.providerId === providerId); - if (!provider) { - throw new ProviderRemoteModelsError("missing-connection", `Provider 不存在: ${providerId}`); - } - const api = provider.effectiveConfig.api; - const access = provider.effectiveConfig.access; - if (!api?.baseUrl?.trim() || !isApiKeyAccess(access) || !access.apiKey?.trim()) { - throw new ProviderRemoteModelsError( - "missing-connection", - "请先填写 Base URL 和 API Key", - ); - } - return { - modelIds: await fetchProviderRemoteModelIds({ - apiType: api.type ?? undefined, - baseUrl: api.baseUrl, - apiKey: access.apiKey, - headers: api.headers, - }), - }; - }, - importRemoteProviderModels: async (providerId, modelIds) => { - await ensureReady(); - const imported = await facade.importRemoteModels(providerId, modelIds); - return { - view: imported.view, - addedCount: imported.addedModelIds.length, - restoredCount: imported.restoredInheritedModelIds.length, - }; - }, - clearProviderModels: async (providerId) => { - await ensureReady(); - return facade.clearVisibleModels(providerId); - }, - savePersonalModelDraft: async (input) => { - await ensureReady(); - return facade.savePersonalModelDraft(input); - }, - setPersonalModelEnabled: async (providerId, modelId, enabled) => { - await ensureReady(); - return facade.setPersonalModelEnabled(providerId, modelId, enabled); - }, - testModelConnectivity: async (input) => { - await ensureReady(); - if (!testConnectivity) { - throw new Error("当前 Environment 未装配模型连通性测试能力"); - } - await facade.waitForProviderOperations(input.providerId); - // 禁用对象仍存在于配置视图,但不进入执行 Registry;不能把未发布误报成配置丢失。 - // 只消费操作完成后的公共资格,不另查 Key、权益,也不替代目标 Environment 最终校验。 - const provider = facade - .getView() - .providers.find((item) => item.providerId === input.providerId); - const model = provider?.models.find((item) => item.modelId === input.modelId); - const unavailable = - !provider || !provider.enabled - ? "provider-unavailable" - : !model || !model.enabled || model.issues.length > 0 - ? "model-unavailable" - : !provider.executable - ? "provider-unavailable" - : !model.executable - ? "model-unavailable" - : undefined; - if (unavailable) { - return { - success: false, - error: { - code: unavailable, - message: - unavailable === "provider-unavailable" - ? "This provider is currently unavailable for connectivity testing." - : "This model is currently unavailable for connectivity testing.", - }, - }; - } - return testConnectivity({ - workspacePath: input.workspacePath, - ...(input.workspaceIdentity ? { workspaceIdentity: input.workspaceIdentity } : {}), - providerId: input.providerId, - modelId: input.modelId, - }); - }, - }; + return { + onDidChange: toEvent((listener) => facade.onDidChange(listener)), + getView: async () => { + await ensureReady(); + return facade.getView(); + }, + refresh: async (reason) => { + await ensureReady(); + return facade.refresh(reason); + }, + createPersonalProvider: async (input) => { + await ensureReady(); + return facade.createPersonalProvider(input); + }, + resolveModelConfig: async (input) => { + await ensureReady(); + 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(); + return facade.savePersonalProviderOverlay(providerId, config, metadata); + }, + deletePersonalProvider: async (providerId) => { + await ensureReady(); + return facade.deletePersonalProvider(providerId); + }, + reorderPersonalProviders: async (providerIds) => { + await ensureReady(); + return facade.reorderPersonalProviders(providerIds); + }, + reorderPersonalModels: async (providerId, modelIds) => { + await ensureReady(); + return facade.reorderPersonalModels(providerId, modelIds); + }, + addPersonalModel: async (providerId, modelId, config, useRecommendedConfig) => { + await ensureReady(); + return facade.addPersonalModel(providerId, modelId, config, useRecommendedConfig); + }, + renamePersonalModel: async (providerId, currentModelId, nextModelId) => { + await ensureReady(); + return facade.renamePersonalModel(providerId, currentModelId, nextModelId); + }, + deletePersonalModel: async (providerId, modelId) => { + await ensureReady(); + return facade.deletePersonalModel(providerId, modelId); + }, + listRemoteProviderModels: async (providerId) => { + await ensureReady(); + await facade.waitForProviderOperations(providerId); + const provider = facade + .getView() + .providers.find((item) => item.providerId === providerId); + if (!provider) { + throw new ProviderRemoteModelsError( + "missing-connection", + `Provider 不存在: ${providerId}`, + ); + } + const api = provider.effectiveConfig.api; + const access = provider.effectiveConfig.access; + if (!api?.baseUrl?.trim() || !isApiKeyAccess(access) || !access.apiKey?.trim()) { + throw new ProviderRemoteModelsError( + "missing-connection", + "请先填写 Base URL 和 API Key", + ); + } + 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 modelConfigs = await catalogConfigsForModels(facade, providerId, modelIds); + const imported = await facade.importRemoteModels(providerId, modelIds, modelConfigs); + return { + view: imported.view, + addedCount: imported.addedModelIds.length, + restoredCount: imported.restoredInheritedModelIds.length, + }; + }, + clearProviderModels: async (providerId) => { + await ensureReady(); + return facade.clearVisibleModels(providerId); + }, + savePersonalModelDraft: async (input) => { + await ensureReady(); + 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(); + return facade.setPersonalModelEnabled(providerId, modelId, enabled); + }, + testModelConnectivity: async (input) => { + await ensureReady(); + if (!testConnectivity) { + throw new Error("当前 Environment 未装配模型连通性测试能力"); + } + await facade.waitForProviderOperations(input.providerId); + // 禁用对象仍存在于配置视图,但不进入执行 Registry;不能把未发布误报成配置丢失。 + // 只消费操作完成后的公共资格,不另查 Key、权益,也不替代目标 Environment 最终校验。 + const provider = facade + .getView() + .providers.find((item) => item.providerId === input.providerId); + const model = provider?.models.find((item) => item.modelId === input.modelId); + const unavailable = + !provider || !provider.enabled + ? "provider-unavailable" + : !model || !model.enabled || model.issues.length > 0 + ? "model-unavailable" + : !provider.executable + ? "provider-unavailable" + : !model.executable + ? "model-unavailable" + : undefined; + if (unavailable) { + return { + success: false, + error: { + code: unavailable, + message: + unavailable === "provider-unavailable" + ? "This provider is currently unavailable for connectivity testing." + : "This model is currently unavailable for connectivity testing.", + }, + }; + } + return testConnectivity({ + workspacePath: input.workspacePath, + ...(input.workspaceIdentity ? { workspaceIdentity: input.workspaceIdentity } : {}), + providerId: input.providerId, + modelId: input.modelId, + }); + }, + }; } export function createModelSelectionService( - facade: ModelSelectionFacade, - ensureReady: () => Promise = async () => {}, - configuredDefaultSource?: ModelSelectionConfiguredDefaultSource, + facade: ModelSelectionFacade, + ensureReady: () => Promise = async () => {}, + configuredDefaultSource?: ModelSelectionConfiguredDefaultSource, ): IModelSelectionService & { dispose(): void } { - const log = createServiceLogger("model-selection"); - let revision = 0; - let disposed = false; - const listeners = new Set<(view: ModelSelectionView) => void>(); - const getView = async (input?: ModelSelectionViewInput): Promise => { - await ensureReady(); - if (disposed) throw new Error("ModelSelectionService 已 dispose"); - const configuredDefault = await configuredDefaultSource?.read(); - if (disposed) throw new Error("ModelSelectionService 已 dispose"); - const base = facade.getView(configuredDefault); - if (revision < base.revision) revision = base.revision; - return facade.getView(configuredDefault, revision, input); - }; - const emit = (): void => { - if (disposed) return; - revision += 1; - void getView().then( - (view) => { - if (disposed) return; - for (const listener of listeners) listener(view); - }, - (error: unknown) => { - // Registry 事件触发的异步 View 重建没有 owner;Host dispose 后它仍会继续 - // 读取已释放的配置仓库,并形成未处理 rejection。dispose 是明确的取消边界;仅在服务 - // 仍存活时记录真实读取失败。 + const log = createServiceLogger("model-selection"); + let revision = 0; + let disposed = false; + const listeners = new Set<(view: ModelSelectionView) => void>(); + const getView = async (input?: ModelSelectionViewInput): Promise => { + await ensureReady(); + if (disposed) throw new Error("ModelSelectionService 已 dispose"); + const configuredDefault = await configuredDefaultSource?.read(); + if (disposed) throw new Error("ModelSelectionService 已 dispose"); + const base = facade.getView(configuredDefault); + if (revision < base.revision) revision = base.revision; + return facade.getView(configuredDefault, revision, input); + }; + const emit = (): void => { if (disposed) return; - log.warn(undefined, `ModelSelection View 刷新失败: ${String(error)}`); - }, - ); - }; - const disposeFacade = facade.onDidChange(emit); - const disposeConfiguredDefault = configuredDefaultSource?.onDidChange?.(emit); + revision += 1; + void getView().then( + (view) => { + if (disposed) return; + for (const listener of listeners) listener(view); + }, + (error: unknown) => { + // Registry 事件触发的异步 View 重建没有 owner;Host dispose 后它仍会继续 + // 读取已释放的配置仓库,并形成未处理 rejection。dispose 是明确的取消边界;仅在服务 + // 仍存活时记录真实读取失败。 + if (disposed) return; + log.warn(undefined, `ModelSelection View 刷新失败: ${String(error)}`); + }, + ); + }; + const disposeFacade = facade.onDidChange(emit); + const disposeConfiguredDefault = configuredDefaultSource?.onDidChange?.(emit); - return { - onDidChange: (listener) => { - listeners.add(listener); - return { dispose: () => listeners.delete(listener) }; - }, - getView, - dispose() { - if (disposed) return; - disposed = true; - disposeFacade(); - disposeConfiguredDefault?.(); - listeners.clear(); - }, - }; + return { + onDidChange: (listener) => { + listeners.add(listener); + return { dispose: () => listeners.delete(listener) }; + }, + getView, + dispose() { + if (disposed) return; + disposed = true; + disposeFacade(); + disposeConfiguredDefault?.(); + listeners.clear(); + }, + }; } function toEvent(subscribe: (listener: (event: T) => void) => () => void): Event { - return (listener) => { - const dispose = subscribe(listener); - return { dispose }; - }; + return (listener) => { + const dispose = subscribe(listener); + 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 442c3bba..e1d58d5c 100644 --- a/packages/services/src/model-provider/providerRemoteModels.ts +++ b/packages/services/src/model-provider/providerRemoteModels.ts @@ -1,125 +1,117 @@ 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"; export class ProviderRemoteModelsError extends Error { - readonly code: "missing-connection" | "http-error" | "invalid-response"; + readonly code: "missing-connection" | "http-error" | "invalid-response"; - constructor(code: ProviderRemoteModelsError["code"], message: string) { - super(message); - this.name = "ProviderRemoteModelsError"; - this.code = code; - } + constructor(code: ProviderRemoteModelsError["code"], message: string) { + super(message); + this.name = "ProviderRemoteModelsError"; + this.code = code; + } } -export function resolveProviderModelsUrl(apiType: ProviderApiType | undefined, baseUrl: string): string { - const trimmed = baseUrl.trim().replace(/\/+$/, ""); - if (!trimmed) { - throw new ProviderRemoteModelsError("missing-connection", "Base URL 不能为空"); - } - if (/\/models$/i.test(trimmed)) return trimmed; - if (apiType === "anthropic-messages") { - return /\/v1$/i.test(trimmed) ? `${trimmed}/models` : `${trimmed}/v1/models`; - } - return `${trimmed}/models`; +export function resolveProviderModelsUrl( + apiType: ProviderApiType | undefined, + baseUrl: string, +): string { + const trimmed = baseUrl.trim().replace(/\/+$/, ""); + if (!trimmed) { + throw new ProviderRemoteModelsError("missing-connection", "Base URL 不能为空"); + } + if (/\/models$/i.test(trimmed)) return trimmed; + if (apiType === "anthropic-messages") { + return /\/v1$/i.test(trimmed) ? `${trimmed}/models` : `${trimmed}/v1/models`; + } + return `${trimmed}/models`; } 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); - }; + return parseProviderModelCatalog(payload).modelIds; +} - 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; - } +export function parseProviderRemoteModelCatalog(payload: unknown): { + readonly modelIds: readonly ModelId[]; + readonly info: Readonly>; +} { + return parseProviderModelCatalog(payload); +} - 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); +export function buildProviderModelsRequestHeaders(input: { + readonly apiType?: ProviderApiType; + readonly apiKey: string; + readonly headers?: Readonly> | null; +}): Headers { + const apiKey = normalizeApiKeyForHeader(input.apiKey); + if (!apiKey) { + throw new ProviderRemoteModelsError("missing-connection", "API Key 不能为空"); } - } - 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); + const headers = new Headers(input.headers ?? undefined); + if (input.apiType === "anthropic-messages") { + if (!headers.has("x-api-key")) headers.set("x-api-key", apiKey); + if (!headers.has("anthropic-version")) headers.set("anthropic-version", ANTHROPIC_VERSION); + } else if (!headers.has("authorization")) { + headers.set("Authorization", `Bearer ${apiKey}`); } - } - return ids; + if (!headers.has("accept")) headers.set("Accept", "application/json"); + return headers; } -export function buildProviderModelsRequestHeaders(input: { - readonly apiType?: ProviderApiType; - readonly apiKey: string; - readonly headers?: Readonly> | null; -}): Headers { - const apiKey = normalizeApiKeyForHeader(input.apiKey); - if (!apiKey) { - throw new ProviderRemoteModelsError("missing-connection", "API Key 不能为空"); - } - const headers = new Headers(input.headers ?? undefined); - if (input.apiType === "anthropic-messages") { - if (!headers.has("x-api-key")) headers.set("x-api-key", apiKey); - if (!headers.has("anthropic-version")) headers.set("anthropic-version", ANTHROPIC_VERSION); - } else if (!headers.has("authorization")) { - headers.set("Authorization", `Bearer ${apiKey}`); - } - if (!headers.has("accept")) headers.set("Accept", "application/json"); - return headers; +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<{ + readonly modelIds: readonly ModelId[]; + readonly info: Readonly>; +}> { + const url = resolveProviderModelsUrl(input.apiType, input.baseUrl); + const headers = buildProviderModelsRequestHeaders(input); + const fetchImpl = input.fetchImpl ?? globalThis.fetch; + let response: Response; + try { + response = await fetchImpl(url, { + method: "GET", + headers, + ...(input.signal ? { signal: input.signal } : {}), + }); + } catch (error) { + if (error instanceof ProviderRemoteModelsError) throw error; + throw new ProviderRemoteModelsError( + "http-error", + error instanceof Error ? error.message : "远端模型列表请求失败", + ); + } + if (!response.ok) { + throw new ProviderRemoteModelsError( + "http-error", + `远端模型列表请求失败(HTTP ${response.status})`, + ); + } + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new ProviderRemoteModelsError("invalid-response", "远端返回的模型列表不是 JSON"); + } + const catalog = parseProviderRemoteModelCatalog(payload); + if (catalog.modelIds.length === 0) { + throw new ProviderRemoteModelsError( + "invalid-response", + "远端返回的模型列表为空或格式无法识别", + ); + } + return catalog; } -export async function fetchProviderRemoteModelIds(input: { - readonly apiType?: ProviderApiType; - readonly baseUrl: string; - readonly apiKey: string; - readonly headers?: Readonly> | null; - readonly fetchImpl?: typeof fetch; - readonly signal?: AbortSignal; -}): Promise { - const url = resolveProviderModelsUrl(input.apiType, input.baseUrl); - const headers = buildProviderModelsRequestHeaders(input); - const fetchImpl = input.fetchImpl ?? globalThis.fetch; - let response: Response; - try { - response = await fetchImpl(url, { - method: "GET", - headers, - ...(input.signal ? { signal: input.signal } : {}), - }); - } catch (error) { - if (error instanceof ProviderRemoteModelsError) throw error; - throw new ProviderRemoteModelsError( - "http-error", - error instanceof Error ? error.message : "远端模型列表请求失败", - ); - } - if (!response.ok) { - throw new ProviderRemoteModelsError( - "http-error", - `远端模型列表请求失败(HTTP ${response.status})`, - ); - } - let payload: unknown; - try { - payload = await response.json(); - } catch { - throw new ProviderRemoteModelsError("invalid-response", "远端返回的模型列表不是 JSON"); - } - const modelIds = parseProviderRemoteModelIds(payload); - if (modelIds.length === 0) { - throw new ProviderRemoteModelsError("invalid-response", "远端返回的模型列表为空或格式无法识别"); - } - return modelIds; +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 113ce2b7..c132fb5d 100644 --- a/packages/services/src/model-provider/providerRuntime.ts +++ b/packages/services/src/model-provider/providerRuntime.ts @@ -1,46 +1,46 @@ import { - NodeModelSelectionConfigRepository, - createNodeModelSelectionFacade, + NodeModelSelectionConfigRepository, + createNodeModelSelectionFacade, } from "@kcode/provider-node"; import { - ProviderRegistryService, - ProviderSettingsFacade, - createFailClosedAccountProviderConfigSnapshot, - type AccountProviderConfigSnapshot, - type ProviderConfigSnapshot, - type ProviderSettingsMutationTarget, - type ProviderSource, + ProviderRegistryService, + ProviderSettingsFacade, + createFailClosedAccountProviderConfigSnapshot, + type AccountProviderConfigSnapshot, + type ProviderConfigSnapshot, + type ProviderSettingsMutationTarget, + type ProviderSource, } from "@kcode/provider"; import { - createProviderConfigRuntime, - type ProviderConfigRuntime, - type ProviderConfigRuntimeOptions, + createProviderConfigRuntime, + type ProviderConfigRuntime, + type ProviderConfigRuntimeOptions, } from "./providerConfigRuntime.js"; import { - createModelSelectionService, - createProviderSettingsService, - type IModelSelectionService, - type IProviderSettingsService, - type ModelSelectionConfiguredDefaultSource, - type ProviderSettingsConnectivityTester, + createModelSelectionService, + createProviderSettingsService, + type IModelSelectionService, + type IProviderSettingsService, + type ModelSelectionConfiguredDefaultSource, + type ProviderSettingsConnectivityTester, } from "./providerFacadeServices.js"; export interface ProviderRuntimeOptions extends ProviderConfigRuntimeOptions { - readonly accountSource?: RefreshableProviderSource; - readonly testConnectivity?: ProviderSettingsConnectivityTester; + readonly accountSource?: RefreshableProviderSource; + readonly testConnectivity?: ProviderSettingsConnectivityTester; } export interface ProviderRuntimeDependencies { - readonly configRuntime: ProviderConfigRuntime; - readonly accountSource?: RefreshableProviderSource; - readonly disposeAccountSource?: () => void; - readonly testConnectivity?: ProviderSettingsConnectivityTester; - readonly modelSelectionConfiguredDefaultSource?: ModelSelectionConfiguredDefaultSource; - readonly disposeModelSelectionConfiguredDefaultSource?: () => void; + readonly configRuntime: ProviderConfigRuntime; + readonly accountSource?: RefreshableProviderSource; + readonly disposeAccountSource?: () => void; + readonly testConnectivity?: ProviderSettingsConnectivityTester; + readonly modelSelectionConfiguredDefaultSource?: ModelSelectionConfiguredDefaultSource; + readonly disposeModelSelectionConfiguredDefaultSource?: () => void; } interface RefreshableProviderSource extends ProviderSource { - refresh?(reason: string): Promise; + refresh?(reason: string): Promise; } /** @@ -48,173 +48,177 @@ interface RefreshableProviderSource extends ProviderSource * Account Provider 由当前 Built-in revision 对齐的 access.entitled=false Overlay 显式 fail-closed。 */ export class EmptyAccountProviderConfigSource implements ProviderSource { - constructor(readonly configSource: ProviderSource) {} + constructor(readonly configSource: ProviderSource) {} - async read(): Promise { - return createFailClosedAccountProviderConfigSnapshot(await this.configSource.read()); - } + async read(): Promise { + return createFailClosedAccountProviderConfigSnapshot(await this.configSource.read()); + } - onDidChange(): () => void { - return () => {}; - } + onDidChange(): () => void { + return () => {}; + } } /** 组装一个进程内共享的 Provider Config、Registry 与 Facade。 */ export class ProviderRuntime { - readonly configService: ProviderConfigRuntime["configService"]; - readonly registryService: ProviderRegistryService; - readonly providerSettings: IProviderSettingsService; - readonly modelSelection: IModelSelectionService; - readonly #configRuntime: ProviderConfigRuntime; - readonly #disposeAccountSource?: () => void; - readonly #disposeBuiltinRecovery: () => void; - readonly #modelSelectionRuntime: IModelSelectionService & { dispose(): void }; - readonly #disposeModelSelectionConfiguredDefaultSource?: () => void; - #startPromise: ReturnType | null = null; - #disposed = false; + readonly configService: ProviderConfigRuntime["configService"]; + readonly registryService: ProviderRegistryService; + readonly providerSettings: IProviderSettingsService; + readonly modelSelection: IModelSelectionService; + readonly #configRuntime: ProviderConfigRuntime; + readonly #disposeAccountSource?: () => void; + readonly #disposeBuiltinRecovery: () => void; + readonly #modelSelectionRuntime: IModelSelectionService & { dispose(): void }; + readonly #disposeModelSelectionConfiguredDefaultSource?: () => void; + #startPromise: ReturnType | null = null; + #disposed = false; - constructor(dependencies: ProviderRuntimeDependencies) { - this.#configRuntime = dependencies.configRuntime; - this.#disposeAccountSource = dependencies.disposeAccountSource; - this.#disposeModelSelectionConfiguredDefaultSource = - dependencies.disposeModelSelectionConfiguredDefaultSource; - this.configService = this.#configRuntime.configService; - const accountSource: RefreshableProviderSource = - dependencies.accountSource ?? new EmptyAccountProviderConfigSource(this.configService); - this.#disposeBuiltinRecovery = this.#configRuntime.onDidCheckKCodeBuiltin(async () => { - const [config, account] = await Promise.all([ - this.configService.read(), - accountSource.read(), - ]); - if (!this.#disposed && config.kcodeBuiltinRevision !== account.basedOnKCodeBuiltinRevision) { - await accountSource.refresh?.("builtin-account-recovery"); - } - }); - this.registryService = new ProviderRegistryService({ - configSource: this.configService, - accountSource, - }); - const mutations = createSettingsMutationTarget( - this.#configRuntime, - this.registryService, - accountSource, - ); - const ensureReady = () => this.start(); - const settingsFacade = new ProviderSettingsFacade(this.registryService, mutations); - this.providerSettings = createProviderSettingsService( - settingsFacade, - ensureReady, - dependencies.testConnectivity, - ); - this.#modelSelectionRuntime = createModelSelectionService( - createNodeModelSelectionFacade(this.registryService), - ensureReady, - dependencies.modelSelectionConfiguredDefaultSource, - ); - this.modelSelection = this.#modelSelectionRuntime; - } + constructor(dependencies: ProviderRuntimeDependencies) { + this.#configRuntime = dependencies.configRuntime; + this.#disposeAccountSource = dependencies.disposeAccountSource; + this.#disposeModelSelectionConfiguredDefaultSource = + dependencies.disposeModelSelectionConfiguredDefaultSource; + this.configService = this.#configRuntime.configService; + const accountSource: RefreshableProviderSource = + dependencies.accountSource ?? new EmptyAccountProviderConfigSource(this.configService); + this.#disposeBuiltinRecovery = this.#configRuntime.onDidCheckKCodeBuiltin(async () => { + const [config, account] = await Promise.all([ + this.configService.read(), + accountSource.read(), + ]); + if ( + !this.#disposed && + config.kcodeBuiltinRevision !== account.basedOnKCodeBuiltinRevision + ) { + await accountSource.refresh?.("builtin-account-recovery"); + } + }); + this.registryService = new ProviderRegistryService({ + configSource: this.configService, + accountSource, + }); + const mutations = createSettingsMutationTarget( + this.#configRuntime, + this.registryService, + accountSource, + ); + const ensureReady = () => this.start(); + const settingsFacade = new ProviderSettingsFacade(this.registryService, mutations); + this.providerSettings = createProviderSettingsService( + settingsFacade, + ensureReady, + dependencies.testConnectivity, + ); + this.#modelSelectionRuntime = createModelSelectionService( + createNodeModelSelectionFacade(this.registryService), + ensureReady, + dependencies.modelSelectionConfiguredDefaultSource, + ); + this.modelSelection = this.#modelSelectionRuntime; + } - start(): Promise { - if (this.#disposed) throw new Error("ProviderRuntime 已 dispose"); - if (this.#startPromise) return this.#startPromise; - const startPromise = this.#configRuntime.start().then(() => this.registryService.start()); - this.#startPromise = startPromise; - void startPromise.catch(() => { - if (this.#startPromise === startPromise) this.#startPromise = null; - }); - return startPromise; - } + start(): Promise { + if (this.#disposed) throw new Error("ProviderRuntime 已 dispose"); + if (this.#startPromise) return this.#startPromise; + const startPromise = this.#configRuntime.start().then(() => this.registryService.start()); + this.#startPromise = startPromise; + void startPromise.catch(() => { + if (this.#startPromise === startPromise) this.#startPromise = null; + }); + return startPromise; + } - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - this.#disposeBuiltinRecovery(); - this.#modelSelectionRuntime.dispose(); - this.registryService.dispose(); - this.#disposeAccountSource?.(); - this.#disposeModelSelectionConfiguredDefaultSource?.(); - this.#configRuntime.dispose(); - } + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#disposeBuiltinRecovery(); + this.#modelSelectionRuntime.dispose(); + this.registryService.dispose(); + this.#disposeAccountSource?.(); + this.#disposeModelSelectionConfiguredDefaultSource?.(); + this.#configRuntime.dispose(); + } } function createSettingsMutationTarget( - configRuntime: ProviderConfigRuntime, - registryService: ProviderRegistryService, - accountSource: RefreshableProviderSource, + configRuntime: ProviderConfigRuntime, + registryService: ProviderRegistryService, + accountSource: RefreshableProviderSource, ): ProviderSettingsMutationTarget { - const configService = configRuntime.configService; - return { - createPersonalProvider: (input) => configService.createPersonalProvider(input), - savePersonalProviderOverlay: (providerId, config, membership, metadata) => - configService.savePersonalProviderOverlay(providerId, config, membership, metadata), - deletePersonalProvider: (providerId) => configService.deletePersonalProvider(providerId), - reorderPersonalProviders: (providerIds) => configService.reorderPersonalProviders(providerIds), - reorderPersonalModels: (providerId, modelIds, membership) => - configService.reorderPersonalModels(providerId, modelIds, membership), - // 手工四参数转发曾丢掉新增的配置模式;直接绑定完整签名,避免装配层截断写入意图。 - addPersonalModel: configService.addPersonalModel.bind(configService), - renamePersonalModel: (providerId, currentModelId, nextModelId, membership) => - configService.renamePersonalModel(providerId, currentModelId, nextModelId, membership), - deletePersonalModel: (providerId, modelId, membership) => - configService.deletePersonalModel(providerId, modelId, membership), - importRemoteModels: (providerId, remoteModelIds, membership) => - configService.importRemoteModels(providerId, remoteModelIds, membership), - clearVisibleModels: (providerId, membership) => - configService.clearVisibleModels(providerId, membership), - setPersonalModelEnabled: (providerId, modelId, enabled, membership) => - configService.setPersonalModelEnabled(providerId, modelId, enabled, membership), - savePersonalModelDraft: ( - providerId, - originalModelId, - nextModelId, - config, - expectedPersonalRevision, - useRecommendedConfig, - membership, - ) => - configService.savePersonalModelDraft( - providerId, - originalModelId, - nextModelId, - config, - expectedPersonalRevision, - useRecommendedConfig, - membership, - ), - refresh: (reason) => registryService.refresh(reason), - refreshSources: async (reason) => { - const sourceResults = await Promise.allSettled([ - configRuntime.refreshKCodeBuiltin({ force: true }), - accountSource.refresh?.(reason) ?? Promise.resolve(), - ]); - const snapshot = await registryService.refresh(reason); - const failed = sourceResults.find( - (result): result is PromiseRejectedResult => result.status === "rejected", - ); - if (failed) throw failed.reason; - return snapshot; - }, - }; + const configService = configRuntime.configService; + return { + createPersonalProvider: (input) => configService.createPersonalProvider(input), + savePersonalProviderOverlay: (providerId, config, membership, metadata) => + configService.savePersonalProviderOverlay(providerId, config, membership, metadata), + deletePersonalProvider: (providerId) => configService.deletePersonalProvider(providerId), + reorderPersonalProviders: (providerIds) => + configService.reorderPersonalProviders(providerIds), + reorderPersonalModels: (providerId, modelIds, membership) => + configService.reorderPersonalModels(providerId, modelIds, membership), + // 手工四参数转发曾丢掉新增的配置模式;直接绑定完整签名,避免装配层截断写入意图。 + addPersonalModel: configService.addPersonalModel.bind(configService), + renamePersonalModel: (providerId, currentModelId, nextModelId, membership) => + configService.renamePersonalModel(providerId, currentModelId, nextModelId, membership), + deletePersonalModel: (providerId, modelId, membership) => + configService.deletePersonalModel(providerId, modelId, membership), + importRemoteModels: (providerId, remoteModelIds, membership, modelConfigs) => + configService.importRemoteModels(providerId, remoteModelIds, membership, modelConfigs), + clearVisibleModels: (providerId, membership) => + configService.clearVisibleModels(providerId, membership), + setPersonalModelEnabled: (providerId, modelId, enabled, membership) => + configService.setPersonalModelEnabled(providerId, modelId, enabled, membership), + savePersonalModelDraft: ( + providerId, + originalModelId, + nextModelId, + config, + expectedPersonalRevision, + useRecommendedConfig, + membership, + ) => + configService.savePersonalModelDraft( + providerId, + originalModelId, + nextModelId, + config, + expectedPersonalRevision, + useRecommendedConfig, + membership, + ), + refresh: (reason) => registryService.refresh(reason), + refreshSources: async (reason) => { + const sourceResults = await Promise.allSettled([ + configRuntime.refreshKCodeBuiltin({ force: true }), + accountSource.refresh?.(reason) ?? Promise.resolve(), + ]); + const snapshot = await registryService.refresh(reason); + const failed = sourceResults.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (failed) throw failed.reason; + return snapshot; + }, + }; } export function createProviderRuntime(options: ProviderRuntimeOptions): ProviderRuntime { - const { accountSource, testConnectivity, ...configRuntimeOptions } = options; - const configRuntime = createProviderConfigRuntime(configRuntimeOptions); - const modelSelectionConfiguredDefaultSource = new NodeModelSelectionConfigRepository({ - personalRepository: configRuntime.personalRepository, - }); - return createProviderRuntimeFromConfigRuntime({ - configRuntime, - accountSource, - testConnectivity, - modelSelectionConfiguredDefaultSource, - disposeModelSelectionConfiguredDefaultSource: () => - modelSelectionConfiguredDefaultSource.dispose(), - }); + const { accountSource, testConnectivity, ...configRuntimeOptions } = options; + const configRuntime = createProviderConfigRuntime(configRuntimeOptions); + const modelSelectionConfiguredDefaultSource = new NodeModelSelectionConfigRepository({ + personalRepository: configRuntime.personalRepository, + }); + return createProviderRuntimeFromConfigRuntime({ + configRuntime, + accountSource, + testConnectivity, + modelSelectionConfiguredDefaultSource, + disposeModelSelectionConfiguredDefaultSource: () => + modelSelectionConfiguredDefaultSource.dispose(), + }); } export function createProviderRuntimeFromConfigRuntime( - dependencies: ProviderRuntimeDependencies, + dependencies: ProviderRuntimeDependencies, ): ProviderRuntime { - return new ProviderRuntime(dependencies); + return new ProviderRuntime(dependencies); } diff --git a/packages/services/test/modelInfoCatalog.test.ts b/packages/services/test/modelInfoCatalog.test.ts new file mode 100644 index 00000000..062776aa --- /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 11202abc..a63144a5 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 仍在 - 获取失败或缺少连接配置时,成员保持原样并提示原因 From 727b65436d518ff959b960fb965231d435b35212 Mon Sep 17 00:00:00 2001 From: KCode Date: Fri, 25 Sep 2026 10:27:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?style:=20=E4=BF=9D=E7=95=99=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=9B=AE=E5=BD=95=E5=A1=AB=E5=85=85=E6=94=B9=E5=8A=A8?= =?UTF-8?q?=E7=9A=84=E5=8E=9F=E6=9C=89=E7=BC=A9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/provider/src/config-service.ts | 1548 ++++++++--------- packages/provider/src/config/model-config.ts | 1022 ++++++----- packages/provider/src/facades.ts | 1317 +++++++------- .../src/model-provider/modelInfoCatalog.ts | 426 ++--- .../model-provider/modelInfoCatalogFetch.ts | 150 +- .../model-provider/modelInfoCatalogParse.ts | 296 ++-- .../model-provider/providerFacadeServices.ts | 646 ++++--- .../model-provider/providerRemoteModels.ts | 166 +- .../src/model-provider/providerRuntime.ts | 356 ++-- .../services/test/modelInfoCatalog.test.ts | 120 +- 10 files changed, 2971 insertions(+), 3076 deletions(-) diff --git a/packages/provider/src/config-service.ts b/packages/provider/src/config-service.ts index 6bc29075..9ec23e24 100644 --- a/packages/provider/src/config-service.ts +++ b/packages/provider/src/config-service.ts @@ -1,906 +1,870 @@ /* oxlint-disable eslint(max-lines) -- Provider/Model 的原子配置生命周期共享一次 Repository 更新边界,拆开会重复顺序与规范化逻辑。 */ import type { - ModelConfigRules, - ModelId, - ProviderConfig, - ProviderId, - ProviderTemplateId, - ProviderConfigRule, - ProviderTemplateLocale, + ModelConfigRules, + ModelId, + ProviderConfig, + ProviderId, + ProviderTemplateId, + ProviderConfigRule, + ProviderTemplateLocale, } from "./config/index.js"; import { - ApiKeyAccessConfig, - ModelConfig, - ProviderConfigMap, - ProviderConfig as ProviderConfigValue, - ProviderTemplateMap, - resolveProviderTemplateName, + ApiKeyAccessConfig, + ModelConfig, + ProviderConfigMap, + ProviderConfig as ProviderConfigValue, + ProviderTemplateMap, + resolveProviderTemplateName, } from "./config/index.js"; import { resolveOwnedOrder } from "./owned-order.js"; import { - normalizeHiddenInheritedModelIds, - resolvePersonalModelDeletionPlan, - resolveRemoteModelImportPlan, - type RemoteModelImportPlan, + normalizeHiddenInheritedModelIds, + resolvePersonalModelDeletionPlan, + resolveRemoteModelImportPlan, + type RemoteModelImportPlan, } from "./personal-model-membership.js"; import type { ModelSelection } from "@kcode/shared/model-selection"; import type { ProviderConfigSnapshot, ProviderSource } from "./sources.js"; import { - omitRetiredOfficialProviders, - omitRetiredOfficialTemplates, + omitRetiredOfficialProviders, + omitRetiredOfficialTemplates, } from "./retiredOfficialProviders.js"; export interface ProviderConfigLayerSnapshot { - readonly revision: string; - readonly providers: ProviderConfigMap; - readonly providerTemplates?: ProviderTemplateMap; - readonly models: ModelConfigRules; - readonly providerOrder?: readonly ProviderId[]; - readonly defaultModelSelection?: ModelSelection; + readonly revision: string; + readonly providers: ProviderConfigMap; + readonly providerTemplates?: ProviderTemplateMap; + readonly models: ModelConfigRules; + readonly providerOrder?: readonly ProviderId[]; + readonly defaultModelSelection?: ModelSelection; } export interface ProviderConfigLayerUpdate { - readonly providers: ProviderConfigMap; - readonly providerTemplates?: ProviderTemplateMap; - readonly models: ModelConfigRules; - readonly providerOrder?: readonly ProviderId[]; - readonly defaultModelSelection?: ModelSelection; + readonly providers: ProviderConfigMap; + readonly providerTemplates?: ProviderTemplateMap; + readonly models: ModelConfigRules; + readonly providerOrder?: readonly ProviderId[]; + readonly defaultModelSelection?: ModelSelection; } export interface PersonalProviderConfigRepository extends ProviderSource { - update( - transform: (current: ProviderConfigLayerSnapshot) => ProviderConfigLayerUpdate, - ): Promise; + update( + transform: (current: ProviderConfigLayerSnapshot) => ProviderConfigLayerUpdate, + ): Promise; } export interface ProviderConfigServiceDependencies { - readonly kcodeBuiltinSource: ProviderSource; - readonly personalRepository: PersonalProviderConfigRepository; + readonly kcodeBuiltinSource: ProviderSource; + readonly personalRepository: PersonalProviderConfigRepository; } export interface PersonalProviderCreation { - readonly providerId: ProviderId; + readonly providerId: ProviderId; } export interface CreatePersonalProviderInput { - readonly templateId?: ProviderTemplateId; - readonly providerName?: string; - readonly locale?: ProviderTemplateLocale; - readonly initialConfig?: ProviderConfig; + readonly templateId?: ProviderTemplateId; + readonly providerName?: string; + readonly locale?: ProviderTemplateLocale; + readonly initialConfig?: ProviderConfig; } /** Facade 提供的 Host 内部成员事实;不得接受 Renderer 自报的模型名单。 */ export interface ProviderModelMembership { - readonly providerId: ProviderId; - readonly inheritedModelIds: readonly ModelId[]; - readonly personalRevision: string; - /** 在 Personal 事务内检查发布快照未过期,不触发网络请求。 */ - readonly assertCurrent: () => void; + readonly providerId: ProviderId; + readonly inheritedModelIds: readonly ModelId[]; + readonly personalRevision: string; + /** 在 Personal 事务内检查发布快照未过期,不触发网络请求。 */ + readonly assertCurrent: () => void; } function assertMembershipCurrent( - membership: ProviderModelMembership | undefined, - providerId: ProviderId, - current: ProviderConfigLayerSnapshot, + membership: ProviderModelMembership | undefined, + providerId: ProviderId, + current: ProviderConfigLayerSnapshot, ): void { - if (!membership) return; - membership.assertCurrent(); - if (membership.providerId !== providerId || membership.personalRevision !== current.revision) { - throw new Error("Provider Settings membership revision conflict"); - } + if (!membership) return; + membership.assertCurrent(); + if (membership.providerId !== providerId || membership.personalRevision !== current.revision) { + throw new Error("Provider Settings membership revision conflict"); + } } /** Personal 根记录只是覆盖层,不是 Provider 存在性的依据;仅继承 Provider 可按需创建覆盖。 */ function writableProviderOverlay( - builtin: ProviderConfigLayerSnapshot, - current: ProviderConfigLayerSnapshot, - providerId: ProviderId, + builtin: ProviderConfigLayerSnapshot, + current: ProviderConfigLayerSnapshot, + providerId: ProviderId, ): ProviderConfig { - const provider = current.providers.get(providerId); - if (provider) return provider; - if (builtin.providers.has(providerId)) return new ProviderConfigValue({}); - // 已删除的自定义/模板实例没有继承 Provider 身份,迟到操作不能把它复活。 - throw new Error(`Provider 不存在: ${providerId}`); + const provider = current.providers.get(providerId); + if (provider) return provider; + if (builtin.providers.has(providerId)) return new ProviderConfigValue({}); + // 已删除的自定义/模板实例没有继承 Provider 身份,迟到操作不能把它复活。 + throw new Error(`Provider 不存在: ${providerId}`); } export class ProviderConfigService implements ProviderSource { - readonly #kcodeBuiltinSource: ProviderSource; - readonly #personalRepository: PersonalProviderConfigRepository; - readonly #listeners = new Set<(reason: string) => void>(); - readonly #sourceDisposers: Array<() => void>; - #disposed = false; - - constructor(dependencies: ProviderConfigServiceDependencies) { - this.#kcodeBuiltinSource = dependencies.kcodeBuiltinSource; - this.#personalRepository = dependencies.personalRepository; - this.#sourceDisposers = [ - this.#kcodeBuiltinSource.onDidChange((reason) => this.#emit(`kcodeBuiltin:${reason}`)), - this.#personalRepository.onDidChange((reason) => this.#emit(`personal:${reason}`)), - ]; - } - - async read(): Promise { - this.#assertNotDisposed(); - const [kcodeBuiltin, personal] = await Promise.all([ - this.#kcodeBuiltinSource.read(), - this.#personalRepository.read(), - ]); - return Object.freeze({ - revision: JSON.stringify([kcodeBuiltin.revision, personal.revision]), - kcodeBuiltinRevision: kcodeBuiltin.revision, - personalRevision: personal.revision, - kcodeBuiltinProviders: omitRetiredOfficialProviders(kcodeBuiltin.providers), - kcodeBuiltinProviderTemplates: omitRetiredOfficialTemplates( - kcodeBuiltin.providerTemplates ?? ProviderTemplateMap.empty(), - ), - personalProviders: omitRetiredOfficialProviders(personal.providers), - kcodeBuiltinModelRules: kcodeBuiltin.models, - personalModels: personal.models, - personalProviderOrder: personal.providerOrder ?? [], - }); - } - - onDidChange(listener: (reason: string) => void): () => void { - this.#assertNotDisposed(); - this.#listeners.add(listener); - return () => this.#listeners.delete(listener); - } - - /** 仅供版本迁移或事实源 cutover 原子替换完整 Personal Overlay。 */ - replacePersonalConfig(config: ProviderConfigLayerUpdate): Promise { - this.#assertNotDisposed(); - // 全量导入/分发与字段编辑不同;新信封未带默认选择时必须清除,不能继承接收端旧值。 - return this.#personalRepository.update(() => config); - } - - async savePersonalProviderOverlay( - providerId: ProviderId, - config: ProviderConfig, - membership?: ProviderModelMembership, - metadata?: Pick, - ): Promise { - assertNonEmptyId("providerId", providerId); - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, providerId, current); - const builtin = kcodeBuiltin.providers.get(providerId); - const currentPersonal = current.providers.get(providerId); - const currentEffectiveProviders = kcodeBuiltin.providers.overlay(current.providers); - // 账号总禁用已撤销;在公共写入边界拒绝新操作,避免隐藏 UI 后仍能写出无效状态。 - if (builtin?.access?.type === "zhipu-account" && metadata?.enabled === false) { - throw new Error(`Account Provider 不允许禁用: ${providerId}`); - } - if (builtin?.access?.type === "zhipu-account" && config.access !== undefined) { - // 通用保存入口只解析 ProviderConfig,曾绕过 Personal Source Schema, - // 允许固定 Account Provider 的 access 被写盘,直到下次读取才整份拒绝。 - throw new Error( - `固定 Account Provider 的 Access 只能由 KCode Built-in Config 声明: ${providerId}`, - ); - } - // 普通保存曾同时承担创建语义,删除后的迟到保存可以凭空复活 Overlay。 - // 创建已经是明确的领域操作,普通保存只更新现有配置,不存在即拒绝。 - if (!currentPersonal && !builtin) { - throw new Error(`Personal Provider 尚未创建: ${providerId}`); - } - let normalized = config; - if (builtin) { - if (normalized.group != null && normalized.group !== builtin.group) { - throw new Error( - `Personal Overlay 不能改写 Built-in Provider group: ${providerId}`, - ); - } - normalized = normalized.withoutGroup(); - } else { - const group = normalized.group ?? currentPersonal?.group; - if (group !== "standard-personal") { - throw new Error( - `Personal-only Provider 必须使用 standard-personal group: ${providerId}`, - ); - } - normalized = normalized.overlay(new ProviderConfigValue({ group })); - } - const membershipBaseline = - builtin ?? resolveTemplateBaseline(kcodeBuiltin, current.providers, providerId); - const next = normalized.withModelMembershipFrom( - // 普通 Provider 保存也保留动态成员顺序;不能改名称时又按静态名单删掉已保存的调序。 - normalizePersonalProviderMembership( - currentPersonal, - membershipBaseline, - membership?.inheritedModelIds, - ), - ); - const currentRule = current.providers.getRule(providerId); - const providers = current.providers.setRule({ - ...currentRule, - providerId, - ...(metadata?.templateId === undefined ? {} : { templateId: metadata.templateId }), - ...(metadata?.enabled === undefined ? {} : { enabled: metadata.enabled }), - ...(metadata?.providerName === undefined - ? {} - : { providerName: metadata.providerName?.trim() || null }), - config: next, - }); - const nextEffectiveProviders = kcodeBuiltin.providers.overlay(providers); - // 旧版本可能已经留下重名 Provider。全量校验会让这些历史问题阻断 - // 任意无关 Provider 的保存;这里仅禁止本次名称变更新引入重名。 - assertProviderLabelMutationIsUnique( - providerId, - currentEffectiveProviders, - nextEffectiveProviders, - ); - return { - providers, - models: current.models, - providerOrder: current.providerOrder, - }; - }); - } - - async createPersonalProvider( - input: CreatePersonalProviderInput = {}, - ): Promise { - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - const templateId = input.templateId?.trim(); - const template = templateId ? kcodeBuiltin.providerTemplates?.get(templateId) : undefined; - if (templateId && !template) throw new Error(`Provider Template 不存在: ${templateId}`); - if (input.initialConfig?.group !== undefined) { - throw new Error("initialConfig 不能包含 group"); + readonly #kcodeBuiltinSource: ProviderSource; + readonly #personalRepository: PersonalProviderConfigRepository; + readonly #listeners = new Set<(reason: string) => void>(); + readonly #sourceDisposers: Array<() => void>; + #disposed = false; + + constructor(dependencies: ProviderConfigServiceDependencies) { + this.#kcodeBuiltinSource = dependencies.kcodeBuiltinSource; + this.#personalRepository = dependencies.personalRepository; + this.#sourceDisposers = [ + this.#kcodeBuiltinSource.onDidChange((reason) => this.#emit(`kcodeBuiltin:${reason}`)), + this.#personalRepository.onDidChange((reason) => this.#emit(`personal:${reason}`)), + ]; + } + + async read(): Promise { + this.#assertNotDisposed(); + const [kcodeBuiltin, personal] = await Promise.all([ + this.#kcodeBuiltinSource.read(), + this.#personalRepository.read(), + ]); + return Object.freeze({ + revision: JSON.stringify([kcodeBuiltin.revision, personal.revision]), + kcodeBuiltinRevision: kcodeBuiltin.revision, + personalRevision: personal.revision, + kcodeBuiltinProviders: omitRetiredOfficialProviders(kcodeBuiltin.providers), + kcodeBuiltinProviderTemplates: omitRetiredOfficialTemplates( + kcodeBuiltin.providerTemplates ?? ProviderTemplateMap.empty(), + ), + personalProviders: omitRetiredOfficialProviders(personal.providers), + kcodeBuiltinModelRules: kcodeBuiltin.models, + personalModels: personal.models, + personalProviderOrder: personal.providerOrder ?? [], + }); + } + + onDidChange(listener: (reason: string) => void): () => void { + this.#assertNotDisposed(); + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + /** 仅供版本迁移或事实源 cutover 原子替换完整 Personal Overlay。 */ + replacePersonalConfig(config: ProviderConfigLayerUpdate): Promise { + this.#assertNotDisposed(); + // 全量导入/分发与字段编辑不同;新信封未带默认选择时必须清除,不能继承接收端旧值。 + return this.#personalRepository.update(() => config); + } + + async savePersonalProviderOverlay( + providerId: ProviderId, + config: ProviderConfig, + membership?: ProviderModelMembership, + metadata?: Pick, + ): Promise { + assertNonEmptyId("providerId", providerId); + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, providerId, current); + const builtin = kcodeBuiltin.providers.get(providerId); + const currentPersonal = current.providers.get(providerId); + const currentEffectiveProviders = kcodeBuiltin.providers.overlay(current.providers); + // 账号总禁用已撤销;在公共写入边界拒绝新操作,避免隐藏 UI 后仍能写出无效状态。 + if (builtin?.access?.type === "zhipu-account" && metadata?.enabled === false) { + throw new Error(`Account Provider 不允许禁用: ${providerId}`); + } + if (builtin?.access?.type === "zhipu-account" && config.access !== undefined) { + // 通用保存入口只解析 ProviderConfig,曾绕过 Personal Source Schema, + // 允许固定 Account Provider 的 access 被写盘,直到下次读取才整份拒绝。 + throw new Error( + `固定 Account Provider 的 Access 只能由 KCode Built-in Config 声明: ${providerId}`, + ); + } + // 普通保存曾同时承担创建语义,删除后的迟到保存可以凭空复活 Overlay。 + // 创建已经是明确的领域操作,普通保存只更新现有配置,不存在即拒绝。 + if (!currentPersonal && !builtin) { + throw new Error(`Personal Provider 尚未创建: ${providerId}`); + } + let normalized = config; + if (builtin) { + if (normalized.group != null && normalized.group !== builtin.group) { + throw new Error(`Personal Overlay 不能改写 Built-in Provider group: ${providerId}`); } - if (input.initialConfig?.builtinModelIds !== undefined) { - throw new Error("initialConfig 不能包含 builtinModelIds"); + normalized = normalized.withoutGroup(); + } else { + const group = normalized.group ?? currentPersonal?.group; + if (group !== "standard-personal") { + throw new Error(`Personal-only Provider 必须使用 standard-personal group: ${providerId}`); } - let createdProviderId: ProviderId | undefined; - await this.#updatePersonal((current) => { - const occupied = new Set([ - ...kcodeBuiltin.providers.keys(), - ...current.providers.keys(), - ]); - const providerId = nextPersonalProviderId(occupied, templateId); - createdProviderId = providerId; - const effectiveProviders = resolvePersonalProviderBaselines( - kcodeBuiltin, - current.providers, - ); - const label = nextPersonalProviderLabel( - input.providerName ?? - (template && templateId - ? resolveProviderTemplateName(templateId, template, input.locale ?? "en-US") - : "new-provider"), - effectiveProviders, - ); - const initial = input.initialConfig ?? new ProviderConfigValue(); - const providers = current.providers.setRule({ - providerId, - ...(templateId ? { templateId } : {}), - providerName: label, - config: new ProviderConfigValue({ - group: "standard-personal", - access: templateId ? undefined : new ApiKeyAccessConfig(), - personalModelIds: [], - modelOrder: [], - }).overlay(initial), - }); - return { - providers, - models: current.models, - providerOrder: appendCurrentProviderOrder( - kcodeBuiltin.providers, - providers, - current.providerOrder, - providerId, - ), - }; - }); - if (!createdProviderId) throw new Error("Personal Provider 创建失败"); - return Object.freeze({ providerId: createdProviderId }); + normalized = normalized.overlay(new ProviderConfigValue({ group })); + } + const membershipBaseline = + builtin ?? resolveTemplateBaseline(kcodeBuiltin, current.providers, providerId); + const next = normalized.withModelMembershipFrom( + // 普通 Provider 保存也保留动态成员顺序;不能改名称时又按静态名单删掉已保存的调序。 + normalizePersonalProviderMembership( + currentPersonal, + membershipBaseline, + membership?.inheritedModelIds, + ), + ); + const currentRule = current.providers.getRule(providerId); + const providers = current.providers.setRule({ + ...currentRule, + providerId, + ...(metadata?.templateId === undefined ? {} : { templateId: metadata.templateId }), + ...(metadata?.enabled === undefined ? {} : { enabled: metadata.enabled }), + ...(metadata?.providerName === undefined + ? {} + : { providerName: metadata.providerName?.trim() || null }), + config: next, + }); + const nextEffectiveProviders = kcodeBuiltin.providers.overlay(providers); + // 旧版本可能已经留下重名 Provider。全量校验会让这些历史问题阻断 + // 任意无关 Provider 的保存;这里仅禁止本次名称变更新引入重名。 + assertProviderLabelMutationIsUnique( + providerId, + currentEffectiveProviders, + nextEffectiveProviders, + ); + return { + providers, + models: current.models, + providerOrder: current.providerOrder, + }; + }); + } + + async createPersonalProvider( + input: CreatePersonalProviderInput = {}, + ): Promise { + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + const templateId = input.templateId?.trim(); + const template = templateId ? kcodeBuiltin.providerTemplates?.get(templateId) : undefined; + if (templateId && !template) throw new Error(`Provider Template 不存在: ${templateId}`); + if (input.initialConfig?.group !== undefined) { + throw new Error("initialConfig 不能包含 group"); } - - deletePersonalProvider(providerId: ProviderId): Promise { - assertNonEmptyId("providerId", providerId); - return this.#updatePersonal((current) => ({ - providers: current.providers.delete(providerId), - models: current.models.deleteExactForProvider(providerId), - providerOrder: current.providerOrder?.filter((candidate) => candidate !== providerId), - })); + if (input.initialConfig?.builtinModelIds !== undefined) { + throw new Error("initialConfig 不能包含 builtinModelIds"); } + let createdProviderId: ProviderId | undefined; + await this.#updatePersonal((current) => { + const occupied = new Set([...kcodeBuiltin.providers.keys(), ...current.providers.keys()]); + const providerId = nextPersonalProviderId(occupied, templateId); + createdProviderId = providerId; + const effectiveProviders = resolvePersonalProviderBaselines(kcodeBuiltin, current.providers); + const label = nextPersonalProviderLabel( + input.providerName ?? + (template && templateId + ? resolveProviderTemplateName(templateId, template, input.locale ?? "en-US") + : "new-provider"), + effectiveProviders, + ); + const initial = input.initialConfig ?? new ProviderConfigValue(); + const providers = current.providers.setRule({ + providerId, + ...(templateId ? { templateId } : {}), + providerName: label, + config: new ProviderConfigValue({ + group: "standard-personal", + access: templateId ? undefined : new ApiKeyAccessConfig(), + personalModelIds: [], + modelOrder: [], + }).overlay(initial), + }); + return { + providers, + models: current.models, + providerOrder: appendCurrentProviderOrder( + kcodeBuiltin.providers, + providers, + current.providerOrder, + providerId, + ), + }; + }); + if (!createdProviderId) throw new Error("Personal Provider 创建失败"); + return Object.freeze({ providerId: createdProviderId }); + } + + deletePersonalProvider(providerId: ProviderId): Promise { + assertNonEmptyId("providerId", providerId); + return this.#updatePersonal((current) => ({ + providers: current.providers.delete(providerId), + models: current.models.deleteExactForProvider(providerId), + providerOrder: current.providerOrder?.filter((candidate) => candidate !== providerId), + })); + } + + async reorderPersonalProviders( + providerIds: readonly ProviderId[], + ): Promise { + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => ({ + providers: current.providers, + models: current.models, + providerOrder: normalizeProviderOrder(kcodeBuiltin.providers, current.providers, providerIds), + })); + } + + async reorderPersonalModels( + providerId: ProviderId, + modelIds: readonly ModelId[], + membership?: ProviderModelMembership, + ): Promise { + assertNonEmptyId("providerId", providerId); + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, providerId, current); + const builtinProvider = + kcodeBuiltin.providers.get(providerId) ?? + resolveTemplateBaseline(kcodeBuiltin, current.providers, providerId); + const provider = writableProviderOverlay(kcodeBuiltin, current, providerId); + const modelOrder = normalizeModelOrder( + membership?.inheritedModelIds ?? builtinProvider?.builtinModelIds ?? [], + provider.personalModelIds ?? [], + modelIds, + ); + return { + providers: current.providers.set(providerId, provider.withModelOrder(modelOrder)), + models: current.models, + providerOrder: current.providerOrder, + }; + }); + } - async reorderPersonalProviders( - providerIds: readonly ProviderId[], - ): Promise { - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => ({ - providers: current.providers, - models: current.models, - providerOrder: normalizeProviderOrder( - kcodeBuiltin.providers, - current.providers, - providerIds, + async addPersonalModel( + providerId: ProviderId, + modelId: ModelId, + config: ModelConfig, + membership?: ProviderModelMembership, + useRecommendedConfig?: boolean, + ): Promise { + const normalizedProviderId = normalizeId("providerId", providerId); + const normalizedModelId = normalizeId("modelId", modelId); + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, normalizedProviderId, current); + const provider = writableProviderOverlay(kcodeBuiltin, current, normalizedProviderId); + const builtinModelIds = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds(kcodeBuiltin, current.providers, normalizedProviderId); + if (builtinModelIds.includes(normalizedModelId)) { + throw new Error(`Model 已存在: ${normalizedProviderId}/${normalizedModelId}`); + } + const currentModelIds = provider.personalModelIds ?? []; + if (currentModelIds.includes(normalizedModelId)) { + throw new Error(`Model 已存在: ${normalizedProviderId}/${normalizedModelId}`); + } + return { + providers: current.providers.set( + normalizedProviderId, + provider.withPersonalModelIds([...currentModelIds, normalizedModelId]).withModelOrder( + // 添加不能重新按成员名单排序,否则会丢掉用户已经保存的顺序。 + normalizeModelOrder( + builtinModelIds, + [...currentModelIds, normalizedModelId], + provider.modelOrder ?? [], ), - })); - } - - async reorderPersonalModels( - providerId: ProviderId, - modelIds: readonly ModelId[], - membership?: ProviderModelMembership, - ): Promise { - assertNonEmptyId("providerId", providerId); - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, providerId, current); - const builtinProvider = - kcodeBuiltin.providers.get(providerId) ?? - resolveTemplateBaseline(kcodeBuiltin, current.providers, providerId); - const provider = writableProviderOverlay(kcodeBuiltin, current, providerId); - const modelOrder = normalizeModelOrder( - membership?.inheritedModelIds ?? builtinProvider?.builtinModelIds ?? [], - provider.personalModelIds ?? [], - modelIds, - ); - return { - providers: current.providers.set(providerId, provider.withModelOrder(modelOrder)), - models: current.models, - providerOrder: current.providerOrder, - }; - }); - } - - async addPersonalModel( - providerId: ProviderId, - modelId: ModelId, - config: ModelConfig, - membership?: ProviderModelMembership, - useRecommendedConfig?: boolean, - ): Promise { - const normalizedProviderId = normalizeId("providerId", providerId); - const normalizedModelId = normalizeId("modelId", modelId); - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, normalizedProviderId, current); - const provider = writableProviderOverlay(kcodeBuiltin, current, normalizedProviderId); - const builtinModelIds = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds( - kcodeBuiltin, - current.providers, - normalizedProviderId, - ); - if (builtinModelIds.includes(normalizedModelId)) { - throw new Error(`Model 已存在: ${normalizedProviderId}/${normalizedModelId}`); - } - const currentModelIds = provider.personalModelIds ?? []; - if (currentModelIds.includes(normalizedModelId)) { - throw new Error(`Model 已存在: ${normalizedProviderId}/${normalizedModelId}`); - } - return { - providers: current.providers.set( - normalizedProviderId, - provider - .withPersonalModelIds([...currentModelIds, normalizedModelId]) - .withModelOrder( - // 添加不能重新按成员名单排序,否则会丢掉用户已经保存的顺序。 - normalizeModelOrder( - builtinModelIds, - [...currentModelIds, normalizedModelId], - provider.modelOrder ?? [], - ), - ), - ), - models: current.models.setExact( - normalizedProviderId, - normalizedModelId, - config.overlay(new ModelConfig({ enabled: true })), - useRecommendedConfig, - ), - providerOrder: current.providerOrder, - }; - }); - } - - async renamePersonalModel( - providerId: ProviderId, - currentModelId: ModelId, - nextModelId: ModelId, - membership?: ProviderModelMembership, - ): Promise { - const normalizedProviderId = normalizeId("providerId", providerId); - const currentId = normalizeId("modelId", currentModelId); - const nextId = normalizeId("modelId", nextModelId); - if (currentId === nextId) return this.#personalRepository.read(); - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, normalizedProviderId, current); - const provider = current.providers.get(normalizedProviderId); - const builtinModelIds = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds( - kcodeBuiltin, - current.providers, - normalizedProviderId, - ); - // 继承归属保护适用于 Facade 和底层直接调用,不能只在有动态上下文时检查。 - if (builtinModelIds.includes(currentId)) - throw new Error(`Built-in Model 不能重命名: ${normalizedProviderId}/${currentId}`); - if (!provider?.personalModelIds?.includes(currentId)) { - throw new Error(`Personal Model 不存在: ${normalizedProviderId}/${currentId}`); - } - if (provider.personalModelIds.includes(nextId)) { - throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); - } - if (builtinModelIds.includes(nextId)) { - throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); - } - const modelIds = provider.personalModelIds.map((modelId) => - modelId === currentId ? nextId : modelId, - ); - const requestedOrder = (provider.modelOrder ?? []).map((modelId) => - modelId === currentId ? nextId : modelId, - ); - return { - providers: current.providers.set( - normalizedProviderId, - provider - .withPersonalModelIds(modelIds) - .withModelOrder( - normalizeModelOrder(builtinModelIds, modelIds, requestedOrder), - ), - ), - models: current.models.renameExactModel(normalizedProviderId, currentId, nextId), - providerOrder: current.providerOrder, - }; - }); - } - - async setPersonalModelEnabled( - providerId: ProviderId, - modelId: ModelId, - enabled: boolean, - membership?: ProviderModelMembership, - ): Promise { - const id = normalizeId("providerId", providerId); - const model = normalizeId("modelId", modelId); - if (typeof enabled !== "boolean") throw new Error("Model enabled 必须是 boolean"); - const builtin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, id, current); - const provider = current.providers.get(id); - const inherited = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds(builtin, current.providers, id); - if (!inherited.includes(model) && !provider?.personalModelIds?.includes(model)) { - throw new Error(`Model 不存在: ${id}/${model}`); - } - // 启停曾复用完整草稿保存,可能覆盖其他编辑或被固定配置完整性阻挡。 - // 在事务内只修改最新 enabled;不改变模式、成员和其他模型字段。 - const config = (current.models.getExact(id, model) ?? new ModelConfig({})).overlay( - new ModelConfig({ enabled }), - ); - return { - providers: current.providers, - models: current.models.setExact(id, model, config), - providerOrder: current.providerOrder, - }; - }); - } - - /** Model 编辑弹窗的唯一写入边界:成员、顺序、精确 Rule 在同一次 Repository update 中提交。 */ - async savePersonalModelDraft( - providerId: ProviderId, - originalModelId: ModelId, - nextModelId: ModelId, - config: ModelConfig, - expectedPersonalRevision: string, - useRecommendedConfig?: boolean, - membership?: ProviderModelMembership, - ): Promise { - const normalizedProviderId = normalizeId("providerId", providerId); - const originalId = normalizeId("modelId", originalModelId); - const nextId = normalizeId("modelId", nextModelId); - const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, normalizedProviderId, current); - if (current.revision !== expectedPersonalRevision) { - throw new Error( - `Personal Provider Config revision conflict: expected ${expectedPersonalRevision}, current ${current.revision}`, - ); - } - const recommended = - useRecommendedConfig ?? - current.models.getExactRule(normalizedProviderId, originalId)?.type !== - "manual-provider-model"; - const provider = current.providers.get(normalizedProviderId); - const builtinModelIds = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds( - kcodeBuiltin, - current.providers, - normalizedProviderId, - ); - const builtinSet = new Set(builtinModelIds); - if (originalId !== nextId && builtinSet.has(originalId)) { - throw new Error(`Built-in Model 不能重命名: ${normalizedProviderId}/${originalId}`); - } - if (originalId !== nextId && builtinSet.has(nextId)) { - throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); - } - const personalModelIds = provider?.personalModelIds ?? []; - const originalExists = - builtinSet.has(originalId) || personalModelIds.includes(originalId); - if (!originalExists) { - throw new Error(`Model 不存在: ${normalizedProviderId}/${originalId}`); - } - if ( - originalId !== nextId && - (personalModelIds.includes(nextId) || builtinSet.has(nextId)) - ) { - throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); - } - - let providers = current.providers; - let models = current.models; - if (originalId !== nextId) { - if (!provider?.personalModelIds?.includes(originalId)) { - throw new Error(`Personal Model 不存在: ${normalizedProviderId}/${originalId}`); - } - const modelIds = provider.personalModelIds.map((modelId) => - modelId === originalId ? nextId : modelId, - ); - const requestedOrder = (provider.modelOrder ?? []).map((modelId) => - modelId === originalId ? nextId : modelId, - ); - providers = providers.set( - normalizedProviderId, - provider - .withPersonalModelIds(modelIds) - .withModelOrder( - normalizeModelOrder(builtinModelIds, modelIds, requestedOrder), - ), - ); - models = models.renameExactModel(normalizedProviderId, originalId, nextId); - } - models = - recommended && isStructurallyEmpty(config.toJSON()) - ? models.deleteExact(normalizedProviderId, nextId) - : models.setExact(normalizedProviderId, nextId, config, recommended); - return { providers, models, providerOrder: current.providerOrder }; - }); - } - - async deletePersonalModel( - providerId: ProviderId, - modelId: ModelId, - membership?: ProviderModelMembership, - ): Promise { - const normalizedProviderId = normalizeId("providerId", providerId); - const normalizedModelId = normalizeId("modelId", modelId); - const builtin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, normalizedProviderId, current); - const provider = writableProviderOverlay(builtin, current, normalizedProviderId); - const inherited = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds(builtin, current.providers, normalizedProviderId); - const plan = resolvePersonalModelDeletionPlan({ - modelId: normalizedModelId, - inheritedModelIds: inherited, - personalModelIds: provider.personalModelIds ?? [], - hiddenInheritedModelIds: provider.hiddenInheritedModelIds, - }); - if (plan.kind === "inherited") { - return { - providers: current.providers.set( - normalizedProviderId, - provider.withHiddenInheritedModelIds(plan.nextHiddenInheritedModelIds), - ), - models: current.models, - providerOrder: current.providerOrder, - }; - } - return { - providers: current.providers.set( - normalizedProviderId, - provider - .withPersonalModelIds(plan.nextPersonalModelIds) - .withModelOrder( - normalizeModelOrder( - inherited, - plan.nextPersonalModelIds, - provider.modelOrder ?? [], - ), - ), - ), - models: current.models.deleteExact(normalizedProviderId, normalizedModelId), - providerOrder: current.providerOrder, - }; - }); - } + ), + ), + models: current.models.setExact( + normalizedProviderId, + normalizedModelId, + config.overlay(new ModelConfig({ enabled: true })), + useRecommendedConfig, + ), + providerOrder: current.providerOrder, + }; + }); + } - async importRemoteModels( - providerId: ProviderId, - remoteModelIds: readonly ModelId[], - membership?: ProviderModelMembership, - modelConfigs?: Readonly>, - ): Promise { - const normalizedProviderId = normalizeId("providerId", providerId); - const builtin = await this.#kcodeBuiltinSource.read(); - let resolvedPlan: RemoteModelImportPlan | undefined; - await this.#updatePersonal((current) => { - assertMembershipCurrent(membership, normalizedProviderId, current); - const provider = writableProviderOverlay(builtin, current, normalizedProviderId); - const inherited = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds(builtin, current.providers, normalizedProviderId); - const plan = resolveRemoteModelImportPlan({ - remoteModelIds, - inheritedModelIds: inherited, - personalModelIds: provider.personalModelIds ?? [], - hiddenInheritedModelIds: provider.hiddenInheritedModelIds, - }); - resolvedPlan = plan; - let models = current.models; - for (const modelId of plan.addedModelIds) { - const provided = modelConfigs?.[modelId]; - models = models.setExact( - normalizedProviderId, - modelId, - provided - ? new ModelConfig({ enabled: true }).overlay(provided) - : new ModelConfig({ enabled: true }), - true, - ); - } - return { - providers: current.providers.set( - normalizedProviderId, - provider - .withPersonalModelIds(plan.nextPersonalModelIds) - .withHiddenInheritedModelIds(plan.nextHiddenInheritedModelIds) - .withModelOrder( - normalizeModelOrder(inherited, plan.nextPersonalModelIds, [ - ...(provider.modelOrder ?? []), - ...plan.addedModelIds, - ...plan.restoredInheritedModelIds, - ]), - ), - ), - models, - providerOrder: current.providerOrder, - }; - }); - if (!resolvedPlan) throw new Error(`导入远端模型失败: ${normalizedProviderId}`); - return resolvedPlan; - } + async renamePersonalModel( + providerId: ProviderId, + currentModelId: ModelId, + nextModelId: ModelId, + membership?: ProviderModelMembership, + ): Promise { + const normalizedProviderId = normalizeId("providerId", providerId); + const currentId = normalizeId("modelId", currentModelId); + const nextId = normalizeId("modelId", nextModelId); + if (currentId === nextId) return this.#personalRepository.read(); + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, normalizedProviderId, current); + const provider = current.providers.get(normalizedProviderId); + const builtinModelIds = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds(kcodeBuiltin, current.providers, normalizedProviderId); + // 继承归属保护适用于 Facade 和底层直接调用,不能只在有动态上下文时检查。 + if (builtinModelIds.includes(currentId)) + throw new Error(`Built-in Model 不能重命名: ${normalizedProviderId}/${currentId}`); + if (!provider?.personalModelIds?.includes(currentId)) { + throw new Error(`Personal Model 不存在: ${normalizedProviderId}/${currentId}`); + } + if (provider.personalModelIds.includes(nextId)) { + throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); + } + if (builtinModelIds.includes(nextId)) { + throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); + } + const modelIds = provider.personalModelIds.map((modelId) => + modelId === currentId ? nextId : modelId, + ); + const requestedOrder = (provider.modelOrder ?? []).map((modelId) => + modelId === currentId ? nextId : modelId, + ); + return { + providers: current.providers.set( + normalizedProviderId, + provider + .withPersonalModelIds(modelIds) + .withModelOrder(normalizeModelOrder(builtinModelIds, modelIds, requestedOrder)), + ), + models: current.models.renameExactModel(normalizedProviderId, currentId, nextId), + providerOrder: current.providerOrder, + }; + }); + } - async clearVisibleModels( - providerId: ProviderId, - membership?: ProviderModelMembership, - ): Promise { - const normalizedProviderId = normalizeId("providerId", providerId); - const builtin = await this.#kcodeBuiltinSource.read(); - return this.#updatePersonal((current) => { - assertMembershipCurrent(membership, normalizedProviderId, current); - const provider = writableProviderOverlay(builtin, current, normalizedProviderId); - const inherited = - membership?.inheritedModelIds ?? - resolveProviderBuiltinModelIds(builtin, current.providers, normalizedProviderId); - let models = current.models; - for (const modelId of provider.personalModelIds ?? []) { - models = models.deleteExact(normalizedProviderId, modelId); - } - return { - providers: current.providers.set( - normalizedProviderId, - provider - .withPersonalModelIds([]) - .withHiddenInheritedModelIds( - normalizeHiddenInheritedModelIds(inherited, inherited), - ) - .withModelOrder([]), - ), - models, - providerOrder: current.providerOrder, - }; - }); - } + async setPersonalModelEnabled( + providerId: ProviderId, + modelId: ModelId, + enabled: boolean, + membership?: ProviderModelMembership, + ): Promise { + const id = normalizeId("providerId", providerId); + const model = normalizeId("modelId", modelId); + if (typeof enabled !== "boolean") throw new Error("Model enabled 必须是 boolean"); + const builtin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, id, current); + const provider = current.providers.get(id); + const inherited = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds(builtin, current.providers, id); + if (!inherited.includes(model) && !provider?.personalModelIds?.includes(model)) { + throw new Error(`Model 不存在: ${id}/${model}`); + } + // 启停曾复用完整草稿保存,可能覆盖其他编辑或被固定配置完整性阻挡。 + // 在事务内只修改最新 enabled;不改变模式、成员和其他模型字段。 + const config = (current.models.getExact(id, model) ?? new ModelConfig({})).overlay( + new ModelConfig({ enabled }), + ); + return { + providers: current.providers, + models: current.models.setExact(id, model, config), + providerOrder: current.providerOrder, + }; + }); + } - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const dispose of this.#sourceDisposers.splice(0)) dispose(); - this.#listeners.clear(); - } + /** Model 编辑弹窗的唯一写入边界:成员、顺序、精确 Rule 在同一次 Repository update 中提交。 */ + async savePersonalModelDraft( + providerId: ProviderId, + originalModelId: ModelId, + nextModelId: ModelId, + config: ModelConfig, + expectedPersonalRevision: string, + useRecommendedConfig?: boolean, + membership?: ProviderModelMembership, + ): Promise { + const normalizedProviderId = normalizeId("providerId", providerId); + const originalId = normalizeId("modelId", originalModelId); + const nextId = normalizeId("modelId", nextModelId); + const kcodeBuiltin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, normalizedProviderId, current); + if (current.revision !== expectedPersonalRevision) { + throw new Error( + `Personal Provider Config revision conflict: expected ${expectedPersonalRevision}, current ${current.revision}`, + ); + } + const recommended = + useRecommendedConfig ?? + current.models.getExactRule(normalizedProviderId, originalId)?.type !== + "manual-provider-model"; + const provider = current.providers.get(normalizedProviderId); + const builtinModelIds = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds(kcodeBuiltin, current.providers, normalizedProviderId); + const builtinSet = new Set(builtinModelIds); + if (originalId !== nextId && builtinSet.has(originalId)) { + throw new Error(`Built-in Model 不能重命名: ${normalizedProviderId}/${originalId}`); + } + if (originalId !== nextId && builtinSet.has(nextId)) { + throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); + } + const personalModelIds = provider?.personalModelIds ?? []; + const originalExists = builtinSet.has(originalId) || personalModelIds.includes(originalId); + if (!originalExists) { + throw new Error(`Model 不存在: ${normalizedProviderId}/${originalId}`); + } + if (originalId !== nextId && (personalModelIds.includes(nextId) || builtinSet.has(nextId))) { + throw new Error(`Model 已存在: ${normalizedProviderId}/${nextId}`); + } + + let providers = current.providers; + let models = current.models; + if (originalId !== nextId) { + if (!provider?.personalModelIds?.includes(originalId)) { + throw new Error(`Personal Model 不存在: ${normalizedProviderId}/${originalId}`); + } + const modelIds = provider.personalModelIds.map((modelId) => + modelId === originalId ? nextId : modelId, + ); + const requestedOrder = (provider.modelOrder ?? []).map((modelId) => + modelId === originalId ? nextId : modelId, + ); + providers = providers.set( + normalizedProviderId, + provider + .withPersonalModelIds(modelIds) + .withModelOrder(normalizeModelOrder(builtinModelIds, modelIds, requestedOrder)), + ); + models = models.renameExactModel(normalizedProviderId, originalId, nextId); + } + models = + recommended && isStructurallyEmpty(config.toJSON()) + ? models.deleteExact(normalizedProviderId, nextId) + : models.setExact(normalizedProviderId, nextId, config, recommended); + return { providers, models, providerOrder: current.providerOrder }; + }); + } - #updatePersonal( - transform: (current: ProviderConfigLayerSnapshot) => ProviderConfigLayerUpdate, - ): Promise { - this.#assertNotDisposed(); - return this.#personalRepository.update((current) => ({ - // Provider/Model/排序只修改自己的成员,不能因共用文件清掉默认选择。 - defaultModelSelection: current.defaultModelSelection, - ...transform(current), - })); - } + async deletePersonalModel( + providerId: ProviderId, + modelId: ModelId, + membership?: ProviderModelMembership, + ): Promise { + const normalizedProviderId = normalizeId("providerId", providerId); + const normalizedModelId = normalizeId("modelId", modelId); + const builtin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, normalizedProviderId, current); + const provider = writableProviderOverlay(builtin, current, normalizedProviderId); + const inherited = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds(builtin, current.providers, normalizedProviderId); + const plan = resolvePersonalModelDeletionPlan({ + modelId: normalizedModelId, + inheritedModelIds: inherited, + personalModelIds: provider.personalModelIds ?? [], + hiddenInheritedModelIds: provider.hiddenInheritedModelIds, + }); + if (plan.kind === "inherited") { + return { + providers: current.providers.set( + normalizedProviderId, + provider.withHiddenInheritedModelIds(plan.nextHiddenInheritedModelIds), + ), + models: current.models, + providerOrder: current.providerOrder, + }; + } + return { + providers: current.providers.set( + normalizedProviderId, + provider + .withPersonalModelIds(plan.nextPersonalModelIds) + .withModelOrder( + normalizeModelOrder(inherited, plan.nextPersonalModelIds, provider.modelOrder ?? []), + ), + ), + models: current.models.deleteExact(normalizedProviderId, normalizedModelId), + providerOrder: current.providerOrder, + }; + }); + } - #emit(reason: string): void { - if (this.#disposed) return; - for (const listener of this.#listeners) listener(reason || "changed"); - } + async importRemoteModels( + providerId: ProviderId, + remoteModelIds: readonly ModelId[], + membership?: ProviderModelMembership, + modelConfigs?: Readonly>, + ): Promise { + const normalizedProviderId = normalizeId("providerId", providerId); + const builtin = await this.#kcodeBuiltinSource.read(); + let resolvedPlan: RemoteModelImportPlan | undefined; + await this.#updatePersonal((current) => { + assertMembershipCurrent(membership, normalizedProviderId, current); + const provider = writableProviderOverlay(builtin, current, normalizedProviderId); + const inherited = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds(builtin, current.providers, normalizedProviderId); + const plan = resolveRemoteModelImportPlan({ + remoteModelIds, + inheritedModelIds: inherited, + personalModelIds: provider.personalModelIds ?? [], + hiddenInheritedModelIds: provider.hiddenInheritedModelIds, + }); + resolvedPlan = plan; + let models = current.models; + for (const modelId of plan.addedModelIds) { + const provided = modelConfigs?.[modelId]; + models = models.setExact( + normalizedProviderId, + modelId, + provided + ? new ModelConfig({ enabled: true }).overlay(provided) + : new ModelConfig({ enabled: true }), + true, + ); + } + return { + providers: current.providers.set( + normalizedProviderId, + provider + .withPersonalModelIds(plan.nextPersonalModelIds) + .withHiddenInheritedModelIds(plan.nextHiddenInheritedModelIds) + .withModelOrder( + normalizeModelOrder( + inherited, + plan.nextPersonalModelIds, + [ + ...(provider.modelOrder ?? []), + ...plan.addedModelIds, + ...plan.restoredInheritedModelIds, + ], + ), + ), + ), + models, + providerOrder: current.providerOrder, + }; + }); + if (!resolvedPlan) throw new Error(`导入远端模型失败: ${normalizedProviderId}`); + return resolvedPlan; + } - #assertNotDisposed(): void { - if (this.#disposed) throw new Error("ProviderConfigService 已 dispose"); - } + async clearVisibleModels( + providerId: ProviderId, + membership?: ProviderModelMembership, + ): Promise { + const normalizedProviderId = normalizeId("providerId", providerId); + const builtin = await this.#kcodeBuiltinSource.read(); + return this.#updatePersonal((current) => { + assertMembershipCurrent(membership, normalizedProviderId, current); + const provider = writableProviderOverlay(builtin, current, normalizedProviderId); + const inherited = + membership?.inheritedModelIds ?? + resolveProviderBuiltinModelIds(builtin, current.providers, normalizedProviderId); + let models = current.models; + for (const modelId of provider.personalModelIds ?? []) { + models = models.deleteExact(normalizedProviderId, modelId); + } + return { + providers: current.providers.set( + normalizedProviderId, + provider + .withPersonalModelIds([]) + .withHiddenInheritedModelIds( + normalizeHiddenInheritedModelIds(inherited, inherited), + ) + .withModelOrder([]), + ), + models, + providerOrder: current.providerOrder, + }; + }); + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + for (const dispose of this.#sourceDisposers.splice(0)) dispose(); + this.#listeners.clear(); + } + + #updatePersonal( + transform: (current: ProviderConfigLayerSnapshot) => ProviderConfigLayerUpdate, + ): Promise { + this.#assertNotDisposed(); + return this.#personalRepository.update((current) => ({ + // Provider/Model/排序只修改自己的成员,不能因共用文件清掉默认选择。 + defaultModelSelection: current.defaultModelSelection, + ...transform(current), + })); + } + + #emit(reason: string): void { + if (this.#disposed) return; + for (const listener of this.#listeners) listener(reason || "changed"); + } + + #assertNotDisposed(): void { + if (this.#disposed) throw new Error("ProviderConfigService 已 dispose"); + } } function isStructurallyEmpty(value: unknown): boolean { - if (value === undefined) return true; - if (value === null || typeof value !== "object") return false; - if (Array.isArray(value)) return value.length === 0; - return Object.values(value).every(isStructurallyEmpty); + if (value === undefined) return true; + if (value === null || typeof value !== "object") return false; + if (Array.isArray(value)) return value.length === 0; + return Object.values(value).every(isStructurallyEmpty); } function assertProviderLabelMutationIsUnique( - providerId: ProviderId, - currentProviders: ProviderConfigMap, - nextProviders: ProviderConfigMap, + providerId: ProviderId, + currentProviders: ProviderConfigMap, + nextProviders: ProviderConfigMap, ): void { - const currentLabel = currentProviders.getRule(providerId)?.providerName?.trim(); - const nextLabel = nextProviders.getRule(providerId)?.providerName?.trim(); - const currentKey = currentLabel?.toLocaleLowerCase(); - const nextKey = nextLabel?.toLocaleLowerCase(); - if (!nextKey || nextKey === currentKey) return; - for (const candidate of nextProviders.rules()) { - const candidateId = candidate.providerId; - if (candidateId === providerId) continue; - if (candidate.providerName?.trim().toLocaleLowerCase() === nextKey) { - throw new Error(`Provider 名称已存在: ${nextLabel}`); - } + const currentLabel = currentProviders.getRule(providerId)?.providerName?.trim(); + const nextLabel = nextProviders.getRule(providerId)?.providerName?.trim(); + const currentKey = currentLabel?.toLocaleLowerCase(); + const nextKey = nextLabel?.toLocaleLowerCase(); + if (!nextKey || nextKey === currentKey) return; + for (const candidate of nextProviders.rules()) { + const candidateId = candidate.providerId; + if (candidateId === providerId) continue; + if (candidate.providerName?.trim().toLocaleLowerCase() === nextKey) { + throw new Error(`Provider 名称已存在: ${nextLabel}`); } + } } function normalizePersonalProviderMembership( - personal: ProviderConfig | undefined, - builtin: ProviderConfig | undefined, - inheritedModelIds?: readonly ModelId[], + personal: ProviderConfig | undefined, + builtin: ProviderConfig | undefined, + inheritedModelIds?: readonly ModelId[], ): ProviderConfig | undefined { - if (!personal) return undefined; - const builtinModelIds = uniqueInOrder(inheritedModelIds ?? builtin?.builtinModelIds ?? []); - const builtinSet = new Set(builtinModelIds); - const personalModelIds = uniqueInOrder(personal.personalModelIds ?? []).filter( - (modelId) => !builtinSet.has(modelId), + if (!personal) return undefined; + const builtinModelIds = uniqueInOrder(inheritedModelIds ?? builtin?.builtinModelIds ?? []); + const builtinSet = new Set(builtinModelIds); + const personalModelIds = uniqueInOrder(personal.personalModelIds ?? []).filter( + (modelId) => !builtinSet.has(modelId), + ); + let normalized = personal + .withPersonalModelIds(personalModelIds) + .withHiddenInheritedModelIds( + normalizeHiddenInheritedModelIds(builtinModelIds, personal.hiddenInheritedModelIds), ); - let normalized = personal - .withPersonalModelIds(personalModelIds) - .withHiddenInheritedModelIds( - normalizeHiddenInheritedModelIds(builtinModelIds, personal.hiddenInheritedModelIds), - ); - if (personal.modelOrder !== undefined && personal.modelOrder !== null) { - normalized = normalized.withModelOrder( - normalizeModelOrder(builtinModelIds, personalModelIds, personal.modelOrder), - ); - } - return normalized; + if (personal.modelOrder !== undefined && personal.modelOrder !== null) { + normalized = normalized.withModelOrder( + normalizeModelOrder(builtinModelIds, personalModelIds, personal.modelOrder), + ); + } + return normalized; } function assertNonEmptyId(label: string, value: string): void { - if (!value.trim()) throw new Error(`${label} 不能为空`); + if (!value.trim()) throw new Error(`${label} 不能为空`); } function normalizeId(label: string, value: string): string { - const normalized = value.trim(); - if (!normalized) throw new Error(`${label} 不能为空`); - return normalized; + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} 不能为空`); + return normalized; } function uniqueInOrder(values: readonly T[]): T[] { - const result: T[] = []; - for (const value of values) if (!result.includes(value)) result.push(value); - return result; + const result: T[] = []; + for (const value of values) if (!result.includes(value)) result.push(value); + return result; } function normalizeProviderOrder( - _kcodeBuiltinProviders: ProviderConfigMap, - personalProviders: ProviderConfigMap, - requested: readonly ProviderId[], + _kcodeBuiltinProviders: ProviderConfigMap, + personalProviders: ProviderConfigMap, + requested: readonly ProviderId[], ): ProviderId[] { - const personalIds = personalProviders - .entries() - .filter(([, config]) => config.group === "standard-personal") - .map(([providerId]) => providerId); - return [...resolveOwnedOrder([], personalIds, requested)]; + const personalIds = personalProviders + .entries() + .filter(([, config]) => config.group === "standard-personal") + .map(([providerId]) => providerId); + return [...resolveOwnedOrder([], personalIds, requested)]; } function appendCurrentProviderOrder( - kcodeBuiltinProviders: ProviderConfigMap, - personalProviders: ProviderConfigMap, - currentOrder: readonly ProviderId[] | undefined, - addedProviderId: ProviderId, + kcodeBuiltinProviders: ProviderConfigMap, + personalProviders: ProviderConfigMap, + currentOrder: readonly ProviderId[] | undefined, + addedProviderId: ProviderId, ): ProviderId[] { - const current = normalizeProviderOrder( - kcodeBuiltinProviders, - personalProviders, - currentOrder ?? [], - ); - return normalizeProviderOrder(kcodeBuiltinProviders, personalProviders, [ - ...current.filter((providerId) => providerId !== addedProviderId), - addedProviderId, - ]); + const current = normalizeProviderOrder( + kcodeBuiltinProviders, + personalProviders, + currentOrder ?? [], + ); + return normalizeProviderOrder(kcodeBuiltinProviders, personalProviders, [ + ...current.filter((providerId) => providerId !== addedProviderId), + addedProviderId, + ]); } function normalizeModelOrder( - builtinModelIds: readonly ModelId[], - personalModelIds: readonly ModelId[], - requested: readonly ModelId[], + builtinModelIds: readonly ModelId[], + personalModelIds: readonly ModelId[], + requested: readonly ModelId[], ): ModelId[] { - return [...resolveOwnedOrder(builtinModelIds, personalModelIds, requested)]; + return [...resolveOwnedOrder(builtinModelIds, personalModelIds, requested)]; } function nextPersonalProviderId( - occupied: ReadonlySet, - templateId?: ProviderTemplateId, + occupied: ReadonlySet, + templateId?: ProviderTemplateId, ): ProviderId { - const base = templateId ? normalizeProviderIdSeed(templateId) : "new-provider"; - if (!occupied.has(base)) return base; - for (let suffix = 2; ; suffix += 1) { - const candidate = `${base}-${suffix}`; - if (!occupied.has(candidate)) return candidate; - } + const base = templateId ? normalizeProviderIdSeed(templateId) : "new-provider"; + if (!occupied.has(base)) return base; + for (let suffix = 2; ; suffix += 1) { + const candidate = `${base}-${suffix}`; + if (!occupied.has(candidate)) return candidate; + } } function normalizeProviderIdSeed(value: string): string { - return ( - value - .trim() - .toLocaleLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "new-provider" - ); + return ( + value + .trim() + .toLocaleLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "new-provider" + ); } function resolvePersonalProviderBaselines( - kcodeBuiltin: ProviderConfigLayerSnapshot, - personalProviders: ProviderConfigMap, + kcodeBuiltin: ProviderConfigLayerSnapshot, + personalProviders: ProviderConfigMap, ): ProviderConfigMap { - const personal = personalProviders.mapConfigs((config, _id, rule) => { - const template = rule.templateId - ? kcodeBuiltin.providerTemplates?.get(rule.templateId) - : undefined; - return template ? template.config.overlay(config) : config; - }); - return kcodeBuiltin.providers.overlay(personal); + const personal = personalProviders.mapConfigs((config, _id, rule) => { + const template = rule.templateId + ? kcodeBuiltin.providerTemplates?.get(rule.templateId) + : undefined; + return template ? template.config.overlay(config) : config; + }); + return kcodeBuiltin.providers.overlay(personal); } function resolveTemplateBaseline( - builtin: ProviderConfigLayerSnapshot, - providers: ProviderConfigMap, - providerId: ProviderId, + builtin: ProviderConfigLayerSnapshot, + providers: ProviderConfigMap, + providerId: ProviderId, ): ProviderConfig | undefined { - const templateId = providers.getRule(providerId)?.templateId; - return templateId ? builtin.providerTemplates?.get(templateId)?.config : undefined; + const templateId = providers.getRule(providerId)?.templateId; + return templateId ? builtin.providerTemplates?.get(templateId)?.config : undefined; } function resolveProviderBuiltinModelIds( - builtin: ProviderConfigLayerSnapshot, - personalProviders: ProviderConfigMap, - providerId: ProviderId, + builtin: ProviderConfigLayerSnapshot, + personalProviders: ProviderConfigMap, + providerId: ProviderId, ): readonly ModelId[] { - return ( - builtin.providers.get(providerId)?.builtinModelIds ?? - resolveTemplateBaseline(builtin, personalProviders, providerId)?.builtinModelIds ?? - [] - ); + return ( + builtin.providers.get(providerId)?.builtinModelIds ?? + resolveTemplateBaseline(builtin, personalProviders, providerId)?.builtinModelIds ?? + [] + ); } function nextPersonalProviderLabel(seed: string, providers: ProviderConfigMap): string { - const base = seed.trim() || "new-provider"; - const labels = new Set( - providers - .rules() - .map((provider) => provider.providerName?.trim().toLocaleLowerCase()) - .filter((label): label is string => Boolean(label)), - ); - if (!labels.has(base.toLocaleLowerCase())) return base; - for (let suffix = 2; ; suffix += 1) { - const candidate = `${base} ${suffix}`; - if (!labels.has(candidate.toLocaleLowerCase())) return candidate; - } + const base = seed.trim() || "new-provider"; + const labels = new Set( + providers + .rules() + .map((provider) => provider.providerName?.trim().toLocaleLowerCase()) + .filter((label): label is string => Boolean(label)), + ); + if (!labels.has(base.toLocaleLowerCase())) return base; + for (let suffix = 2; ; suffix += 1) { + const candidate = `${base} ${suffix}`; + if (!labels.has(candidate.toLocaleLowerCase())) return candidate; + } } diff --git a/packages/provider/src/config/model-config.ts b/packages/provider/src/config/model-config.ts index 6cfb3e20..f2e80e09 100644 --- a/packages/provider/src/config/model-config.ts +++ b/packages/provider/src/config/model-config.ts @@ -2,36 +2,36 @@ import { ConfigOverlay, type ConfigValidationIssue } from "../config-overlay.js"; import type { z } from "zod"; import { - completeEnumOptionSpecDataSchema, - completeLimitOptionSpecDataSchema, - completeModelInputFormatDataSchema, - completeModelOutputFormatDataSchema, - completeModelPropertiesDataSchema, - completeModelOptionSpecsDataSchema, - completeModelConfigDataSchema, - type enumOptionSpecDataSchema, - type limitOptionSpecDataSchema, - type modelInputFormatDataSchema, - type modelOutputFormatDataSchema, - type modelPropertiesDataSchema, - type modelOptionSpecsDataSchema, - type modelConfigDataSchema, + completeEnumOptionSpecDataSchema, + completeLimitOptionSpecDataSchema, + completeModelInputFormatDataSchema, + completeModelOutputFormatDataSchema, + completeModelPropertiesDataSchema, + completeModelOptionSpecsDataSchema, + completeModelConfigDataSchema, + type enumOptionSpecDataSchema, + type limitOptionSpecDataSchema, + type modelInputFormatDataSchema, + type modelOutputFormatDataSchema, + type modelPropertiesDataSchema, + type modelOptionSpecsDataSchema, + type modelConfigDataSchema, } from "@kcode/shared/model-config"; import { validateConfigSchema } from "./schema-validation.js"; import { clearManualModelConfig } from "./manual-model-config.js"; import { - providerModelConfigRuleSchema, - manualProviderModelConfigRuleSchema, - personalModelConfigRulesSchema, - builtinModelConfigRulesSchema, - type ProviderModelConfigRuleData, - type ManualProviderModelConfigRuleData, - type TemplateModelConfigRuleData, - type ModelMatchConfigRuleData, - type ModelApiMatchConfigRuleData, - type ProviderSiteMatchConfigRuleData, - type BuiltinModelConfigRulesData, - type PersonalModelConfigRulesData, + providerModelConfigRuleSchema, + manualProviderModelConfigRuleSchema, + personalModelConfigRulesSchema, + builtinModelConfigRulesSchema, + type ProviderModelConfigRuleData, + type ManualProviderModelConfigRuleData, + type TemplateModelConfigRuleData, + type ModelMatchConfigRuleData, + type ModelApiMatchConfigRuleData, + type ProviderSiteMatchConfigRuleData, + type BuiltinModelConfigRulesData, + type PersonalModelConfigRulesData, } from "./rule-data-schema.js"; export type EnumOptionSpec = Readonly>; @@ -41,69 +41,69 @@ export type LimitOptionSpec = Readonly>; export class EnumOptionSpecConfig extends ConfigOverlay { - readonly values?: EnumOptionSpecConfigInput["values"]; - readonly map?: EnumOptionSpecConfigInput["map"]; - - constructor(input: EnumOptionSpecConfigInput = {}) { - super(); - this.values = - input.values === undefined - ? undefined - : input.values === null - ? null - : Object.freeze([...input.values]); - this.map = input.map; - Object.freeze(this); - } - - overlay(next: EnumOptionSpecConfig): EnumOptionSpecConfig { - return new EnumOptionSpecConfig({ - values: this.overlayValue(this.values, next.values), - map: this.overlayValue(this.map, next.map), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeEnumOptionSpecDataSchema, this.toJSON(), path, true); - } - - toJSON(): EnumOptionSpecConfigInput { - return objectWithoutUndefined({ - values: this.values, - map: this.map, - }); - } + readonly values?: EnumOptionSpecConfigInput["values"]; + readonly map?: EnumOptionSpecConfigInput["map"]; + + constructor(input: EnumOptionSpecConfigInput = {}) { + super(); + this.values = + input.values === undefined + ? undefined + : input.values === null + ? null + : Object.freeze([...input.values]); + this.map = input.map; + Object.freeze(this); + } + + overlay(next: EnumOptionSpecConfig): EnumOptionSpecConfig { + return new EnumOptionSpecConfig({ + values: this.overlayValue(this.values, next.values), + map: this.overlayValue(this.map, next.map), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeEnumOptionSpecDataSchema, this.toJSON(), path, true); + } + + toJSON(): EnumOptionSpecConfigInput { + return objectWithoutUndefined({ + values: this.values, + map: this.map, + }); + } } export type LimitOptionSpecConfigInput = Readonly>; export class LimitOptionSpecConfig extends ConfigOverlay { - readonly max?: LimitOptionSpecConfigInput["max"]; - readonly map?: LimitOptionSpecConfigInput["map"]; - - constructor(input: LimitOptionSpecConfigInput = {}) { - super(); - Object.assign(this, input); - Object.freeze(this); - } - - overlay(next: LimitOptionSpecConfig): LimitOptionSpecConfig { - return new LimitOptionSpecConfig({ - max: this.overlayValue(this.max, next.max), - map: this.overlayValue(this.map, next.map), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeLimitOptionSpecDataSchema, this.toJSON(), path, true); - } - - toJSON(): LimitOptionSpecConfigInput { - return objectWithoutUndefined({ - max: this.max, - map: this.map, - }); - } + readonly max?: LimitOptionSpecConfigInput["max"]; + readonly map?: LimitOptionSpecConfigInput["map"]; + + constructor(input: LimitOptionSpecConfigInput = {}) { + super(); + Object.assign(this, input); + Object.freeze(this); + } + + overlay(next: LimitOptionSpecConfig): LimitOptionSpecConfig { + return new LimitOptionSpecConfig({ + max: this.overlayValue(this.max, next.max), + map: this.overlayValue(this.map, next.map), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeLimitOptionSpecDataSchema, this.toJSON(), path, true); + } + + toJSON(): LimitOptionSpecConfigInput { + return objectWithoutUndefined({ + max: this.max, + map: this.map, + }); + } } export type ModelPropertiesConfigInput = Readonly>; @@ -111,227 +111,227 @@ export type ModelPropertiesConfigInput = Readonly>; export class ModelInputFormatConfig extends ConfigOverlay { - readonly supportsText?: ModelInputFormatConfigInput["supportsText"]; - readonly supportsImage?: ModelInputFormatConfigInput["supportsImage"]; - readonly supportsVideo?: ModelInputFormatConfigInput["supportsVideo"]; - readonly supportsAudio?: ModelInputFormatConfigInput["supportsAudio"]; - readonly supportsPdf?: ModelInputFormatConfigInput["supportsPdf"]; - - constructor(input: ModelInputFormatConfigInput = {}) { - super(); - Object.assign(this, input); - Object.freeze(this); - } - - overlay(next: ModelInputFormatConfig): ModelInputFormatConfig { - return new ModelInputFormatConfig({ - supportsText: this.overlayValue(this.supportsText, next.supportsText), - supportsImage: this.overlayValue(this.supportsImage, next.supportsImage), - supportsVideo: this.overlayValue(this.supportsVideo, next.supportsVideo), - supportsAudio: this.overlayValue(this.supportsAudio, next.supportsAudio), - supportsPdf: this.overlayValue(this.supportsPdf, next.supportsPdf), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeModelInputFormatDataSchema, this.toJSON(), path); - } - - toJSON(): ModelInputFormatConfigInput { - return objectWithoutUndefined({ - supportsText: this.supportsText, - supportsImage: this.supportsImage, - supportsVideo: this.supportsVideo, - supportsAudio: this.supportsAudio, - supportsPdf: this.supportsPdf, - }); - } + readonly supportsText?: ModelInputFormatConfigInput["supportsText"]; + readonly supportsImage?: ModelInputFormatConfigInput["supportsImage"]; + readonly supportsVideo?: ModelInputFormatConfigInput["supportsVideo"]; + readonly supportsAudio?: ModelInputFormatConfigInput["supportsAudio"]; + readonly supportsPdf?: ModelInputFormatConfigInput["supportsPdf"]; + + constructor(input: ModelInputFormatConfigInput = {}) { + super(); + Object.assign(this, input); + Object.freeze(this); + } + + overlay(next: ModelInputFormatConfig): ModelInputFormatConfig { + return new ModelInputFormatConfig({ + supportsText: this.overlayValue(this.supportsText, next.supportsText), + supportsImage: this.overlayValue(this.supportsImage, next.supportsImage), + supportsVideo: this.overlayValue(this.supportsVideo, next.supportsVideo), + supportsAudio: this.overlayValue(this.supportsAudio, next.supportsAudio), + supportsPdf: this.overlayValue(this.supportsPdf, next.supportsPdf), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeModelInputFormatDataSchema, this.toJSON(), path); + } + + toJSON(): ModelInputFormatConfigInput { + return objectWithoutUndefined({ + supportsText: this.supportsText, + supportsImage: this.supportsImage, + supportsVideo: this.supportsVideo, + supportsAudio: this.supportsAudio, + supportsPdf: this.supportsPdf, + }); + } } export type ModelOutputFormatConfigInput = Readonly>; export class ModelOutputFormatConfig extends ConfigOverlay { - readonly supportsText?: ModelOutputFormatConfigInput["supportsText"]; - - constructor(input: ModelOutputFormatConfigInput = {}) { - super(); - Object.assign(this, input); - Object.freeze(this); - } - - overlay(next: ModelOutputFormatConfig): ModelOutputFormatConfig { - return new ModelOutputFormatConfig({ - supportsText: this.overlayValue(this.supportsText, next.supportsText), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeModelOutputFormatDataSchema, this.toJSON(), path); - } - - toJSON(): ModelOutputFormatConfigInput { - return objectWithoutUndefined({ supportsText: this.supportsText }); - } + readonly supportsText?: ModelOutputFormatConfigInput["supportsText"]; + + constructor(input: ModelOutputFormatConfigInput = {}) { + super(); + Object.assign(this, input); + Object.freeze(this); + } + + overlay(next: ModelOutputFormatConfig): ModelOutputFormatConfig { + return new ModelOutputFormatConfig({ + supportsText: this.overlayValue(this.supportsText, next.supportsText), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeModelOutputFormatDataSchema, this.toJSON(), path); + } + + toJSON(): ModelOutputFormatConfigInput { + return objectWithoutUndefined({ supportsText: this.supportsText }); + } } export class ModelPropertiesConfig extends ConfigOverlay { - readonly requiresMfjsToolSchema?: ModelPropertiesConfigInput["requiresMfjsToolSchema"]; - readonly contextWindow?: ModelPropertiesConfigInput["contextWindow"]; - readonly inputFormat?: ModelInputFormatConfig | null; - readonly outputFormat?: ModelOutputFormatConfig | null; - readonly supportsToolCall?: ModelPropertiesConfigInput["supportsToolCall"]; - readonly supportsJsonSchemaOutput?: ModelPropertiesConfigInput["supportsJsonSchemaOutput"]; - readonly supportsNativeWebSearch?: ModelPropertiesConfigInput["supportsNativeWebSearch"]; - readonly supportsMidConversationSystem?: ModelPropertiesConfigInput["supportsMidConversationSystem"]; - - constructor(input: ModelPropertiesConfigInput = {}) { - super(); - Object.assign(this, { - ...input, - inputFormat: - input.inputFormat instanceof ModelInputFormatConfig || input.inputFormat == null - ? input.inputFormat - : new ModelInputFormatConfig(input.inputFormat), - outputFormat: - input.outputFormat instanceof ModelOutputFormatConfig || input.outputFormat == null - ? input.outputFormat - : new ModelOutputFormatConfig(input.outputFormat), - }); - Object.freeze(this); - } - - overlay(next: ModelPropertiesConfig): ModelPropertiesConfig { - return new ModelPropertiesConfig({ - requiresMfjsToolSchema: this.overlayValue( - this.requiresMfjsToolSchema, - next.requiresMfjsToolSchema, - ), - contextWindow: this.overlayValue(this.contextWindow, next.contextWindow), - inputFormat: this.overlayConfig(this.inputFormat, next.inputFormat), - outputFormat: this.overlayConfig(this.outputFormat, next.outputFormat), - supportsToolCall: this.overlayValue(this.supportsToolCall, next.supportsToolCall), - supportsJsonSchemaOutput: this.overlayValue( - this.supportsJsonSchemaOutput, - next.supportsJsonSchemaOutput, - ), - supportsNativeWebSearch: this.overlayValue( - this.supportsNativeWebSearch, - next.supportsNativeWebSearch, - ), - supportsMidConversationSystem: this.overlayValue( - this.supportsMidConversationSystem, - next.supportsMidConversationSystem, - ), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeModelPropertiesDataSchema, this.toJSON(), path); - } - - toJSON(): ModelPropertiesConfigInput { - return objectWithoutUndefined({ - requiresMfjsToolSchema: this.requiresMfjsToolSchema, - contextWindow: this.contextWindow, - inputFormat: this.inputFormat?.toJSON() ?? this.inputFormat, - outputFormat: this.outputFormat?.toJSON() ?? this.outputFormat, - supportsToolCall: this.supportsToolCall, - supportsJsonSchemaOutput: this.supportsJsonSchemaOutput, - supportsNativeWebSearch: this.supportsNativeWebSearch, - supportsMidConversationSystem: this.supportsMidConversationSystem, - }); - } + readonly requiresMfjsToolSchema?: ModelPropertiesConfigInput["requiresMfjsToolSchema"]; + readonly contextWindow?: ModelPropertiesConfigInput["contextWindow"]; + readonly inputFormat?: ModelInputFormatConfig | null; + readonly outputFormat?: ModelOutputFormatConfig | null; + readonly supportsToolCall?: ModelPropertiesConfigInput["supportsToolCall"]; + readonly supportsJsonSchemaOutput?: ModelPropertiesConfigInput["supportsJsonSchemaOutput"]; + readonly supportsNativeWebSearch?: ModelPropertiesConfigInput["supportsNativeWebSearch"]; + readonly supportsMidConversationSystem?: ModelPropertiesConfigInput["supportsMidConversationSystem"]; + + constructor(input: ModelPropertiesConfigInput = {}) { + super(); + Object.assign(this, { + ...input, + inputFormat: + input.inputFormat instanceof ModelInputFormatConfig || input.inputFormat == null + ? input.inputFormat + : new ModelInputFormatConfig(input.inputFormat), + outputFormat: + input.outputFormat instanceof ModelOutputFormatConfig || input.outputFormat == null + ? input.outputFormat + : new ModelOutputFormatConfig(input.outputFormat), + }); + Object.freeze(this); + } + + overlay(next: ModelPropertiesConfig): ModelPropertiesConfig { + return new ModelPropertiesConfig({ + requiresMfjsToolSchema: this.overlayValue( + this.requiresMfjsToolSchema, + next.requiresMfjsToolSchema, + ), + contextWindow: this.overlayValue(this.contextWindow, next.contextWindow), + inputFormat: this.overlayConfig(this.inputFormat, next.inputFormat), + outputFormat: this.overlayConfig(this.outputFormat, next.outputFormat), + supportsToolCall: this.overlayValue(this.supportsToolCall, next.supportsToolCall), + supportsJsonSchemaOutput: this.overlayValue( + this.supportsJsonSchemaOutput, + next.supportsJsonSchemaOutput, + ), + supportsNativeWebSearch: this.overlayValue( + this.supportsNativeWebSearch, + next.supportsNativeWebSearch, + ), + supportsMidConversationSystem: this.overlayValue( + this.supportsMidConversationSystem, + next.supportsMidConversationSystem, + ), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeModelPropertiesDataSchema, this.toJSON(), path); + } + + toJSON(): ModelPropertiesConfigInput { + return objectWithoutUndefined({ + requiresMfjsToolSchema: this.requiresMfjsToolSchema, + contextWindow: this.contextWindow, + inputFormat: this.inputFormat?.toJSON() ?? this.inputFormat, + outputFormat: this.outputFormat?.toJSON() ?? this.outputFormat, + supportsToolCall: this.supportsToolCall, + supportsJsonSchemaOutput: this.supportsJsonSchemaOutput, + supportsNativeWebSearch: this.supportsNativeWebSearch, + supportsMidConversationSystem: this.supportsMidConversationSystem, + }); + } } export type ModelOptionSpecsConfigInput = Readonly>; export class ModelOptionSpecsConfig extends ConfigOverlay { - readonly reasoningLevel?: EnumOptionSpecConfig | null; - readonly maxOutputTokens?: LimitOptionSpecConfig | null; - - constructor(input: ModelOptionSpecsConfigInput = {}) { - super(); - this.reasoningLevel = toEnumOptionSpecConfig(input.reasoningLevel); - this.maxOutputTokens = toLimitOptionSpecConfig(input.maxOutputTokens); - Object.freeze(this); - } - - overlay(next: ModelOptionSpecsConfig): ModelOptionSpecsConfig { - return new ModelOptionSpecsConfig({ - reasoningLevel: this.overlayConfig(this.reasoningLevel, next.reasoningLevel), - maxOutputTokens: this.overlayConfig(this.maxOutputTokens, next.maxOutputTokens), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeModelOptionSpecsDataSchema, this.toJSON(), path, true); - } - - toJSON(): ModelOptionSpecsConfigInput { - return objectWithoutUndefined({ - reasoningLevel: this.reasoningLevel?.toJSON() ?? this.reasoningLevel, - maxOutputTokens: this.maxOutputTokens?.toJSON() ?? this.maxOutputTokens, - }); - } + readonly reasoningLevel?: EnumOptionSpecConfig | null; + readonly maxOutputTokens?: LimitOptionSpecConfig | null; + + constructor(input: ModelOptionSpecsConfigInput = {}) { + super(); + this.reasoningLevel = toEnumOptionSpecConfig(input.reasoningLevel); + this.maxOutputTokens = toLimitOptionSpecConfig(input.maxOutputTokens); + Object.freeze(this); + } + + overlay(next: ModelOptionSpecsConfig): ModelOptionSpecsConfig { + return new ModelOptionSpecsConfig({ + reasoningLevel: this.overlayConfig(this.reasoningLevel, next.reasoningLevel), + maxOutputTokens: this.overlayConfig(this.maxOutputTokens, next.maxOutputTokens), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeModelOptionSpecsDataSchema, this.toJSON(), path, true); + } + + toJSON(): ModelOptionSpecsConfigInput { + return objectWithoutUndefined({ + reasoningLevel: this.reasoningLevel?.toJSON() ?? this.reasoningLevel, + maxOutputTokens: this.maxOutputTokens?.toJSON() ?? this.maxOutputTokens, + }); + } } export type ModelConfigInput = Omit & { - readonly properties?: ModelPropertiesConfig | null; - readonly optionSpecs?: ModelOptionSpecsConfig | null; + readonly properties?: ModelPropertiesConfig | null; + readonly optionSpecs?: ModelOptionSpecsConfig | null; }; export type ModelConfigObject = Readonly>; export class ModelConfig extends ConfigOverlay { - readonly enabled?: ModelConfigObject["enabled"]; - readonly properties?: ModelPropertiesConfig | null; - readonly optionSpecs?: ModelOptionSpecsConfig | null; - - constructor(input: ModelConfigInput = {}) { - super(); - this.enabled = input.enabled; - this.properties = input.properties; - this.optionSpecs = input.optionSpecs; - Object.freeze(this); - } - - static empty(): ModelConfig { - return new ModelConfig(); - } - - static fromData(config: ModelConfigObject): ModelConfig { - return new ModelConfig({ - enabled: config.enabled, - properties: - config.properties == null - ? config.properties - : new ModelPropertiesConfig(config.properties), - optionSpecs: - config.optionSpecs == null - ? config.optionSpecs - : new ModelOptionSpecsConfig(config.optionSpecs), - }); - } - - overlay(next: ModelConfig): ModelConfig { - return new ModelConfig({ - enabled: this.overlayValue(this.enabled, next.enabled), - properties: this.overlayConfig(this.properties, next.properties), - optionSpecs: this.overlayConfig(this.optionSpecs, next.optionSpecs), - }); - } - - validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { - return validateConfigSchema(completeModelConfigDataSchema, this.toJSON(), path); - } - - toJSON(): ModelConfigObject { - return objectWithoutUndefined({ - enabled: this.enabled, - properties: this.properties?.toJSON() ?? this.properties, - optionSpecs: this.optionSpecs?.toJSON() ?? this.optionSpecs, - }); - } + readonly enabled?: ModelConfigObject["enabled"]; + readonly properties?: ModelPropertiesConfig | null; + readonly optionSpecs?: ModelOptionSpecsConfig | null; + + constructor(input: ModelConfigInput = {}) { + super(); + this.enabled = input.enabled; + this.properties = input.properties; + this.optionSpecs = input.optionSpecs; + Object.freeze(this); + } + + static empty(): ModelConfig { + return new ModelConfig(); + } + + static fromData(config: ModelConfigObject): ModelConfig { + return new ModelConfig({ + enabled: config.enabled, + properties: + config.properties == null + ? config.properties + : new ModelPropertiesConfig(config.properties), + optionSpecs: + config.optionSpecs == null + ? config.optionSpecs + : new ModelOptionSpecsConfig(config.optionSpecs), + }); + } + + overlay(next: ModelConfig): ModelConfig { + return new ModelConfig({ + enabled: this.overlayValue(this.enabled, next.enabled), + properties: this.overlayConfig(this.properties, next.properties), + optionSpecs: this.overlayConfig(this.optionSpecs, next.optionSpecs), + }); + } + + validateComplete(path: readonly string[] = []): readonly ConfigValidationIssue[] { + return validateConfigSchema(completeModelConfigDataSchema, this.toJSON(), path); + } + + toJSON(): ModelConfigObject { + return objectWithoutUndefined({ + enabled: this.enabled, + properties: this.properties?.toJSON() ?? this.properties, + optionSpecs: this.optionSpecs?.toJSON() ?? this.optionSpecs, + }); + } } type RuleWithConfig = Readonly & { config: ModelConfig }>; @@ -344,273 +344,245 @@ export type ProviderSiteMatchConfigRule = RuleWithConfig; export interface ModelConfigRuleResolutionInput { - readonly providerId: string; - readonly templateId?: string | null; - readonly modelId: string; - readonly apiType?: string | null; - readonly baseUrl?: string | null; + readonly providerId: string; + readonly templateId?: string | null; + readonly modelId: string; + readonly apiType?: string | null; + readonly baseUrl?: string | null; } export class ModelConfigRules { - readonly #rules: readonly ModelConfigRule[]; - - constructor(rules: readonly ModelConfigRule[] = []) { - this.#rules = Object.freeze(rules.map((rule) => Object.freeze({ ...rule }))); - Object.freeze(this); - } - - static empty(): ModelConfigRules { - return new ModelConfigRules(); - } - - /** Built-in 保留原层次和组内顺序;个人只包含普通/手动精确规则。 */ - static composeEffective( - builtin: ModelConfigRules, - personal: ModelConfigRules, - ): ModelConfigRules { - return new ModelConfigRules([ - ...builtin.rules(), - ...personal.rules().filter(isExactModelRule), - ]); - } - - rules(): readonly ModelConfigRule[] { - return this.#rules; - } - - /** 去掉通用 `.*` 模型兜底,用来判断某字段是否已有更具体的内置规则。 */ - withoutGenericModelFallback(): ModelConfigRules { - return new ModelConfigRules( - this.#rules.filter((rule) => !(rule.type === "model" && rule.modelMatch === ".*")), - ); - } - - resolve(input: ModelConfigRuleResolutionInput): ModelConfig { - let result = ModelConfig.empty(); - const baseUrl = - input.baseUrl == null ? undefined : normalizeBaseURLForRuleMatch(input.baseUrl); - for (const rule of this.#rules) { - if (isExactModelRule(rule)) { - if (rule.providerId !== input.providerId || rule.modelId !== input.modelId) - continue; - // 手动规则要求所有可编辑叶子齐全,因此可直接覆盖;系统叶子继续来自当前身份的规则。 - // 清空整份基线会既丢失系统映射,也迫使 UI 把旧模型的隐藏配置复制进个人规则。 - result = ( - rule.type === "manual-provider-model" - ? ModelConfig.fromData(clearManualModelConfig(result.toJSON())) - : result - ).overlay(rule.config); - continue; - } - if (rule.type === "template-model") { - if (rule.templateId === input.templateId && rule.modelId === input.modelId) - result = result.overlay(rule.config); - continue; - } - // 只放宽推荐规则匹配,不改真实请求里的模型 ID。 - if (!matchesRule(rule.modelMatch, input.modelId, true)) continue; - if ( - (rule.type === "model-api" || rule.type === "provider-site") && - rule.apiTypeMatch !== undefined - ) { - if (input.apiType == null || !matchesRule(rule.apiTypeMatch, input.apiType)) - continue; - } - if ( - rule.type === "provider-site" && - (baseUrl === undefined || !matchesRule(rule.baseUrlMatch, baseUrl)) - ) - continue; - result = result.overlay(rule.config); - } - return result; - } - - setExact( - providerId: string, - modelId: string, - config: ModelConfig, - useRecommendedConfig?: boolean, - ): ModelConfigRules { - const previous = this.getExactRule(providerId, modelId); - const manual = - useRecommendedConfig === undefined - ? previous?.type === "manual-provider-model" - : !useRecommendedConfig; - // 保存入口复用整条规则 schema,不再另写一份“完整但忽略 enabled”的校验。 - const data = { providerId, modelId, config: config.toJSON() }; - (manual ? manualProviderModelConfigRuleSchema : providerModelConfigRuleSchema).parse(data); - const replacement: ExactModelConfigRule = { - type: manual ? "manual-provider-model" : "provider-model", - providerId, - modelId, - config, - }; - const result: ModelConfigRule[] = []; - let replaced = false; - for (const rule of this.#rules) { - if ( - isExactModelRule(rule) && - rule.providerId === providerId && - rule.modelId === modelId - ) { - if (!replaced) result.push(replacement); - replaced = true; - } else { - result.push(rule); - } - } + readonly #rules: readonly ModelConfigRule[]; + + constructor(rules: readonly ModelConfigRule[] = []) { + this.#rules = Object.freeze(rules.map((rule) => Object.freeze({ ...rule }))); + Object.freeze(this); + } + + static empty(): ModelConfigRules { + return new ModelConfigRules(); + } + + /** Built-in 保留原层次和组内顺序;个人只包含普通/手动精确规则。 */ + static composeEffective(builtin: ModelConfigRules, personal: ModelConfigRules): ModelConfigRules { + return new ModelConfigRules([...builtin.rules(), ...personal.rules().filter(isExactModelRule)]); + } + + rules(): readonly ModelConfigRule[] { + return this.#rules; + } + + /** 去掉通用 `.*` 模型兜底,用来判断某字段是否已有更具体的内置规则。 */ + withoutGenericModelFallback(): ModelConfigRules { + return new ModelConfigRules( + this.#rules.filter((rule) => !(rule.type === "model" && rule.modelMatch === ".*")), + ); + } + + resolve(input: ModelConfigRuleResolutionInput): ModelConfig { + let result = ModelConfig.empty(); + const baseUrl = input.baseUrl == null ? undefined : normalizeBaseURLForRuleMatch(input.baseUrl); + for (const rule of this.#rules) { + if (isExactModelRule(rule)) { + if (rule.providerId !== input.providerId || rule.modelId !== input.modelId) continue; + // 手动规则要求所有可编辑叶子齐全,因此可直接覆盖;系统叶子继续来自当前身份的规则。 + // 清空整份基线会既丢失系统映射,也迫使 UI 把旧模型的隐藏配置复制进个人规则。 + result = ( + rule.type === "manual-provider-model" + ? ModelConfig.fromData(clearManualModelConfig(result.toJSON())) + : result + ).overlay(rule.config); + continue; + } + if (rule.type === "template-model") { + if (rule.templateId === input.templateId && rule.modelId === input.modelId) + result = result.overlay(rule.config); + continue; + } + // 只放宽推荐规则匹配,不改真实请求里的模型 ID。 + if (!matchesRule(rule.modelMatch, input.modelId, true)) continue; + if ( + (rule.type === "model-api" || rule.type === "provider-site") && + rule.apiTypeMatch !== undefined + ) { + if (input.apiType == null || !matchesRule(rule.apiTypeMatch, input.apiType)) continue; + } + if ( + rule.type === "provider-site" && + (baseUrl === undefined || !matchesRule(rule.baseUrlMatch, baseUrl)) + ) + continue; + result = result.overlay(rule.config); + } + return result; + } + + setExact( + providerId: string, + modelId: string, + config: ModelConfig, + useRecommendedConfig?: boolean, + ): ModelConfigRules { + const previous = this.getExactRule(providerId, modelId); + const manual = + useRecommendedConfig === undefined + ? previous?.type === "manual-provider-model" + : !useRecommendedConfig; + // 保存入口复用整条规则 schema,不再另写一份“完整但忽略 enabled”的校验。 + const data = { providerId, modelId, config: config.toJSON() }; + (manual ? manualProviderModelConfigRuleSchema : providerModelConfigRuleSchema).parse(data); + const replacement: ExactModelConfigRule = { + type: manual ? "manual-provider-model" : "provider-model", + providerId, + modelId, + config, + }; + const result: ModelConfigRule[] = []; + let replaced = false; + for (const rule of this.#rules) { + if (isExactModelRule(rule) && rule.providerId === providerId && rule.modelId === modelId) { if (!replaced) result.push(replacement); - return new ModelConfigRules(result); - } - - deleteExact(providerId: string, modelId: string): ModelConfigRules { - return new ModelConfigRules( - this.#rules.filter( - (rule) => - !isExactModelRule(rule) || - rule.providerId !== providerId || - rule.modelId !== modelId, - ), - ); - } - - renameExactModel( - providerId: string, - currentModelId: string, - nextModelId: string, - ): ModelConfigRules { - if (currentModelId === nextModelId) return this; - return new ModelConfigRules( - this.#rules.map((rule) => - isExactModelRule(rule) && - rule.providerId === providerId && - rule.modelId === currentModelId - ? { ...rule, modelId: nextModelId } - : rule, - ), - ); - } - - deleteExactForProvider(providerId: string): ModelConfigRules { - return new ModelConfigRules( - this.#rules.filter((rule) => !isExactModelRule(rule) || rule.providerId !== providerId), - ); - } - - getExact(providerId: string, modelId: string): ModelConfig | undefined { - let result: ModelConfig | undefined; - for (const rule of this.#rules) { - if ( - !isExactModelRule(rule) || - rule.providerId !== providerId || - rule.modelId !== modelId - ) - continue; - result = result ? result.overlay(rule.config) : rule.config; - } - return result; - } - - getExactRule(providerId: string, modelId: string): ExactModelConfigRule | undefined { - for (let index = this.#rules.length - 1; index >= 0; index -= 1) { - const rule = this.#rules[index]!; - if ( - isExactModelRule(rule) && - rule.providerId === providerId && - rule.modelId === modelId - ) - return rule; - } - return undefined; - } - - toKCodeBuiltinJSON(): BuiltinModelConfigRulesData { - return builtinModelConfigRulesSchema.parse({ - modelRules: this.#collect("model"), - modelApiRules: this.#collect("model-api"), - providerSiteRules: this.#collect("provider-site"), - templateModelRules: this.#collect("template-model"), - builtinProviderModelRules: this.#collect("provider-model"), - }); - } - - toPersonalJSON(): PersonalModelConfigRulesData { - // 完整规则必须在编码边界再校验,不能把直接构造的不完整手动值写入文件。 - return personalModelConfigRulesSchema.parse({ - providerModelRules: this.#collect("provider-model"), - manualProviderModelRules: this.#collect("manual-provider-model"), - }); - } - - toJSON() { - return { - ...this.toKCodeBuiltinJSON(), - manualProviderModelRules: this.#collect("manual-provider-model"), - }; - } - - #collect(type: T) { - return this.#rules - .filter((rule): rule is Extract => rule.type === type) - .map(({ type: _type, config, ...identity }) => ({ - ...identity, - config: config.toJSON(), - })); - } + replaced = true; + } else { + result.push(rule); + } + } + if (!replaced) result.push(replacement); + return new ModelConfigRules(result); + } + + deleteExact(providerId: string, modelId: string): ModelConfigRules { + return new ModelConfigRules( + this.#rules.filter( + (rule) => + !isExactModelRule(rule) || rule.providerId !== providerId || rule.modelId !== modelId, + ), + ); + } + + renameExactModel( + providerId: string, + currentModelId: string, + nextModelId: string, + ): ModelConfigRules { + if (currentModelId === nextModelId) return this; + return new ModelConfigRules( + this.#rules.map((rule) => + isExactModelRule(rule) && rule.providerId === providerId && rule.modelId === currentModelId + ? { ...rule, modelId: nextModelId } + : rule, + ), + ); + } + + deleteExactForProvider(providerId: string): ModelConfigRules { + return new ModelConfigRules( + this.#rules.filter((rule) => !isExactModelRule(rule) || rule.providerId !== providerId), + ); + } + + getExact(providerId: string, modelId: string): ModelConfig | undefined { + let result: ModelConfig | undefined; + for (const rule of this.#rules) { + if (!isExactModelRule(rule) || rule.providerId !== providerId || rule.modelId !== modelId) + continue; + result = result ? result.overlay(rule.config) : rule.config; + } + return result; + } + + getExactRule(providerId: string, modelId: string): ExactModelConfigRule | undefined { + for (let index = this.#rules.length - 1; index >= 0; index -= 1) { + const rule = this.#rules[index]!; + if (isExactModelRule(rule) && rule.providerId === providerId && rule.modelId === modelId) + return rule; + } + return undefined; + } + + toKCodeBuiltinJSON(): BuiltinModelConfigRulesData { + return builtinModelConfigRulesSchema.parse({ + modelRules: this.#collect("model"), + modelApiRules: this.#collect("model-api"), + providerSiteRules: this.#collect("provider-site"), + templateModelRules: this.#collect("template-model"), + builtinProviderModelRules: this.#collect("provider-model"), + }); + } + + toPersonalJSON(): PersonalModelConfigRulesData { + // 完整规则必须在编码边界再校验,不能把直接构造的不完整手动值写入文件。 + return personalModelConfigRulesSchema.parse({ + providerModelRules: this.#collect("provider-model"), + manualProviderModelRules: this.#collect("manual-provider-model"), + }); + } + + toJSON() { + return { + ...this.toKCodeBuiltinJSON(), + manualProviderModelRules: this.#collect("manual-provider-model"), + }; + } + + #collect(type: T) { + return this.#rules + .filter((rule): rule is Extract => rule.type === type) + .map(({ type: _type, config, ...identity }) => ({ ...identity, config: config.toJSON() })); + } } function isExactModelRule(rule: ModelConfigRule): rule is ExactModelConfigRule { - return rule.type === "provider-model" || rule.type === "manual-provider-model"; + return rule.type === "provider-model" || rule.type === "manual-provider-model"; } function matchesRule(pattern: string, value: string, ignoreCase = false): boolean { - return new RegExp(`^(?:${pattern})$`, ignoreCase ? "i" : undefined).test(value); + return new RegExp(`^(?:${pattern})$`, ignoreCase ? "i" : undefined).test(value); } function normalizeBaseURLForRuleMatch(value: string): string | undefined { - try { - // Host 大小写、默认端口和尾部斜杠不应改变配置命中;URL parser 会保留 path/query 大小写。 - const parsed = new URL(value); - const suffix = `${parsed.search}${parsed.hash}`; - const serialized = parsed.toString(); - const endpoint = suffix.length === 0 ? serialized : serialized.slice(0, -suffix.length); - return `${endpoint.replace(/\/+$/, "")}${suffix}`; - } catch { - return undefined; - } + try { + // Host 大小写、默认端口和尾部斜杠不应改变配置命中;URL parser 会保留 path/query 大小写。 + const parsed = new URL(value); + const suffix = `${parsed.search}${parsed.hash}`; + const serialized = parsed.toString(); + const endpoint = suffix.length === 0 ? serialized : serialized.slice(0, -suffix.length); + return `${endpoint.replace(/\/+$/, "")}${suffix}`; + } catch { + return undefined; + } } function toEnumOptionSpecConfig( - spec: EnumOptionSpecConfig | EnumOptionSpecConfigInput | null | undefined, + spec: EnumOptionSpecConfig | EnumOptionSpecConfigInput | null | undefined, ): EnumOptionSpecConfig | null | undefined { - return spec instanceof EnumOptionSpecConfig || spec == null - ? spec - : new EnumOptionSpecConfig(spec); + return spec instanceof EnumOptionSpecConfig || spec == null + ? spec + : new EnumOptionSpecConfig(spec); } function toLimitOptionSpecConfig( - spec: LimitOptionSpecConfig | LimitOptionSpecConfigInput | null | undefined, + spec: LimitOptionSpecConfig | LimitOptionSpecConfigInput | null | undefined, ): LimitOptionSpecConfig | null | undefined { - return spec instanceof LimitOptionSpecConfig || spec == null - ? spec - : new LimitOptionSpecConfig(spec); + return spec instanceof LimitOptionSpecConfig || spec == null + ? spec + : new LimitOptionSpecConfig(spec); } function objectWithoutUndefined(value: T): T { - return Object.fromEntries( - Object.entries(value).filter(([, fieldValue]) => fieldValue !== undefined), - ) as T; + return Object.fromEntries( + Object.entries(value).filter(([, fieldValue]) => fieldValue !== undefined), + ) as T; } diff --git a/packages/provider/src/facades.ts b/packages/provider/src/facades.ts index 9928ac35..c6f0525c 100644 --- a/packages/provider/src/facades.ts +++ b/packages/provider/src/facades.ts @@ -3,778 +3,751 @@ import type { ConfigValidationIssue } from "./config-overlay.js"; import type { ProviderModelMembership } from "./config-service.js"; import type { RemoteModelImportPlan } from "./personal-model-membership.js"; import type { - ModelConfig, - ModelConfigObject, - ModelId, - ProviderConfig, - ProviderConfigObject, - ProviderId, - ProviderTemplateId, + ModelConfig, + ModelConfigObject, + ModelId, + ProviderConfig, + ProviderConfigObject, + ProviderId, + ProviderTemplateId, } from "./config/index.js"; import { - ModelConfigRules, - ProviderTemplateMap, - type ProviderConfigRule, - type ProviderTemplateNameMap, - type ProviderTemplateLocale, - parseModelConfig, - parseProviderConfig, + ModelConfigRules, + ProviderTemplateMap, + type ProviderConfigRule, + type ProviderTemplateNameMap, + type ProviderTemplateLocale, + parseModelConfig, + parseProviderConfig, } from "./config/index.js"; import type { ProviderRegistryView } from "./registry.js"; import { - serializeRegistryModelConfig, - serializeRegistryProviderConfig, - type ProviderConfigResolution, - type RegistryModelConfigObject, + serializeRegistryModelConfig, + serializeRegistryProviderConfig, + type ProviderConfigResolution, + type RegistryModelConfigObject, } from "./resolver.js"; import { resolveInitialModelSelection } from "./model-selection-config.js"; import { - resolveEffectiveModelSelection, - type EffectiveModelSelectionResult, - type ModelSelectionProviderClassifier, + resolveEffectiveModelSelection, + type EffectiveModelSelectionResult, + type ModelSelectionProviderClassifier, } from "./effective-model-selection.js"; import type { ModelSelection } from "./registry.js"; import type { - ProviderRegistryServiceChangedEvent, - ProviderRegistryServiceSnapshot, + ProviderRegistryServiceChangedEvent, + ProviderRegistryServiceSnapshot, } from "./registry-service.js"; export interface ProviderRegistryFacadeSource { - getSnapshot(): ProviderRegistryServiceSnapshot | null; - getView(): ProviderRegistryView; - refresh(reason: string): Promise; - onDidChange(listener: (event: ProviderRegistryServiceChangedEvent) => void): () => void; + getSnapshot(): ProviderRegistryServiceSnapshot | null; + getView(): ProviderRegistryView; + refresh(reason: string): Promise; + onDidChange(listener: (event: ProviderRegistryServiceChangedEvent) => void): () => void; } export interface ProviderSettingsMutationTarget { - createPersonalProvider(input?: { - readonly templateId?: ProviderTemplateId; - readonly providerName?: string; - readonly locale?: ProviderTemplateLocale; - readonly initialConfig?: ProviderConfig; - }): Promise<{ readonly providerId: ProviderId }>; - savePersonalProviderOverlay( - providerId: ProviderId, - config: ProviderConfig, - membership?: ProviderModelMembership, - metadata?: Pick, - ): Promise; - deletePersonalProvider(providerId: ProviderId): Promise; - reorderPersonalProviders(providerIds: readonly ProviderId[]): Promise; - reorderPersonalModels( - providerId: ProviderId, - modelIds: readonly ModelId[], - membership?: ProviderModelMembership, - ): Promise; - addPersonalModel( - providerId: ProviderId, - modelId: ModelId, - config: ModelConfig, - membership?: ProviderModelMembership, - useRecommendedConfig?: boolean, - ): Promise; - renamePersonalModel( - providerId: ProviderId, - currentModelId: ModelId, - nextModelId: ModelId, - membership?: ProviderModelMembership, - ): Promise; - deletePersonalModel( - providerId: ProviderId, - modelId: ModelId, - membership?: ProviderModelMembership, - ): Promise; - importRemoteModels( - providerId: ProviderId, - remoteModelIds: readonly ModelId[], - membership?: ProviderModelMembership, - modelConfigs?: Readonly>, - ): Promise; - clearVisibleModels( - providerId: ProviderId, - membership?: ProviderModelMembership, - ): Promise; - savePersonalModelDraft( - providerId: ProviderId, - originalModelId: ModelId, - nextModelId: ModelId, - config: ModelConfig, - expectedPersonalRevision: string, - useRecommendedConfig?: boolean, - membership?: ProviderModelMembership, - ): Promise; - setPersonalModelEnabled( - providerId: ProviderId, - modelId: ModelId, - enabled: boolean, - membership?: ProviderModelMembership, - ): Promise; - refresh(reason: string): Promise; - /** 主动重读上游事实后再刷新 Registry。 */ - refreshSources?(reason: string): Promise; + createPersonalProvider(input?: { + readonly templateId?: ProviderTemplateId; + readonly providerName?: string; + readonly locale?: ProviderTemplateLocale; + readonly initialConfig?: ProviderConfig; + }): Promise<{ readonly providerId: ProviderId }>; + savePersonalProviderOverlay( + providerId: ProviderId, + config: ProviderConfig, + membership?: ProviderModelMembership, + metadata?: Pick, + ): Promise; + deletePersonalProvider(providerId: ProviderId): Promise; + reorderPersonalProviders(providerIds: readonly ProviderId[]): Promise; + reorderPersonalModels( + providerId: ProviderId, + modelIds: readonly ModelId[], + membership?: ProviderModelMembership, + ): Promise; + addPersonalModel( + providerId: ProviderId, + modelId: ModelId, + config: ModelConfig, + membership?: ProviderModelMembership, + useRecommendedConfig?: boolean, + ): Promise; + renamePersonalModel( + providerId: ProviderId, + currentModelId: ModelId, + nextModelId: ModelId, + membership?: ProviderModelMembership, + ): Promise; + deletePersonalModel( + providerId: ProviderId, + modelId: ModelId, + membership?: ProviderModelMembership, + ): Promise; + importRemoteModels( + providerId: ProviderId, + remoteModelIds: readonly ModelId[], + membership?: ProviderModelMembership, + modelConfigs?: Readonly>, + ): Promise; + clearVisibleModels( + providerId: ProviderId, + membership?: ProviderModelMembership, + ): Promise; + savePersonalModelDraft( + providerId: ProviderId, + originalModelId: ModelId, + nextModelId: ModelId, + config: ModelConfig, + expectedPersonalRevision: string, + useRecommendedConfig?: boolean, + membership?: ProviderModelMembership, + ): Promise; + setPersonalModelEnabled( + providerId: ProviderId, + modelId: ModelId, + enabled: boolean, + membership?: ProviderModelMembership, + ): Promise; + refresh(reason: string): Promise; + /** 主动重读上游事实后再刷新 Registry。 */ + refreshSources?(reason: string): Promise; } export interface ProviderSettingsModelCandidateView { - readonly kind: "candidate"; - readonly modelId: ModelId; - readonly builtin: boolean; - readonly effectiveBuiltinConfig: ModelConfigObject; - readonly personalExactConfig?: ModelConfigObject; - readonly useRecommendedConfig?: boolean; - readonly effectiveConfig: RegistryModelConfigObject | ModelConfigObject; - readonly enabled: boolean; - readonly executable: boolean; - readonly selectable: boolean; - readonly issues: readonly ConfigValidationIssue[]; + readonly kind: "candidate"; + readonly modelId: ModelId; + readonly builtin: boolean; + readonly effectiveBuiltinConfig: ModelConfigObject; + readonly personalExactConfig?: ModelConfigObject; + readonly useRecommendedConfig?: boolean; + readonly effectiveConfig: RegistryModelConfigObject | ModelConfigObject; + readonly enabled: boolean; + readonly executable: boolean; + readonly selectable: boolean; + readonly issues: readonly ConfigValidationIssue[]; } export type ProviderSettingsModelView = ProviderSettingsModelCandidateView; export interface ModelConfigResolution { - readonly inheritedConfig: ModelConfigObject; - readonly effectiveConfig: ModelConfigObject; - readonly issues: readonly ConfigValidationIssue[]; + readonly inheritedConfig: ModelConfigObject; + readonly effectiveConfig: ModelConfigObject; + readonly issues: readonly ConfigValidationIssue[]; } export type ResolveModelConfigInput = - | { - readonly providerId: ProviderId; - readonly modelId: ModelId; - } - | { - readonly providerId: ProviderId; - readonly originalModelId: ModelId; - readonly modelId: ModelId; - readonly personalConfig: ModelConfigObject; - }; + | { + readonly providerId: ProviderId; + readonly modelId: ModelId; + } + | { + readonly providerId: ProviderId; + readonly originalModelId: ModelId; + readonly modelId: ModelId; + readonly personalConfig: ModelConfigObject; + }; export interface SavePersonalModelDraftInput { - readonly providerId: ProviderId; - readonly originalModelId: ModelId; - readonly nextModelId: ModelId; - readonly personalConfig: ModelConfigObject; - readonly useRecommendedConfig?: boolean; - readonly basedOnRevision: number; + readonly providerId: ProviderId; + readonly originalModelId: ModelId; + readonly nextModelId: ModelId; + readonly personalConfig: ModelConfigObject; + readonly useRecommendedConfig?: boolean; + readonly basedOnRevision: number; } export interface ProviderSettingsProviderView extends Pick< - ProviderConfigRule, - "providerName" | "templateId" + ProviderConfigRule, + "providerName" | "templateId" > { - readonly enabled: boolean; - readonly accountState?: import("./account-provider-state.js").AccountProviderState; - readonly providerId: ProviderId; - /** 当前 Effective Config 是否已经进入 Registry,可用于模型选择和创建。 */ - readonly executable: boolean; - readonly effectiveBuiltinConfig?: ProviderConfigObject; - readonly personalConfig?: ProviderConfigObject; - readonly effectiveConfig: ProviderConfigObject; - readonly issues: readonly ConfigValidationIssue[]; - readonly models: readonly ProviderSettingsModelView[]; + readonly enabled: boolean; + readonly accountState?: import("./account-provider-state.js").AccountProviderState; + readonly providerId: ProviderId; + /** 当前 Effective Config 是否已经进入 Registry,可用于模型选择和创建。 */ + readonly executable: boolean; + readonly effectiveBuiltinConfig?: ProviderConfigObject; + readonly personalConfig?: ProviderConfigObject; + readonly effectiveConfig: ProviderConfigObject; + readonly issues: readonly ConfigValidationIssue[]; + readonly models: readonly ProviderSettingsModelView[]; } export interface ProviderSettingsTemplateView { - readonly templateId: ProviderTemplateId; - readonly templateNameMap: ProviderTemplateNameMap; - readonly config: ProviderConfigObject; + readonly templateId: ProviderTemplateId; + readonly templateNameMap: ProviderTemplateNameMap; + readonly config: ProviderConfigObject; } export interface ProviderSettingsView { - readonly revision: number; - readonly providerTemplates: readonly ProviderSettingsTemplateView[]; - readonly providerOrder: readonly ProviderId[]; - readonly providers: readonly ProviderSettingsProviderView[]; + readonly revision: number; + readonly providerTemplates: readonly ProviderSettingsTemplateView[]; + readonly providerOrder: readonly ProviderId[]; + readonly providers: readonly ProviderSettingsProviderView[]; } export interface ProviderSettingsRemoteModelsImportResult { - readonly view: ProviderSettingsView; - readonly addedModelIds: readonly ModelId[]; - readonly restoredInheritedModelIds: readonly ModelId[]; - readonly skippedModelIds: readonly ModelId[]; + readonly view: ProviderSettingsView; + readonly addedModelIds: readonly ModelId[]; + readonly restoredInheritedModelIds: readonly ModelId[]; + readonly skippedModelIds: readonly ModelId[]; } export interface ProviderSettingsCreationResult { - readonly providerId: ProviderId; - readonly view: ProviderSettingsView; + readonly providerId: ProviderId; + readonly view: ProviderSettingsView; } export interface ModelSelectionModelView { - readonly modelId: ModelId; - readonly config: RegistryModelConfigObject; + readonly modelId: ModelId; + readonly config: RegistryModelConfigObject; } export interface ModelSelectionProviderView extends Pick< - ProviderConfigRule, - "providerName" | "templateId" + ProviderConfigRule, + "providerName" | "templateId" > { - readonly providerId: ProviderId; - readonly config: ProviderConfigObject; - readonly models: readonly ModelSelectionModelView[]; + readonly providerId: ProviderId; + readonly config: ProviderConfigObject; + readonly models: readonly ModelSelectionModelView[]; } export interface ModelSelectionViewInput { - readonly selection: ModelSelection | null; + readonly selection: ModelSelection | null; } export interface ModelSelectionView extends Partial { - readonly revision: number; - readonly providers: readonly ModelSelectionProviderView[]; - readonly preferredSelection?: ModelSelection; + readonly revision: number; + readonly providers: readonly ModelSelectionProviderView[]; + readonly preferredSelection?: ModelSelection; } export class ProviderSettingsFacade { - readonly #source: ProviderRegistryFacadeSource; - readonly #mutations?: ProviderSettingsMutationTarget; - readonly #providerMutationTails = new Map>(); - - constructor(source: ProviderRegistryFacadeSource, mutations?: ProviderSettingsMutationTarget) { - this.#source = source; - this.#mutations = mutations; - } - - getView(): ProviderSettingsView { - const snapshot = requireSnapshot(this.#source); - return createProviderSettingsView({ - revision: snapshot.registry.revision, - kcodeBuiltinProviders: snapshot.config.kcodeBuiltinProviders, - kcodeBuiltinProviderTemplates: snapshot.config.kcodeBuiltinProviderTemplates, - personalProviders: snapshot.config.personalProviders, - personalModels: snapshot.config.personalModels, - resolution: snapshot.resolution, - accountStates: snapshot.account.states, - }); - } - - async refresh(reason: string): Promise { - if (this.#mutations?.refreshSources) { - await this.#mutations.refreshSources(`settings:${reason}`); - } else { - await this.#source.refresh(reason); - } - return this.getView(); - } - - resolveModelConfig(input: ResolveModelConfigInput): ModelConfigResolution { - const snapshot = requireSnapshot(this.#source); - const provider = requireEffectiveProvider(snapshot, input.providerId); - if (!("personalConfig" in input)) { - const modelRules = ModelConfigRules.composeEffective( - snapshot.config.kcodeBuiltinModelRules, - snapshot.config.personalModels, - ); - const config = modelRules.resolve({ - providerId: input.providerId, - templateId: provider.templateId, - modelId: input.modelId, - apiType: provider.config.api?.type, - baseUrl: provider.config.api?.baseUrl, - }); - return Object.freeze({ - inheritedConfig: config.toJSON(), - effectiveConfig: config.toJSON(), - issues: Object.freeze([ - ...config.validateComplete([ - "providers", - input.providerId, - "models", - input.modelId, - ]), - ]), - }); - } - - const personalConfig = parseModelConfig(input.personalConfig); - const inheritedConfig = snapshot.config.kcodeBuiltinModelRules.resolve({ - providerId: input.providerId, - templateId: provider.templateId, - modelId: input.modelId, - apiType: provider.config.api?.type, - baseUrl: provider.config.api?.baseUrl, - }); - let personalRules = snapshot.config.personalModels; - if (input.originalModelId !== input.modelId) { - personalRules = personalRules.renameExactModel( - input.providerId, - input.originalModelId, - input.modelId, - ); - } - // 此入口预览智能配置草稿;固定模式不请求推荐,重新开启时不能沿用旧固定标记。 - personalRules = personalRules.setExact( - input.providerId, - input.modelId, - personalConfig, - true, - ); - const config = ModelConfigRules.composeEffective( - snapshot.config.kcodeBuiltinModelRules, - personalRules, - ).resolve({ - providerId: input.providerId, - templateId: provider.templateId, - modelId: input.modelId, - apiType: provider.config.api?.type, - baseUrl: provider.config.api?.baseUrl, - }); - return Object.freeze({ - inheritedConfig: inheritedConfig.toJSON(), - effectiveConfig: config.toJSON(), - issues: Object.freeze([ - ...config.validateComplete([ - "providers", - input.providerId, - "models", - input.modelId, - ]), - ]), - }); - } - - /** 不含通用 `.*` 模型兜底,供目录信息只填补真正空缺的字段。 */ - 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())); - } - - createPersonalProvider(input?: { - readonly templateId?: ProviderTemplateId; - readonly providerName?: string; - readonly locale?: ProviderTemplateLocale; - readonly initialConfig?: ProviderConfigObject; - }): Promise { - return this.#mutateWithResult("create-provider", (target) => - target.createPersonalProvider({ - ...(input?.templateId ? { templateId: input.templateId } : {}), - ...(input?.providerName ? { providerName: input.providerName } : {}), - ...(input?.locale ? { locale: input.locale } : {}), - ...(input?.initialConfig - ? { initialConfig: parseProviderConfig(input.initialConfig) } - : {}), - }), - ).then(({ result, view }) => ({ providerId: result.providerId, view })); - } + readonly #source: ProviderRegistryFacadeSource; + readonly #mutations?: ProviderSettingsMutationTarget; + readonly #providerMutationTails = new Map>(); + + constructor(source: ProviderRegistryFacadeSource, mutations?: ProviderSettingsMutationTarget) { + this.#source = source; + this.#mutations = mutations; + } + + getView(): ProviderSettingsView { + const snapshot = requireSnapshot(this.#source); + return createProviderSettingsView({ + revision: snapshot.registry.revision, + kcodeBuiltinProviders: snapshot.config.kcodeBuiltinProviders, + kcodeBuiltinProviderTemplates: snapshot.config.kcodeBuiltinProviderTemplates, + personalProviders: snapshot.config.personalProviders, + personalModels: snapshot.config.personalModels, + resolution: snapshot.resolution, + accountStates: snapshot.account.states, + }); + } - savePersonalProviderOverlay( - providerId: ProviderId, - config: ProviderConfigObject, - metadata?: Pick, - ): Promise { - return this.#mutateProvider(providerId, "save-provider", (target) => - target.savePersonalProviderOverlay( - providerId, - parseProviderConfig(config), - this.#modelMembership(providerId), - metadata, - ), - ); + async refresh(reason: string): Promise { + if (this.#mutations?.refreshSources) { + await this.#mutations.refreshSources(`settings:${reason}`); + } else { + await this.#source.refresh(reason); } - - deletePersonalProvider(providerId: ProviderId): Promise { - return this.#mutateProvider(providerId, "delete-provider", (target) => - target.deletePersonalProvider(providerId), - ); + return this.getView(); + } + + resolveModelConfig(input: ResolveModelConfigInput): ModelConfigResolution { + const snapshot = requireSnapshot(this.#source); + const provider = requireEffectiveProvider(snapshot, input.providerId); + if (!("personalConfig" in input)) { + const modelRules = ModelConfigRules.composeEffective( + snapshot.config.kcodeBuiltinModelRules, + snapshot.config.personalModels, + ); + const config = modelRules.resolve({ + providerId: input.providerId, + templateId: provider.templateId, + modelId: input.modelId, + apiType: provider.config.api?.type, + baseUrl: provider.config.api?.baseUrl, + }); + return Object.freeze({ + inheritedConfig: config.toJSON(), + effectiveConfig: config.toJSON(), + issues: Object.freeze([ + ...config.validateComplete(["providers", input.providerId, "models", input.modelId]), + ]), + }); } - reorderPersonalProviders(providerIds: readonly ProviderId[]): Promise { - return this.#mutate("reorder-providers", (target) => - target.reorderPersonalProviders(providerIds), - ); + const personalConfig = parseModelConfig(input.personalConfig); + const inheritedConfig = snapshot.config.kcodeBuiltinModelRules.resolve({ + providerId: input.providerId, + templateId: provider.templateId, + modelId: input.modelId, + apiType: provider.config.api?.type, + baseUrl: provider.config.api?.baseUrl, + }); + let personalRules = snapshot.config.personalModels; + if (input.originalModelId !== input.modelId) { + personalRules = personalRules.renameExactModel( + input.providerId, + input.originalModelId, + input.modelId, + ); } + // 此入口预览智能配置草稿;固定模式不请求推荐,重新开启时不能沿用旧固定标记。 + personalRules = personalRules.setExact(input.providerId, input.modelId, personalConfig, true); + const config = ModelConfigRules.composeEffective( + snapshot.config.kcodeBuiltinModelRules, + personalRules, + ).resolve({ + providerId: input.providerId, + templateId: provider.templateId, + modelId: input.modelId, + apiType: provider.config.api?.type, + baseUrl: provider.config.api?.baseUrl, + }); + return Object.freeze({ + inheritedConfig: inheritedConfig.toJSON(), + effectiveConfig: config.toJSON(), + issues: Object.freeze([ + ...config.validateComplete(["providers", input.providerId, "models", input.modelId]), + ]), + }); + } - reorderPersonalModels( - providerId: ProviderId, - modelIds: readonly ModelId[], - ): Promise { - return this.#mutateProvider(providerId, "reorder-models", (target) => - target.reorderPersonalModels(providerId, modelIds, this.#modelMembership(providerId)), - ); - } + /** 不含通用 `.*` 模型兜底,供目录信息只填补真正空缺的字段。 */ + 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())); + } + + createPersonalProvider(input?: { + readonly templateId?: ProviderTemplateId; + readonly providerName?: string; + readonly locale?: ProviderTemplateLocale; + readonly initialConfig?: ProviderConfigObject; + }): Promise { + return this.#mutateWithResult("create-provider", (target) => + target.createPersonalProvider({ + ...(input?.templateId ? { templateId: input.templateId } : {}), + ...(input?.providerName ? { providerName: input.providerName } : {}), + ...(input?.locale ? { locale: input.locale } : {}), + ...(input?.initialConfig + ? { initialConfig: parseProviderConfig(input.initialConfig) } + : {}), + }), + ).then(({ result, view }) => ({ providerId: result.providerId, view })); + } + + savePersonalProviderOverlay( + providerId: ProviderId, + config: ProviderConfigObject, + metadata?: Pick, + ): Promise { + return this.#mutateProvider(providerId, "save-provider", (target) => + target.savePersonalProviderOverlay( + providerId, + parseProviderConfig(config), + this.#modelMembership(providerId), + metadata, + ), + ); + } - addPersonalModel( - providerId: ProviderId, - modelId: ModelId, - config: ModelConfigObject, - useRecommendedConfig?: boolean, - ): Promise { - return this.#mutateProvider(providerId, "add-model", (target) => - target.addPersonalModel( - providerId, - modelId, - parseModelConfig(config), - this.#modelMembership(providerId), - useRecommendedConfig, - ), - ); - } + deletePersonalProvider(providerId: ProviderId): Promise { + return this.#mutateProvider(providerId, "delete-provider", (target) => + target.deletePersonalProvider(providerId), + ); + } - renamePersonalModel( - providerId: ProviderId, - currentModelId: ModelId, - nextModelId: ModelId, - ): Promise { - return this.#mutateProvider(providerId, "rename-model", (target) => - target.renamePersonalModel( - providerId, - currentModelId, - nextModelId, - this.#modelMembership(providerId), - ), - ); - } + reorderPersonalProviders(providerIds: readonly ProviderId[]): Promise { + return this.#mutate("reorder-providers", (target) => + target.reorderPersonalProviders(providerIds), + ); + } - deletePersonalModel(providerId: ProviderId, modelId: ModelId): Promise { - return this.#mutateProvider(providerId, "delete-personal-model", (target) => - target.deletePersonalModel(providerId, modelId, this.#modelMembership(providerId)), - ); - } + reorderPersonalModels( + providerId: ProviderId, + modelIds: readonly ModelId[], + ): Promise { + return this.#mutateProvider(providerId, "reorder-models", (target) => + target.reorderPersonalModels(providerId, modelIds, this.#modelMembership(providerId)), + ); + } - 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) => { - plan = await target.importRemoteModels( - providerId, - remoteModelIds, - this.#modelMembership(providerId), - modelConfigs, - ); - }, - ); - if (!plan) throw new Error(`导入远端模型失败: ${providerId}`); - return { - view, - addedModelIds: plan.addedModelIds, - restoredInheritedModelIds: plan.restoredInheritedModelIds, - skippedModelIds: plan.skippedModelIds, - }; - } + addPersonalModel( + providerId: ProviderId, + modelId: ModelId, + config: ModelConfigObject, + useRecommendedConfig?: boolean, + ): Promise { + return this.#mutateProvider(providerId, "add-model", (target) => + target.addPersonalModel( + providerId, + modelId, + parseModelConfig(config), + this.#modelMembership(providerId), + useRecommendedConfig, + ), + ); + } - clearVisibleModels(providerId: ProviderId): Promise { - return this.#mutateProvider(providerId, "clear-visible-models", (target) => - target.clearVisibleModels(providerId, this.#modelMembership(providerId)), - ); - } + renamePersonalModel( + providerId: ProviderId, + currentModelId: ModelId, + nextModelId: ModelId, + ): Promise { + return this.#mutateProvider(providerId, "rename-model", (target) => + target.renamePersonalModel( + providerId, + currentModelId, + nextModelId, + this.#modelMembership(providerId), + ), + ); + } - savePersonalModelDraft(input: SavePersonalModelDraftInput): Promise { - return this.#mutateProvider(input.providerId, "save-model-draft", async (target) => { - const snapshot = requireSnapshot(this.#source); - if (snapshot.registry.revision !== input.basedOnRevision) { - throw new Error( - `Provider Settings revision conflict: expected ${input.basedOnRevision}, current ${snapshot.registry.revision}`, - ); - } - const parsedConfig = parseModelConfig(input.personalConfig); - await target.savePersonalModelDraft( - input.providerId, - input.originalModelId, - input.nextModelId, - parsedConfig, - snapshot.config.personalRevision, - input.useRecommendedConfig, - this.#modelMembership(input.providerId, snapshot), - ); - }); - } + deletePersonalModel(providerId: ProviderId, modelId: ModelId): Promise { + return this.#mutateProvider(providerId, "delete-personal-model", (target) => + target.deletePersonalModel(providerId, modelId, this.#modelMembership(providerId)), + ); + } - setPersonalModelEnabled( - providerId: ProviderId, - modelId: ModelId, - enabled: boolean, - ): Promise { - return this.#mutateProvider(providerId, "set-model-enabled", (target) => - target.setPersonalModelEnabled( - providerId, - modelId, - enabled, - this.#modelMembership(providerId), - ), - ); - } + 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) => { + plan = await target.importRemoteModels( + providerId, + remoteModelIds, + this.#modelMembership(providerId), + modelConfigs, + ); + }); + if (!plan) throw new Error(`导入远端模型失败: ${providerId}`); + return { + view, + addedModelIds: plan.addedModelIds, + restoredInheritedModelIds: plan.restoredInheritedModelIds, + skippedModelIds: plan.skippedModelIds, + }; + } - #modelMembership( - providerId: ProviderId, - snapshot = requireSnapshot(this.#source), - ): ProviderModelMembership { - const provider = snapshot.resolution.resolvedProviders.find( - (item) => item.providerId === providerId, + clearVisibleModels(providerId: ProviderId): Promise { + return this.#mutateProvider(providerId, "clear-visible-models", (target) => + target.clearVisibleModels(providerId, this.#modelMembership(providerId)), + ); + } + + savePersonalModelDraft(input: SavePersonalModelDraftInput): Promise { + return this.#mutateProvider(input.providerId, "save-model-draft", async (target) => { + const snapshot = requireSnapshot(this.#source); + if (snapshot.registry.revision !== input.basedOnRevision) { + throw new Error( + `Provider Settings revision conflict: expected ${input.basedOnRevision}, current ${snapshot.registry.revision}`, ); - if (!provider) throw new Error(`Provider 不存在: ${providerId}`); - // 配置成员与可执行/可见模型不是同一名单:禁用、无权益、不完整和已隐藏的 - // 继承模型仍属于该实例,不能只按当前 Settings 列表回推。 - // Account 的空/替换名单以 overlay 后的 builtinModelIds 为准,不再与静态 Built-in 取并集。 - return Object.freeze({ - providerId, - inheritedModelIds: Object.freeze([...(provider.config.builtinModelIds ?? [])]), - personalRevision: snapshot.config.personalRevision, - assertCurrent: () => { - if (this.#source.getSnapshot() !== snapshot) - throw new Error("Provider Settings snapshot revision conflict"); - }, - }); - } - - async #mutate( - reason: string, - operation: (target: ProviderSettingsMutationTarget) => Promise, - ): Promise { - if (!this.#mutations) throw new Error("ProviderSettingsFacade 未配置写入目标"); - await operation(this.#mutations); - await this.#mutations.refresh(`settings:${reason}`); - return this.getView(); - } - - #mutateProvider( - providerId: ProviderId, - reason: string, - operation: (target: ProviderSettingsMutationTarget) => Promise, - ): Promise { - const previous = this.#providerMutationTails.get(providerId) ?? Promise.resolve(); - const waitForPrevious = previous.catch(() => undefined); - const result = waitForPrevious.then(() => this.#mutate(reason, operation)); - this.#providerMutationTails.set(providerId, result); - const cleanup = () => { - if (this.#providerMutationTails.get(providerId) === result) { - this.#providerMutationTails.delete(providerId); - } - }; - void result.then(cleanup, cleanup); - return result; - } + } + const parsedConfig = parseModelConfig(input.personalConfig); + await target.savePersonalModelDraft( + input.providerId, + input.originalModelId, + input.nextModelId, + parsedConfig, + snapshot.config.personalRevision, + input.useRecommendedConfig, + this.#modelMembership(input.providerId, snapshot), + ); + }); + } - async waitForProviderOperations(providerId: ProviderId): Promise { - await this.#providerMutationTails.get(providerId); - } + setPersonalModelEnabled( + providerId: ProviderId, + modelId: ModelId, + enabled: boolean, + ): Promise { + return this.#mutateProvider(providerId, "set-model-enabled", (target) => + target.setPersonalModelEnabled( + providerId, + modelId, + enabled, + this.#modelMembership(providerId), + ), + ); + } - async #mutateWithResult( - reason: string, - operation: (target: ProviderSettingsMutationTarget) => Promise, - ): Promise<{ readonly result: TResult; readonly view: ProviderSettingsView }> { - if (!this.#mutations) throw new Error("ProviderSettingsFacade 未配置写入目标"); - const result = await operation(this.#mutations); - await this.#mutations.refresh(`settings:${reason}`); - return { result, view: this.getView() }; - } + #modelMembership( + providerId: ProviderId, + snapshot = requireSnapshot(this.#source), + ): ProviderModelMembership { + const provider = snapshot.resolution.resolvedProviders.find( + (item) => item.providerId === providerId, + ); + if (!provider) throw new Error(`Provider 不存在: ${providerId}`); + // 配置成员与可执行/可见模型不是同一名单:禁用、无权益、不完整和已隐藏的 + // 继承模型仍属于该实例,不能只按当前 Settings 列表回推。 + // Account 的空/替换名单以 overlay 后的 builtinModelIds 为准,不再与静态 Built-in 取并集。 + return Object.freeze({ + providerId, + inheritedModelIds: Object.freeze([...(provider.config.builtinModelIds ?? [])]), + personalRevision: snapshot.config.personalRevision, + assertCurrent: () => { + if (this.#source.getSnapshot() !== snapshot) + throw new Error("Provider Settings snapshot revision conflict"); + }, + }); + } + + async #mutate( + reason: string, + operation: (target: ProviderSettingsMutationTarget) => Promise, + ): Promise { + if (!this.#mutations) throw new Error("ProviderSettingsFacade 未配置写入目标"); + await operation(this.#mutations); + await this.#mutations.refresh(`settings:${reason}`); + return this.getView(); + } + + #mutateProvider( + providerId: ProviderId, + reason: string, + operation: (target: ProviderSettingsMutationTarget) => Promise, + ): Promise { + const previous = this.#providerMutationTails.get(providerId) ?? Promise.resolve(); + const waitForPrevious = previous.catch(() => undefined); + const result = waitForPrevious.then(() => this.#mutate(reason, operation)); + this.#providerMutationTails.set(providerId, result); + const cleanup = () => { + if (this.#providerMutationTails.get(providerId) === result) { + this.#providerMutationTails.delete(providerId); + } + }; + void result.then(cleanup, cleanup); + return result; + } + + async waitForProviderOperations(providerId: ProviderId): Promise { + await this.#providerMutationTails.get(providerId); + } + + async #mutateWithResult( + reason: string, + operation: (target: ProviderSettingsMutationTarget) => Promise, + ): Promise<{ readonly result: TResult; readonly view: ProviderSettingsView }> { + if (!this.#mutations) throw new Error("ProviderSettingsFacade 未配置写入目标"); + const result = await operation(this.#mutations); + await this.#mutations.refresh(`settings:${reason}`); + return { result, view: this.getView() }; + } } export class ModelSelectionFacade { - readonly #source: ProviderRegistryFacadeSource; - readonly #classifyProvider: ModelSelectionProviderClassifier; - readonly #resolveLegacyReasoningLevel?: ( - snapshot: ProviderRegistryServiceSnapshot, - selection: ModelSelection, - ) => string | undefined; - - constructor( - source: ProviderRegistryFacadeSource, - classifyProvider: ModelSelectionProviderClassifier = () => "ordinary", - resolveLegacyReasoningLevel?: ( - snapshot: ProviderRegistryServiceSnapshot, - selection: ModelSelection, - ) => string | undefined, - ) { - this.#source = source; - this.#classifyProvider = classifyProvider; - this.#resolveLegacyReasoningLevel = resolveLegacyReasoningLevel; - } - - getView( - configuredDefault?: ModelSelection, - revision?: number, - input?: ModelSelectionViewInput, - ): ModelSelectionView { - // 账号事实与候选从同一已应用快照读取;不把最新 Settings 配给旧 Registry。 - const snapshot = input ? requireSnapshot(this.#source) : this.#source.getSnapshot(); - const registry = snapshot?.registry ?? this.#source.getView(); - const resolveLegacyReasoningLevel = - snapshot && this.#resolveLegacyReasoningLevel - ? (selection: ModelSelection) => - this.#resolveLegacyReasoningLevel!(snapshot, selection) - : undefined; - // 默认偏好只归一化档位,不借此改写账号身份或保存配置。 - const normalizedDefault = - configuredDefault && resolveLegacyReasoningLevel - ? (resolveEffectiveModelSelection({ - selection: configuredDefault, - registry, - classifyProvider: () => "ordinary", - resolveLegacyReasoningLevel, - }).effectiveSelection ?? undefined) - : configuredDefault; - const initial = resolveInitialModelSelection({ + readonly #source: ProviderRegistryFacadeSource; + readonly #classifyProvider: ModelSelectionProviderClassifier; + readonly #resolveLegacyReasoningLevel?: ( + snapshot: ProviderRegistryServiceSnapshot, + selection: ModelSelection, + ) => string | undefined; + + constructor( + source: ProviderRegistryFacadeSource, + classifyProvider: ModelSelectionProviderClassifier = () => "ordinary", + resolveLegacyReasoningLevel?: ( + snapshot: ProviderRegistryServiceSnapshot, + selection: ModelSelection, + ) => string | undefined, + ) { + this.#source = source; + this.#classifyProvider = classifyProvider; + this.#resolveLegacyReasoningLevel = resolveLegacyReasoningLevel; + } + + getView( + configuredDefault?: ModelSelection, + revision?: number, + input?: ModelSelectionViewInput, + ): ModelSelectionView { + // 账号事实与候选从同一已应用快照读取;不把最新 Settings 配给旧 Registry。 + const snapshot = input ? requireSnapshot(this.#source) : this.#source.getSnapshot(); + const registry = snapshot?.registry ?? this.#source.getView(); + const resolveLegacyReasoningLevel = + snapshot && this.#resolveLegacyReasoningLevel + ? (selection: ModelSelection) => this.#resolveLegacyReasoningLevel!(snapshot, selection) + : undefined; + // 默认偏好只归一化档位,不借此改写账号身份或保存配置。 + const normalizedDefault = + configuredDefault && resolveLegacyReasoningLevel + ? (resolveEffectiveModelSelection({ + selection: configuredDefault, registry, - configuredDefault: normalizedDefault, - }); - return Object.freeze({ - revision: revision ?? registry.revision, - providers: Object.freeze( - registry.providers - .filter((provider) => provider.config.visibility !== "hidden") - .map(projectModelSelectionProviderView), - ), - ...(initial.source === "none" ? {} : { preferredSelection: initial.selection }), - ...(input - ? resolveEffectiveModelSelection({ - selection: input.selection, - registry, - accountStates: snapshot?.account.states, - classifyProvider: this.#classifyProvider, - resolveLegacyReasoningLevel, - }) - : {}), - }); - } + classifyProvider: () => "ordinary", + resolveLegacyReasoningLevel, + }).effectiveSelection ?? undefined) + : configuredDefault; + const initial = resolveInitialModelSelection({ + registry, + configuredDefault: normalizedDefault, + }); + return Object.freeze({ + revision: revision ?? registry.revision, + providers: Object.freeze( + registry.providers + .filter((provider) => provider.config.visibility !== "hidden") + .map(projectModelSelectionProviderView), + ), + ...(initial.source === "none" ? {} : { preferredSelection: initial.selection }), + ...(input + ? resolveEffectiveModelSelection({ + selection: input.selection, + registry, + accountStates: snapshot?.account.states, + classifyProvider: this.#classifyProvider, + resolveLegacyReasoningLevel, + }) + : {}), + }); + } - onDidChange(listener: (view: ModelSelectionView) => void): () => void { - return this.#source.onDidChange(() => listener(this.getView())); - } + onDidChange(listener: (view: ModelSelectionView) => void): () => void { + return this.#source.onDidChange(() => listener(this.getView())); + } } /** 把一个已进入 Registry 的 Provider 投影为标准模型选择候选。调用方负责产品作用域。 */ export function projectModelSelectionProviderView( - provider: ProviderRegistryView["providers"][number], + provider: ProviderRegistryView["providers"][number], ): ModelSelectionProviderView { - return Object.freeze({ - providerId: provider.providerId, - providerName: provider.providerName, - templateId: provider.templateId, - config: serializeRegistryProviderConfig(provider.config), - models: Object.freeze( - provider.models.map((model) => - Object.freeze({ - modelId: model.modelId, - config: serializeRegistryModelConfig(model.config), - }), - ), - ), - }); + return Object.freeze({ + providerId: provider.providerId, + providerName: provider.providerName, + templateId: provider.templateId, + config: serializeRegistryProviderConfig(provider.config), + models: Object.freeze( + provider.models.map((model) => + Object.freeze({ + modelId: model.modelId, + config: serializeRegistryModelConfig(model.config), + }), + ), + ), + }); } function requireSnapshot(source: ProviderRegistryFacadeSource): ProviderRegistryServiceSnapshot { - const snapshot = source.getSnapshot(); - if (!snapshot) throw new Error("ProviderRegistryService 尚未 start()"); - return snapshot; + const snapshot = source.getSnapshot(); + if (!snapshot) throw new Error("ProviderRegistryService 尚未 start()"); + return snapshot; } function requireEffectiveProvider( - snapshot: ProviderRegistryServiceSnapshot, - providerId: ProviderId, + snapshot: ProviderRegistryServiceSnapshot, + providerId: ProviderId, ): ProviderConfigRule { - const provider = snapshot.resolution.effectiveProviders.getRule(providerId); - if (!provider) throw new Error(`Provider 不存在: ${providerId}`); - return provider; + const provider = snapshot.resolution.effectiveProviders.getRule(providerId); + if (!provider) throw new Error(`Provider 不存在: ${providerId}`); + return provider; } function createProviderSettingsView(input: { - revision: number; - kcodeBuiltinProviders: ProviderRegistryServiceSnapshot["config"]["kcodeBuiltinProviders"]; - kcodeBuiltinProviderTemplates: ProviderRegistryServiceSnapshot["config"]["kcodeBuiltinProviderTemplates"]; - personalProviders: ProviderRegistryServiceSnapshot["config"]["personalProviders"]; - personalModels: ProviderRegistryServiceSnapshot["config"]["personalModels"]; - resolution: ProviderConfigResolution; - accountStates?: import("./account-provider-state.js").AccountProviderStates; + revision: number; + kcodeBuiltinProviders: ProviderRegistryServiceSnapshot["config"]["kcodeBuiltinProviders"]; + kcodeBuiltinProviderTemplates: ProviderRegistryServiceSnapshot["config"]["kcodeBuiltinProviderTemplates"]; + personalProviders: ProviderRegistryServiceSnapshot["config"]["personalProviders"]; + personalModels: ProviderRegistryServiceSnapshot["config"]["personalModels"]; + resolution: ProviderConfigResolution; + accountStates?: import("./account-provider-state.js").AccountProviderStates; }): ProviderSettingsView { - const executableProviderIds = new Set( - input.resolution.registryProviders.map((provider) => provider.providerId), - ); - const projectProvider = (provider: (typeof input.resolution.resolvedProviders)[number]) => { - const personalConfig = input.personalProviders.get(provider.providerId); - return Object.freeze({ - providerId: provider.providerId, - providerName: provider.providerName, - templateId: provider.templateId, - enabled: provider.enabled, - ...(input.accountStates?.[provider.providerId] - ? { accountState: input.accountStates[provider.providerId] } - : {}), - executable: executableProviderIds.has(provider.providerId), - ...(provider.templateConfig - ? { templateConfig: provider.templateConfig.toJSON() } - : {}), - ...(provider.effectiveBuiltinConfig - ? { effectiveBuiltinConfig: provider.effectiveBuiltinConfig.toJSON() } - : {}), - ...(personalConfig ? { personalConfig: personalConfig.toJSON() } : {}), - effectiveConfig: provider.config.toJSON(), - issues: provider.providerIssues, - models: Object.freeze( - provider.models.map((model) => { - const personalModelConfig = input.personalModels.getExact( - provider.providerId, - model.modelId, - ); - const personalModelRule = input.personalModels.getExactRule( - provider.providerId, - model.modelId, - ); - return Object.freeze({ - kind: model.kind, - modelId: model.modelId, - builtin: model.source === "builtin", - effectiveBuiltinConfig: model.effectiveBuiltinConfig.toJSON(), - ...(personalModelConfig - ? { personalExactConfig: personalModelConfig.toJSON() } - : {}), - ...(personalModelRule - ? { - useRecommendedConfig: - personalModelRule.type !== "manual-provider-model", - } - : {}), - effectiveConfig: model.config.toJSON(), - enabled: model.enabled, - executable: model.executable, - selectable: model.selectable, - issues: model.issues, - }); - }), - ), - }); - }; - const visibleProviders = input.resolution.resolvedProviders.filter( - (provider) => provider.config.visibility !== "hidden", - ); - const configuredProviders = visibleProviders; + const executableProviderIds = new Set( + input.resolution.registryProviders.map((provider) => provider.providerId), + ); + const projectProvider = (provider: (typeof input.resolution.resolvedProviders)[number]) => { + const personalConfig = input.personalProviders.get(provider.providerId); return Object.freeze({ - revision: input.revision, - providerTemplates: Object.freeze( - (input.kcodeBuiltinProviderTemplates ?? ProviderTemplateMap.empty()) - .entries() - .map(([templateId, template]) => - Object.freeze({ - templateId, - templateNameMap: template.templateNameMap, - config: template.config.toJSON(), - }), - ), - ), - providerOrder: Object.freeze( - configuredProviders - .filter((provider) => provider.config.group === "standard-personal") - .map((provider) => provider.providerId), - ), - providers: Object.freeze(configuredProviders.map(projectProvider)), + providerId: provider.providerId, + providerName: provider.providerName, + templateId: provider.templateId, + enabled: provider.enabled, + ...(input.accountStates?.[provider.providerId] + ? { accountState: input.accountStates[provider.providerId] } + : {}), + executable: executableProviderIds.has(provider.providerId), + ...(provider.templateConfig ? { templateConfig: provider.templateConfig.toJSON() } : {}), + ...(provider.effectiveBuiltinConfig + ? { effectiveBuiltinConfig: provider.effectiveBuiltinConfig.toJSON() } + : {}), + ...(personalConfig ? { personalConfig: personalConfig.toJSON() } : {}), + effectiveConfig: provider.config.toJSON(), + issues: provider.providerIssues, + models: Object.freeze( + provider.models.map((model) => { + const personalModelConfig = input.personalModels.getExact( + provider.providerId, + model.modelId, + ); + const personalModelRule = input.personalModels.getExactRule( + provider.providerId, + model.modelId, + ); + return Object.freeze({ + kind: model.kind, + modelId: model.modelId, + builtin: model.source === "builtin", + effectiveBuiltinConfig: model.effectiveBuiltinConfig.toJSON(), + ...(personalModelConfig ? { personalExactConfig: personalModelConfig.toJSON() } : {}), + ...(personalModelRule + ? { useRecommendedConfig: personalModelRule.type !== "manual-provider-model" } + : {}), + effectiveConfig: model.config.toJSON(), + enabled: model.enabled, + executable: model.executable, + selectable: model.selectable, + issues: model.issues, + }); + }), + ), }); + }; + const visibleProviders = input.resolution.resolvedProviders.filter( + (provider) => provider.config.visibility !== "hidden", + ); + const configuredProviders = visibleProviders; + return Object.freeze({ + revision: input.revision, + providerTemplates: Object.freeze( + (input.kcodeBuiltinProviderTemplates ?? ProviderTemplateMap.empty()) + .entries() + .map(([templateId, template]) => + Object.freeze({ + templateId, + templateNameMap: template.templateNameMap, + config: template.config.toJSON(), + }), + ), + ), + providerOrder: Object.freeze( + configuredProviders + .filter((provider) => provider.config.group === "standard-personal") + .map((provider) => provider.providerId), + ), + providers: Object.freeze(configuredProviders.map(projectProvider)), + }); } diff --git a/packages/services/src/model-provider/modelInfoCatalog.ts b/packages/services/src/model-provider/modelInfoCatalog.ts index 6fd6a590..fa0f322d 100644 --- a/packages/services/src/model-provider/modelInfoCatalog.ts +++ b/packages/services/src/model-provider/modelInfoCatalog.ts @@ -1,251 +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, + 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[]; + 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>; + 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 }; + 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, + 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, - }; + 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 }; - } - } - }); + 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, + 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 }; - } - }); + 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 }; - } - }); + 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, + 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 } : {}), - }); + 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, + 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; + 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)), - }); + 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 index a84f9930..fe62852c 100644 --- a/packages/services/src/model-provider/modelInfoCatalogFetch.ts +++ b/packages/services/src/model-provider/modelInfoCatalogFetch.ts @@ -1,15 +1,15 @@ import type { CatalogModelInfo } from "./modelInfoCatalog.js"; import { - findLiteLlmInfo, - findModelsDevInfo, - findOpenRouterInfo, - mergeCatalogModelInfo, + 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"; + "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"; @@ -20,98 +20,98 @@ const catalogInflight = new Map>(); const providerInfoCache = new Map(); export function rememberProviderModelInfo( - providerId: string, - info: Readonly>, + providerId: string, + info: Readonly>, ): void { - for (const [modelId, modelInfo] of Object.entries(info)) { - providerInfoCache.set(cacheKey(providerId, modelId), modelInfo); - } + for (const [modelId, modelInfo] of Object.entries(info)) { + providerInfoCache.set(cacheKey(providerId, modelId), modelInfo); + } } export function cachedProviderModelInfo( - providerId: string, - modelId: string, + providerId: string, + modelId: string, ): CatalogModelInfo | undefined { - return providerInfoCache.get(cacheKey(providerId, modelId)); + return providerInfoCache.get(cacheKey(providerId, modelId)); } /** 公开目录失败时返回空,不挡住供应商自己已经给出的模型信息。 */ export async function lookupPublicModelInfo( - modelId: string, - fetchImpl: typeof fetch = globalThis.fetch, + 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; + 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, + 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; + 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, + 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); - } + 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); - } + 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}`; + return `${providerId}\u0000${modelId}`; } diff --git a/packages/services/src/model-provider/modelInfoCatalogParse.ts b/packages/services/src/model-provider/modelInfoCatalogParse.ts index a6ba35d6..f1430246 100644 --- a/packages/services/src/model-provider/modelInfoCatalogParse.ts +++ b/packages/services/src/model-provider/modelInfoCatalogParse.ts @@ -1,223 +1,223 @@ import type { CatalogModelInfo } from "./modelInfoCatalog.js"; const KCODE_REASONING_LEVELS = [ - "disabled", - "enabled", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", - "ultra", + "disabled", + "enabled", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", ] as const; export function modalityFlags( - modalities: readonly string[] | undefined, + 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"), - }; + 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; + 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 }>, + 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; - } + 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; + } + 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]; + 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); - } + 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; + } + 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); + 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; } - return levels.length > 0 ? levels : undefined; + 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), - ); + 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; + 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; - } + 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); + 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 []; + 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; + 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; + 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", - ); + 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)); + 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]; + 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; + 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)); + 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; + 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; + 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; + 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"); + 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 33bec000..66d37167 100644 --- a/packages/services/src/model-provider/providerFacadeServices.ts +++ b/packages/services/src/model-provider/providerFacadeServices.ts @@ -1,389 +1,385 @@ import type { Event } from "@kcode/rpc"; import { ServiceChannels } from "@kcode/shared"; import { - ModelConfig, - type ModelConfigObject, - type ModelId, - type ModelSelection, - type ModelSelectionFacade, - type ModelSelectionView, - type ModelSelectionViewInput, - type ProviderConfigObject, - type ProviderId, - type ProviderSettingsFacade, - type ProviderSettingsCreationResult, - type ModelConfigResolution, - type ProviderSettingsView, - type ResolveModelConfigInput, - type SavePersonalModelDraftInput, - isApiKeyAccess, + ModelConfig, + type ModelConfigObject, + type ModelId, + type ModelSelection, + type ModelSelectionFacade, + type ModelSelectionView, + type ModelSelectionViewInput, + type ProviderConfigObject, + type ProviderId, + type ProviderSettingsFacade, + type ProviderSettingsCreationResult, + type ModelConfigResolution, + type ProviderSettingsView, + type ResolveModelConfigInput, + type SavePersonalModelDraftInput, + isApiKeyAccess, } from "@kcode/provider"; -import { fetchProviderRemoteModels, ProviderRemoteModelsError } from "./providerRemoteModels.js"; +import { + fetchProviderRemoteModels, + ProviderRemoteModelsError, +} from "./providerRemoteModels.js"; import { mergeCatalogModelInfo, modelConfigFromCatalog } from "./modelInfoCatalog.js"; import { - cachedProviderModelInfo, - lookupPublicModelInfo, - rememberProviderModelInfo, + cachedProviderModelInfo, + lookupPublicModelInfo, + rememberProviderModelInfo, } from "./modelInfoCatalogFetch.js"; import { createServiceDescriptor } from "../descriptors.js"; import type { ModelConnectivityResult } from "@kcode/shared"; import { createServiceLogger } from "../logger/serviceLogger.js"; export type { - ProviderSettingsProviderView, - ModelSelectionView, - ModelSelectionViewInput, - ProviderSettingsView, + ProviderSettingsProviderView, + ModelSelectionView, + ModelSelectionViewInput, + ProviderSettingsView, } from "@kcode/provider"; export interface ProviderSettingsRemoteModelsResult { - readonly view: ProviderSettingsView; - readonly addedCount: number; - readonly restoredCount: number; + readonly view: ProviderSettingsView; + readonly addedCount: number; + readonly restoredCount: number; } export interface ProviderSettingsRemoteModelsListResult { - readonly modelIds: readonly string[]; + readonly modelIds: readonly string[]; } export interface IProviderSettingsService { - readonly onDidChange: Event; - getView(): Promise; - refresh(reason: string): Promise; - createPersonalProvider( - input?: Parameters[0], - ): Promise; - resolveModelConfig(input: ResolveModelConfigInput): Promise; - savePersonalProviderOverlay( - providerId: ProviderId, - config: ProviderConfigObject, - metadata?: Parameters[2], - ): Promise; - deletePersonalProvider(providerId: ProviderId): Promise; - reorderPersonalProviders(providerIds: readonly ProviderId[]): Promise; - reorderPersonalModels( - providerId: ProviderId, - modelIds: readonly ModelId[], - ): Promise; - addPersonalModel( - providerId: ProviderId, - modelId: ModelId, - config: ModelConfigObject, - useRecommendedConfig?: boolean, - ): Promise; - renamePersonalModel( - providerId: ProviderId, - currentModelId: ModelId, - nextModelId: ModelId, - ): Promise; - deletePersonalModel(providerId: ProviderId, modelId: ModelId): Promise; - listRemoteProviderModels( - providerId: ProviderId, - ): Promise; - importRemoteProviderModels( - providerId: ProviderId, - modelIds: readonly string[], - ): Promise; - clearProviderModels(providerId: ProviderId): Promise; - savePersonalModelDraft(input: SavePersonalModelDraftInput): Promise; - setPersonalModelEnabled( - providerId: ProviderId, - modelId: ModelId, - enabled: boolean, - ): Promise; - /** 测试已经保存并进入目标 Environment Registry 的正式 Model。 */ - testModelConnectivity( - input: ProviderSettingsConnectivityRequest, - ): Promise; + readonly onDidChange: Event; + getView(): Promise; + refresh(reason: string): Promise; + createPersonalProvider( + input?: Parameters[0], + ): Promise; + resolveModelConfig(input: ResolveModelConfigInput): Promise; + savePersonalProviderOverlay( + providerId: ProviderId, + config: ProviderConfigObject, + metadata?: Parameters[2], + ): Promise; + deletePersonalProvider(providerId: ProviderId): Promise; + reorderPersonalProviders(providerIds: readonly ProviderId[]): Promise; + reorderPersonalModels( + providerId: ProviderId, + modelIds: readonly ModelId[], + ): Promise; + addPersonalModel( + providerId: ProviderId, + modelId: ModelId, + config: ModelConfigObject, + useRecommendedConfig?: boolean, + ): Promise; + renamePersonalModel( + providerId: ProviderId, + currentModelId: ModelId, + nextModelId: ModelId, + ): Promise; + deletePersonalModel(providerId: ProviderId, modelId: ModelId): Promise; + listRemoteProviderModels(providerId: ProviderId): Promise; + importRemoteProviderModels( + providerId: ProviderId, + modelIds: readonly string[], + ): Promise; + clearProviderModels(providerId: ProviderId): Promise; + savePersonalModelDraft(input: SavePersonalModelDraftInput): Promise; + setPersonalModelEnabled( + providerId: ProviderId, + modelId: ModelId, + enabled: boolean, + ): Promise; + /** 测试已经保存并进入目标 Environment Registry 的正式 Model。 */ + testModelConnectivity( + input: ProviderSettingsConnectivityRequest, + ): Promise; } export const IProviderSettingsService = createServiceDescriptor( - ServiceChannels.ProviderSettings, + ServiceChannels.ProviderSettings, ); export interface ProviderSettingsConnectivityTestInput { - readonly workspacePath: string; - readonly workspaceIdentity?: string; - readonly providerId: ProviderId; - readonly modelId: ModelId; + readonly workspacePath: string; + readonly workspaceIdentity?: string; + readonly providerId: ProviderId; + readonly modelId: ModelId; } export interface ProviderSettingsConnectivityRequest { - readonly workspacePath: string; - readonly workspaceIdentity?: string; - readonly providerId: ProviderId; - readonly modelId: ModelId; + readonly workspacePath: string; + readonly workspaceIdentity?: string; + readonly providerId: ProviderId; + readonly modelId: ModelId; } export type ProviderSettingsConnectivityTester = ( - input: ProviderSettingsConnectivityTestInput, + input: ProviderSettingsConnectivityTestInput, ) => Promise; export interface IModelSelectionService { - readonly onDidChange: Event; - getView(input?: ModelSelectionViewInput): Promise; + readonly onDidChange: Event; + getView(input?: ModelSelectionViewInput): Promise; } export interface ModelSelectionConfiguredDefaultSource { - read(): Promise; - onDidChange?(listener: () => void): () => void; + read(): Promise; + onDidChange?(listener: () => void): () => void; } export const IModelSelectionService = createServiceDescriptor( - ServiceChannels.ModelSelection, + ServiceChannels.ModelSelection, ); export function createProviderSettingsService( - facade: ProviderSettingsFacade, - ensureReady: () => Promise = async () => {}, - testConnectivity?: ProviderSettingsConnectivityTester, + facade: ProviderSettingsFacade, + ensureReady: () => Promise = async () => {}, + testConnectivity?: ProviderSettingsConnectivityTester, ): IProviderSettingsService { - return { - onDidChange: toEvent((listener) => facade.onDidChange(listener)), - getView: async () => { - await ensureReady(); - return facade.getView(); - }, - refresh: async (reason) => { - await ensureReady(); - return facade.refresh(reason); - }, - createPersonalProvider: async (input) => { - await ensureReady(); - return facade.createPersonalProvider(input); - }, - resolveModelConfig: async (input) => { - await ensureReady(); - 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(); - return facade.savePersonalProviderOverlay(providerId, config, metadata); - }, - deletePersonalProvider: async (providerId) => { - await ensureReady(); - return facade.deletePersonalProvider(providerId); - }, - reorderPersonalProviders: async (providerIds) => { - await ensureReady(); - return facade.reorderPersonalProviders(providerIds); - }, - reorderPersonalModels: async (providerId, modelIds) => { - await ensureReady(); - return facade.reorderPersonalModels(providerId, modelIds); - }, - addPersonalModel: async (providerId, modelId, config, useRecommendedConfig) => { - await ensureReady(); - return facade.addPersonalModel(providerId, modelId, config, useRecommendedConfig); - }, - renamePersonalModel: async (providerId, currentModelId, nextModelId) => { - await ensureReady(); - return facade.renamePersonalModel(providerId, currentModelId, nextModelId); - }, - deletePersonalModel: async (providerId, modelId) => { - await ensureReady(); - return facade.deletePersonalModel(providerId, modelId); - }, - listRemoteProviderModels: async (providerId) => { - await ensureReady(); - await facade.waitForProviderOperations(providerId); - const provider = facade - .getView() - .providers.find((item) => item.providerId === providerId); - if (!provider) { - throw new ProviderRemoteModelsError( - "missing-connection", - `Provider 不存在: ${providerId}`, - ); - } - const api = provider.effectiveConfig.api; - const access = provider.effectiveConfig.access; - if (!api?.baseUrl?.trim() || !isApiKeyAccess(access) || !access.apiKey?.trim()) { - throw new ProviderRemoteModelsError( - "missing-connection", - "请先填写 Base URL 和 API Key", - ); - } - 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 modelConfigs = await catalogConfigsForModels(facade, providerId, modelIds); - const imported = await facade.importRemoteModels(providerId, modelIds, modelConfigs); - return { - view: imported.view, - addedCount: imported.addedModelIds.length, - restoredCount: imported.restoredInheritedModelIds.length, - }; - }, - clearProviderModels: async (providerId) => { - await ensureReady(); - return facade.clearVisibleModels(providerId); - }, - savePersonalModelDraft: async (input) => { - await ensureReady(); - 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(); - return facade.setPersonalModelEnabled(providerId, modelId, enabled); - }, - testModelConnectivity: async (input) => { - await ensureReady(); - if (!testConnectivity) { - throw new Error("当前 Environment 未装配模型连通性测试能力"); - } - await facade.waitForProviderOperations(input.providerId); - // 禁用对象仍存在于配置视图,但不进入执行 Registry;不能把未发布误报成配置丢失。 - // 只消费操作完成后的公共资格,不另查 Key、权益,也不替代目标 Environment 最终校验。 - const provider = facade - .getView() - .providers.find((item) => item.providerId === input.providerId); - const model = provider?.models.find((item) => item.modelId === input.modelId); - const unavailable = - !provider || !provider.enabled - ? "provider-unavailable" - : !model || !model.enabled || model.issues.length > 0 - ? "model-unavailable" - : !provider.executable - ? "provider-unavailable" - : !model.executable - ? "model-unavailable" - : undefined; - if (unavailable) { - return { - success: false, - error: { - code: unavailable, - message: - unavailable === "provider-unavailable" - ? "This provider is currently unavailable for connectivity testing." - : "This model is currently unavailable for connectivity testing.", - }, - }; - } - return testConnectivity({ - workspacePath: input.workspacePath, - ...(input.workspaceIdentity ? { workspaceIdentity: input.workspaceIdentity } : {}), - providerId: input.providerId, - modelId: input.modelId, - }); - }, - }; + return { + onDidChange: toEvent((listener) => facade.onDidChange(listener)), + getView: async () => { + await ensureReady(); + return facade.getView(); + }, + refresh: async (reason) => { + await ensureReady(); + return facade.refresh(reason); + }, + createPersonalProvider: async (input) => { + await ensureReady(); + return facade.createPersonalProvider(input); + }, + resolveModelConfig: async (input) => { + await ensureReady(); + 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(); + return facade.savePersonalProviderOverlay(providerId, config, metadata); + }, + deletePersonalProvider: async (providerId) => { + await ensureReady(); + return facade.deletePersonalProvider(providerId); + }, + reorderPersonalProviders: async (providerIds) => { + await ensureReady(); + return facade.reorderPersonalProviders(providerIds); + }, + reorderPersonalModels: async (providerId, modelIds) => { + await ensureReady(); + return facade.reorderPersonalModels(providerId, modelIds); + }, + addPersonalModel: async (providerId, modelId, config, useRecommendedConfig) => { + await ensureReady(); + return facade.addPersonalModel(providerId, modelId, config, useRecommendedConfig); + }, + renamePersonalModel: async (providerId, currentModelId, nextModelId) => { + await ensureReady(); + return facade.renamePersonalModel(providerId, currentModelId, nextModelId); + }, + deletePersonalModel: async (providerId, modelId) => { + await ensureReady(); + return facade.deletePersonalModel(providerId, modelId); + }, + listRemoteProviderModels: async (providerId) => { + await ensureReady(); + await facade.waitForProviderOperations(providerId); + const provider = facade + .getView() + .providers.find((item) => item.providerId === providerId); + if (!provider) { + throw new ProviderRemoteModelsError("missing-connection", `Provider 不存在: ${providerId}`); + } + const api = provider.effectiveConfig.api; + const access = provider.effectiveConfig.access; + if (!api?.baseUrl?.trim() || !isApiKeyAccess(access) || !access.apiKey?.trim()) { + throw new ProviderRemoteModelsError( + "missing-connection", + "请先填写 Base URL 和 API Key", + ); + } + 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 modelConfigs = await catalogConfigsForModels(facade, providerId, modelIds); + const imported = await facade.importRemoteModels(providerId, modelIds, modelConfigs); + return { + view: imported.view, + addedCount: imported.addedModelIds.length, + restoredCount: imported.restoredInheritedModelIds.length, + }; + }, + clearProviderModels: async (providerId) => { + await ensureReady(); + return facade.clearVisibleModels(providerId); + }, + savePersonalModelDraft: async (input) => { + await ensureReady(); + 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(); + return facade.setPersonalModelEnabled(providerId, modelId, enabled); + }, + testModelConnectivity: async (input) => { + await ensureReady(); + if (!testConnectivity) { + throw new Error("当前 Environment 未装配模型连通性测试能力"); + } + await facade.waitForProviderOperations(input.providerId); + // 禁用对象仍存在于配置视图,但不进入执行 Registry;不能把未发布误报成配置丢失。 + // 只消费操作完成后的公共资格,不另查 Key、权益,也不替代目标 Environment 最终校验。 + const provider = facade + .getView() + .providers.find((item) => item.providerId === input.providerId); + const model = provider?.models.find((item) => item.modelId === input.modelId); + const unavailable = + !provider || !provider.enabled + ? "provider-unavailable" + : !model || !model.enabled || model.issues.length > 0 + ? "model-unavailable" + : !provider.executable + ? "provider-unavailable" + : !model.executable + ? "model-unavailable" + : undefined; + if (unavailable) { + return { + success: false, + error: { + code: unavailable, + message: + unavailable === "provider-unavailable" + ? "This provider is currently unavailable for connectivity testing." + : "This model is currently unavailable for connectivity testing.", + }, + }; + } + return testConnectivity({ + workspacePath: input.workspacePath, + ...(input.workspaceIdentity ? { workspaceIdentity: input.workspaceIdentity } : {}), + providerId: input.providerId, + modelId: input.modelId, + }); + }, + }; } export function createModelSelectionService( - facade: ModelSelectionFacade, - ensureReady: () => Promise = async () => {}, - configuredDefaultSource?: ModelSelectionConfiguredDefaultSource, + facade: ModelSelectionFacade, + ensureReady: () => Promise = async () => {}, + configuredDefaultSource?: ModelSelectionConfiguredDefaultSource, ): IModelSelectionService & { dispose(): void } { - const log = createServiceLogger("model-selection"); - let revision = 0; - let disposed = false; - const listeners = new Set<(view: ModelSelectionView) => void>(); - const getView = async (input?: ModelSelectionViewInput): Promise => { - await ensureReady(); - if (disposed) throw new Error("ModelSelectionService 已 dispose"); - const configuredDefault = await configuredDefaultSource?.read(); - if (disposed) throw new Error("ModelSelectionService 已 dispose"); - const base = facade.getView(configuredDefault); - if (revision < base.revision) revision = base.revision; - return facade.getView(configuredDefault, revision, input); - }; - const emit = (): void => { + const log = createServiceLogger("model-selection"); + let revision = 0; + let disposed = false; + const listeners = new Set<(view: ModelSelectionView) => void>(); + const getView = async (input?: ModelSelectionViewInput): Promise => { + await ensureReady(); + if (disposed) throw new Error("ModelSelectionService 已 dispose"); + const configuredDefault = await configuredDefaultSource?.read(); + if (disposed) throw new Error("ModelSelectionService 已 dispose"); + const base = facade.getView(configuredDefault); + if (revision < base.revision) revision = base.revision; + return facade.getView(configuredDefault, revision, input); + }; + const emit = (): void => { + if (disposed) return; + revision += 1; + void getView().then( + (view) => { if (disposed) return; - revision += 1; - void getView().then( - (view) => { - if (disposed) return; - for (const listener of listeners) listener(view); - }, - (error: unknown) => { - // Registry 事件触发的异步 View 重建没有 owner;Host dispose 后它仍会继续 - // 读取已释放的配置仓库,并形成未处理 rejection。dispose 是明确的取消边界;仅在服务 - // 仍存活时记录真实读取失败。 - if (disposed) return; - log.warn(undefined, `ModelSelection View 刷新失败: ${String(error)}`); - }, - ); - }; - const disposeFacade = facade.onDidChange(emit); - const disposeConfiguredDefault = configuredDefaultSource?.onDidChange?.(emit); + for (const listener of listeners) listener(view); + }, + (error: unknown) => { + // Registry 事件触发的异步 View 重建没有 owner;Host dispose 后它仍会继续 + // 读取已释放的配置仓库,并形成未处理 rejection。dispose 是明确的取消边界;仅在服务 + // 仍存活时记录真实读取失败。 + if (disposed) return; + log.warn(undefined, `ModelSelection View 刷新失败: ${String(error)}`); + }, + ); + }; + const disposeFacade = facade.onDidChange(emit); + const disposeConfiguredDefault = configuredDefaultSource?.onDidChange?.(emit); - return { - onDidChange: (listener) => { - listeners.add(listener); - return { dispose: () => listeners.delete(listener) }; - }, - getView, - dispose() { - if (disposed) return; - disposed = true; - disposeFacade(); - disposeConfiguredDefault?.(); - listeners.clear(); - }, - }; + return { + onDidChange: (listener) => { + listeners.add(listener); + return { dispose: () => listeners.delete(listener) }; + }, + getView, + dispose() { + if (disposed) return; + disposed = true; + disposeFacade(); + disposeConfiguredDefault?.(); + listeners.clear(); + }, + }; } function toEvent(subscribe: (listener: (event: T) => void) => () => void): Event { - return (listener) => { - const dispose = subscribe(listener); - return { dispose }; - }; + return (listener) => { + const dispose = subscribe(listener); + return { dispose }; + }; } async function catalogConfigsForModels( - facade: ProviderSettingsFacade, - providerId: string, - modelIds: readonly string[], + 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; + 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, + 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); + 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 e1d58d5c..12f2c277 100644 --- a/packages/services/src/model-provider/providerRemoteModels.ts +++ b/packages/services/src/model-provider/providerRemoteModels.ts @@ -5,113 +5,107 @@ import { parseProviderModelCatalog, type CatalogModelInfo } from "./modelInfoCat const ANTHROPIC_VERSION = "2023-06-01"; export class ProviderRemoteModelsError extends Error { - readonly code: "missing-connection" | "http-error" | "invalid-response"; + readonly code: "missing-connection" | "http-error" | "invalid-response"; - constructor(code: ProviderRemoteModelsError["code"], message: string) { - super(message); - this.name = "ProviderRemoteModelsError"; - this.code = code; - } + constructor(code: ProviderRemoteModelsError["code"], message: string) { + super(message); + this.name = "ProviderRemoteModelsError"; + this.code = code; + } } -export function resolveProviderModelsUrl( - apiType: ProviderApiType | undefined, - baseUrl: string, -): string { - const trimmed = baseUrl.trim().replace(/\/+$/, ""); - if (!trimmed) { - throw new ProviderRemoteModelsError("missing-connection", "Base URL 不能为空"); - } - if (/\/models$/i.test(trimmed)) return trimmed; - if (apiType === "anthropic-messages") { - return /\/v1$/i.test(trimmed) ? `${trimmed}/models` : `${trimmed}/v1/models`; - } - return `${trimmed}/models`; +export function resolveProviderModelsUrl(apiType: ProviderApiType | undefined, baseUrl: string): string { + const trimmed = baseUrl.trim().replace(/\/+$/, ""); + if (!trimmed) { + throw new ProviderRemoteModelsError("missing-connection", "Base URL 不能为空"); + } + if (/\/models$/i.test(trimmed)) return trimmed; + if (apiType === "anthropic-messages") { + return /\/v1$/i.test(trimmed) ? `${trimmed}/models` : `${trimmed}/v1/models`; + } + return `${trimmed}/models`; } export function parseProviderRemoteModelIds(payload: unknown): ModelId[] { - return parseProviderModelCatalog(payload).modelIds; + return parseProviderModelCatalog(payload).modelIds; } export function parseProviderRemoteModelCatalog(payload: unknown): { - readonly modelIds: readonly ModelId[]; - readonly info: Readonly>; + readonly modelIds: readonly ModelId[]; + readonly info: Readonly>; } { - return parseProviderModelCatalog(payload); + return parseProviderModelCatalog(payload); } export function buildProviderModelsRequestHeaders(input: { - readonly apiType?: ProviderApiType; - readonly apiKey: string; - readonly headers?: Readonly> | null; + readonly apiType?: ProviderApiType; + readonly apiKey: string; + readonly headers?: Readonly> | null; }): Headers { - const apiKey = normalizeApiKeyForHeader(input.apiKey); - if (!apiKey) { - throw new ProviderRemoteModelsError("missing-connection", "API Key 不能为空"); - } - const headers = new Headers(input.headers ?? undefined); - if (input.apiType === "anthropic-messages") { - if (!headers.has("x-api-key")) headers.set("x-api-key", apiKey); - if (!headers.has("anthropic-version")) headers.set("anthropic-version", ANTHROPIC_VERSION); - } else if (!headers.has("authorization")) { - headers.set("Authorization", `Bearer ${apiKey}`); - } - if (!headers.has("accept")) headers.set("Accept", "application/json"); - return headers; + const apiKey = normalizeApiKeyForHeader(input.apiKey); + if (!apiKey) { + throw new ProviderRemoteModelsError("missing-connection", "API Key 不能为空"); + } + const headers = new Headers(input.headers ?? undefined); + if (input.apiType === "anthropic-messages") { + if (!headers.has("x-api-key")) headers.set("x-api-key", apiKey); + if (!headers.has("anthropic-version")) headers.set("anthropic-version", ANTHROPIC_VERSION); + } else if (!headers.has("authorization")) { + headers.set("Authorization", `Bearer ${apiKey}`); + } + if (!headers.has("accept")) headers.set("Accept", "application/json"); + return headers; } 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; + readonly apiType?: ProviderApiType; + readonly baseUrl: string; + readonly apiKey: string; + readonly headers?: Readonly> | null; + readonly fetchImpl?: typeof fetch; + readonly signal?: AbortSignal; }): Promise<{ - readonly modelIds: readonly ModelId[]; - readonly info: Readonly>; + readonly modelIds: readonly ModelId[]; + readonly info: Readonly>; }> { - const url = resolveProviderModelsUrl(input.apiType, input.baseUrl); - const headers = buildProviderModelsRequestHeaders(input); - const fetchImpl = input.fetchImpl ?? globalThis.fetch; - let response: Response; - try { - response = await fetchImpl(url, { - method: "GET", - headers, - ...(input.signal ? { signal: input.signal } : {}), - }); - } catch (error) { - if (error instanceof ProviderRemoteModelsError) throw error; - throw new ProviderRemoteModelsError( - "http-error", - error instanceof Error ? error.message : "远端模型列表请求失败", - ); - } - if (!response.ok) { - throw new ProviderRemoteModelsError( - "http-error", - `远端模型列表请求失败(HTTP ${response.status})`, - ); - } - let payload: unknown; - try { - payload = await response.json(); - } catch { - throw new ProviderRemoteModelsError("invalid-response", "远端返回的模型列表不是 JSON"); - } - const catalog = parseProviderRemoteModelCatalog(payload); - if (catalog.modelIds.length === 0) { - throw new ProviderRemoteModelsError( - "invalid-response", - "远端返回的模型列表为空或格式无法识别", - ); - } - return catalog; + const url = resolveProviderModelsUrl(input.apiType, input.baseUrl); + const headers = buildProviderModelsRequestHeaders(input); + const fetchImpl = input.fetchImpl ?? globalThis.fetch; + let response: Response; + try { + response = await fetchImpl(url, { + method: "GET", + headers, + ...(input.signal ? { signal: input.signal } : {}), + }); + } catch (error) { + if (error instanceof ProviderRemoteModelsError) throw error; + throw new ProviderRemoteModelsError( + "http-error", + error instanceof Error ? error.message : "远端模型列表请求失败", + ); + } + if (!response.ok) { + throw new ProviderRemoteModelsError( + "http-error", + `远端模型列表请求失败(HTTP ${response.status})`, + ); + } + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new ProviderRemoteModelsError("invalid-response", "远端返回的模型列表不是 JSON"); + } + const catalog = parseProviderRemoteModelCatalog(payload); + if (catalog.modelIds.length === 0) { + throw new ProviderRemoteModelsError("invalid-response", "远端返回的模型列表为空或格式无法识别"); + } + return catalog; } export async function fetchProviderRemoteModelIds( - input: Parameters[0], + input: Parameters[0], ): Promise { - return (await fetchProviderRemoteModels(input)).modelIds; + return (await fetchProviderRemoteModels(input)).modelIds; } diff --git a/packages/services/src/model-provider/providerRuntime.ts b/packages/services/src/model-provider/providerRuntime.ts index c132fb5d..47780de1 100644 --- a/packages/services/src/model-provider/providerRuntime.ts +++ b/packages/services/src/model-provider/providerRuntime.ts @@ -1,46 +1,46 @@ import { - NodeModelSelectionConfigRepository, - createNodeModelSelectionFacade, + NodeModelSelectionConfigRepository, + createNodeModelSelectionFacade, } from "@kcode/provider-node"; import { - ProviderRegistryService, - ProviderSettingsFacade, - createFailClosedAccountProviderConfigSnapshot, - type AccountProviderConfigSnapshot, - type ProviderConfigSnapshot, - type ProviderSettingsMutationTarget, - type ProviderSource, + ProviderRegistryService, + ProviderSettingsFacade, + createFailClosedAccountProviderConfigSnapshot, + type AccountProviderConfigSnapshot, + type ProviderConfigSnapshot, + type ProviderSettingsMutationTarget, + type ProviderSource, } from "@kcode/provider"; import { - createProviderConfigRuntime, - type ProviderConfigRuntime, - type ProviderConfigRuntimeOptions, + createProviderConfigRuntime, + type ProviderConfigRuntime, + type ProviderConfigRuntimeOptions, } from "./providerConfigRuntime.js"; import { - createModelSelectionService, - createProviderSettingsService, - type IModelSelectionService, - type IProviderSettingsService, - type ModelSelectionConfiguredDefaultSource, - type ProviderSettingsConnectivityTester, + createModelSelectionService, + createProviderSettingsService, + type IModelSelectionService, + type IProviderSettingsService, + type ModelSelectionConfiguredDefaultSource, + type ProviderSettingsConnectivityTester, } from "./providerFacadeServices.js"; export interface ProviderRuntimeOptions extends ProviderConfigRuntimeOptions { - readonly accountSource?: RefreshableProviderSource; - readonly testConnectivity?: ProviderSettingsConnectivityTester; + readonly accountSource?: RefreshableProviderSource; + readonly testConnectivity?: ProviderSettingsConnectivityTester; } export interface ProviderRuntimeDependencies { - readonly configRuntime: ProviderConfigRuntime; - readonly accountSource?: RefreshableProviderSource; - readonly disposeAccountSource?: () => void; - readonly testConnectivity?: ProviderSettingsConnectivityTester; - readonly modelSelectionConfiguredDefaultSource?: ModelSelectionConfiguredDefaultSource; - readonly disposeModelSelectionConfiguredDefaultSource?: () => void; + readonly configRuntime: ProviderConfigRuntime; + readonly accountSource?: RefreshableProviderSource; + readonly disposeAccountSource?: () => void; + readonly testConnectivity?: ProviderSettingsConnectivityTester; + readonly modelSelectionConfiguredDefaultSource?: ModelSelectionConfiguredDefaultSource; + readonly disposeModelSelectionConfiguredDefaultSource?: () => void; } interface RefreshableProviderSource extends ProviderSource { - refresh?(reason: string): Promise; + refresh?(reason: string): Promise; } /** @@ -48,177 +48,173 @@ interface RefreshableProviderSource extends ProviderSource * Account Provider 由当前 Built-in revision 对齐的 access.entitled=false Overlay 显式 fail-closed。 */ export class EmptyAccountProviderConfigSource implements ProviderSource { - constructor(readonly configSource: ProviderSource) {} + constructor(readonly configSource: ProviderSource) {} - async read(): Promise { - return createFailClosedAccountProviderConfigSnapshot(await this.configSource.read()); - } + async read(): Promise { + return createFailClosedAccountProviderConfigSnapshot(await this.configSource.read()); + } - onDidChange(): () => void { - return () => {}; - } + onDidChange(): () => void { + return () => {}; + } } /** 组装一个进程内共享的 Provider Config、Registry 与 Facade。 */ export class ProviderRuntime { - readonly configService: ProviderConfigRuntime["configService"]; - readonly registryService: ProviderRegistryService; - readonly providerSettings: IProviderSettingsService; - readonly modelSelection: IModelSelectionService; - readonly #configRuntime: ProviderConfigRuntime; - readonly #disposeAccountSource?: () => void; - readonly #disposeBuiltinRecovery: () => void; - readonly #modelSelectionRuntime: IModelSelectionService & { dispose(): void }; - readonly #disposeModelSelectionConfiguredDefaultSource?: () => void; - #startPromise: ReturnType | null = null; - #disposed = false; + readonly configService: ProviderConfigRuntime["configService"]; + readonly registryService: ProviderRegistryService; + readonly providerSettings: IProviderSettingsService; + readonly modelSelection: IModelSelectionService; + readonly #configRuntime: ProviderConfigRuntime; + readonly #disposeAccountSource?: () => void; + readonly #disposeBuiltinRecovery: () => void; + readonly #modelSelectionRuntime: IModelSelectionService & { dispose(): void }; + readonly #disposeModelSelectionConfiguredDefaultSource?: () => void; + #startPromise: ReturnType | null = null; + #disposed = false; - constructor(dependencies: ProviderRuntimeDependencies) { - this.#configRuntime = dependencies.configRuntime; - this.#disposeAccountSource = dependencies.disposeAccountSource; - this.#disposeModelSelectionConfiguredDefaultSource = - dependencies.disposeModelSelectionConfiguredDefaultSource; - this.configService = this.#configRuntime.configService; - const accountSource: RefreshableProviderSource = - dependencies.accountSource ?? new EmptyAccountProviderConfigSource(this.configService); - this.#disposeBuiltinRecovery = this.#configRuntime.onDidCheckKCodeBuiltin(async () => { - const [config, account] = await Promise.all([ - this.configService.read(), - accountSource.read(), - ]); - if ( - !this.#disposed && - config.kcodeBuiltinRevision !== account.basedOnKCodeBuiltinRevision - ) { - await accountSource.refresh?.("builtin-account-recovery"); - } - }); - this.registryService = new ProviderRegistryService({ - configSource: this.configService, - accountSource, - }); - const mutations = createSettingsMutationTarget( - this.#configRuntime, - this.registryService, - accountSource, - ); - const ensureReady = () => this.start(); - const settingsFacade = new ProviderSettingsFacade(this.registryService, mutations); - this.providerSettings = createProviderSettingsService( - settingsFacade, - ensureReady, - dependencies.testConnectivity, - ); - this.#modelSelectionRuntime = createModelSelectionService( - createNodeModelSelectionFacade(this.registryService), - ensureReady, - dependencies.modelSelectionConfiguredDefaultSource, - ); - this.modelSelection = this.#modelSelectionRuntime; - } + constructor(dependencies: ProviderRuntimeDependencies) { + this.#configRuntime = dependencies.configRuntime; + this.#disposeAccountSource = dependencies.disposeAccountSource; + this.#disposeModelSelectionConfiguredDefaultSource = + dependencies.disposeModelSelectionConfiguredDefaultSource; + this.configService = this.#configRuntime.configService; + const accountSource: RefreshableProviderSource = + dependencies.accountSource ?? new EmptyAccountProviderConfigSource(this.configService); + this.#disposeBuiltinRecovery = this.#configRuntime.onDidCheckKCodeBuiltin(async () => { + const [config, account] = await Promise.all([ + this.configService.read(), + accountSource.read(), + ]); + if (!this.#disposed && config.kcodeBuiltinRevision !== account.basedOnKCodeBuiltinRevision) { + await accountSource.refresh?.("builtin-account-recovery"); + } + }); + this.registryService = new ProviderRegistryService({ + configSource: this.configService, + accountSource, + }); + const mutations = createSettingsMutationTarget( + this.#configRuntime, + this.registryService, + accountSource, + ); + const ensureReady = () => this.start(); + const settingsFacade = new ProviderSettingsFacade(this.registryService, mutations); + this.providerSettings = createProviderSettingsService( + settingsFacade, + ensureReady, + dependencies.testConnectivity, + ); + this.#modelSelectionRuntime = createModelSelectionService( + createNodeModelSelectionFacade(this.registryService), + ensureReady, + dependencies.modelSelectionConfiguredDefaultSource, + ); + this.modelSelection = this.#modelSelectionRuntime; + } - start(): Promise { - if (this.#disposed) throw new Error("ProviderRuntime 已 dispose"); - if (this.#startPromise) return this.#startPromise; - const startPromise = this.#configRuntime.start().then(() => this.registryService.start()); - this.#startPromise = startPromise; - void startPromise.catch(() => { - if (this.#startPromise === startPromise) this.#startPromise = null; - }); - return startPromise; - } + start(): Promise { + if (this.#disposed) throw new Error("ProviderRuntime 已 dispose"); + if (this.#startPromise) return this.#startPromise; + const startPromise = this.#configRuntime.start().then(() => this.registryService.start()); + this.#startPromise = startPromise; + void startPromise.catch(() => { + if (this.#startPromise === startPromise) this.#startPromise = null; + }); + return startPromise; + } - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - this.#disposeBuiltinRecovery(); - this.#modelSelectionRuntime.dispose(); - this.registryService.dispose(); - this.#disposeAccountSource?.(); - this.#disposeModelSelectionConfiguredDefaultSource?.(); - this.#configRuntime.dispose(); - } + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#disposeBuiltinRecovery(); + this.#modelSelectionRuntime.dispose(); + this.registryService.dispose(); + this.#disposeAccountSource?.(); + this.#disposeModelSelectionConfiguredDefaultSource?.(); + this.#configRuntime.dispose(); + } } function createSettingsMutationTarget( - configRuntime: ProviderConfigRuntime, - registryService: ProviderRegistryService, - accountSource: RefreshableProviderSource, + configRuntime: ProviderConfigRuntime, + registryService: ProviderRegistryService, + accountSource: RefreshableProviderSource, ): ProviderSettingsMutationTarget { - const configService = configRuntime.configService; - return { - createPersonalProvider: (input) => configService.createPersonalProvider(input), - savePersonalProviderOverlay: (providerId, config, membership, metadata) => - configService.savePersonalProviderOverlay(providerId, config, membership, metadata), - deletePersonalProvider: (providerId) => configService.deletePersonalProvider(providerId), - reorderPersonalProviders: (providerIds) => - configService.reorderPersonalProviders(providerIds), - reorderPersonalModels: (providerId, modelIds, membership) => - configService.reorderPersonalModels(providerId, modelIds, membership), - // 手工四参数转发曾丢掉新增的配置模式;直接绑定完整签名,避免装配层截断写入意图。 - addPersonalModel: configService.addPersonalModel.bind(configService), - renamePersonalModel: (providerId, currentModelId, nextModelId, membership) => - configService.renamePersonalModel(providerId, currentModelId, nextModelId, membership), - deletePersonalModel: (providerId, modelId, membership) => - configService.deletePersonalModel(providerId, modelId, membership), - importRemoteModels: (providerId, remoteModelIds, membership, modelConfigs) => - configService.importRemoteModels(providerId, remoteModelIds, membership, modelConfigs), - clearVisibleModels: (providerId, membership) => - configService.clearVisibleModels(providerId, membership), - setPersonalModelEnabled: (providerId, modelId, enabled, membership) => - configService.setPersonalModelEnabled(providerId, modelId, enabled, membership), - savePersonalModelDraft: ( - providerId, - originalModelId, - nextModelId, - config, - expectedPersonalRevision, - useRecommendedConfig, - membership, - ) => - configService.savePersonalModelDraft( - providerId, - originalModelId, - nextModelId, - config, - expectedPersonalRevision, - useRecommendedConfig, - membership, - ), - refresh: (reason) => registryService.refresh(reason), - refreshSources: async (reason) => { - const sourceResults = await Promise.allSettled([ - configRuntime.refreshKCodeBuiltin({ force: true }), - accountSource.refresh?.(reason) ?? Promise.resolve(), - ]); - const snapshot = await registryService.refresh(reason); - const failed = sourceResults.find( - (result): result is PromiseRejectedResult => result.status === "rejected", - ); - if (failed) throw failed.reason; - return snapshot; - }, - }; + const configService = configRuntime.configService; + return { + createPersonalProvider: (input) => configService.createPersonalProvider(input), + savePersonalProviderOverlay: (providerId, config, membership, metadata) => + configService.savePersonalProviderOverlay(providerId, config, membership, metadata), + deletePersonalProvider: (providerId) => configService.deletePersonalProvider(providerId), + reorderPersonalProviders: (providerIds) => configService.reorderPersonalProviders(providerIds), + reorderPersonalModels: (providerId, modelIds, membership) => + configService.reorderPersonalModels(providerId, modelIds, membership), + // 手工四参数转发曾丢掉新增的配置模式;直接绑定完整签名,避免装配层截断写入意图。 + addPersonalModel: configService.addPersonalModel.bind(configService), + renamePersonalModel: (providerId, currentModelId, nextModelId, membership) => + configService.renamePersonalModel(providerId, currentModelId, nextModelId, membership), + deletePersonalModel: (providerId, modelId, membership) => + configService.deletePersonalModel(providerId, modelId, membership), + importRemoteModels: (providerId, remoteModelIds, membership, modelConfigs) => + configService.importRemoteModels(providerId, remoteModelIds, membership, modelConfigs), + clearVisibleModels: (providerId, membership) => + configService.clearVisibleModels(providerId, membership), + setPersonalModelEnabled: (providerId, modelId, enabled, membership) => + configService.setPersonalModelEnabled(providerId, modelId, enabled, membership), + savePersonalModelDraft: ( + providerId, + originalModelId, + nextModelId, + config, + expectedPersonalRevision, + useRecommendedConfig, + membership, + ) => + configService.savePersonalModelDraft( + providerId, + originalModelId, + nextModelId, + config, + expectedPersonalRevision, + useRecommendedConfig, + membership, + ), + refresh: (reason) => registryService.refresh(reason), + refreshSources: async (reason) => { + const sourceResults = await Promise.allSettled([ + configRuntime.refreshKCodeBuiltin({ force: true }), + accountSource.refresh?.(reason) ?? Promise.resolve(), + ]); + const snapshot = await registryService.refresh(reason); + const failed = sourceResults.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (failed) throw failed.reason; + return snapshot; + }, + }; } export function createProviderRuntime(options: ProviderRuntimeOptions): ProviderRuntime { - const { accountSource, testConnectivity, ...configRuntimeOptions } = options; - const configRuntime = createProviderConfigRuntime(configRuntimeOptions); - const modelSelectionConfiguredDefaultSource = new NodeModelSelectionConfigRepository({ - personalRepository: configRuntime.personalRepository, - }); - return createProviderRuntimeFromConfigRuntime({ - configRuntime, - accountSource, - testConnectivity, - modelSelectionConfiguredDefaultSource, - disposeModelSelectionConfiguredDefaultSource: () => - modelSelectionConfiguredDefaultSource.dispose(), - }); + const { accountSource, testConnectivity, ...configRuntimeOptions } = options; + const configRuntime = createProviderConfigRuntime(configRuntimeOptions); + const modelSelectionConfiguredDefaultSource = new NodeModelSelectionConfigRepository({ + personalRepository: configRuntime.personalRepository, + }); + return createProviderRuntimeFromConfigRuntime({ + configRuntime, + accountSource, + testConnectivity, + modelSelectionConfiguredDefaultSource, + disposeModelSelectionConfiguredDefaultSource: () => + modelSelectionConfiguredDefaultSource.dispose(), + }); } export function createProviderRuntimeFromConfigRuntime( - dependencies: ProviderRuntimeDependencies, + dependencies: ProviderRuntimeDependencies, ): ProviderRuntime { - return new ProviderRuntime(dependencies); + return new ProviderRuntime(dependencies); } diff --git a/packages/services/test/modelInfoCatalog.test.ts b/packages/services/test/modelInfoCatalog.test.ts index 062776aa..65ed7acc 100644 --- a/packages/services/test/modelInfoCatalog.test.ts +++ b/packages/services/test/modelInfoCatalog.test.ts @@ -2,76 +2,76 @@ import assert from "node:assert/strict"; import test from "node:test"; import { ModelConfig } from "@kcode/provider"; import { - findModelsDevInfo, - mergeCatalogModelInfo, - modelConfigFromCatalog, - parseProviderModelCatalog, + 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 }, - ], - }); + 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); + 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); + 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"] }, - }, - }, - }, + 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); + }, + }, + "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); + 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); });