From cf9638b23150162b18a42f6e89d83c4b1bfbaa8b Mon Sep 17 00:00:00 2001 From: ElshadHu Date: Sat, 10 Jan 2026 22:49:21 -0500 Subject: [PATCH 001/185] feat: add ai-sdk/google-vertex dependency --- package-lock.json | 279 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + 2 files changed, 274 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index f3cc9e05..1d13704e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +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/openai": "^3.0.0", "@ai-sdk/react": "^3.0.1", "@aws-sdk/client-dynamodb": "^3.957.0", @@ -221,6 +222,25 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "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==", + "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", + "google-auth-library": "^10.5.0" + }, + "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", @@ -9768,7 +9788,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -13467,7 +13486,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -14091,7 +14109,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -14127,6 +14144,15 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -14300,6 +14326,12 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", @@ -15293,6 +15325,15 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-urls": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", @@ -15800,6 +15841,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/eciesjs": { "version": "0.4.16", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.16.tgz", @@ -17282,6 +17332,38 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fetch-blob/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -17488,6 +17570,18 @@ "node": ">= 12.20" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -17621,6 +17715,112 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/gaxios/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gaxios/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/gaxios/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -17852,6 +18052,33 @@ "dev": true, "license": "MIT" }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -17904,6 +18131,19 @@ "dev": true, "license": "MIT" }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gzip-size": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", @@ -18208,7 +18448,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -19145,7 +19384,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -19278,6 +19516,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -19361,6 +19608,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -23742,7 +24010,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", diff --git a/package.json b/package.json index 615524d3..445183eb 100644 --- a/package.json +++ b/package.json @@ -37,6 +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/openai": "^3.0.0", "@ai-sdk/react": "^3.0.1", "@aws-sdk/client-dynamodb": "^3.957.0", From 8f538193dd0742985bf5731b86fc49fab79bd5cf Mon Sep 17 00:00:00 2001 From: ElshadHu Date: Sun, 11 Jan 2026 00:11:30 -0500 Subject: [PATCH 002/185] feat: add Google Vertex AI as new provider --- env.example | 14 ++++++++-- lib/ai-providers.ts | 59 ++++++++++++++++++++++++++++++++++++++- lib/types/model-config.ts | 13 +++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/env.example b/env.example index fcd63f08..447f420f 100644 --- a/env.example +++ b/env.example @@ -1,6 +1,6 @@ # AI Provider Configuration # AI_PROVIDER: Which provider to use -# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway +# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway # Default: bedrock AI_PROVIDER=bedrock @@ -38,7 +38,17 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 # GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter # Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true) # GOOGLE_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking) -# GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high) +# GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (minimal/low/medium/high) + +# Google Vertex AI Configuration (Enterprise GCP) +# For enterprise users needing data residency, VPC Service Controls, or GCP integration +# Uses GCP service account authentication instead of API keys +# GOOGLE_VERTEX_PROJECT=your-gcp-project-id # Required: GCP project ID +# GOOGLE_VERTEX_LOCATION=us-central1 # Optional: defaults to us-central1 +# GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json # Path to service account key file +# Note: When running on GCP (Cloud Run, GKE, Compute Engine), uses Application Default Credentials automatically +# GOOGLE_VERTEX_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (1024-100000) +# GOOGLE_VERTEX_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (minimal/low/medium/high) # Azure OpenAI Configuration # Configure endpoint using ONE of these methods: diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index 918dafe5..8d588261 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -4,6 +4,7 @@ import { azure, createAzure } from "@ai-sdk/azure" import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" import { createGateway, gateway } from "@ai-sdk/gateway" import { createGoogleGenerativeAI, google } from "@ai-sdk/google" +import { createVertex } from "@ai-sdk/google-vertex" import { createOpenAI, openai } from "@ai-sdk/openai" import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { createOpenRouter } from "@openrouter/ai-sdk-provider" @@ -38,6 +39,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [ "openai", "anthropic", "google", + "vertexai", "azure", "bedrock", "openrouter", @@ -95,7 +97,9 @@ function parseIntSafe( * - ANTHROPIC_THINKING_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000) * - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled) * - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000) - * - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high) + * - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (minimal/low/medium/high) + * - GOOGLE_VERTEX_THINKING_BUDGET: Vertex AI Gemini 2.5 thinking budget in tokens (1024-100000) + * - GOOGLE_VERTEX_THINKING_LEVEL: Vertex AI Gemini 3 thinking level (minimal/low/medium/high) * - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high) * - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed) * - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000) @@ -260,7 +264,39 @@ function buildProviderOptions( } break } + case "vertexai": { + // Google Vertex supports the same thinking config as standard Google provider + const thinkingBudget = parseIntSafe( + process.env.GOOGLE_VERTEX_THINKING_BUDGET, + "GOOGLE_VERTEX_THINKING_BUDGET", + 1024, + 100000, + ) + const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL + if ( + modelId && + (modelId.includes("gemini-2") || + modelId.includes("gemini-3") || + modelId.includes("gemini2") || + modelId.includes("gemini3")) + ) { + const thinkingConfig: Record = { + includeThoughts: true, + } + if (thinkingBudget) { + thinkingConfig.thinkingBudget = thinkingBudget + } else if (thinkingLevel) { + thinkingConfig.thinkingLevel = thinkingLevel as + | "minimal" + | "low" + | "medium" + | "high" + } + options.google = { thinkingConfig } + } + break + } case "azure": { const reasoningEffort = process.env.AZURE_REASONING_EFFORT const reasoningSummary = process.env.AZURE_REASONING_SUMMARY @@ -362,6 +398,7 @@ const PROVIDER_ENV_VARS: Record = { openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", google: "GOOGLE_GENERATIVE_AI_API_KEY", + vertexai: null, // Uses GOOGLE_APPLICATION_CREDENTIALS or IAM role azure: "AZURE_API_KEY", ollama: null, // No credentials needed for local Ollama openrouter: "OPENROUTER_API_KEY", @@ -644,6 +681,26 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { } break } + case "vertexai": { + // Google Vertex AI uses GCP service account authentication, not API keys + // Auth is handled via GOOGLE_APPLICATION_CREDENTIALS env var or GCP default credentials + const vertexProject = process.env.GOOGLE_VERTEX_PROJECT + const vertexLocation = + process.env.GOOGLE_VERTEX_LOCATION || "us-central1" + + if (!vertexProject) { + throw new Error( + "GOOGLE_VERTEX_PROJECT environment variable is required for vertexai provider.", + ) + } + + const vertexProvider = createVertex({ + project: vertexProject, + location: vertexLocation, + }) + model = vertexProvider(modelId) + break + } case "azure": { const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY diff --git a/lib/types/model-config.ts b/lib/types/model-config.ts index edb66822..00eecb8a 100644 --- a/lib/types/model-config.ts +++ b/lib/types/model-config.ts @@ -4,6 +4,7 @@ export type ProviderName = | "openai" | "anthropic" | "google" + | "vertexai" | "azure" | "bedrock" | "ollama" @@ -75,6 +76,7 @@ export const PROVIDER_INFO: Record< defaultBaseUrl: "https://api.anthropic.com/v1", }, google: { label: "Google" }, + vertexai: { label: "Google Vertex AI" }, azure: { label: "Azure OpenAI" }, bedrock: { label: "Amazon Bedrock" }, ollama: { @@ -157,6 +159,17 @@ export const SUGGESTED_MODELS: Partial> = { // Legacy "gemini-pro", ], + vertexai: [ + // Gemini 2.5 series + "gemini-2.5-pro", + "gemini-2.5-flash", + // Gemini 2.0 series + "gemini-2.0-flash", + "gemini-2.0-flash-exp", + // Gemini 1.5 series + "gemini-1.5-pro", + "gemini-1.5-flash", + ], azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"], bedrock: [ // Anthropic Claude From 6a20f03805cc55e9ab0d4d428819fc0dd6a0191b Mon Sep 17 00:00:00 2001 From: ElshadHu Date: Sun, 11 Jan 2026 00:49:43 -0500 Subject: [PATCH 003/185] feat: add Vertex AI UI support and validation endpoint --- app/api/validate-model/route.ts | 32 +++++++++++++++++++++++++++++- components/model-config-dialog.tsx | 8 ++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/api/validate-model/route.ts b/app/api/validate-model/route.ts index b8b258e6..dc961f15 100644 --- a/app/api/validate-model/route.ts +++ b/app/api/validate-model/route.ts @@ -3,6 +3,7 @@ import { createAnthropic } from "@ai-sdk/anthropic" import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" import { createGateway } from "@ai-sdk/gateway" import { createGoogleGenerativeAI } from "@ai-sdk/google" +import { createVertex } from "@ai-sdk/google-vertex" import { createOpenAI } from "@ai-sdk/openai" import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { generateText } from "ai" @@ -121,7 +122,12 @@ export async function POST(req: Request) { { status: 400 }, ) } - } else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) { + } else if ( + provider !== "ollama" && + provider !== "edgeone" && + provider !== "vertexai" && + !apiKey + ) { return NextResponse.json( { valid: false, error: "API key is required" }, { status: 400 }, @@ -158,6 +164,30 @@ export async function POST(req: Request) { break } + case "vertexai": { + // Vertex AI uses GCP service account authentication via environment variables + const vertexProject = process.env.GOOGLE_VERTEX_PROJECT + const vertexLocation = + process.env.GOOGLE_VERTEX_LOCATION || "us-central1" + + if (!vertexProject) { + return NextResponse.json( + { + valid: false, + error: "GOOGLE_VERTEX_PROJECT environment variable is required", + }, + { status: 400 }, + ) + } + + const vertex = createVertex({ + project: vertexProject, + location: vertexLocation, + }) + model = vertex(modelId) + break + } + case "azure": { const azure = createOpenAI({ apiKey, diff --git a/components/model-config-dialog.tsx b/components/model-config-dialog.tsx index 0c3aae37..eff9d1b2 100644 --- a/components/model-config-dialog.tsx +++ b/components/model-config-dialog.tsx @@ -78,6 +78,7 @@ const PROVIDER_LOGO_MAP: Record = { sglang: "openai", // SGLang is OpenAI-compatible gateway: "vercel", edgeone: "tencent-cloud", + vertexai: "google", doubao: "bytedance", modelscope: "modelscope", } @@ -280,6 +281,7 @@ export function ModelConfigDialog({ // Check credentials based on provider type const isBedrock = selectedProvider.provider === "bedrock" const isEdgeOne = selectedProvider.provider === "edgeone" + const isVertexAI = selectedProvider.provider === "vertexai" if (isBedrock) { if ( !selectedProvider.awsAccessKeyId || @@ -288,7 +290,7 @@ export function ModelConfigDialog({ ) { return } - } else if (!isEdgeOne && !selectedProvider.apiKey) { + } else if (!isEdgeOne && !isVertexAI && !selectedProvider.apiKey) { return } @@ -867,7 +869,9 @@ export function ModelConfigDialog({ ) : selectedProvider.provider === - "edgeone" ? ( + "edgeone" || + selectedProvider.provider === + "vertexai" ? (
) : selectedProvider.provider === - "edgeone" || + "vertexai" ? ( + <> + {/* Google Cloud Project ID */} +
+ + + handleProviderUpdate( + "vertexProject", + e.target + .value, + ) + } + placeholder={ + "Enter your GCP Project ID (optional)" + } + className="h-9 font-mono text-xs" + /> +
+ + {/* Location */} +
+ + + handleProviderUpdate( + "vertexLocation", + e.target + .value, + ) + } + placeholder="us-central1" + className="h-9 font-mono text-xs" + /> +

