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
23 changes: 23 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,29 @@ Controls the daemon's task scheduler.
| `heartbeat_interval_minutes` | `30` | Daemon health check frequency |
| `use_claude_p` | `true` | Use `claude -p` (subscription) for AI-powered tasks |
| `api_key_env` | `null` | Environment variable name for API key override. When `null`, uses `claude -p` OAuth credentials |
| `provider` | `null` | Selects an inference provider for AI-powered tasks. When `null`, uses `claude -p` OAuth credentials |

### `cron.provider`

When set, AI-powered cron tasks (`ai_task`) run against a configured inference
provider that exposes an Anthropic-compatible endpoint, instead of the default
`claude -p` subscription credentials. The `claude` CLI is pointed at the
provider's regional base URL and asked for the selected model.

| Key | Default | Description |
|-----|---------|-------------|
| `id` | — | Provider registry key. Supported: `minimax` |
| `region` | `global_en` | Endpoint region. `minimax`: `global_en`, `cn_zh` |
| `model` | provider default | Model to request. `minimax`: `MiniMax-M3`, `MiniMax-M2.7` |

The provider API key is read from the provider's environment variable
(`minimax`: `MINIMAX_API_KEY`). Example:

```json
"cron": {
"provider": { "id": "minimax", "region": "global_en", "model": "MiniMax-M3" }
}
```

## `memory`

Expand Down
35 changes: 31 additions & 4 deletions src/daemon/cron-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import cron from "node-cron";
import { readJSON, writeJSON, readText, writeText, appendText } from "../utils/fs-safe.js";
import { scanProject } from "../scanner/anatomy-scanner.js";
import { detectWaste } from "../tracker/waste-detector.js";
import { resolveProviderConfig, type ProviderConfig } from "./providers.js";
import type { Logger } from "../utils/logger.js";

interface CronAction {
Expand Down Expand Up @@ -106,6 +107,14 @@ export class CronEngine {
);
}

private readProviderConfig(): ProviderConfig | null {
const config = readJSON<{ openwolf?: { cron?: { provider?: ProviderConfig | null } } }>(
path.join(this.wolfDir, "config.json"),
{}
);
return config.openwolf?.cron?.provider ?? null;
}

