Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions docs/getting-started/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
142 changes: 142 additions & 0 deletions src/lib/ai/provider.test.ts
Original file line number Diff line number Diff line change
@@ -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`];
}
});
});
13 changes: 8 additions & 5 deletions src/lib/ai/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ const perModelProviderCache = new Map<string, ReturnType<typeof createOpenAI>>()

/**
* 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,
Expand All @@ -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}.`
);
}

Expand Down
Loading