[Feature] Server-side multi-provider/model support (#583)

* [Feature] Server side multi-pvorider/model support

* copilot suggesition implemented

* feat: improve model selector UI and auto-select default server model

- Replace emoji headers with Lucide icons (Monitor, User)
- Fix transition-all to explicit properties per web guidelines
- Use CSS padding instead of hardcoded space indentation
- Add ModelSelectorSectionHeader component for section headers
- Replace Star icon with "default" text label
- Style Configure button with muted text color
- Auto-select default server model when page loads
- Support AI_MODELS_CONFIG env var for cloud deployments
- Support custom apiKeyEnv/baseUrlEnv per provider config

* docs: update server-side multi-model configuration documentation

- Add AI_MODELS_CONFIG env var option for cloud deployments
- Document apiKeyEnv and baseUrlEnv fields for custom env var names
- Document default field for auto-selecting default model
- Remove deprecated version field from examples
- Add field reference table for clarity

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
This commit is contained in:
Biki Kalita
2026-01-16 00:58:22 +09:00
committed by GitHub
co-authored by dayuan.jiang
parent b128c57e94
commit b23b9179a0
24 changed files with 1021 additions and 128 deletions
+2 -1
View File
@@ -68,4 +68,5 @@ CLAUDE.md
# edgeone
.edgeone
opencode.json
opencode.json
ai-models.json
+4
View File
@@ -222,6 +222,10 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
### 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.
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
+31 -1
View File
@@ -34,6 +34,7 @@ import {
setTraceOutput,
wrapWithObserve,
} from "@/lib/langfuse"
import { findServerModelById } from "@/lib/server-model-config"
import { getSystemPrompt } from "@/lib/system-prompts"
import { getUserIdFromRequest } from "@/lib/user-id"
@@ -168,6 +169,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Read client AI provider overrides from headers
const provider = req.headers.get("x-ai-provider")
let baseUrl = req.headers.get("x-ai-base-url")
const selectedModelId = req.headers.get("x-selected-model-id")
// For EdgeOne provider, construct full URL from request origin
// because createOpenAI needs absolute URL, not relative path
@@ -179,8 +181,30 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
const cookieHeader = req.headers.get("cookie")
// Check if this is a server model with custom env var names
let serverModelConfig: {
apiKeyEnv?: string
baseUrlEnv?: string
provider?: string
} = {}
if (selectedModelId?.startsWith("server:")) {
const serverModel = await findServerModelById(selectedModelId)
console.log(
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
)
if (serverModel) {
serverModelConfig = {
apiKeyEnv: serverModel.apiKeyEnv,
baseUrlEnv: serverModel.baseUrlEnv,
// Use actual provider from config (client header may have incorrect value due to ID format change)
provider: serverModel.provider,
}
}
}
const clientOverrides = {
provider,
// Server model provider takes precedence over client header
provider: serverModelConfig.provider || provider,
baseUrl,
apiKey: req.headers.get("x-ai-api-key"),
modelId: req.headers.get("x-ai-model"),
@@ -189,6 +213,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
awsRegion: req.headers.get("x-aws-region"),
awsSessionToken: req.headers.get("x-aws-session-token"),
// Server model custom env var names
...serverModelConfig,
// Vertex AI credentials (Express Mode)
vertexApiKey: req.headers.get("x-vertex-api-key"),
// Pass cookies for EdgeOne Pages authentication
@@ -201,6 +227,10 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Read minimal style preference from header
const minimalStyle = req.headers.get("x-minimal-style") === "true"
console.log(
`[Client Overrides] provider: ${clientOverrides.provider}, modelId: ${clientOverrides.modelId}`,
)
// Get AI model with optional client overrides
const { model, providerOptions, headers, modelId } =
getAIModel(clientOverrides)
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server"
import { loadFlattenedServerModels } from "@/lib/server-model-config"
// Use dynamic rendering to read AI_MODEL/AI_PROVIDER env vars at runtime
// This ensures Docker users can set these values when starting containers
export const dynamic = "force-dynamic"
export async function GET() {
const models = await loadFlattenedServerModels()
return NextResponse.json({
models,
hasConfig: models.length > 0,
})
}
+24
View File
@@ -169,3 +169,27 @@ export const ModelSelectorName = ({
}: ModelSelectorNameProps) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} />
)
export type ModelSelectorSectionHeaderProps = {
icon: ReactNode
label: string
className?: string
}
export const ModelSelectorSectionHeader = ({
icon,
label,
className,
}: ModelSelectorSectionHeaderProps) => (
<div
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
className,
)}
>
<span className="[&>svg]:size-3.5" aria-hidden="true">
{icon}
</span>
<span>{label}</span>
</div>
)
+4
View File
@@ -968,6 +968,10 @@ export default function ChatPanel({
"x-vertex-api-key": config.vertexApiKey,
}),
}),
// Send selected model ID for server model lookup (apiKeyEnv/baseUrlEnv)
...(config.selectedModelId && {
"x-selected-model-id": config.selectedModelId,
}),
...(minimalStyle && {
"x-minimal-style": "true",
}),
+184 -75
View File
@@ -5,8 +5,10 @@ import {
Bot,
Check,
ChevronDown,
Monitor,
Server,
Settings2,
User,
} from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
@@ -19,6 +21,7 @@ import {
ModelSelectorLogo,
ModelSelectorName,
ModelSelector as ModelSelectorRoot,
ModelSelectorSectionHeader,
ModelSelectorSeparator,
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector"
@@ -63,7 +66,11 @@ function groupModelsByProvider(
{ provider: string; models: FlattenedModel[] }
>()
for (const model of models) {
const key = model.providerLabel
// For server models, strip "Server · " prefix for cleaner grouping
const key =
model.source === "server"
? model.providerLabel.replace(/^Server · /, "")
: model.providerLabel
const existing = groups.get(key)
if (existing) {
existing.models.push(model)
@@ -91,10 +98,26 @@ export function ModelSelector({
}
return models.filter((m) => m.validated === true)
}, [models, showUnvalidatedModels])
const groupedModels = useMemo(
() => groupModelsByProvider(displayModels),
// Separate server and user models
const serverModels = useMemo(
() => displayModels.filter((m) => m.source === "server"),
[displayModels],
)
const userModels = useMemo(
() => displayModels.filter((m) => m.source !== "server"),
[displayModels],
)
// Group each category separately
const groupedServerModels = useMemo(
() => groupModelsByProvider(serverModels),
[serverModels],
)
const groupedUserModels = useMemo(
() => groupModelsByProvider(userModels),
[userModels],
)
// Find selected model for display
const selectedModel = useMemo(
@@ -161,7 +184,7 @@ export function ModelSelector({
size="sm"
disabled={disabled}
className={cn(
"hover:bg-accent gap-1.5 h-8 px-2 transition-all duration-150 ease-in-out",
"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out",
!showLabel && "px-1.5 justify-center",
)}
// accessibility: expose label to screen readers
@@ -198,83 +221,169 @@ export function ModelSelector({
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
{/* Server Default Option */}
<ModelSelectorGroup heading={dict.modelConfig.default}>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
{/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && (
<ModelSelectorGroup
heading={dict.modelConfig.default}
>
<Check
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn(
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{/* Configured Models by Provider */}
{Array.from(groupedModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={providerLabel}
heading={providerLabel}
>
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() =>
handleSelect(model.id)
}
className="cursor-pointer"
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
)}
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
)}
{/* Server Models Section */}
{serverModels.length > 0 && (
<>
<ModelSelectorSectionHeader
icon={<Monitor />}
label={dict.modelConfig.serverModels}
/>
{Array.from(groupedServerModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`server-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId === model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.validated !== true && (
<span
title={
dict.modelConfig
.unvalidatedModelWarning
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() =>
handleSelect(model.id)
}
className="cursor-pointer"
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.isDefault && (
<span
title={
dict.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict.modelConfig
.default
}
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
</>
)}
{/* User Models Section */}
{userModels.length > 0 && (
<>
{serverModels.length > 0 && (
<ModelSelectorSeparator />
)}
<ModelSelectorSectionHeader
icon={<User />}
label={dict.modelConfig.userModels}
/>
{Array.from(groupedUserModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`user-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() =>
handleSelect(model.id)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.validated !==
true && (
<span
title={
dict.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
</>
)}
{/* Configure Option */}
@@ -283,7 +392,7 @@ export function ModelSelector({
<ModelSelectorItem
value="__configure__"
onSelect={handleSelect}
className="cursor-pointer"
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
<Settings2 className="mr-2 h-4 w-4" />
<ModelSelectorName>
+4
View File
@@ -214,6 +214,10 @@ npm run dev
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
### 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
+57
View File
@@ -217,6 +217,63 @@ AI_MODEL=openai/gpt-4o
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
```
## 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。
### 配置方式
**方式一:环境变量**(推荐用于云部署)
设置 `AI_MODELS_CONFIG` 为 JSON 字符串:
```bash
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
```
**方式二:配置文件**
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
### 配置示例
```json
{
"providers": [
{
"name": "OpenAI Production",
"provider": "openai",
"models": ["gpt-4o", "gpt-4o-mini"],
"default": true
},
{
"name": "Custom DeepSeek",
"provider": "deepseek",
"models": ["deepseek-chat"],
"apiKeyEnv": "MY_DEEPSEEK_KEY",
"baseUrlEnv": "MY_DEEPSEEK_URL"
}
]
}
```
### 字段说明
| 字段 | 必填 | 说明 |
|------|------|------|
| `name` | 是 | 显示名称(支持同一提供商多个配置) |
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
| `models` | 是 | 模型 ID 列表 |
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY` |
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
### 说明
- API Key 和凭证通过环境变量提供。默认使用标准变量名(如 `OPENAI_API_KEY`),也可通过 `apiKeyEnv` 指定自定义变量名。
- `name` 字段允许同一提供商多个配置(例如 "OpenAI Production" 和 "OpenAI Staging" 都使用 `provider: "openai"``apiKeyEnv` 不同)。
- 如果配置不存在,应用会回退到 `AI_PROVIDER`/`AI_MODEL` 环境变量配置。
## 模型能力要求
此任务对模型能力要求极高,因为它涉及生成具有严格格式约束(draw.io XML)的长文本。
+57
View File
@@ -232,6 +232,63 @@ If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope
```
## Server-Side Multi-Model Configuration
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys.
### Configuration Methods
**Option 1: Environment Variable** (recommended for cloud deployments)
Set `AI_MODELS_CONFIG` as a JSON string:
```bash
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
```
**Option 2: Config File**
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
### Example Configuration
```json
{
"providers": [
{
"name": "OpenAI Production",
"provider": "openai",
"models": ["gpt-4o", "gpt-4o-mini"],
"default": true
},
{
"name": "Custom DeepSeek",
"provider": "deepseek",
"models": ["deepseek-chat"],
"apiKeyEnv": "MY_DEEPSEEK_KEY",
"baseUrlEnv": "MY_DEEPSEEK_URL"
}
]
}
```
### Field Reference
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Display name (supports multiple configs for same provider) |
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
| `models` | Yes | List of model IDs |
| `default` | No | Set to `true` to auto-select this provider's first model as default |
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
| `baseUrlEnv` | No | Custom base URL env var name |
### Notes
- API keys and credentials are provided via environment variables. By default, standard var names are used (e.g., `OPENAI_API_KEY`), but you can specify custom var names with `apiKeyEnv`.
- The `name` field allows multiple configurations for the same provider (e.g., "OpenAI Production" and "OpenAI Staging" both using `provider: "openai"` but with different `apiKeyEnv` values).
- If config is not present, the app falls back to `AI_PROVIDER`/`AI_MODEL` environment variable configuration.
## Model Capability Requirements
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
+21
View File
@@ -22,6 +22,27 @@ cp env.example .env
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
### Using server-side model configuration
You can mount an `ai-models.json` file into the container to provide multiple server-side models without exposing user API keys:
```bash
docker run -d -p 3000:3000 \
-e OPENAI_API_KEY=your_api_key \
-v $(pwd)/ai-models.json:/app/ai-models.json:ro \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
If you prefer to keep the config in a different path inside the container, set `AI_MODELS_CONFIG_PATH`:
```bash
docker run -d -p 3000:3000 \
-e OPENAI_API_KEY=your_api_key \
-e AI_MODELS_CONFIG_PATH=/config/ai-models.json \
-v $(pwd)/ai-models.json:/config/ai-models.json:ro \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
Replace the environment variables with your preferred AI provider configuration. See [AI Providers](./ai-providers.md) for available options.
+4
View File
@@ -215,6 +215,10 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
### サーバーサイドマルチモデル設定
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
+57
View File
@@ -217,6 +217,63 @@ AI_MODEL=openai/gpt-4o
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
```
## サーバーサイドマルチモデル設定
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。
### 設定方法
**方法1:環境変数**(クラウドデプロイ推奨)
`AI_MODELS_CONFIG` をJSON文字列として設定:
```bash
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
```
**方法2:設定ファイル**
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
### 設定例
```json
{
"providers": [
{
"name": "OpenAI Production",
"provider": "openai",
"models": ["gpt-4o", "gpt-4o-mini"],
"default": true
},
{
"name": "Custom DeepSeek",
"provider": "deepseek",
"models": ["deepseek-chat"],
"apiKeyEnv": "MY_DEEPSEEK_KEY",
"baseUrlEnv": "MY_DEEPSEEK_URL"
}
]
}
```
### フィールド説明
| フィールド | 必須 | 説明 |
|------------|------|------|
| `name` | はい | 表示名(同一プロバイダーの複数設定をサポート) |
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
| `models` | はい | モデルIDのリスト |
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数) |
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
### 備考
- APIキーと認証情報は環境変数で提供します。デフォルトは標準変数名(例:`OPENAI_API_KEY`)を使用しますが、`apiKeyEnv` でカスタム変数名を指定できます。
- `name` フィールドにより同一プロバイダーの複数設定が可能です(例:「OpenAI Production」と「OpenAI Staging」が両方とも `provider: "openai"` を使用しつつ、異なる `apiKeyEnv` を持つ)。
- 設定が存在しない場合、アプリは `AI_PROVIDER`/`AI_MODEL` 環境変数設定にフォールバックします。
## モデル性能要件
このタスクは、厳密なフォーマット制約(draw.io XML)を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。
+5
View File
@@ -101,6 +101,11 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# LANGFUSE_SECRET_KEY=sk-lf-...
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
# Optional server-side multi-model configuration
# If set, points to a JSON file with server-provided models (see README for schema).
# Default: ./ai-models.json in project root
# AI_MODELS_CONFIG_PATH=/path/to/ai-models.json
# Temperature (Optional)
# Controls randomness in AI responses. Lower = more deterministic.
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
+98 -6
View File
@@ -1,6 +1,7 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import type { FlattenedServerModel } from "@/lib/server-model-config"
import { STORAGE_KEYS } from "@/lib/storage"
import {
createEmptyConfig,
@@ -132,14 +133,56 @@ export interface UseModelConfigReturn {
export function useModelConfig(): UseModelConfigReturn {
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
const [isLoaded, setIsLoaded] = useState(false)
const [serverModels, setServerModels] = useState<FlattenedServerModel[]>([])
const [serverLoaded, setServerLoaded] = useState(false)
// Load config on mount
// Load client config on mount
useEffect(() => {
const loaded = loadConfig()
setConfig(loaded)
setIsLoaded(true)
}, [])
// Load server models on mount (if any)
useEffect(() => {
if (typeof window === "undefined") return
fetch("/api/server-models")
.then((res) => {
if (!res.ok) {
console.error(
"Failed to load server models:",
res.status,
res.statusText,
)
throw new Error(`Request failed with status ${res.status}`)
}
return res.json()
})
.then((data) => {
const raw: FlattenedServerModel[] = data?.models || []
setServerModels(raw)
setServerLoaded(true)
// Auto-select default server model if no model is currently selected
setConfig((prev) => {
if (!prev.selectedModelId && raw.length > 0) {
const defaultModel = raw.find((m) => m.isDefault)
if (defaultModel) {
return { ...prev, selectedModelId: defaultModel.id }
}
// If no default marked, use first server model
return { ...prev, selectedModelId: raw[0].id }
}
return prev
})
})
.catch((error) => {
console.error("Error while loading server models:", error)
setServerLoaded(true)
})
}, [])
// Save config whenever it changes (after initial load)
useEffect(() => {
if (isLoaded) {
@@ -148,9 +191,33 @@ export function useModelConfig(): UseModelConfigReturn {
}, [config, isLoaded])
// Derived state
const models = flattenModels(config)
const userModels = flattenModels(config)
const models: FlattenedModel[] = [
// Server models (read-only, credentials from env)
...serverModels.map((m) => ({
id: m.id,
modelId: m.modelId,
provider: m.provider,
providerLabel: `Server · ${m.providerLabel}`,
apiKey: "",
baseUrl: undefined,
awsAccessKeyId: undefined,
awsSecretAccessKey: undefined,
awsRegion: undefined,
awsSessionToken: undefined,
validated: true,
source: "server" as const,
isDefault: m.isDefault,
apiKeyEnv: m.apiKeyEnv,
baseUrlEnv: m.baseUrlEnv,
})),
// User models from local configuration
...userModels,
]
const selectedModel = config.selectedModelId
? findModelById(config, config.selectedModelId)
? models.find((m) => m.id === config.selectedModelId)
: undefined
// Actions
@@ -282,7 +349,7 @@ export function useModelConfig(): UseModelConfigReturn {
return {
config,
isLoaded,
isLoaded: isLoaded && serverLoaded,
models,
selectedModel,
selectedModelId: config.selectedModelId,
@@ -314,6 +381,8 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: string
awsRegion: string
awsSessionToken: string
// Selected model ID (for server model lookup)
selectedModelId: string
// Vertex AI credentials (Express Mode)
vertexApiKey: string
} {
@@ -327,6 +396,7 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: "",
awsRegion: "",
awsSessionToken: "",
selectedModelId: "",
vertexApiKey: "",
}
@@ -350,6 +420,7 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: "",
awsRegion: "",
awsSessionToken: "",
selectedModelId: "",
vertexApiKey: "",
}
}
@@ -361,12 +432,32 @@ export function getSelectedAIConfig(): {
return { ...empty, accessCode }
}
// No selected model = use server default
// No selected model = use server default (AI_PROVIDER/AI_MODEL/env auto-detect)
if (!config.selectedModelId) {
return { ...empty, accessCode }
}
// Find selected model
// Server-side model selection (id = "server:<name-slug>:<modelId>")
// Provider is resolved server-side via findServerModelById()
if (config.selectedModelId.startsWith("server:")) {
const parts = config.selectedModelId.split(":")
const nameSlug = parts[1] || ""
const modelId = parts.slice(2).join(":") // Preserve Bedrock-style IDs
return {
...empty,
accessCode,
// Note: nameSlug is NOT the provider, but we send it for backwards compat
// Server uses selectedModelId to lookup the actual provider
aiProvider: nameSlug,
aiBaseUrl: "",
aiApiKey: "",
aiModel: modelId,
selectedModelId: config.selectedModelId,
}
}
// Find selected user-defined model
const model = findModelById(config, config.selectedModelId)
if (!model) {
return { ...empty, accessCode }
@@ -383,6 +474,7 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: model.awsSecretAccessKey || "",
awsRegion: model.awsRegion || "",
awsSessionToken: model.awsSessionToken || "",
selectedModelId: config.selectedModelId || "",
// Vertex AI credentials (Express Mode)
vertexApiKey: model.vertexApiKey || "",
}
+108 -26
View File
@@ -34,6 +34,9 @@ export interface ClientOverrides {
vertexApiKey?: string | null // Express Mode API key
// Custom headers (e.g., for EdgeOne cookie auth)
headers?: Record<string, string>
// Custom env var names for server models (allows multiple API keys per provider)
apiKeyEnv?: string
baseUrlEnv?: string
}
// Providers that can be used with client-provided API keys
@@ -92,6 +95,36 @@ export function resolveBaseURL(
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined
}
/**
* Resolve API key from custom env var name or default env var.
* Supports multiple API keys per provider via ai-models.json apiKeyEnv config.
*
* Priority:
* 1. User-provided API key (overrides.apiKey)
* 2. Custom env var from ai-models.json (overrides.apiKeyEnv)
* 3. Default provider env var (defaultEnvVar)
*/
function resolveApiKey(
overrides: ClientOverrides | undefined,
defaultEnvVar: string,
): string | undefined {
if (overrides?.apiKey) return overrides.apiKey
if (overrides?.apiKeyEnv) return process.env[overrides.apiKeyEnv]
return process.env[defaultEnvVar]
}
/**
* Resolve base URL from custom env var name or default env var.
* Supports multiple base URLs per provider via ai-models.json baseUrlEnv config.
*/
function resolveBaseUrlEnv(
overrides: ClientOverrides | undefined,
defaultEnvVar: string,
): string | undefined {
if (overrides?.baseUrlEnv) return process.env[overrides.baseUrlEnv]
return process.env[defaultEnvVar]
}
/**
* Safely parse integer from environment variable with validation
*/
@@ -481,9 +514,15 @@ function detectProvider(): ProviderName | null {
/**
* Validate that required API keys are present for the selected provider
* @param provider - The provider to validate
* @param customApiKeyEnv - Optional custom env var name (from ai-models.json apiKeyEnv)
*/
function validateProviderCredentials(provider: ProviderName): void {
const requiredVar = PROVIDER_ENV_VARS[provider]
function validateProviderCredentials(
provider: ProviderName,
customApiKeyEnv?: string,
): void {
// Use custom env var name if provided, otherwise use default
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
if (requiredVar && !process.env[requiredVar]) {
throw new Error(
`${requiredVar} environment variable is required for ${provider} provider. ` +
@@ -621,7 +660,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// Only validate server credentials if client isn't providing their own API key
if (!isClientOverride) {
validateProviderCredentials(provider)
validateProviderCredentials(provider, overrides?.apiKeyEnv)
}
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
@@ -671,11 +710,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "openai": {
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
const apiKey = resolveApiKey(overrides, "OPENAI_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"OPENAI_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.OPENAI_BASE_URL,
serverBaseUrl,
)
if (baseURL) {
// Custom base URL = third-party proxy, use Chat Completions API
@@ -694,11 +737,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "anthropic": {
const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY
const apiKey = resolveApiKey(overrides, "ANTHROPIC_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"ANTHROPIC_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.ANTHROPIC_BASE_URL,
serverBaseUrl,
"https://api.anthropic.com/v1",
)
const customProvider = createAnthropic({
@@ -713,12 +760,18 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "google": {
const apiKey =
overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY
const apiKey = resolveApiKey(
overrides,
"GOOGLE_GENERATIVE_AI_API_KEY",
)
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"GOOGLE_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.GOOGLE_BASE_URL,
serverBaseUrl,
)
if (baseURL || overrides?.apiKey) {
const customGoogle = createGoogleGenerativeAI({
@@ -756,11 +809,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "azure": {
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
const apiKey = resolveApiKey(overrides, "AZURE_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(overrides, "AZURE_BASE_URL")
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.AZURE_BASE_URL,
serverBaseUrl,
)
// Only use server's resourceName if user is NOT providing their own API key
const resourceName = overrides?.apiKey
@@ -794,11 +848,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break
case "openrouter": {
const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY
const apiKey = resolveApiKey(overrides, "OPENROUTER_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"OPENROUTER_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.OPENROUTER_BASE_URL,
serverBaseUrl,
)
const openrouter = createOpenRouter({
apiKey,
@@ -809,11 +867,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "deepseek": {
const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"DEEPSEEK_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.DEEPSEEK_BASE_URL,
serverBaseUrl,
)
if (baseURL || overrides?.apiKey) {
const customDeepSeek = createDeepSeek({
@@ -828,11 +890,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "siliconflow": {
const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY
const apiKey = resolveApiKey(overrides, "SILICONFLOW_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"SILICONFLOW_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.SILICONFLOW_BASE_URL,
serverBaseUrl,
"https://api.siliconflow.cn/v1",
)
const siliconflowProvider = createOpenAI({
@@ -844,11 +910,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "sglang": {
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
const apiKey = resolveApiKey(overrides, "SGLANG_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"SGLANG_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.SGLANG_BASE_URL,
serverBaseUrl,
)
const sglangProvider = createOpenAI({
@@ -957,11 +1027,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// Vercel AI Gateway - unified access to multiple AI providers
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
// See: https://vercel.com/ai-gateway
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
const apiKey = resolveApiKey(overrides, "AI_GATEWAY_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"AI_GATEWAY_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.AI_GATEWAY_BASE_URL,
serverBaseUrl,
)
// Only use custom configuration if explicitly set (local dev or custom Gateway)
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
@@ -993,11 +1067,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "doubao": {
const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
const apiKey = resolveApiKey(overrides, "DOUBAO_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"DOUBAO_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.DOUBAO_BASE_URL,
serverBaseUrl,
"https://ark.cn-beijing.volces.com/api/v3",
)
const lowerModelId = modelId.toLowerCase()
@@ -1022,11 +1100,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "modelscope": {
const apiKey = overrides?.apiKey || process.env.MODELSCOPE_API_KEY
const apiKey = resolveApiKey(overrides, "MODELSCOPE_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"MODELSCOPE_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.MODELSCOPE_BASE_URL,
serverBaseUrl,
"https://api-inference.modelscope.cn/v1",
)
const modelscopeProvider = createOpenAI({
+4 -1
View File
@@ -305,10 +305,13 @@
"noModelsFound": "No models found.",
"default": "Default",
"serverDefault": "Server Default",
"serverModels": "Server Models",
"userModels": "User Models",
"configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown",
"showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated"
"unvalidatedModelWarning": "This model has not been validated",
"serverDefaultModel": "Server default model"
}
}
+4 -1
View File
@@ -305,10 +305,13 @@
"noModelsFound": "モデルが見つかりません。",
"default": "デフォルト",
"serverDefault": "サーバーデフォルト",
"serverModels": "サーバーモデル",
"userModels": "ユーザーモデル",
"configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示",
"showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません"
"unvalidatedModelWarning": "このモデルは検証されていません",
"serverDefaultModel": "サーバーデフォルトモデル"
}
}
+4 -1
View File
@@ -305,10 +305,13 @@
"noModelsFound": "未找到模型。",
"default": "默认",
"serverDefault": "服务器默认",
"serverModels": "服务器模型",
"userModels": "用户模型",
"configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型",
"showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证"
"unvalidatedModelWarning": "此模型尚未验证",
"serverDefaultModel": "服务器默认模型"
}
}
+151
View File
@@ -0,0 +1,151 @@
import fs from "fs/promises"
import path from "path"
import { z } from "zod"
import type { ProviderName } from "@/lib/types/model-config"
import { PROVIDER_INFO } from "@/lib/types/model-config"
export const ProviderNameSchema: z.ZodType<ProviderName> = z
.string()
.refine((val): val is ProviderName => val in PROVIDER_INFO, {
message: "Invalid provider name",
})
export const ServerProviderSchema = z.object({
name: z.string().min(1),
provider: ProviderNameSchema,
models: z.array(z.string().min(1)),
// Optional: custom environment variable name for API key
// e.g., "OPENAI_API_KEY_TEAM_A" instead of default "OPENAI_API_KEY"
apiKeyEnv: z.string().min(1).optional(),
// Optional: custom environment variable name for base URL
baseUrlEnv: z.string().min(1).optional(),
// Optional: mark the first model in this provider as the default
default: z.boolean().optional(),
})
export const ServerModelsConfigSchema = z.object({
providers: z.array(ServerProviderSchema),
})
export type ServerProviderConfig = z.infer<typeof ServerProviderSchema>
export type ServerModelsConfig = z.infer<typeof ServerModelsConfigSchema>
export interface FlattenedServerModel {
id: string // "server:<slugified-name>:<modelId>" - name ensures uniqueness for multiple API keys per provider
modelId: string
provider: ProviderName
providerLabel: string
isDefault: boolean
// Custom env var names for credentials (optional)
apiKeyEnv?: string
baseUrlEnv?: string
}
/**
* Convert provider name to URL-safe slug for use in model ID
* e.g., "OpenAI Production" → "openai-production"
*/
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
}
function getConfigPath(): string {
const custom = process.env.AI_MODELS_CONFIG_PATH
if (custom && custom.trim().length > 0) return custom
return path.join(process.cwd(), "ai-models.json")
}
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
const envConfig = process.env.AI_MODELS_CONFIG
if (envConfig && envConfig.trim().length > 0) {
try {
const json = JSON.parse(envConfig)
return ServerModelsConfigSchema.parse(json)
} catch (err) {
console.error(
"[server-model-config] Failed to parse AI_MODELS_CONFIG:",
err,
)
return null
}
}
// Priority 2: ai-models.json file
const configPath = getConfigPath()
try {
const jsonStr = await fs.readFile(configPath, "utf8")
const json = JSON.parse(jsonStr)
return ServerModelsConfigSchema.parse(json)
} catch (err: any) {
if (err?.code === "ENOENT") {
return null
}
console.error(
"[server-model-config] Failed to load ai-models.json:",
err,
)
return null
}
}
export async function loadFlattenedServerModels(): Promise<
FlattenedServerModel[]
> {
const cfg = await loadRawServerModelsConfig()
if (!cfg) return []
const defaultProvider = process.env.AI_PROVIDER as ProviderName | undefined
const defaultModelId = process.env.AI_MODEL
const flattened: FlattenedServerModel[] = []
for (const p of cfg.providers) {
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)
for (const modelId of p.models) {
const id = `server:${nameSlug}:${modelId}`
// Default model priority:
// 1. From ai-models.json: first model of provider with default: true
// 2. From env vars: AI_MODEL matches (legacy behavior)
const isDefault =
(p.default === true && modelId === p.models[0]) ||
(!!defaultModelId &&
modelId === defaultModelId &&
(!defaultProvider || defaultProvider === p.provider))
flattened.push({
id,
modelId,
provider: p.provider,
providerLabel,
isDefault,
apiKeyEnv: p.apiKeyEnv,
baseUrlEnv: p.baseUrlEnv,
})
}
}
return flattened
}
/**
* Find a server model by its ID (format: "server:<slugified-name>:<modelId>")
* Returns the model config including apiKeyEnv/baseUrlEnv if configured
*/
export async function findServerModelById(
modelId: string,
): Promise<FlattenedServerModel | null> {
if (!modelId.startsWith("server:")) return null
const models = await loadFlattenedServerModels()
return models.find((m) => m.id === modelId) || null
}
+11 -2
View File
@@ -54,7 +54,7 @@ export interface MultiModelConfig {
// Flattened model for dropdown display
export interface FlattenedModel {
id: string // Model config UUID
id: string // Model config UUID or synthetic server ID (e.g., "server:provider:modelId")
modelId: string // Actual model ID
provider: ProviderName
providerLabel: string // Provider display name
@@ -69,6 +69,13 @@ export interface FlattenedModel {
vertexApiKey?: string // Express Mode API key
validated?: boolean // Has this model been validated
// Source of this model config: user-defined (client) or server-defined
source?: "user" | "server"
// Whether this model is the server default (matches AI_MODEL env var)
isDefault?: boolean
// Custom env var names for server models (allows multiple API keys per provider)
apiKeyEnv?: string
baseUrlEnv?: string
}
// Provider metadata
@@ -307,7 +314,7 @@ export function createModelConfig(modelId: string): ModelConfig {
}
}
// Get all models as flattened list for dropdown
// Get all models as flattened list for dropdown (user-defined only)
export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
const models: FlattenedModel[] = []
@@ -333,6 +340,8 @@ export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
vertexApiKey: provider.vertexApiKey,
validated: model.validated,
source: "user",
isDefault: false,
})
}
}
+87 -13
View File
@@ -15,7 +15,7 @@
"@ai-sdk/deepseek": "^2.0.0",
"@ai-sdk/gateway": "^3.0.0",
"@ai-sdk/google": "^3.0.0",
"@ai-sdk/google-vertex": "^4.0.11",
"@ai-sdk/google-vertex": "^4.0.16",
"@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.1",
"@aws-sdk/client-dynamodb": "^3.957.0",
@@ -207,13 +207,13 @@
}
},
"node_modules/@ai-sdk/google": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.6.tgz",
"integrity": "sha512-Nr7E+ouWd/bKO9SFlgLnJJ1+fiGHC07KAeFr08faT+lvkECWlxVox3aL0dec8uCgBDUghYbq7f4S5teUrCc+QQ==",
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.9.tgz",
"integrity": "sha512-whRdK0gCZL92UbEHdmQ6edm3cp5ZZSqwE79AIvTEaJR+BeaMUJchTxz/I5w9l3EHGOiDxrKYssklqO3z45KGXg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.2",
"@ai-sdk/provider-utils": "4.0.4"
"@ai-sdk/provider": "3.0.3",
"@ai-sdk/provider-utils": "4.0.7"
},
"engines": {
"node": ">=18"
@@ -223,15 +223,15 @@
}
},
"node_modules/@ai-sdk/google-vertex": {
"version": "4.0.11",
"resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-4.0.11.tgz",
"integrity": "sha512-G4pdEboGAp9fEdGfImSB5psOnhKudrsm170ZsTJ1L14WtSRS5S+LgMrOedpoDW1xyTsK7Y4FYnyhLBq1hHJdXA==",
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-4.0.16.tgz",
"integrity": "sha512-paY4NGCaqMbd0kssK27ssTMggDybNPIFudJ99QsdzRDJ/Kgo0EjeXZn7G/CoBY/Snmf0fA0PEZW34yV9kloFCQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/anthropic": "3.0.9",
"@ai-sdk/google": "3.0.6",
"@ai-sdk/provider": "3.0.2",
"@ai-sdk/provider-utils": "4.0.4",
"@ai-sdk/anthropic": "3.0.14",
"@ai-sdk/google": "3.0.9",
"@ai-sdk/provider": "3.0.3",
"@ai-sdk/provider-utils": "4.0.7",
"google-auth-library": "^10.5.0"
},
"engines": {
@@ -241,6 +241,80 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google-vertex/node_modules/@ai-sdk/anthropic": {
"version": "3.0.14",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.14.tgz",
"integrity": "sha512-71BaVg60FM6tN0JaRY7kRb/6qZtHi5R9PFF3NES+kqonY3nVD4PCPP8DoMb/tqxC7f/XezlCqsHrveT2mGfi3A==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.3",
"@ai-sdk/provider-utils": "4.0.7"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google-vertex/node_modules/@ai-sdk/provider": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.3.tgz",
"integrity": "sha512-qGPYdoAuECaUXPrrz0BPX1SacZQuJ6zky0aakxpW89QW1hrY0eF4gcFm/3L9Pk8C5Fwe+RvBf2z7ZjDhaPjnlg==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/google-vertex/node_modules/@ai-sdk/provider-utils": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.7.tgz",
"integrity": "sha512-ItzTdBxRLieGz1GHPwl9X3+HKfwTfFd9MdIa91aXRnOjUVRw68ENjAGKm3FcXGsBLkXDLaFWgjbTVdXe2igs2w==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.3",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.3.tgz",
"integrity": "sha512-qGPYdoAuECaUXPrrz0BPX1SacZQuJ6zky0aakxpW89QW1hrY0eF4gcFm/3L9Pk8C5Fwe+RvBf2z7ZjDhaPjnlg==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider-utils": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.7.tgz",
"integrity": "sha512-ItzTdBxRLieGz1GHPwl9X3+HKfwTfFd9MdIa91aXRnOjUVRw68ENjAGKm3FcXGsBLkXDLaFWgjbTVdXe2igs2w==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.3",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.7.tgz",
+1 -1
View File
@@ -37,7 +37,7 @@
"@ai-sdk/deepseek": "^2.0.0",
"@ai-sdk/gateway": "^3.0.0",
"@ai-sdk/google": "^3.0.0",
"@ai-sdk/google-vertex": "^4.0.11",
"@ai-sdk/google-vertex": "^4.0.16",
"@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.1",
"@aws-sdk/client-dynamodb": "^3.957.0",
+85
View File
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it } from "vitest"
import {
loadFlattenedServerModels,
type ServerModelsConfig,
ServerModelsConfigSchema,
} from "@/lib/server-model-config"
const ORIGINAL_ENV = { ...process.env }
afterEach(() => {
process.env.AI_PROVIDER = ORIGINAL_ENV.AI_PROVIDER
process.env.AI_MODEL = ORIGINAL_ENV.AI_MODEL
process.env.AI_MODELS_CONFIG_PATH = ORIGINAL_ENV.AI_MODELS_CONFIG_PATH
process.env.AI_MODELS_CONFIG = ORIGINAL_ENV.AI_MODELS_CONFIG
})
describe("ServerModelsConfigSchema", () => {
it("accepts valid provider names", () => {
const config: ServerModelsConfig = {
providers: [
{
name: "OpenAI Server",
provider: "openai",
models: ["gpt-4o"],
},
],
}
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
})
it("rejects invalid provider names", () => {
const invalidConfig = {
providers: [
{
name: "Invalid Provider",
// Cast to any so we can verify runtime validation, not TypeScript
provider: "invalid-provider" as any,
models: ["model-1"],
},
],
}
expect(() =>
ServerModelsConfigSchema.parse(invalidConfig as any),
).toThrow()
})
})
describe("loadFlattenedServerModels", () => {
it("returns empty array when config file is missing", async () => {
// Point to a non-existent config path so fs.readFile throws ENOENT
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
const models = await loadFlattenedServerModels()
expect(models).toEqual([])
})
it("flattens providers and marks default model from env var config", async () => {
// Use AI_MODELS_CONFIG env var instead of file
const config: ServerModelsConfig = {
providers: [
{
name: "OpenAI Server",
provider: "openai",
models: ["gpt-4o", "gpt-4o-mini"],
default: true,
},
],
}
process.env.AI_MODELS_CONFIG = JSON.stringify(config)
process.env.AI_MODELS_CONFIG_PATH = "" // Clear file path
const models = await loadFlattenedServerModels()
expect(models.length).toBe(2)
const defaults = models.filter((m) => m.isDefault)
expect(defaults.length).toBe(1)
const defaultModel = defaults[0]
expect(defaultModel.provider).toBe("openai")
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
})
})