diff --git a/lib/server-model-config.ts b/lib/server-model-config.ts index f00d1485..8cb31cca 100644 --- a/lib/server-model-config.ts +++ b/lib/server-model-config.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto" import fs from "fs/promises" import path from "path" import { z } from "zod" @@ -56,6 +57,36 @@ function slugify(name: string): string { .replace(/^-|-$/g, "") } +function getProviderSlugs(providers: ServerProviderConfig[]): string[] { + const baseSlugs = providers.map((provider) => slugify(provider.name)) + const slugCounts = new Map() + + for (const slug of baseSlugs) { + slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1) + } + + return providers.map((provider, index) => { + const baseSlug = baseSlugs[index] + if (baseSlug && slugCounts.get(baseSlug) === 1) return baseSlug + + // Preserve existing IDs unless a slug is empty or ambiguous. The + // suffix keeps colliding providers stable across config reloads and + // includes credential routing fields so identical display names can + // still resolve to the intended server configuration. + const identity = JSON.stringify([ + provider.name, + provider.provider, + provider.apiKeyEnv ?? null, + provider.baseUrlEnv ?? null, + ]) + const suffix = createHash("sha256") + .update(identity) + .digest("hex") + .slice(0, 12) + return `${baseSlug || provider.provider}-${suffix}` + }) +} + function getConfigPath(): string { const custom = process.env.AI_MODELS_CONFIG_PATH if (custom && custom.trim().length > 0) return custom @@ -189,13 +220,14 @@ export async function loadFlattenedServerModels(): Promise< const defaultModelId = process.env.AI_MODEL const flattened: FlattenedServerModel[] = [] + const providerSlugs = getProviderSlugs(cfg.providers) - for (const p of cfg.providers) { + for (const [providerIndex, p] of cfg.providers.entries()) { const providerLabel = p.name || PROVIDER_INFO[p.provider]?.label || p.provider // Use slugified name for unique ID (supports multiple API keys per provider) - const nameSlug = slugify(p.name) + const nameSlug = providerSlugs[providerIndex] for (const modelId of p.models) { const id = `server:${nameSlug}:${modelId}` diff --git a/tests/unit/server-model-config.test.ts b/tests/unit/server-model-config.test.ts index 80d0c879..cfbbc29d 100644 --- a/tests/unit/server-model-config.test.ts +++ b/tests/unit/server-model-config.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest" import { _resetForTests } from "@/lib/admin/settings" import { + findServerModelById, loadFlattenedServerModels, type ServerModelsConfig, ServerModelsConfigSchema, @@ -232,4 +233,74 @@ describe("loadFlattenedServerModels", () => { expect(models.length).toBe(1) expect(models[0].apiKeyEnv).toEqual(["OPENAI_KEY_1", "OPENAI_KEY_2"]) }) + + it("keeps colliding provider name slugs routed to the right credentials", async () => { + const config: ServerModelsConfig = { + providers: [ + { + name: "Open AI", + provider: "openai", + models: ["shared-model"], + apiKeyEnv: "OPENAI_KEY_PRIMARY", + }, + { + name: "Open-AI", + provider: "openai", + models: ["shared-model"], + apiKeyEnv: "OPENAI_KEY_BACKUP", + }, + ], + } + process.env.AI_MODELS_CONFIG = JSON.stringify(config) + + const models = await loadFlattenedServerModels() + + expect(new Set(models.map((model) => model.id)).size).toBe(2) + const backup = await findServerModelById(models[1].id) + expect(backup?.apiKeyEnv).toBe("OPENAI_KEY_BACKUP") + }) + + it("creates distinct IDs for non-Latin provider names", async () => { + const config: ServerModelsConfig = { + providers: [ + { + name: "生产环境", + provider: "openai", + models: ["shared-model"], + apiKeyEnv: "OPENAI_KEY_PRIMARY", + }, + { + name: "备用环境", + provider: "openai", + models: ["shared-model"], + apiKeyEnv: "OPENAI_KEY_BACKUP", + }, + ], + } + process.env.AI_MODELS_CONFIG = JSON.stringify(config) + + const models = await loadFlattenedServerModels() + + expect(new Set(models.map((model) => model.id)).size).toBe(2) + expect(models.every((model) => !model.id.startsWith("server::"))).toBe( + true, + ) + }) + + it("preserves existing IDs when provider slugs do not collide", async () => { + const config: ServerModelsConfig = { + providers: [ + { + name: "OpenAI Production", + provider: "openai", + models: ["gpt-4o"], + }, + ], + } + process.env.AI_MODELS_CONFIG = JSON.stringify(config) + + const models = await loadFlattenedServerModels() + + expect(models[0].id).toBe("server:openai-production:gpt-4o") + }) })