diff --git a/docs/configuration.md b/docs/configuration.md index 2b780f5..1735ca3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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` diff --git a/src/daemon/cron-engine.ts b/src/daemon/cron-engine.ts index 0a4a8ab..454bc50 100644 --- a/src/daemon/cron-engine.ts +++ b/src/daemon/cron-engine.ts @@ -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 { @@ -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( path.join(this.wolfDir, "cron-state.json"), @@ -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", diff --git a/src/daemon/providers.ts b/src/daemon/providers.ts new file mode 100644 index 0000000..8d19444 --- /dev/null +++ b/src/daemon/providers.ts @@ -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 = { + 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 }; +} diff --git a/src/templates/config.json b/src/templates/config.json index 3a26787..e798186 100644 --- a/src/templates/config.json +++ b/src/templates/config.json @@ -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, diff --git a/tests/providers.test.ts b/tests/providers.test.ts new file mode 100644 index 0000000..5b739a8 --- /dev/null +++ b/tests/providers.test.ts @@ -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); + }); +});