From 90b4e4113899e785d1edec1a37f0de205cf18955 Mon Sep 17 00:00:00 2001 From: spring Date: Tue, 30 Jun 2026 17:56:15 +0800 Subject: [PATCH] feat(config): support fallback base URLs --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- commands/config.md | 18 +++++- commands/start.md | 10 ++- examples/fallback-base-url/demo.ts | 19 ++++++ src/__tests__/config.test.ts | 64 +++++++++++++++++++ src/__tests__/runner-env.test.ts | 51 ++++++++++++++++ src/config.ts | 7 +++ src/runner.ts | 98 ++++++++++++++++++------------ 9 files changed, 224 insertions(+), 47 deletions(-) create mode 100644 examples/fallback-base-url/demo.ts create mode 100644 src/__tests__/config.test.ts create mode 100644 src/__tests__/runner-env.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 888422e7..85219676 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "claudeclaw", "source": "./", "description": "Cron-like daemon that runs Claude prompts on a schedule", - "version": "1.0.44", + "version": "1.0.45", "keywords": [ "cron", "heartbeat", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 644babac..cc993e55 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "claudeclaw", - "version": "1.0.44", + "version": "1.0.45", "description": "Cron-like daemon that runs Claude prompts on a schedule" } diff --git a/commands/config.md b/commands/config.md index d00d3a92..1b1169c8 100644 --- a/commands/config.md +++ b/commands/config.md @@ -154,6 +154,16 @@ Set or clear the API token for the fallback model. 4. Set `fallback.api` to the new value. 5. Write and confirm. +### `fallback base-url ` / `fallback base-url` + +Set or clear the Anthropic-compatible base URL for the fallback model. + +1. If URL is in `$ARGUMENTS`, use it directly. +2. Otherwise, use **AskUserQuestion**: "What base URL should ClaudeClaw use for fallback model?" (header: "Fallback URL", options: let user type via Other) +3. Read `.claude/claudeclaw/settings.json`. +4. Set `fallback.baseUrl` to the new value (`""` to clear). +5. Write and confirm. + ### `timezone ` / `timezone` Set the IANA timezone (e.g. `America/New_York`, `Europe/London`, `UTC`). @@ -251,9 +261,11 @@ Location: `.claude/claudeclaw/settings.json` { "model": "opus", "api": "", + "baseUrl": "", "fallback": { "model": "glm", - "api": "" + "api": "", + "baseUrl": "" }, "timezone": "America/New_York", "timezoneOffsetMinutes": -300, @@ -286,9 +298,11 @@ Location: `.claude/claudeclaw/settings.json` | Key | Type | Description | |----------------------------|------------|------------------------------------------------| | `model` | string | Claude model (`opus`, `sonnet`, `haiku`, `glm`, or full ID). Empty = default | -| `api` | string | API token used when model is `glm` (mapped to `ANTHROPIC_AUTH_TOKEN`) | +| `api` | string | API token mapped to `ANTHROPIC_AUTH_TOKEN` for the primary model | +| `baseUrl` | string | Optional Anthropic-compatible base URL for the primary model | | `fallback.model` | string | Backup model used automatically if primary run returns rate-limit text (recommend `glm` for provider diversity) | | `fallback.api` | string | API token used with `fallback.model` (optional) | +| `fallback.baseUrl` | string | Optional Anthropic-compatible base URL for fallback providers | | `timezone` | string | IANA timezone name (e.g. `America/New_York`) | | `timezoneOffsetMinutes` | number | UTC offset in minutes (auto-resolved from timezone) | | `heartbeat.enabled` | boolean | Whether the recurring heartbeat runs | diff --git a/commands/start.md b/commands/start.md index f74a82d2..36062be3 100644 --- a/commands/start.md +++ b/commands/start.md @@ -77,7 +77,7 @@ Start the heartbeat daemon for this project. Follow these steps exactly: - If "Yes": Set `agentic.enabled` to `true` with default modes (planning→opus, implementation→sonnet). The user can customize modes later via `/config`. - If "No": Set `agentic.enabled` to `false`. - Ask whether to set a fallback model. Recommend `glm` first so fallback uses a different provider path than the primary Claude model. If yes, set `fallback.model` and optionally `fallback.api`. - - Ask whether to enable GLM fallback (kicks in automatically when your Claude token limit is hit). The fallback model is always `glm` — no other model is supported. Use AskUserQuestion: "Enable GLM fallback? Automatically switches to GLM when your Claude limit is hit." (header: "Fallback", options: "Yes — enable GLM fallback", "Skip"). If yes, ask in normal free-form text for the GLM API token (optional, user can skip). Set `fallback.model` to `"glm"` and `fallback.api` to the token if provided. + - Ask whether to enable fallback (kicks in automatically when your Claude token limit is hit). Use AskUserQuestion: "Enable fallback? Automatically switches providers when your Claude limit is hit." (header: "Fallback", options: "Yes — enable GLM fallback (Recommended)", "Skip"). If yes, ask in normal free-form text for the fallback API token (optional, user can skip). Set `fallback.model` to `"glm"` and `fallback.api` to the token if provided. Advanced users can later set `fallback.baseUrl` for OpenRouter, LiteLLM, or a local Anthropic-compatible proxy. - **If yes to heartbeat**: Use AskUserQuestion again with one question: - "How often should it run in minutes?" (header: "Interval", options: "5", "15", "30 (Recommended)", "60") @@ -172,9 +172,11 @@ Defaults: `WEB_HOST=127.0.0.1`, `WEB_PORT=4632` unless changed via settings or ` { "model": "opus", "api": "", + "baseUrl": "", "fallback": { "model": "glm", - "api": "" + "api": "", + "baseUrl": "" }, "agentic": { "enabled": true, @@ -218,9 +220,11 @@ Defaults: `WEB_HOST=127.0.0.1`, `WEB_PORT=4632` unless changed via settings or ` } ``` - `model` — Claude model to use (`opus`, `sonnet`, `haiku`, `glm`, or full model ID). Empty string uses default. Ignored when `agentic.enabled` is true. -- `api` — API token used when `model` is `glm` (passed as `ANTHROPIC_AUTH_TOKEN` for that provider path). +- `api` — optional API token passed as `ANTHROPIC_AUTH_TOKEN` for the primary model. +- `baseUrl` — optional Anthropic-compatible base URL for the primary model. If omitted and `model` is `glm`, ClaudeClaw uses the legacy GLM endpoint. - `fallback.model` — backup model used automatically if the primary run returns a rate-limit message. Prefer `glm` for provider diversity. - `fallback.api` — optional API token to use with `fallback.model`. +- `fallback.baseUrl` — optional Anthropic-compatible base URL for fallback providers such as OpenRouter, LiteLLM, or local proxies. If omitted and `fallback.model` is `glm`, ClaudeClaw uses the legacy GLM endpoint. - `agentic.enabled` — when true, automatically routes tasks to appropriate models based on task type - `agentic.defaultMode` — which mode to use when no keywords match (default: `"implementation"`) - `agentic.modes` — array of routing modes, each with: `name` (string), `model` (string), `keywords` (string[]), optional `phrases` (string[], checked before keywords with higher priority). Old `planningModel`/`implementationModel` format is auto-converted. diff --git a/examples/fallback-base-url/demo.ts b/examples/fallback-base-url/demo.ts new file mode 100644 index 00000000..6ff98080 --- /dev/null +++ b/examples/fallback-base-url/demo.ts @@ -0,0 +1,19 @@ +import { buildChildEnv } from "../../src/runner"; +import type { ModelConfig } from "../../src/config"; + +const fallback: ModelConfig = { + model: "deepseek-chat", + api: "demo-fallback-token", + baseUrl: "https://proxy.example.com/anthropic", +}; + +const env = buildChildEnv({ PATH: process.env.PATH ?? "" }, fallback); + +console.log(JSON.stringify({ + fallback, + childEnv: { + ANTHROPIC_AUTH_TOKEN: env.ANTHROPIC_AUTH_TOKEN, + ANTHROPIC_BASE_URL: env.ANTHROPIC_BASE_URL, + API_TIMEOUT_MS: env.API_TIMEOUT_MS ?? null, + }, +}, null, 2)); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts new file mode 100644 index 00000000..e5523154 --- /dev/null +++ b/src/__tests__/config.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test, afterAll, beforeEach } from "bun:test"; +import { mkdir, rm, writeFile } from "fs/promises"; +import { join } from "path"; + +const TEST_ROOT = join(import.meta.dir, "../../test-sandbox-config"); +const SETTINGS_DIR = join(TEST_ROOT, ".claude", "claudeclaw"); +const SETTINGS_FILE = join(SETTINGS_DIR, "settings.json"); + +async function resetSandbox() { + await rm(TEST_ROOT, { recursive: true, force: true }); + await mkdir(SETTINGS_DIR, { recursive: true }); +} + +async function loadSettingsInSandbox(settings: Record) { + await writeFile(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n"); + const script = ` +import { loadSettings } from ${JSON.stringify(join(import.meta.dir, "..", "config"))}; +const settings = await loadSettings(); +process.stdout.write(JSON.stringify({ + baseUrl: settings.baseUrl, + fallback: settings.fallback, +})); +`; + const scriptPath = join(TEST_ROOT, "_load-settings.ts"); + await writeFile(scriptPath, script); + const proc = Bun.spawn(["bun", "run", scriptPath], { + cwd: TEST_ROOT, + stdout: "pipe", + stderr: "pipe", + }); + const out = await new Response(proc.stdout).text(); + const err = await new Response(proc.stderr).text(); + await proc.exited; + if (proc.exitCode !== 0) throw new Error(err); + return JSON.parse(out); +} + +beforeEach(resetSandbox); + +afterAll(async () => { + await rm(TEST_ROOT, { recursive: true, force: true }); +}); + +describe("settings model baseUrl parsing", () => { + test("parses primary and fallback base URLs", async () => { + const settings = await loadSettingsInSandbox({ + model: "sonnet", + api: "primary-key", + baseUrl: " https://primary.example/anthropic ", + fallback: { + model: "deepseek", + api: "fallback-key", + baseUrl: " https://fallback.example/anthropic ", + }, + }); + + expect(settings.baseUrl).toBe("https://primary.example/anthropic"); + expect(settings.fallback).toEqual({ + model: "deepseek", + api: "fallback-key", + baseUrl: "https://fallback.example/anthropic", + }); + }); +}); diff --git a/src/__tests__/runner-env.test.ts b/src/__tests__/runner-env.test.ts new file mode 100644 index 00000000..84db783b --- /dev/null +++ b/src/__tests__/runner-env.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { buildChildEnv } from "../runner"; + +describe("buildChildEnv", () => { + test("uses explicit baseUrl for Anthropic-compatible providers", () => { + const env = buildChildEnv( + { EXISTING: "1" }, + { model: "deepseek", api: " fallback-key ", baseUrl: " https://proxy.example/anthropic " }, + ); + + expect(env.EXISTING).toBe("1"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("fallback-key"); + expect(env.ANTHROPIC_BASE_URL).toBe("https://proxy.example/anthropic"); + }); + + test("does not forward ambient Anthropic tokens to custom baseUrl without explicit api", () => { + const env = buildChildEnv( + { + ANTHROPIC_API_KEY: "real-anthropic-key", + ANTHROPIC_AUTH_TOKEN: "real-auth-token", + }, + { model: "deepseek", api: "", baseUrl: "https://proxy.example/anthropic" }, + ); + + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(env.ANTHROPIC_BASE_URL).toBe("https://proxy.example/anthropic"); + }); + + test("sets extended timeout for custom baseUrl providers", () => { + const env = buildChildEnv( + {}, + { model: "deepseek", api: "", baseUrl: "https://proxy.example/anthropic" }, + ); + + expect(env.API_TIMEOUT_MS).toBe("3000000"); + }); + + test("keeps the legacy GLM base URL when no baseUrl is configured", () => { + const env = buildChildEnv({}, { model: "glm", api: "" }); + + expect(env.ANTHROPIC_BASE_URL).toBe("https://api.z.ai/api/anthropic"); + expect(env.API_TIMEOUT_MS).toBe("3000000"); + }); + + test("does not set a base URL for non-GLM models without explicit baseUrl", () => { + const env = buildChildEnv({}, { model: "sonnet", api: "" }); + + expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); + }); +}); diff --git a/src/config.ts b/src/config.ts index 461aad1c..7607498a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -33,6 +33,7 @@ export function getAgentsDir(): string { const DEFAULT_SETTINGS: Settings = { model: "", api: "", + baseUrl: "", fallback: { model: "", api: "", @@ -170,6 +171,7 @@ export interface TimeoutsConfig { export interface Settings { model: string; api: string; + baseUrl: string; fallback: ModelConfig; agentic: AgenticConfig; timezone: string; @@ -207,6 +209,7 @@ export interface AgenticConfig { export interface ModelConfig { model: string; api: string; + baseUrl?: string; } export interface WebConfig { @@ -323,9 +326,13 @@ function parseSettings( return { model: typeof raw.model === "string" ? raw.model.trim() : "", api: typeof raw.api === "string" ? raw.api.trim() : "", + baseUrl: typeof raw.baseUrl === "string" ? raw.baseUrl.trim() : "", fallback: { model: typeof raw.fallback?.model === "string" ? raw.fallback.model.trim() : "", api: typeof raw.fallback?.api === "string" ? raw.fallback.api.trim() : "", + ...(typeof raw.fallback?.baseUrl === "string" && raw.fallback.baseUrl.trim() + ? { baseUrl: raw.fallback.baseUrl.trim() } + : {}), }, agentic: parseAgenticConfig(raw.agentic), timezone: parsedTimezone, diff --git a/src/runner.ts b/src/runner.ts index 171642c9..e0258607 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -308,11 +308,19 @@ function extractRateLimitMessage(stdout: string, stderr: string): string | null } function sameModelConfig(a: ModelConfig, b: ModelConfig): boolean { - return a.model.trim().toLowerCase() === b.model.trim().toLowerCase() && a.api.trim() === b.api.trim(); + return ( + a.model.trim().toLowerCase() === b.model.trim().toLowerCase() && + a.api.trim() === b.api.trim() && + (a.baseUrl ?? "").trim() === (b.baseUrl ?? "").trim() + ); } function hasModelConfig(value: ModelConfig): boolean { - return value.model.trim().length > 0 || value.api.trim().length > 0; + return ( + value.model.trim().length > 0 || + value.api.trim().length > 0 || + (value.baseUrl ?? "").trim().length > 0 + ); } function isNotFoundError(error: unknown): boolean { @@ -323,14 +331,28 @@ function isNotFoundError(error: unknown): boolean { return /enoent|no such file or directory/i.test(message); } -function buildChildEnv(baseEnv: Record, model: string, api: string): Record { +export function buildChildEnv(baseEnv: Record, config: ModelConfig): Record { const childEnv: Record = { ...baseEnv }; - const normalizedModel = model.trim().toLowerCase(); - - if (api.trim()) childEnv.ANTHROPIC_AUTH_TOKEN = api.trim(); + const model = config.model.trim(); + const api = config.api.trim(); + const baseUrl = (config.baseUrl ?? "").trim(); + const normalizedModel = model.toLowerCase(); + const hasCustomBaseUrl = baseUrl.length > 0; + + if (api) { + childEnv.ANTHROPIC_AUTH_TOKEN = api; + } else if (hasCustomBaseUrl) { + delete childEnv.ANTHROPIC_API_KEY; + delete childEnv.ANTHROPIC_AUTH_TOKEN; + } - if (normalizedModel === "glm") { + if (hasCustomBaseUrl) { + childEnv.ANTHROPIC_BASE_URL = baseUrl; + } else if (normalizedModel === "glm") { childEnv.ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic"; + } + + if (hasCustomBaseUrl || normalizedModel === "glm") { childEnv.API_TIMEOUT_MS = "3000000"; } @@ -407,20 +429,20 @@ async function collectStream(stream: ReadableStream, maxBytes: numbe async function runClaudeOnce( baseArgs: string[], - model: string, - api: string, + modelConfig: ModelConfig, baseEnv: Record, timeoutMs: number = DEFAULT_SESSION_TIMEOUT_MS, cwd?: string ): Promise<{ rawStdout: string; stderr: string; exitCode: number }> { const args = [...baseArgs]; + const model = modelConfig.model; const normalizedModel = model.trim().toLowerCase(); if (model.trim() && normalizedModel !== "glm") args.push("--model", model.trim()); const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe", - env: buildChildEnv(baseEnv, model, api), + env: buildChildEnv(baseEnv, modelConfig), ...(cwd ? { cwd } : {}), }); @@ -474,8 +496,7 @@ async function runClaudeOnce( // captured from the stream/init event. async function runClaudeStream( baseArgs: string[], - model: string, - api: string, + modelConfig: ModelConfig, baseEnv: Record, timeoutMs: number = DEFAULT_SESSION_TIMEOUT_MS, cwd?: string, @@ -483,13 +504,14 @@ async function runClaudeStream( onToolEvent?: (line: string) => void ): Promise<{ rawStdout: string; stderr: string; exitCode: number; sessionId?: string }> { const args = [...baseArgs]; + const model = modelConfig.model; const normalizedModel = model.trim().toLowerCase(); if (model.trim() && normalizedModel !== "glm") args.push("--model", model.trim()); const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe", - env: buildChildEnv(baseEnv, model, api), + env: buildChildEnv(baseEnv, modelConfig), ...(cwd ? { cwd } : {}), }); @@ -628,20 +650,20 @@ function extractToolResultText(content: unknown): string { */ async function runClaudeStreaming( baseArgs: string[], - model: string, - api: string, + modelConfig: ModelConfig, baseEnv: Record, onChunk?: (text: string) => void, onToolEvent?: (line: string) => void ): Promise<{ result: string; stderr: string; exitCode: number; sessionId?: string; isRateLimit: boolean }> { const args = [...baseArgs]; + const model = modelConfig.model; const normalizedModel = model.trim().toLowerCase(); if (model.trim() && normalizedModel !== "glm") args.push("--model", model.trim()); const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe", - env: buildChildEnv(baseEnv, model, api), + env: buildChildEnv(baseEnv, modelConfig), }); mainActiveProcs.add(proc); @@ -940,8 +962,7 @@ export async function loadHeartbeatPromptTemplate(): Promise { /** Run /compact on the current session to reduce context size. */ export async function runCompact( sessionId: string, - model: string, - api: string, + modelConfig: ModelConfig, baseEnv: Record, securityArgs: string[], timeoutMs: number, @@ -954,7 +975,7 @@ export async function runCompact( ...securityArgs, ]; console.log(`[${new Date().toLocaleTimeString()}] Running /compact on session ${sessionId.slice(0, 8)}...`); - const result = await runClaudeOnce(compactArgs, model, api, baseEnv, timeoutMs, cwd); + const result = await runClaudeOnce(compactArgs, modelConfig, baseEnv, timeoutMs, cwd); const success = result.exitCode === 0; console.log(`[${new Date().toLocaleTimeString()}] Compact ${success ? "succeeded" : `failed (exit ${result.exitCode})`}`); return success; @@ -976,8 +997,7 @@ export async function compactCurrentSession(agentName?: string): Promise<{ succe const compactCwd = agentName ? await ensureAgentDir(agentName) : undefined; const ok = await runCompact( existing.sessionId, - settings.model, - settings.api, + { model: settings.model, api: settings.api, baseUrl: settings.baseUrl }, baseEnv, securityArgs, timeoutMs, @@ -1006,8 +1026,7 @@ export async function compactCurrentThreadSession( const compactCwd = agentName ? await ensureAgentDir(agentName) : undefined; const ok = await runCompact( existing.sessionId, - settings.model, - settings.api, + { model: settings.model, api: settings.api, baseUrl: settings.baseUrl }, baseEnv, securityArgs, timeoutMs, @@ -1057,7 +1076,7 @@ async function execClaude( const logFile = join(LOGS_DIR, `${name}-${timestamp}.log`); const settings = getSettings(); - const { security, model, api, fallback, agentic, watchdog } = settings; + const { security, model, api, baseUrl, fallback, agentic, watchdog } = settings; // Determine which model to use based on agentic routing let primaryConfig: ModelConfig; @@ -1065,23 +1084,24 @@ async function execClaude( let routingReasoning = ""; if (modelOverride) { - primaryConfig = { model: modelOverride, api }; + primaryConfig = { model: modelOverride, api, baseUrl }; console.log(`[${new Date().toLocaleTimeString()}] Job model override: ${modelOverride}`); } else if (agentic.enabled) { const routing = selectModel(prompt, agentic.modes, agentic.defaultMode); - primaryConfig = { model: routing.model, api }; + primaryConfig = { model: routing.model, api, baseUrl }; taskType = routing.taskType; routingReasoning = routing.reasoning; console.log( `[${new Date().toLocaleTimeString()}] Agentic routing: ${routing.taskType} → ${routing.model} (${routing.reasoning})` ); } else { - primaryConfig = { model, api }; + primaryConfig = { model, api, baseUrl }; } const fallbackConfig: ModelConfig = { model: fallback?.model ?? "", api: fallback?.api ?? "", + baseUrl: fallback?.baseUrl, }; const securityArgs = buildSecurityArgs(security); const timeoutMs = timeoutMsOverride ?? resolveTimeoutMs(timeoutCategory ?? name); @@ -1141,7 +1161,7 @@ async function execClaude( const baseEnv = cleanSpawnEnv(); const spawnCwd = agentName ? await ensureAgentDir(agentName) : undefined; - let exec = await runClaudeStream(args, primaryConfig.model, primaryConfig.api, baseEnv, timeoutMs, spawnCwd, onChunk, onToolEvent); + let exec = await runClaudeStream(args, primaryConfig, baseEnv, timeoutMs, spawnCwd, onChunk, onToolEvent); const primaryRateLimit = extractRateLimitMessage(exec.rawStdout, exec.stderr); let usedFallback = false; @@ -1157,7 +1177,7 @@ async function execClaude( if (appendParts.length > 0) { fallbackArgs.push("--append-system-prompt", appendParts.join("\n\n")); } - exec = await runClaudeStream(fallbackArgs, fallbackConfig.model, fallbackConfig.api, baseEnv, timeoutMs, spawnCwd); + exec = await runClaudeStream(fallbackArgs, fallbackConfig, baseEnv, timeoutMs, spawnCwd); usedFallback = true; let fallbackRateLimit = extractRateLimitMessage(exec.rawStdout, exec.stderr); @@ -1169,7 +1189,7 @@ async function execClaude( `[${new Date().toLocaleTimeString()}] Detected corrupted fallback session (thinking block signature mismatch). Reset${flabel}, retrying fallback fresh...` ); const freshFallbackArgs = fallbackArgs.filter((a) => a !== "--resume" && a !== fallbackSession.sessionId); - exec = await runClaudeStream(freshFallbackArgs, fallbackConfig.model, fallbackConfig.api, baseEnv, timeoutMs, spawnCwd); + exec = await runClaudeStream(freshFallbackArgs, fallbackConfig, baseEnv, timeoutMs, spawnCwd); fallbackRateLimit = extractRateLimitMessage(exec.rawStdout, exec.stderr); if (!fallbackRateLimit && exec.sessionId) { await createFallbackSession(exec.sessionId, agentName, threadId); @@ -1209,7 +1229,7 @@ async function execClaude( const freshArgs = args.filter((a) => a !== "--resume" && a !== existing?.sessionId); const fmtIdx = freshArgs.indexOf("--output-format"); if (fmtIdx !== -1 && fmtIdx + 1 < freshArgs.length) freshArgs[fmtIdx + 1] = "stream-json"; - exec = await runClaudeStream(freshArgs, primaryConfig.model, primaryConfig.api, baseEnv, timeoutMs, spawnCwd); + exec = await runClaudeStream(freshArgs, primaryConfig, baseEnv, timeoutMs, spawnCwd); rawStdout = exec.rawStdout; stderr = exec.stderr; exitCode = exec.exitCode; @@ -1261,8 +1281,7 @@ async function execClaude( const retryConfig = usedFallback ? fallbackConfig : primaryConfig; exec = await runClaudeStream( retryArgs, - retryConfig.model, - retryConfig.api, + retryConfig, baseEnv, timeoutMs, spawnCwd @@ -1373,8 +1392,7 @@ async function execClaude( emitCompactEvent({ type: "auto-compact-start" }); const compactOk = await runCompact( existing.sessionId, - primaryConfig.model, - primaryConfig.api, + primaryConfig, baseEnv, securityArgs, timeoutMs, @@ -1385,7 +1403,7 @@ async function execClaude( if (compactOk) { console.log(`[${new Date().toLocaleTimeString()}] Retrying ${name} after compact...`); - const retryExec = await runClaudeStream(args, primaryConfig.model, primaryConfig.api, baseEnv, timeoutMs, spawnCwd); + const retryExec = await runClaudeStream(args, primaryConfig, baseEnv, timeoutMs, spawnCwd); const retryResult: RunResult = { stdout: retryExec.rawStdout, stderr: retryExec.stderr, @@ -1464,7 +1482,7 @@ async function streamClaude( } const existing = await getSession(); - const { security, model, api } = getSettings(); + const { security, model, api, baseUrl } = getSettings(); const securityArgs = buildSecurityArgs(security); // Plugins: before_agent_start @@ -1505,7 +1523,7 @@ async function streamClaude( const normalizedModel = model.trim().toLowerCase(); if (model.trim() && normalizedModel !== "glm") args.push("--model", model.trim()); - const childEnv = buildChildEnv(cleanSpawnEnv(), model, api); + const childEnv = buildChildEnv(cleanSpawnEnv(), { model, api, baseUrl }); console.log(`[${new Date().toLocaleTimeString()}] Running: ${name} (stream-json, session: ${existing?.sessionId?.slice(0, 8) ?? "new"})`); @@ -1728,7 +1746,7 @@ const FORK_TIMEOUT_MS = 120_000; * cannot hang indefinitely or grow memory unbounded. */ export async function runFork(prompt: string): Promise { - const { api, security } = getSettings(); + const { api, baseUrl, security } = getSettings(); const baseEnv = cleanSpawnEnv(); const securityArgs = buildSecurityArgs(security); @@ -1743,7 +1761,7 @@ export async function runFork(prompt: string): Promise { const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe", - env: buildChildEnv(baseEnv, FORK_MODEL, api), + env: buildChildEnv(baseEnv, { model: FORK_MODEL, api, baseUrl }), }); let timeoutId: ReturnType | null = null;