From e94cf1e2518ed668a5cd0cc40d4febac8d5ded69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:14:18 +0000 Subject: [PATCH 1/2] Initial plan From 761652b927c6a46ce5785cdb44d4a081bdd482d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:18:41 +0000 Subject: [PATCH 2/2] Remove LLM_MODEL from .env.example; add vendor-level base URL fallback for per-model-URL providers Co-authored-by: tangshixiang <15044508+tangshixiang@users.noreply.github.com> --- .env.example | 21 +-- docs/getting-started/environment-variables.md | 12 ++ src/lib/ai/provider.test.ts | 142 ++++++++++++++++++ src/lib/ai/provider.ts | 13 +- 4 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 src/lib/ai/provider.test.ts diff --git a/.env.example b/.env.example index 870ddcdc..f69a2818 100644 --- a/.env.example +++ b/.env.example @@ -23,26 +23,27 @@ DEEPSEEK_API_KEY=... MINIMAX_API_KEY=... ZHIPU_API_KEY=... -# Per-model base URLs (required for per-model-URL providers). -# Each model is served from its own endpoint; set the URL for each model you use. -# SH-Lab (Intern models) +# Vendor-level base URLs for per-model-URL providers. +# Set one URL per vendor to use the same endpoint for all models of that provider. +# SHLAB_BASE_URL=https://your-host/v1 +# QWEN_BASE_URL=https://your-host/v1 +# MOONSHOT_BASE_URL=https://your-host/v1 +# DEEPSEEK_BASE_URL=https://your-host/v1 +# MINIMAX_BASE_URL=https://your-host/v1 +# ZHIPU_BASE_URL=https://your-host/v1 +# +# Per-model base URLs (optional, override the vendor-level URL for a specific model). # SHLAB_INTERN_S1_PRO_BASE_URL=https://your-host/v1 # SHLAB_INTERN_S1_BASE_URL=https://your-host/v1 -# Qwen # QWEN_QWEN3_235B_BASE_URL=https://your-host/v1 # QWEN_QWEN3_5_397B_BASE_URL=https://your-host/v1 -# Moonshot # MOONSHOT_KIMI_K2_5_BASE_URL=https://your-host/v1 -# DeepSeek # DEEPSEEK_DEEPSEEK_V3_2_BASE_URL=https://your-host/v1 -# MiniMax # MINIMAX_MINIMAX2_5_BASE_URL=https://your-host/v1 -# Zhipu # ZHIPU_GLM_5_BASE_URL=https://your-host/v1 -# Default LLM provider and model (overridable in the Settings UI) +# Default LLM provider (overridable in the Settings UI) # LLM_PROVIDER=openai -# LLM_MODEL=gpt-4o-mini # Maximum number of agent tool-call steps per request (optional, defaults to 10, max 100) # Increase this for complex multi-step tasks, but be aware of higher token costs and longer request times. diff --git a/docs/getting-started/environment-variables.md b/docs/getting-started/environment-variables.md index a916ccc8..3160ed44 100644 --- a/docs/getting-started/environment-variables.md +++ b/docs/getting-started/environment-variables.md @@ -17,9 +17,21 @@ A complete reference of all environment variables used by InnoClaw. | `OPENAI_API_KEY` | `string` | No | — | OpenAI API key for chat and embedding. | | `ANTHROPIC_API_KEY` | `string` | No | — | Anthropic API key for Claude models. | | `GEMINI_API_KEY` | `string` | No | — | Google Gemini API key for Gemini models. | +| `SHLAB_API_KEY` | `string` | No | — | SH-Lab API key for Intern models. | +| `QWEN_API_KEY` | `string` | No | — | Qwen API key. | +| `MOONSHOT_API_KEY` | `string` | No | — | Moonshot API key for Kimi models. | +| `DEEPSEEK_API_KEY` | `string` | No | — | DeepSeek API key. | +| `MINIMAX_API_KEY` | `string` | No | — | MiniMax API key. | +| `ZHIPU_API_KEY` | `string` | No | — | Zhipu API key for GLM models. | | `OPENAI_BASE_URL` | `string` | No | `https://api.openai.com/v1` | Custom OpenAI-compatible API endpoint (for proxies or third-party providers). | | `ANTHROPIC_BASE_URL` | `string` | No | `https://api.anthropic.com` | Custom Anthropic API endpoint. | | `GEMINI_BASE_URL` | `string` | No | — | Custom Gemini-compatible API endpoint (OpenAI-compatible proxy). | +| `SHLAB_BASE_URL` | `string` | No | — | Vendor-level base URL for all SH-Lab models. Per-model URLs (e.g. `SHLAB_INTERN_S1_PRO_BASE_URL`) take priority. | +| `QWEN_BASE_URL` | `string` | No | — | Vendor-level base URL for all Qwen models. Per-model URLs (e.g. `QWEN_QWEN3_235B_BASE_URL`) take priority. | +| `MOONSHOT_BASE_URL` | `string` | No | — | Vendor-level base URL for all Moonshot models. Per-model URLs (e.g. `MOONSHOT_KIMI_K2_5_BASE_URL`) take priority. | +| `DEEPSEEK_BASE_URL` | `string` | No | — | Vendor-level base URL for all DeepSeek models. Per-model URLs (e.g. `DEEPSEEK_DEEPSEEK_V3_2_BASE_URL`) take priority. | +| `MINIMAX_BASE_URL` | `string` | No | — | Vendor-level base URL for all MiniMax models. Per-model URLs (e.g. `MINIMAX_MINIMAX2_5_BASE_URL`) take priority. | +| `ZHIPU_BASE_URL` | `string` | No | — | Vendor-level base URL for all Zhipu models. Per-model URLs (e.g. `ZHIPU_GLM_5_BASE_URL`) take priority. | | `LLM_PROVIDER` | `string` | No | `openai` | Default LLM provider: `openai`, `anthropic`, or `gemini`. Overridable in Settings UI. | | `LLM_MODEL` | `string` | No | `gpt-4o-mini` | Default model ID. Overridable in Settings UI. | diff --git a/src/lib/ai/provider.test.ts b/src/lib/ai/provider.test.ts new file mode 100644 index 00000000..58450679 --- /dev/null +++ b/src/lib/ai/provider.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +/** + * Unit tests for getPerModelProvider base-URL resolution in provider.ts. + * + * We mock @ai-sdk/openai and @ai-sdk/anthropic so the tests don't need real + * API keys or network access—only the env-var lookup logic is exercised. + */ + +// Fake chat model returned by the mocked provider +const fakeChatModel = { modelId: "test" }; + +// Track calls to createOpenAI so we can assert baseURL / apiKey +const createOpenAISpy = vi.fn(() => ({ + chat: vi.fn(() => fakeChatModel), +})); + +vi.mock("@ai-sdk/openai", () => ({ + openai: { chat: vi.fn(() => fakeChatModel) }, + createOpenAI: (...args: unknown[]) => createOpenAISpy(...args), +})); + +vi.mock("@ai-sdk/anthropic", () => ({ + createAnthropic: vi.fn(() => vi.fn(() => fakeChatModel)), +})); + +// Mock the DB import so it doesn't try to open SQLite +vi.mock("@/lib/db", () => ({ db: {} })); +vi.mock("@/lib/db/schema", () => ({ appSettings: {} })); + +describe("getPerModelProvider – base URL resolution", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + createOpenAISpy.mockClear(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + /** + * We can't directly import getPerModelProvider because it's not exported. + * Instead, we test through the exported getModelFromOverride which calls + * buildLanguageModel → getPerModelProvider for per-model-URL providers. + */ + async function callGetModelFromOverride(provider: string, model: string) { + // Clear module cache so env vars are re-read + vi.resetModules(); + + // Re-apply mocks after resetModules + vi.doMock("@ai-sdk/openai", () => ({ + openai: { chat: vi.fn(() => fakeChatModel) }, + createOpenAI: (...args: unknown[]) => createOpenAISpy(...args), + })); + vi.doMock("@ai-sdk/anthropic", () => ({ + createAnthropic: vi.fn(() => vi.fn(() => fakeChatModel)), + })); + vi.doMock("@/lib/db", () => ({ db: {} })); + vi.doMock("@/lib/db/schema", () => ({ appSettings: {} })); + + const mod = await import("./provider"); + return mod.getModelFromOverride(provider, model); + } + + it("uses per-model base URL when set", async () => { + process.env.MOONSHOT_API_KEY = "sk-test"; + process.env.MOONSHOT_KIMI_K2_5_BASE_URL = "https://per-model.example.com/v1"; + process.env.MOONSHOT_BASE_URL = "https://vendor.example.com/v1"; + + await callGetModelFromOverride("moonshot", "kimi-k2.5"); + + expect(createOpenAISpy).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://per-model.example.com/v1", + }), + ); + }); + + it("falls back to vendor-level base URL when per-model URL is not set", async () => { + process.env.QWEN_API_KEY = "sk-test"; + process.env.QWEN_BASE_URL = "https://vendor.example.com/v1"; + + await callGetModelFromOverride("qwen", "Qwen3-235B"); + + expect(createOpenAISpy).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://vendor.example.com/v1", + }), + ); + }); + + it("throws when neither per-model nor vendor-level URL is set", async () => { + process.env.DEEPSEEK_API_KEY = "sk-test"; + delete process.env.DEEPSEEK_DEEPSEEK_V3_2_BASE_URL; + delete process.env.DEEPSEEK_BASE_URL; + + await expect( + callGetModelFromOverride("deepseek", "deepseek-v3.2"), + ).rejects.toThrow(/DEEPSEEK_DEEPSEEK_V3_2_BASE_URL.*DEEPSEEK_BASE_URL/); + }); + + it("error message mentions both env var names", async () => { + process.env.ZHIPU_API_KEY = "sk-test"; + delete process.env.ZHIPU_GLM_5_BASE_URL; + delete process.env.ZHIPU_BASE_URL; + + await expect( + callGetModelFromOverride("zhipu", "glm-5"), + ).rejects.toThrow("Set ZHIPU_GLM_5_BASE_URL or ZHIPU_BASE_URL"); + }); + + it("works for all per-model-URL providers with vendor-level URL", async () => { + const cases = [ + { provider: "shlab", model: "intern-s1-pro", envPrefix: "SHLAB" }, + { provider: "qwen", model: "Qwen3-235B", envPrefix: "QWEN" }, + { provider: "moonshot", model: "kimi-k2.5", envPrefix: "MOONSHOT" }, + { provider: "deepseek", model: "deepseek-v3.2", envPrefix: "DEEPSEEK" }, + { provider: "minimax", model: "minimax2.5", envPrefix: "MINIMAX" }, + { provider: "zhipu", model: "glm-5", envPrefix: "ZHIPU" }, + ]; + + for (const { provider, model, envPrefix } of cases) { + process.env[`${envPrefix}_API_KEY`] = "sk-test"; + process.env[`${envPrefix}_BASE_URL`] = `https://${provider}.example.com/v1`; + createOpenAISpy.mockClear(); + + await callGetModelFromOverride(provider, model); + + expect(createOpenAISpy).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: `https://${provider}.example.com/v1`, + }), + ); + + // Cleanup + delete process.env[`${envPrefix}_API_KEY`]; + delete process.env[`${envPrefix}_BASE_URL`]; + } + }); +}); diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index d93eb3c8..7a7ca962 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -53,8 +53,10 @@ const perModelProviderCache = new Map>() /** * Create (or return cached) OpenAI-compatible provider for providers that use - * per-model base URLs. The env var name is derived from the provider and model: - * e.g. provider="moonshot", model="kimi-k2.5" → MOONSHOT_KIMI_K2_5_BASE_URL + * per-model base URLs. Resolution order: + * 1. Per-model env var: e.g. MOONSHOT_KIMI_K2_5_BASE_URL + * 2. Vendor-level env var: e.g. MOONSHOT_BASE_URL + * 3. Error if neither is set. */ function getPerModelProvider( providerId: string, @@ -70,16 +72,17 @@ function getPerModelProvider( ); } - const envVarName = + const perModelEnvVar = prefix + "_" + modelId.toUpperCase().replace(/[^A-Z0-9]/g, "_") + "_BASE_URL"; - const baseURL = process.env[envVarName]; + const vendorEnvVar = prefix + "_BASE_URL"; + const baseURL = process.env[perModelEnvVar] || process.env[vendorEnvVar]; if (!baseURL) { throw new Error( `No base URL configured for ${providerDef?.name ?? providerId} model "${modelId}". ` + - `Set the ${envVarName} environment variable.` + `Set ${perModelEnvVar} or ${vendorEnvVar}.` ); }