private readState(): CronState {
return readJSON<CronState>(
path.join(this.wolfDir, "cron-state.json"),
Expand Down Expand Up @@ -327,13 +336,31 @@ export class CronEngine {
try {
// Use spawnSync to pipe prompt via stdin — avoids command-line length limits on Windows
// claude -p (no argument) reads prompt from stdin
// Strip ANTHROPIC_API_KEY so claude uses OAuth subscription credentials
// instead of a potentially depleted API key
const env = { ...process.env };
delete env.ANTHROPIC_API_KEY;
const args = ["-p", "--output-format", "text"];

// A configured provider redirects the CLI at an Anthropic-compatible
// endpoint and model; otherwise the runner keeps its default behavior.
const resolved = resolveProviderConfig(this.readProviderConfig());
if (resolved) {
const apiKey = process.env[resolved.provider.apiKeyEnv];
if (!apiKey) {
throw new Error(
`${resolved.provider.name} provider selected but ${resolved.provider.apiKeyEnv} is not set.`
);
}
env.ANTHROPIC_BASE_URL = resolved.endpoint.anthropicBaseUrl;
env.ANTHROPIC_AUTH_TOKEN = apiKey;
delete env.ANTHROPIC_API_KEY;
args.push("--model", resolved.model);
} else {
// Strip ANTHROPIC_API_KEY so claude uses OAuth subscription credentials
// instead of a potentially depleted API key.
delete env.ANTHROPIC_API_KEY;
}

const claudeBin = process.platform === "win32" ? "claude.cmd" : "claude";
const proc = spawnSync(claudeBin, ["-p", "--output-format", "text"], {
const proc = spawnSync(claudeBin, args, {
input: fullPrompt,
timeout: 120000,
encoding: "utf-8",
Expand Down
131 changes: 131 additions & 0 deletions src/daemon/providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Inference provider registry for the cron AI task runner.
*
* The scheduled `ai_task` jobs drive the `claude` CLI, which speaks the
* Anthropic-compatible wire protocol. By default the runner relies on the CLI's
* OAuth subscription credentials. When a provider is selected in
* `.wolf/config.json` (`openwolf.cron.provider`), the runner instead points the
* CLI at that provider's Anthropic-compatible base URL and requests one of its
* models, so alternative backends can serve the scheduled tasks.
*
* Each provider ships the models it exposes and its regional endpoints, so a
* task can be pinned to a specific region without hard-coding a URL in config.
*/

export interface ProviderEndpoint {
/** Stable region key referenced by config (e.g. "global_en", "cn_zh"). */
region: string;
/** Anthropic-compatible base URL consumed by the `claude` CLI. */
anthropicBaseUrl: string;
/** OpenAI-compatible base URL for direct HTTP clients. */
openaiBaseUrl: string;
}

export interface ProviderModel {
id: string;
/** Maximum combined input + output tokens the model accepts. */
contextWindow: number;
}

export interface InferenceProvider {
/** Human-readable provider name. */
name: string;
/** Environment variable that holds the provider API key. */
apiKeyEnv: string;
/** Model requested when a task does not name one. */
defaultModel: string;
models: ProviderModel[];
endpoints: ProviderEndpoint[];
}

/** Region used when a provider is selected without naming one. */
export const DEFAULT_REGION = "global_en";

export const PROVIDERS: Record<string, InferenceProvider> = {
minimax: {
name: "MiniMax",
apiKeyEnv: "MINIMAX_API_KEY",
defaultModel: "MiniMax-M3",
models: [
{ id: "MiniMax-M3", contextWindow: 1_000_000 },
{ id: "MiniMax-M2.7", contextWindow: 204_800 },
],
endpoints: [
{
region: "global_en",
anthropicBaseUrl: "https://api.minimax.io/anthropic",
openaiBaseUrl: "https://api.minimax.io/v1",
},
{
region: "cn_zh",
anthropicBaseUrl: "https://api.minimaxi.com/anthropic",
openaiBaseUrl: "https://api.minimaxi.com/v1",
},
],
},
};

/** Selection block stored under `openwolf.cron.provider` in config.json. */
export interface ProviderConfig {
/** Provider registry key (e.g. "minimax"). */
id?: string;
/** Endpoint region key; falls back to DEFAULT_REGION. */
region?: string;
/** Model id to request; falls back to the provider's default model. */
model?: string;
}

export interface ResolvedProvider {
provider: InferenceProvider;
endpoint: ProviderEndpoint;
model: string;
}

export function getProvider(id: string): InferenceProvider | undefined {
return PROVIDERS[id.trim().toLowerCase()];
}

export function getEndpoint(
provider: InferenceProvider,
region: string
): ProviderEndpoint | undefined {
return provider.endpoints.find((e) => e.region === region);
}

/**
* Resolve a `provider` config block into a concrete provider, endpoint, and
* model. Returns `null` when no provider is selected (the runner then keeps its
* default subscription behavior). Throws when the provider, region, or model is
* named but unknown, so a misconfigured task fails loudly instead of silently
* running against the wrong backend.
*/
export function resolveProviderConfig(
config: ProviderConfig | undefined | null
): ResolvedProvider | null {
if (!config || !config.id) return null;

const provider = getProvider(config.id);
if (!provider) {
const known = Object.keys(PROVIDERS).join(", ");
throw new Error(`Unknown inference provider "${config.id}" (known: ${known})`);
}

const region = config.region ?? DEFAULT_REGION;
const endpoint = getEndpoint(provider, region);
if (!endpoint) {
const known = provider.endpoints.map((e) => e.region).join(", ");
throw new Error(
`Unknown region "${region}" for provider ${provider.name} (known: ${known})`
);
}

const model = config.model ?? provider.defaultModel;
if (!provider.models.some((m) => m.id === model)) {
const known = provider.models.map((m) => m.id).join(", ");
throw new Error(
`Unknown model "${model}" for provider ${provider.name} (known: ${known})`
);
}

return { provider, endpoint, model };
}
3 changes: 2 additions & 1 deletion src/templates/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"dead_letter_enabled": true,
"heartbeat_interval_minutes": 30,
"use_claude_p": true,
"api_key_env": null
"api_key_env": null,
"provider": null
},
"memory": {
"consolidation_after_days": 7,
Expand Down
86 changes: 86 additions & 0 deletions tests/providers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { test, describe } from "node:test";
import * as assert from "node:assert";

import {
resolveProviderConfig,
getProvider,
getEndpoint,
PROVIDERS,
DEFAULT_REGION,
} from "../src/daemon/providers.ts";

describe("inference provider registry", () => {
test("no provider selected resolves to null", () => {
assert.strictEqual(resolveProviderConfig(null), null);
assert.strictEqual(resolveProviderConfig(undefined), null);
assert.strictEqual(resolveProviderConfig({}), null);
});

test("resolves the default region and model when only id is given", () => {
const resolved = resolveProviderConfig({ id: "minimax" });
assert.ok(resolved);
assert.strictEqual(resolved!.provider.name, "MiniMax");
assert.strictEqual(resolved!.endpoint.region, DEFAULT_REGION);
assert.strictEqual(resolved!.model, PROVIDERS.minimax.defaultModel);
assert.strictEqual(
resolved!.endpoint.anthropicBaseUrl,
"https://api.minimax.io/anthropic"
);
});

test("id lookup is case-insensitive and trims whitespace", () => {
assert.ok(getProvider(" MiniMax "));
const resolved = resolveProviderConfig({ id: "MINIMAX" });
assert.strictEqual(resolved!.provider.name, "MiniMax");
});

test("resolves the CN region endpoint", () => {
const resolved = resolveProviderConfig({ id: "minimax", region: "cn_zh" });
assert.strictEqual(
resolved!.endpoint.anthropicBaseUrl,
"https://api.minimaxi.com/anthropic"
);
assert.strictEqual(
resolved!.endpoint.openaiBaseUrl,
"https://api.minimaxi.com/v1"
);
});

test("both regions are registered for minimax", () => {
const regions = PROVIDERS.minimax.endpoints.map((e) => e.region).sort();
assert.deepStrictEqual(regions, ["cn_zh", "global_en"]);
});

test("exposes both models with their context windows", () => {
const byId = new Map(PROVIDERS.minimax.models.map((m) => [m.id, m.contextWindow]));
assert.strictEqual(byId.get("MiniMax-M3"), 1_000_000);
assert.strictEqual(byId.get("MiniMax-M2.7"), 204_800);
});

test("accepts an explicitly named supported model", () => {
const resolved = resolveProviderConfig({ id: "minimax", model: "MiniMax-M2.7" });
assert.strictEqual(resolved!.model, "MiniMax-M2.7");
});

test("throws on unknown provider", () => {
assert.throws(() => resolveProviderConfig({ id: "nope" }), /Unknown inference provider/);
});

test("throws on unknown region", () => {
assert.throws(
() => resolveProviderConfig({ id: "minimax", region: "mars" }),
/Unknown region/
);
});

test("throws on unknown model", () => {
assert.throws(
() => resolveProviderConfig({ id: "minimax", model: "MiniMax-Z9" }),
/Unknown model/
);
});

test("getEndpoint returns undefined for an unknown region", () => {
assert.strictEqual(getEndpoint(PROVIDERS.minimax, "nowhere"), undefined);
});
});