+ Default: us-central1 +

+
+ + ) : selectedProvider.provider === + "ollama" || selectedProvider.provider === - "vertexai" ? ( + "edgeone" ? (
+
+ +
+ {validationStatus === + "error" && + validationError && ( +

+ + { + validationError + } +

+ )}
- {/* Location */} + {/* Base URL (optional) */}
handleProviderUpdate( - "vertexLocation", + "baseUrl", e.target .value, ) } - placeholder="us-central1" + placeholder="Custom endpoint URL" className="h-9 font-mono text-xs" /> -

- Default: us-central1 -

) : selectedProvider.provider === diff --git a/hooks/use-model-config.ts b/hooks/use-model-config.ts index 42d51593..5853eb96 100644 --- a/hooks/use-model-config.ts +++ b/hooks/use-model-config.ts @@ -314,6 +314,8 @@ export function getSelectedAIConfig(): { awsSecretAccessKey: string awsRegion: string awsSessionToken: string + // Vertex AI credentials (Express Mode) + vertexApiKey: string } { const empty = { accessCode: "", @@ -325,6 +327,7 @@ export function getSelectedAIConfig(): { awsSecretAccessKey: "", awsRegion: "", awsSessionToken: "", + vertexApiKey: "", } if (typeof window === "undefined") return empty @@ -347,6 +350,7 @@ export function getSelectedAIConfig(): { awsSecretAccessKey: "", awsRegion: "", awsSessionToken: "", + vertexApiKey: "", } } @@ -379,5 +383,7 @@ export function getSelectedAIConfig(): { awsSecretAccessKey: model.awsSecretAccessKey || "", awsRegion: model.awsRegion || "", awsSessionToken: model.awsSessionToken || "", + // Vertex AI credentials (Express Mode) + vertexApiKey: model.vertexApiKey || "", } } From 04290a53d0693fad39d3de706a504f1ddfeb2cee Mon Sep 17 00:00:00 2001 From: ElshadHu Date: Wed, 14 Jan 2026 03:25:34 -0500 Subject: [PATCH 015/185] Update Docs --- docs/en/ai-providers.md | 15 +++++++++++++++ env.example | 10 ++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/en/ai-providers.md b/docs/en/ai-providers.md index 0df82bf9..ac608933 100644 --- a/docs/en/ai-providers.md +++ b/docs/en/ai-providers.md @@ -33,6 +33,21 @@ Optional custom endpoint: GOOGLE_BASE_URL=https://your-custom-endpoint ``` +### Google Vertex AI (Enterprise GCP) + +Google Vertex AI offers enterprise-grade features and data residency. **Express Mode** allows for simple API key authentication, making it compatible with edge runtimes like Vercel and Cloudflare. + +```bash +GOOGLE_VERTEX_API_KEY=your_api_key +AI_MODEL=gemini-2.0-flash +``` + +Optional custom endpoint: + +```bash +GOOGLE_VERTEX_BASE_URL=https://your-custom-endpoint +``` + ### OpenAI ```bash diff --git a/env.example b/env.example index f849cbaa..f9b6dedd 100644 --- a/env.example +++ b/env.example @@ -42,13 +42,11 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 # Google Vertex AI Configuration (Enterprise GCP) # For enterprise users needing data residency, VPC Service Controls, or GCP integration -# Uses GCP service account authentication instead of API keys -# GOOGLE_VERTEX_PROJECT=your-gcp-project-id # Required: GCP project ID -# GOOGLE_VERTEX_LOCATION=us-central1 # Optional: defaults to us-central1 -# GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json # Path to service account key file -# Note: When running on GCP (Cloud Run, GKE, Compute Engine), uses Application Default Credentials automatically +# GOOGLE_VERTEX_API_KEY= # Required: Express Mode API key +# GOOGLE_VERTEX_BASE_URL=https://... # Optional: Custom endpoint URL +# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true) # GOOGLE_VERTEX_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (1024-100000) -# GOOGLE_VERTEX_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high) +# GOOGLE_VERTEX_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (minimal/low/medium/high) # Azure OpenAI Configuration # Configure endpoint using ONE of these methods: From 4691a7119045cf45f9632e7ca80e9e5b209208b6 Mon Sep 17 00:00:00 2001 From: ElshadHu Date: Wed, 14 Jan 2026 04:00:05 -0500 Subject: [PATCH 016/185] Add base URL + fix thinking --- app/api/validate-model/route.ts | 1 + lib/ai-providers.ts | 34 +++++++++++++++++++-------------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/app/api/validate-model/route.ts b/app/api/validate-model/route.ts index 6bc6717e..99daad75 100644 --- a/app/api/validate-model/route.ts +++ b/app/api/validate-model/route.ts @@ -176,6 +176,7 @@ export async function POST(req: Request) { case "vertexai": { const vertex = createVertex({ apiKey: vertexApiKey, + ...(baseUrl && { baseURL: baseUrl }), }) model = vertex(modelId) break diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index 8f6b21e8..894ba1c1 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -293,19 +293,21 @@ function buildProviderOptions( break } case "vertexai": { - // Google Vertex supports thinking config for thinking-enabled models - // Only models with "thinking" in their name support this feature - const isThinkingModel = modelId?.toLowerCase().includes("thinking") - - if (isThinkingModel) { - const thinkingBudget = parseIntSafe( - process.env.GOOGLE_VERTEX_THINKING_BUDGET, - "GOOGLE_VERTEX_THINKING_BUDGET", - 1024, - 100000, - ) - const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL + const thinkingBudget = parseIntSafe( + process.env.GOOGLE_VERTEX_THINKING_BUDGET, + "GOOGLE_VERTEX_THINKING_BUDGET", + 1024, + 100000, + ) + const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL + if ( + modelId && + (modelId.includes("gemini-2") || + modelId.includes("gemini-3") || + modelId.includes("gemini2") || + modelId.includes("gemini3")) + ) { const thinkingConfig: Record = { includeThoughts: true, } @@ -313,14 +315,17 @@ function buildProviderOptions( const isGemini3 = modelId?.includes("gemini-3") || modelId?.includes("gemini3") + const isGemini25 = + modelId?.includes("2.5") || modelId?.includes("2-5") + if (isGemini3 && thinkingLevel) { - // Gemini 3: Use thinkingLevel (minimal/low/medium/high) + // Vertex AI provider in AI SDK supports more granular levels (minimal/low/medium/high) thinkingConfig.thinkingLevel = thinkingLevel as | "minimal" | "low" | "medium" | "high" - } else if (!isGemini3 && thinkingBudget) { + } else if (isGemini25 && thinkingBudget) { thinkingConfig.thinkingBudget = thinkingBudget } options.google = { thinkingConfig } @@ -532,6 +537,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { if ( overrides?.baseUrl && !overrides?.apiKey && + !(overrides?.provider === "vertexai" && overrides?.vertexApiKey) && overrides?.provider !== "edgeone" ) { throw new Error( From b23b9179a0dc5a05faac2ec68e619ed60f2a9e69 Mon Sep 17 00:00:00 2001 From: Biki Kalita <86558912+Biki-dev@users.noreply.github.com> Date: Thu, 15 Jan 2026 21:28:22 +0530 Subject: [PATCH 017/185] [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 --- .gitignore | 3 +- README.md | 4 + app/api/chat/route.ts | 32 ++- app/api/server-models/route.ts | 14 ++ components/ai-elements/model-selector.tsx | 24 ++ components/chat-panel.tsx | 4 + components/model-selector.tsx | 259 +++++++++++++++------- docs/cn/README_CN.md | 4 + docs/cn/ai-providers.md | 57 +++++ docs/en/ai-providers.md | 57 +++++ docs/en/docker.md | 21 ++ docs/ja/README_JA.md | 4 + docs/ja/ai-providers.md | 57 +++++ env.example | 5 + hooks/use-model-config.ts | 104 ++++++++- lib/ai-providers.ts | 134 ++++++++--- lib/i18n/dictionaries/en.json | 5 +- lib/i18n/dictionaries/ja.json | 5 +- lib/i18n/dictionaries/zh.json | 5 +- lib/server-model-config.ts | 151 +++++++++++++ lib/types/model-config.ts | 13 +- package-lock.json | 100 +++++++-- package.json | 2 +- tests/unit/server-model-config.test.ts | 85 +++++++ 24 files changed, 1021 insertions(+), 128 deletions(-) create mode 100644 app/api/server-models/route.ts create mode 100644 lib/server-model-config.ts create mode 100644 tests/unit/server-model-config.test.ts diff --git a/.gitignore b/.gitignore index ef02da91..02046e98 100644 --- a/.gitignore +++ b/.gitignore @@ -68,4 +68,5 @@ CLAUDE.md # edgeone .edgeone -opencode.json \ No newline at end of file +opencode.json +ai-models.json diff --git a/README.md b/README.md index 88b93c78..255706cc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index b4d35d50..ed6f7467 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -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 { // 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 { // 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 { 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 { // 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) diff --git a/app/api/server-models/route.ts b/app/api/server-models/route.ts new file mode 100644 index 00000000..49ea12bb --- /dev/null +++ b/app/api/server-models/route.ts @@ -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, + }) +} diff --git a/components/ai-elements/model-selector.tsx b/components/ai-elements/model-selector.tsx index 1b71cb70..ff771c8b 100644 --- a/components/ai-elements/model-selector.tsx +++ b/components/ai-elements/model-selector.tsx @@ -169,3 +169,27 @@ export const ModelSelectorName = ({ }: ModelSelectorNameProps) => ( ) + +export type ModelSelectorSectionHeaderProps = { + icon: ReactNode + label: string + className?: string +} + +export const ModelSelectorSectionHeader = ({ + icon, + label, + className, +}: ModelSelectorSectionHeaderProps) => ( +
+ + {label} +
+) diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index d92f7fc6..ba601988 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -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", }), diff --git a/components/model-selector.tsx b/components/model-selector.tsx index 3cf9aa2e..68cd1432 100644 --- a/components/model-selector.tsx +++ b/components/model-selector.tsx @@ -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} - {/* Server Default Option */} - - - - - - {dict.modelConfig.serverDefault} - - - - - {/* Configured Models by Provider */} - {Array.from(groupedModels.entries()).map( - ([ - providerLabel, - { provider, models: providerModels }, - ]) => ( - - {providerModels.map((model) => ( - - handleSelect(model.id) - } - className="cursor-pointer" + + + + {dict.modelConfig.serverDefault} + + + + )} + + {/* Server Models Section */} + {serverModels.length > 0 && ( + <> + } + label={dict.modelConfig.serverModels} + /> + {Array.from(groupedServerModels.entries()).map( + ([ + providerLabel, + { provider, models: providerModels }, + ]) => ( + - - - - {model.modelId} - - {model.validated !== true && ( - ( + + handleSelect(model.id) } + className="cursor-pointer" > - - - )} - - ))} - - ), + + + + {model.modelId} + + {model.isDefault && ( + + { + dict.modelConfig + .default + } + + )} + + ))} + + ), + )} + + )} + + {/* User Models Section */} + {userModels.length > 0 && ( + <> + {serverModels.length > 0 && ( + + )} + } + label={dict.modelConfig.userModels} + /> + {Array.from(groupedUserModels.entries()).map( + ([ + providerLabel, + { provider, models: providerModels }, + ]) => ( + + {providerModels.map((model) => ( + + handleSelect(model.id) + } + className="cursor-pointer" + > + + + + {model.modelId} + + {model.validated !== + true && ( + + + + )} + + ))} + + ), + )} + )} {/* Configure Option */} @@ -283,7 +392,7 @@ export function ModelSelector({ diff --git a/docs/cn/README_CN.md b/docs/cn/README_CN.md index 17ef5645..3f561cc0 100644 --- a/docs/cn/README_CN.md +++ b/docs/cn/README_CN.md @@ -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 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。 diff --git a/docs/cn/ai-providers.md b/docs/cn/ai-providers.md index fe2de2af..432fa9a1 100644 --- a/docs/cn/ai-providers.md +++ b/docs/cn/ai-providers.md @@ -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)的长文本。 diff --git a/docs/en/ai-providers.md b/docs/en/ai-providers.md index ac608933..74122da5 100644 --- a/docs/en/ai-providers.md +++ b/docs/en/ai-providers.md @@ -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). diff --git a/docs/en/docker.md b/docs/en/docker.md index d0e6d360..57ca3768 100644 --- a/docs/en/docker.md +++ b/docs/en/docker.md @@ -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. diff --git a/docs/ja/README_JA.md b/docs/ja/README_JA.md index 00f9a4e3..0a4258af 100644 --- a/docs/ja/README_JA.md +++ b/docs/ja/README_JA.md @@ -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ダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。 diff --git a/docs/ja/ai-providers.md b/docs/ja/ai-providers.md index a7ad9311..fd5f5065 100644 --- a/docs/ja/ai-providers.md +++ b/docs/ja/ai-providers.md @@ -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)を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。 diff --git a/env.example b/env.example index f9b6dedd..aab90e3b 100644 --- a/env.example +++ b/env.example @@ -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) diff --git a/hooks/use-model-config.ts b/hooks/use-model-config.ts index 5853eb96..9b480b45 100644 --- a/hooks/use-model-config.ts +++ b/hooks/use-model-config.ts @@ -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(createEmptyConfig) const [isLoaded, setIsLoaded] = useState(false) + const [serverModels, setServerModels] = useState([]) + 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::") + // 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 || "", } diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index 894ba1c1..f63bfb4a 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -34,6 +34,9 @@ export interface ClientOverrides { vertexApiKey?: string | null // Express Mode API key // Custom headers (e.g., for EdgeOne cookie auth) headers?: Record + // 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({ diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json index 745c9bb1..5c62b580 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -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" } } diff --git a/lib/i18n/dictionaries/ja.json b/lib/i18n/dictionaries/ja.json index 9f6464d7..57bb6ba5 100644 --- a/lib/i18n/dictionaries/ja.json +++ b/lib/i18n/dictionaries/ja.json @@ -305,10 +305,13 @@ "noModelsFound": "モデルが見つかりません。", "default": "デフォルト", "serverDefault": "サーバーデフォルト", + "serverModels": "サーバーモデル", + "userModels": "ユーザーモデル", "configureModels": "モデルを設定...", "onlyVerifiedShown": "検証済みのモデルのみ表示", "showUnvalidatedModels": "未検証のモデルを表示", "allModelsShown": "すべてのモデルを表示(未検証を含む)", - "unvalidatedModelWarning": "このモデルは検証されていません" + "unvalidatedModelWarning": "このモデルは検証されていません", + "serverDefaultModel": "サーバーデフォルトモデル" } } diff --git a/lib/i18n/dictionaries/zh.json b/lib/i18n/dictionaries/zh.json index 5fd6df0e..9d96f5c3 100644 --- a/lib/i18n/dictionaries/zh.json +++ b/lib/i18n/dictionaries/zh.json @@ -305,10 +305,13 @@ "noModelsFound": "未找到模型。", "default": "默认", "serverDefault": "服务器默认", + "serverModels": "服务器模型", + "userModels": "用户模型", "configureModels": "配置模型...", "onlyVerifiedShown": "仅显示已验证的模型", "showUnvalidatedModels": "显示未验证的模型", "allModelsShown": "显示所有模型(包括未验证的)", - "unvalidatedModelWarning": "此模型尚未验证" + "unvalidatedModelWarning": "此模型尚未验证", + "serverDefaultModel": "服务器默认模型" } } diff --git a/lib/server-model-config.ts b/lib/server-model-config.ts new file mode 100644 index 00000000..4e777b0c --- /dev/null +++ b/lib/server-model-config.ts @@ -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 = 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 +export type ServerModelsConfig = z.infer + +export interface FlattenedServerModel { + id: string // "server::" - 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 { + // 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::") + * Returns the model config including apiKeyEnv/baseUrlEnv if configured + */ +export async function findServerModelById( + modelId: string, +): Promise { + if (!modelId.startsWith("server:")) return null + + const models = await loadFlattenedServerModels() + return models.find((m) => m.id === modelId) || null +} diff --git a/lib/types/model-config.ts b/lib/types/model-config.ts index 27aaeeb5..9673138a 100644 --- a/lib/types/model-config.ts +++ b/lib/types/model-config.ts @@ -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, }) } } diff --git a/package-lock.json b/package-lock.json index 1d13704e..1f90080a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 445183eb..c5b83164 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/unit/server-model-config.test.ts b/tests/unit/server-model-config.test.ts new file mode 100644 index 00000000..7cf56a8d --- /dev/null +++ b/tests/unit/server-model-config.test.ts @@ -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 + }) +}) From 85be3a25610cb50663e4b980466a2c529162b440 Mon Sep 17 00:00:00 2001 From: broBinChen <139344558+broBinChen@users.noreply.github.com> Date: Fri, 16 Jan 2026 18:55:02 +0800 Subject: [PATCH 018/185] improve: disable save button when diagram is empty (#591) --- components/chat-input.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/chat-input.tsx b/components/chat-input.tsx index 6848dc58..7938c910 100644 --- a/components/chat-input.tsx +++ b/components/chat-input.tsx @@ -27,6 +27,7 @@ import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { STORAGE_KEYS } from "@/lib/storage" import type { FlattenedModel } from "@/lib/types/model-config" import { extractUrlContent, type UrlData } from "@/lib/url-utils" +import { isRealDiagram } from "@/lib/utils" import { FilePreviewList } from "./file-preview-list" const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB @@ -181,6 +182,7 @@ export function ChatInput({ }: ChatInputProps) { const dict = useDictionary() const { + chartXML, diagramHistory, saveDiagramToFile, showSaveDialog, @@ -454,7 +456,7 @@ export function ChatInput({ variant="ghost" size="sm" onClick={() => setShowSaveDialog(true)} - disabled={isDisabled} + disabled={isDisabled || !isRealDiagram(chartXML)} tooltipContent={dict.chat.saveDiagram} className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground" > From 1ad6575e04a3a07cad1bf86070b3045279a0654e Mon Sep 17 00:00:00 2001 From: broBinChen <139344558+broBinChen@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:57:38 +0800 Subject: [PATCH 019/185] fix: add missing @opentelemetry/api dependency (#592) --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index c5b83164..f83a69c3 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@next/third-parties": "^16.0.6", "@opennextjs/cloudflare": "1.14.8", "@openrouter/ai-sdk-provider": "^1.5.4", + "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.209.0", "@opentelemetry/sdk-trace-node": "^2.2.0", "@radix-ui/react-alert-dialog": "^1.1.15", From 5007c7bbe4b72b6df4a2a5f33d1dde2cc80ceac9 Mon Sep 17 00:00:00 2001 From: Dayuan Jiang <34411969+DayuanJiang@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:45:46 +0900 Subject: [PATCH 020/185] fix(mcp): allow edit_diagram immediately after create_new_diagram (#595) After create_new_diagram, edit_diagram would fail with "You must call get_diagram first" because lastGetDiagramTime was never set (remained 0 from session init). This fix sets lastGetDiagramTime after creating a diagram, allowing immediate edits. Fixes #534, Fixes #589 --- packages/mcp-server/package.json | 2 +- packages/mcp-server/src/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index d425a4fe..d1bb4d63 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.1.12", + "version": "0.1.13", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "type": "module", "main": "dist/index.js", diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 2b604b1d..af9efbcf 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -260,6 +260,7 @@ COMMON STYLES: // Update session state currentSession.xml = xml currentSession.version++ + currentSession.lastGetDiagramTime = Date.now() // Push to embedded server state setState(currentSession.id, xml) From 44699940ce7cbbf95b02ce50824cc58a4ba77036 Mon Sep 17 00:00:00 2001 From: Biki Kalita <86558912+Biki-dev@users.noreply.github.com> Date: Thu, 15 Jan 2026 18:32:18 +0530 Subject: [PATCH 021/185] Set focus to input area after clicking Start Fresh Chat --- components/chat-input.tsx | 734 ++++++++++++++++++++------------------ components/chat-panel.tsx | 11 +- 2 files changed, 391 insertions(+), 354 deletions(-) diff --git a/components/chat-input.tsx b/components/chat-input.tsx index 7938c910..88c75ebe 100644 --- a/components/chat-input.tsx +++ b/components/chat-input.tsx @@ -9,7 +9,14 @@ import { Send, } from "lucide-react" import type React from "react" -import { useCallback, useEffect, useRef, useState } from "react" +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react" import { toast } from "sonner" import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ErrorToast } from "@/components/error-toast" @@ -162,119 +169,201 @@ interface ChatInputProps { onConfigureModels?: () => void } -export function ChatInput({ - input, - status, - onSubmit, - onChange, - files = [], - onFileChange = () => {}, - pdfData = new Map(), - urlData, - onUrlChange, - sessionId, - error = null, - models = [], - selectedModelId, - onModelSelect = () => {}, - showUnvalidatedModels = false, - onConfigureModels = () => {}, -}: ChatInputProps) { - const dict = useDictionary() - const { - chartXML, - diagramHistory, - saveDiagramToFile, - showSaveDialog, - setShowSaveDialog, - } = useDiagram() +export interface ChatInputRef { + focus: () => void +} - const textareaRef = useRef(null) - const fileInputRef = useRef(null) - const [isDragging, setIsDragging] = useState(false) - const [showHistory, setShowHistory] = useState(false) - const [showUrlDialog, setShowUrlDialog] = useState(false) - const [isExtractingUrl, setIsExtractingUrl] = useState(false) - const [sendShortcut, setSendShortcut] = useState("ctrl-enter") - // Allow retry when there's an error (even if status is still "streaming" or "submitted") - const isDisabled = - (status === "streaming" || status === "submitted") && !error +export const ChatInput = forwardRef( + ( + { + input, + status, + onSubmit, + onChange, + files = [], + onFileChange = () => {}, + pdfData = new Map(), + urlData, + onUrlChange, + sessionId, + error = null, + models = [], + selectedModelId, + onModelSelect = () => {}, + showUnvalidatedModels = false, + onConfigureModels = () => {}, + }: ChatInputProps, + ref, + ) => { + const dict = useDictionary() + const { + diagramHistory, + saveDiagramToFile, + showSaveDialog, + setShowSaveDialog, + } = useDiagram() - const adjustTextareaHeight = useCallback(() => { - const textarea = textareaRef.current - if (textarea) { - textarea.style.height = "auto" - textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px` - } - }, []) - // Handle programmatic input changes (e.g., setInput("") after form submission) - useEffect(() => { - adjustTextareaHeight() - }, [input, adjustTextareaHeight]) + const textareaRef = useRef(null) + const fileInputRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) - // Load send shortcut preference from localStorage and listen for changes - useEffect(() => { - const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut) - if (stored) setSendShortcut(stored) + // Expose an imperative focus() method so parents can focus the input after actions like "Start Fresh Chat" + useImperativeHandle(ref, () => ({ + focus: () => { + textareaRef.current?.focus() + }, + })) - const handleChange = (e: CustomEvent) => - setSendShortcut(e.detail) - window.addEventListener( - "sendShortcutChange", - handleChange as EventListener, - ) - return () => - window.removeEventListener( + const [showHistory, setShowHistory] = useState(false) + const [showUrlDialog, setShowUrlDialog] = useState(false) + const [isExtractingUrl, setIsExtractingUrl] = useState(false) + const [sendShortcut, setSendShortcut] = useState("ctrl-enter") + // Allow retry when there's an error (even if status is still "streaming" or "submitted") + const isDisabled = + (status === "streaming" || status === "submitted") && !error + + const adjustTextareaHeight = useCallback(() => { + const textarea = textareaRef.current + if (textarea) { + textarea.style.height = "auto" + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px` + } + }, []) + // Handle programmatic input changes (e.g., setInput("") after form submission) + useEffect(() => { + adjustTextareaHeight() + }, [input, adjustTextareaHeight]) + + // Load send shortcut preference from localStorage and listen for changes + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut) + if (stored) setSendShortcut(stored) + + const handleChange = (e: CustomEvent) => + setSendShortcut(e.detail) + window.addEventListener( "sendShortcutChange", handleChange as EventListener, ) - }, []) + return () => + window.removeEventListener( + "sendShortcutChange", + handleChange as EventListener, + ) + }, []) - const handleChange = (e: React.ChangeEvent) => { - onChange(e) - adjustTextareaHeight() - } + const handleChange = (e: React.ChangeEvent) => { + onChange(e) + adjustTextareaHeight() + } - const handleKeyDown = (e: React.KeyboardEvent) => { - const shouldSend = - sendShortcut === "enter" - ? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey - : (e.metaKey || e.ctrlKey) && e.key === "Enter" + const handleKeyDown = (e: React.KeyboardEvent) => { + const shouldSend = + sendShortcut === "enter" + ? e.key === "Enter" && + !e.shiftKey && + !e.ctrlKey && + !e.metaKey + : (e.metaKey || e.ctrlKey) && e.key === "Enter" - if (shouldSend) { - e.preventDefault() - const form = e.currentTarget.closest("form") - if (form && input.trim() && !isDisabled) { - form.requestSubmit() + if (shouldSend) { + e.preventDefault() + const form = e.currentTarget.closest("form") + if (form && input.trim() && !isDisabled) { + form.requestSubmit() + } } } - } - const handlePaste = async (e: React.ClipboardEvent) => { - if (isDisabled) return + const handlePaste = async (e: React.ClipboardEvent) => { + if (isDisabled) return - const items = e.clipboardData.items - const imageItems = Array.from(items).filter((item) => - item.type.startsWith("image/"), - ) + const items = e.clipboardData.items + const imageItems = Array.from(items).filter((item) => + item.type.startsWith("image/"), + ) - if (imageItems.length > 0) { - const imageFiles = ( - await Promise.all( - imageItems.map(async (item, index) => { - const file = item.getAsFile() - if (!file) return null - return new File( - [file], - `pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`, - { type: file.type }, - ) - }), + if (imageItems.length > 0) { + const imageFiles = ( + await Promise.all( + imageItems.map(async (item, index) => { + const file = item.getAsFile() + if (!file) return null + return new File( + [file], + `pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`, + { type: file.type }, + ) + }), + ) + ).filter((f): f is File => f !== null) + + const { validFiles, errors } = validateFiles( + imageFiles, + files.length, + dict, ) - ).filter((f): f is File => f !== null) + showValidationErrors(errors, dict) + if (validFiles.length > 0) { + onFileChange([...files, ...validFiles]) + } + } + } + + const handleFileChange = (e: React.ChangeEvent) => { + const newFiles = Array.from(e.target.files || []) + const { validFiles, errors } = validateFiles( + newFiles, + files.length, + dict, + ) + showValidationErrors(errors, dict) + if (validFiles.length > 0) { + onFileChange([...files, ...validFiles]) + } + + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + + const handleRemoveFile = (fileToRemove: File) => { + onFileChange(files.filter((file) => file !== fileToRemove)) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + + const triggerFileInput = () => { + fileInputRef.current?.click() + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + + if (isDisabled) return + + const droppedFiles = e.dataTransfer.files + const supportedFiles = Array.from(droppedFiles).filter((file) => + isValidFileType(file), + ) const { validFiles, errors } = validateFiles( - imageFiles, + supportedFiles, files.length, dict, ) @@ -283,278 +372,217 @@ export function ChatInput({ onFileChange([...files, ...validFiles]) } } - } - const handleFileChange = (e: React.ChangeEvent) => { - const newFiles = Array.from(e.target.files || []) - const { validFiles, errors } = validateFiles( - newFiles, - files.length, - dict, - ) - showValidationErrors(errors, dict) - if (validFiles.length > 0) { - onFileChange([...files, ...validFiles]) + const handleUrlExtract = async (url: string) => { + if (!onUrlChange) return + + setIsExtractingUrl(true) + + try { + const existing = urlData + ? new Map(urlData) + : new Map() + existing.set(url, { + url, + title: url, + content: "", + charCount: 0, + isExtracting: true, + }) + onUrlChange(existing) + + const data = await extractUrlContent(url) + + const newUrlData = new Map(existing) + newUrlData.set(url, data) + onUrlChange(newUrlData) + + setShowUrlDialog(false) + } catch (error) { + // Remove the URL from the data map on error + const newUrlData = urlData + ? new Map(urlData) + : new Map() + newUrlData.delete(url) + onUrlChange(newUrlData) + showErrorToast( + + {error instanceof Error + ? error.message + : "Failed to extract URL content"} + , + ) + } finally { + setIsExtractingUrl(false) + } } - if (fileInputRef.current) { - fileInputRef.current.value = "" - } - } - - const handleRemoveFile = (fileToRemove: File) => { - onFileChange(files.filter((file) => file !== fileToRemove)) - if (fileInputRef.current) { - fileInputRef.current.value = "" - } - } - - const triggerFileInput = () => { - fileInputRef.current?.click() - } - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(true) - } - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(false) - } - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(false) - - if (isDisabled) return - - const droppedFiles = e.dataTransfer.files - const supportedFiles = Array.from(droppedFiles).filter((file) => - isValidFileType(file), - ) - - const { validFiles, errors } = validateFiles( - supportedFiles, - files.length, - dict, - ) - showValidationErrors(errors, dict) - if (validFiles.length > 0) { - onFileChange([...files, ...validFiles]) - } - } - - const handleUrlExtract = async (url: string) => { - if (!onUrlChange) return - - setIsExtractingUrl(true) - - try { - const existing = urlData - ? new Map(urlData) - : new Map() - existing.set(url, { - url, - title: url, - content: "", - charCount: 0, - isExtracting: true, - }) - onUrlChange(existing) - - const data = await extractUrlContent(url) - - const newUrlData = new Map(existing) - newUrlData.set(url, data) - onUrlChange(newUrlData) - - setShowUrlDialog(false) - } catch (error) { - // Remove the URL from the data map on error - const newUrlData = urlData - ? new Map(urlData) - : new Map() - newUrlData.delete(url) - onUrlChange(newUrlData) - showErrorToast( - - {error instanceof Error - ? error.message - : "Failed to extract URL content"} - , - ) - } finally { - setIsExtractingUrl(false) - } - } - - return ( -
- {/* File & URL previews */} - {(files.length > 0 || (urlData && urlData.size > 0)) && ( -
- { - const next = new Map(urlData) - next.delete(url) - onUrlChange(next) - } - : undefined - } + return ( + + {/* File & URL previews */} + {(files.length > 0 || (urlData && urlData.size > 0)) && ( +
+ { + const next = new Map(urlData) + next.delete(url) + onUrlChange(next) + } + : undefined + } + /> +
+ )} +
+