From 8e42dd9da8fcb7d1205bc21b0c7f26e3a14e9e65 Mon Sep 17 00:00:00 2001 From: Dayuan Jiang <34411969+DayuanJiang@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:27:55 +0900 Subject: [PATCH] feat: support comma-separated AI_MODEL for quick multi-model setup (#870) Users expected setting AI_MODEL to a comma-separated list to expose multiple models in the picker, but the value was used verbatim as a single model id, leaving the picker with only the "Server Default" fallback. Add a third-priority fallback in loadEnvServerModelsConfig: when AI_MODELS_CONFIG and ai-models.json are both absent, AI_MODEL contains a comma, and AI_PROVIDER is set to a known provider, synthesize an equivalent ServerModelsConfig with the provider's models trimmed, deduplicated, and the first marked as default. Also makes getAIModel and getValidationModel pick the first comma-split value when falling back to AI_MODEL, so requests started before the client picker hydrates still resolve to a real model id. Docs (en/cn/ja) and env.example updated; tests cover the new fallback plus the no-comma / no-AI_PROVIDER negative cases. --- README.md | 2 +- docs/cn/README_CN.md | 2 +- docs/cn/ai-providers.md | 11 +++++ docs/en/ai-providers.md | 11 +++++ docs/ja/README_JA.md | 2 +- docs/ja/ai-providers.md | 11 +++++ env.example | 4 ++ lib/ai-providers.ts | 10 +++-- lib/server-model-config.ts | 61 +++++++++++++++++++++++--- tests/unit/server-model-config.test.ts | 38 ++++++++++++++++ 10 files changed, 140 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 738ccaf..7362bb9 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints. ### Server-Side Multi-Model Configuration -Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. +Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`. ### Admin Panel diff --git a/docs/cn/README_CN.md b/docs/cn/README_CN.md index ec518a6..b52dd7c 100644 --- a/docs/cn/README_CN.md +++ b/docs/cn/README_CN.md @@ -217,7 +217,7 @@ npm run dev ### 服务端多模型配置 -管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。 +管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。 **模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。 diff --git a/docs/cn/ai-providers.md b/docs/cn/ai-providers.md index 8522fdb..c221cd0 100644 --- a/docs/cn/ai-providers.md +++ b/docs/cn/ai-providers.md @@ -336,6 +336,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":[" 在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。 +**方式三:`AI_MODEL` 用逗号分隔**(单 provider 的快速配置) + +如果只需要暴露同一 provider 下的多个模型,可以直接在 `AI_MODEL` 里用逗号分隔。第一个模型会作为默认值。 + +```bash +AI_PROVIDER=doubao +AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro +``` + +这是等价 `ai-models.json` 的简写形式。如果需要配置多个 provider,或自定义 `apiKeyEnv` / `baseUrlEnv`,请使用方式一或方式二。 + ### 配置示例 ```json diff --git a/docs/en/ai-providers.md b/docs/en/ai-providers.md index 4e1e702..b89ef60 100644 --- a/docs/en/ai-providers.md +++ b/docs/en/ai-providers.md @@ -351,6 +351,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":[" Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location). +**Option 3: Comma-separated `AI_MODEL`** (quick setup, single provider) + +If you only need multiple models from one provider, list them in `AI_MODEL` separated by commas. The first model is treated as the default. + +```bash +AI_PROVIDER=doubao +AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro +``` + +This is shorthand for the equivalent `ai-models.json`. For multiple providers or custom `apiKeyEnv` / `baseUrlEnv`, use Option 1 or 2 instead. + ### Example Configuration ```json diff --git a/docs/ja/README_JA.md b/docs/ja/README_JA.md index 757e49e..57408d3 100644 --- a/docs/ja/README_JA.md +++ b/docs/ja/README_JA.md @@ -216,7 +216,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム ### サーバーサイドマルチモデル設定 -管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。 +管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。 **モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。 diff --git a/docs/ja/ai-providers.md b/docs/ja/ai-providers.md index 89eeee3..aeaffb2 100644 --- a/docs/ja/ai-providers.md +++ b/docs/ja/ai-providers.md @@ -336,6 +336,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":[" プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。 +**方法3:`AI_MODEL` をカンマ区切りで指定**(単一プロバイダーの簡易設定) + +同一プロバイダー内の複数モデルだけを公開したい場合は、`AI_MODEL` にカンマ区切りで列挙できます。最初のモデルがデフォルトになります。 + +```bash +AI_PROVIDER=doubao +AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro +``` + +これは等価な `ai-models.json` の簡易表記です。複数のプロバイダーや、カスタム `apiKeyEnv` / `baseUrlEnv` を使う場合は、方法1または方法2を使ってください。 + ### 設定例 ```json diff --git a/env.example b/env.example index d1a98c1..fbf1d49 100644 --- a/env.example +++ b/env.example @@ -5,6 +5,10 @@ AI_PROVIDER=bedrock # AI_MODEL: The model ID for your chosen provider (REQUIRED) +# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs. +# The first one becomes the default and the rest appear in the model picker. +# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead. +# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 # AWS Bedrock Configuration diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index 8704f42..38735be 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -729,8 +729,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { (overrides?.provider === "vertexai" && overrides?.vertexApiKey)) ) - // Use client override if provided, otherwise fall back to env vars - const modelId = overrides?.modelId || process.env.AI_MODEL + // Use client override if provided, otherwise fall back to env vars. + // AI_MODEL may be comma-separated (multi-model fallback); pick the first. + const envModel = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined + const modelId = overrides?.modelId || envModel if (!modelId) { if (isClientOverride) { @@ -1490,7 +1492,9 @@ export function supportsImageInput(modelId: string): boolean { * Throws if the model doesn't support image input. */ export function getValidationModel(): ReturnType["model"] { - const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL + // AI_MODEL may be comma-separated (multi-model fallback); pick the first. + const envFallback = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined + const modelId = process.env.VALIDATION_MODEL || envFallback if (!modelId) { throw new Error( diff --git a/lib/server-model-config.ts b/lib/server-model-config.ts index 65ff0cc..f00d148 100644 --- a/lib/server-model-config.ts +++ b/lib/server-model-config.ts @@ -62,6 +62,53 @@ function getConfigPath(): string { return path.join(process.cwd(), "ai-models.json") } +/** + * Synthesize a config from a comma-separated AI_MODEL value (Priority 3 fallback). + * Lets users expose multiple models without authoring AI_MODELS_CONFIG / ai-models.json. + * Triggers only when AI_MODEL contains a comma AND AI_PROVIDER is set to a known provider. + */ +function configFromCommaSeparatedAiModel(): ServerModelsConfig | null { + const aiModel = process.env.AI_MODEL + if (!aiModel || !aiModel.includes(",")) return null + + const aiProvider = process.env.AI_PROVIDER + if (!aiProvider) { + console.warn( + "[server-model-config] AI_MODEL contains commas but AI_PROVIDER is not set; " + + "skipping multi-model fallback. Set AI_PROVIDER, or use AI_MODELS_CONFIG / ai-models.json.", + ) + return null + } + if (!(aiProvider in PROVIDER_INFO)) { + console.warn( + `[server-model-config] AI_PROVIDER="${aiProvider}" is not a known provider; skipping multi-model fallback.`, + ) + return null + } + + const models = Array.from( + new Set( + aiModel + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0), + ), + ) + if (models.length === 0) return null + + const providerName = aiProvider as ProviderName + return { + providers: [ + { + name: PROVIDER_INFO[providerName]?.label || providerName, + provider: providerName, + models, + default: true, + }, + ], + } +} + export async function loadEnvServerModelsConfig(): Promise { // Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments const envConfig = process.env.AI_MODELS_CONFIG @@ -85,15 +132,17 @@ export async function loadEnvServerModelsConfig(): Promise { diff --git a/tests/unit/server-model-config.test.ts b/tests/unit/server-model-config.test.ts index febdd20..4dda82b 100644 --- a/tests/unit/server-model-config.test.ts +++ b/tests/unit/server-model-config.test.ts @@ -159,6 +159,44 @@ describe("loadFlattenedServerModels", () => { expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider }) + it("falls back to comma-separated AI_MODEL when no other config is set", async () => { + process.env.AI_MODELS_CONFIG = "" + process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json` + process.env.AI_PROVIDER = "openai" + process.env.AI_MODEL = "gpt-4o, gpt-4o-mini, gpt-4o" + + const models = await loadFlattenedServerModels() + + // Trims, deduplicates, and preserves order + expect(models.map((m) => m.modelId)).toEqual(["gpt-4o", "gpt-4o-mini"]) + expect(models.every((m) => m.provider === "openai")).toBe(true) + + // First model is marked default (provider has default: true) + const defaults = models.filter((m) => m.isDefault) + expect(defaults.length).toBe(1) + expect(defaults[0].modelId).toBe("gpt-4o") + }) + + it("does not synthesize when AI_MODEL has no comma", async () => { + process.env.AI_MODELS_CONFIG = "" + process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json` + process.env.AI_PROVIDER = "openai" + process.env.AI_MODEL = "gpt-4o" + + const models = await loadFlattenedServerModels() + expect(models).toEqual([]) + }) + + it("does not synthesize when AI_PROVIDER is unset", async () => { + process.env.AI_MODELS_CONFIG = "" + process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json` + delete process.env.AI_PROVIDER + process.env.AI_MODEL = "gpt-4o, gpt-4o-mini" + + const models = await loadFlattenedServerModels() + expect(models).toEqual([]) + }) + it("preserves apiKeyEnv array in flattened models for load balancing", async () => { const config: ServerModelsConfig = { providers: [