Skip to content
Open
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
18 changes: 16 additions & 2 deletions commands/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <tz>` / `timezone`

Set the IANA timezone (e.g. `America/New_York`, `Europe/London`, `UTC`).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 |
Expand Down
10 changes: 7 additions & 3 deletions commands/start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions examples/fallback-base-url/demo.ts
Original file line number Diff line number Diff line change
@@ -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));
64 changes: 64 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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",
});
});
});
51 changes: 51 additions & 0 deletions src/__tests__/runner-env.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function getAgentsDir(): string {
const DEFAULT_SETTINGS: Settings = {
model: "",
api: "",
baseUrl: "",
fallback: {
model: "",
api: "",
Expand Down Expand Up @@ -170,6 +171,7 @@ export interface TimeoutsConfig {
export interface Settings {
model: string;
api: string;
baseUrl: string;
fallback: ModelConfig;
agentic: AgenticConfig;
timezone: string;
Expand Down Expand Up @@ -207,6 +209,7 @@ export interface AgenticConfig {
export interface ModelConfig {
model: string;
api: string;
baseUrl?: string;
}

export interface WebConfig {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading