From 45be87e57b6a530743abbb40c897b52019c18d9d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 09:36:39 -0400 Subject: [PATCH] refactor(provider): support coding harnesses only --- CHANGELOG.md | 1 + README.md | 5 +- VISION.md | 69 +++++ docs/index.md | 3 +- docs/providers.md | 56 +--- docs/spec.md | 5 +- package.json | 2 +- pnpm-lock.yaml | 9 - src/package-smoke.test.ts | 8 +- src/provider.test.ts | 402 +----------------------- src/provider.ts | 620 -------------------------------------- website/README.md | 2 +- website/index.html | 2 +- 13 files changed, 92 insertions(+), 1092 deletions(-) create mode 100644 VISION.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 79911fab..9b1dc186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.6.1 - Unreleased +- Removed the direct MiniMax HTTP provider and its transport dependency; provider integrations are now explicitly limited to coding harnesses and agent CLIs. - Added uv workspace member mapping with repository-relative paths and member-local test commands while preserving mixed root source and test groups, thanks @srnm. ## 0.6.0 - 2026-06-11 diff --git a/README.md b/README.md index 0eac1cd7..86c6ee32 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,6 @@ Supported provider names today: - `claude`: local Claude Code CLI in print mode - `cursor`: local Cursor Agent CLI (experimental; `doctor` is enabled by default) - `grok`: local Grok Build CLI -- `minimax`: MiniMax OpenAI-compatible HTTP API; supports `map`, `review`, and `revalidate`, but not `fix` - `opencode`: local OpenCode CLI - `pi`: local Pi coding agent in print mode - `mock`: deterministic test provider @@ -230,4 +229,6 @@ to features so runs can resume and be audited. - Provider output is parsed through strict schemas. - Symlinked directories and generated build output are skipped during mapping. -See `docs/spec.md` for the longer product and implementation spec. +See [`VISION.md`](VISION.md) for project scope and `docs/spec.md` for the +longer product and implementation spec. Provider integrations are limited to +coding harnesses and agent CLIs; direct model API providers are out of scope. diff --git a/VISION.md b/VISION.md new file mode 100644 index 00000000..e05a7e28 --- /dev/null +++ b/VISION.md @@ -0,0 +1,69 @@ +# Clawpatch Vision + +Clawpatch is an automated code-review and repair tool built on coding +harnesses. It maps repositories into bounded features, asks a coding agent to +review or repair them, validates the result, and preserves an auditable record. + +Project overview: [`README.md`](README.md) +Provider details: [`docs/providers.md`](docs/providers.md) + +## Core Boundary + +Clawpatch integrates coding harnesses and agent CLIs, not model APIs. + +A provider adapter belongs in Clawpatch when it launches an installed coding +harness that owns model transport, authentication, tool use, and coding-agent +behavior. Current examples include Codex, ACPX, Claude Code, Cursor Agent, Grok +Build, OpenCode, and Pi. + +Clawpatch will not add direct model inference integrations. This excludes: + +- HTTP clients or SDKs for chat, responses, or model inference APIs +- Clawpatch-owned provider API keys, base URLs, request envelopes, or billing behavior +- model-specific retry, quota, streaming, or structured-output transports +- read-only chat providers that cannot operate as coding harnesses + +If a model provider needs support, add it to a coding harness or ACP adapter +first, then integrate that harness through its stable CLI or protocol. Model +selection and API authentication remain the harness's responsibility. +Adapters may pass documented authentication environment variables through to +the harness; they must not use those credentials to call model APIs themselves. + +This boundary does not prohibit network access for non-model infrastructure +such as package registries. It specifically keeps model transport out of +Clawpatch. + +## Principles + +### 1. Harnesses own agent execution + +Clawpatch should coordinate coding agents, not reimplement their model clients. +Harnesses own authentication, sessions, tools, permissions, model protocols, +and provider-specific compatibility. + +### 2. Review and repair are one workflow + +Provider integrations should fit the full coding lifecycle: inspect a checkout, +produce structured findings, plan or apply explicit fixes, and revalidate. +Partial support needs a strong reason and must still come from a coding +harness, not a bare inference endpoint. + +### 3. Safety stays explicit + +Review and revalidation should be read-only. Repair remains an explicit command +with clean-worktree checks, bounded permissions, validation, and an audit trail. +Clawpatch should expose the harness's security boundary honestly rather than +claiming stronger isolation than the harness provides. + +### 4. Keep provider code small + +An adapter should translate Clawpatch's stable inputs and schemas to a harness's +documented CLI or protocol. Provider-specific transport stacks and credential +systems are out of scope. + +## Contribution Guardrail + +Pull requests and issues proposing direct model API providers are out of scope +and will be closed. Proposals for new coding harnesses should document the +harness command or protocol, authentication ownership, read/write permission +model, structured-output path, timeout behavior, and real review/repair proof. diff --git a/docs/index.md b/docs/index.md index 6e25dec2..43e113f4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -45,6 +45,7 @@ stderr so pipes stay parseable. - **Fixing findings.** [Patching](patching.md) documents the explicit fix workflow and validation steps. - **Reading reports.** [Reporting](reporting.md) shows how to generate Markdown reports and filter by severity. - **Configuring providers.** [Providers](providers.md) lists supported backends and future provider integration plans. +- **Understanding project scope.** [Vision](https://github.com/openclaw/clawpatch/blob/main/VISION.md) defines the coding-harness-only provider boundary. ## All features @@ -65,4 +66,4 @@ stderr so pipes stay parseable. ## Project -Active development; the [changelog](https://github.com/openclaw/clawpatch/blob/main/CHANGELOG.md) tracks recent releases. Goals and implementation details in [spec.md](spec.md). Released under the [MIT license](https://github.com/openclaw/clawpatch/blob/main/LICENSE). +Active development; the [changelog](https://github.com/openclaw/clawpatch/blob/main/CHANGELOG.md) tracks recent releases. Product scope lives in [VISION.md](https://github.com/openclaw/clawpatch/blob/main/VISION.md), with implementation details in [spec.md](spec.md). Released under the [MIT license](https://github.com/openclaw/clawpatch/blob/main/LICENSE). diff --git a/docs/providers.md b/docs/providers.md index b9fe5606..b58fa1a5 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -7,6 +7,9 @@ description: "AI provider configuration and model selection" The default provider is the local Codex CLI. +Clawpatch integrates coding harnesses and agent CLIs only. Direct model API +providers are out of scope; see the project [vision](../VISION.md). + ```bash clawpatch doctor ``` @@ -17,7 +20,6 @@ Provider names today: - `acpx`: routes through any ACP-compatible coding agent via `acpx` - `claude`: shells out to Claude Code in print mode (`claude -p`) - `grok`: shells out to the xAI Grok Build CLI in headless mode (`grok --prompt-file`) -- `minimax`: calls the MiniMax OpenAI-compatible HTTP API directly - `opencode`: shells out to `opencode run --format json` - `pi`: shells out to `pi -p` (non-interactive print mode) - `cursor`: shells out to `cursor-agent -p --output-format json` @@ -350,55 +352,3 @@ implementation uses `--trust` for the explicit trusted-workspace path and never uses `--force` or `--yolo`. Complete HITL verification before promoting this to default provider support, especially for ambient rules, MCP configuration, temporary prompt file handling, timeout behavior, and any claimed read-only mode. - -## MiniMax - -The `minimax` provider calls the MiniMax OpenAI-compatible HTTP API directly. It -requires a Token Plan API key in `MINIMAX_API_KEY` and defaults to -`https://api.minimax.io/v1`. - -```bash -export MINIMAX_API_KEY=sk-cp-... -clawpatch doctor --provider minimax -clawpatch review --provider minimax -clawpatch review --provider minimax --model MiniMax-M2.7-highspeed -``` - -How the MiniMax provider works: - -- Endpoint: `POST ${MINIMAX_BASE_URL:-https://api.minimax.io/v1}/chat/completions` - with `Authorization: Bearer ${MINIMAX_API_KEY}`. `MINIMAX_BASE_URL` is trimmed, - normalized, and must use `https` unless it targets loopback HTTP for local - development; the bearer token is sent to that configured endpoint. -- Operations: `map`, `review`, and `revalidate` are supported. `fix` is not - supported because the chat completions API cannot edit the worktree; it fails - before checking credentials or making network calls with `unsupported-provider` - and exit code 2. -- Structured output: MiniMax M-series Chat Completions do not document OpenAI - `response_format.json_schema` support. Clawpatch therefore sends the provider - schema in the prompt, disables M3 thinking with `thinking: {type: "disabled"}`, - asks for `reasoning_split`, extracts the returned JSON, and validates it - locally with the same Zod schemas used by other providers. -- Model selection: `--model ` sets the request `model`; otherwise - `MINIMAX_MODEL` is used, then `MiniMax-M3`. -- HTTP failures: `401` and `403` map to exit code 4, `429` maps to exit code 5, - and other non-2xx statuses map to exit code 1. Embedded MiniMax auth, - rate-limit, balance, and usage-limit status codes are mapped the same way. - Error bodies are reduced to safe status/type/code signals and byte counts. -- Timeout: 30 minutes by default for provider calls, override with - `CLAWPATCH_MINIMAX_TIMEOUT_MS` or `CLAWPATCH_PROVIDER_TIMEOUT_MS`. The `/models` - doctor probe uses a 30-second timeout. Clawpatch uses a custom undici - dispatcher and an operation abort signal so socket headers/body timeouts and - full response-body reads track the configured timeout. -- Bounds: request bodies over 64 MiB and responses over 10 MiB fail before - parsing; error bodies are capped separately. - -Permission caveat: MiniMax is a remote API provider. Review inputs are sent to -the configured MiniMax endpoint, and read-only behavior depends on the remote -model following the prompt. For untrusted code, run clawpatch in an isolated -checkout and avoid sending secret-bearing files. - -Direct local-model and multi-model panel providers are not implemented yet. The -`acpx` provider is the generic route for ACP-compatible agents; the `grok`, -`opencode`, `pi`, and `cursor` providers are direct integrations for local CLIs; -the `minimax` provider is a direct integration for the MiniMax HTTP API. diff --git a/docs/spec.md b/docs/spec.md index e2bb41dd..d42eaba0 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -421,8 +421,8 @@ Initial config: ".clawpatch/**" ], "provider": { - "name": "openai", - "model": "gpt-5.2" + "name": "codex", + "model": null }, "commands": { "typecheck": null, @@ -906,7 +906,6 @@ Implemented providers: - `claude`: Claude Code CLI in print mode. - `cursor`: experimental Cursor Agent CLI integration. - `grok`: Grok Build CLI. -- `minimax`: MiniMax OpenAI-compatible HTTP API for map, review, and revalidate. - `opencode`: OpenCode CLI. - `pi`: pi coding agent. - `mock` / `mock-fail`: deterministic test providers. diff --git a/package.json b/package.json index dc010b55..536132c9 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "files": [ "dist", "README.md", + "VISION.md", "LICENSE" ], "type": "module", @@ -30,7 +31,6 @@ "crabbox:warmup": "crabbox warmup" }, "dependencies": { - "undici": "^6.26.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68cec90c..b4afbee8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - undici: - specifier: ^6.26.0 - version: 6.26.0 zod: specifier: ^4.4.3 version: 4.4.3 @@ -656,10 +653,6 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@6.26.0: - resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} - engines: {node: '>=18.17'} - vite@8.0.12: resolution: {integrity: sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1194,8 +1187,6 @@ snapshots: undici-types@7.24.6: {} - undici@6.26.0: {} - vite@8.0.12(@types/node@25.9.2): dependencies: lightningcss: 1.32.0 diff --git a/src/package-smoke.test.ts b/src/package-smoke.test.ts index 0c8a6519..39c9c656 100644 --- a/src/package-smoke.test.ts +++ b/src/package-smoke.test.ts @@ -28,14 +28,14 @@ async function packageSmokeTesting(): Promise { it("installs the packed clawpatch artifact with packed runtime dependencies", async () => { const smoke = await packageSmokeTesting(); - const dependencySource = "/repo/node_modules/.pnpm/undici@6.26.0/node_modules/undici"; + const dependencySource = "/repo/node_modules/.pnpm/zod@4.4.3/node_modules/zod"; const clawpatchTarball = "/tmp/clawpatch-0.5.1.tgz"; - const dependencyTarball = "/tmp/undici-6.26.0.tgz"; + const dependencyTarball = "/tmp/zod-4.4.3.tgz"; const dependencyNames = smoke.runtimeDependencyNames({ - dependencies: { undici: "^6.26.0", zod: "^4.4.3" }, + dependencies: { zod: "^4.4.3" }, }); - expect(dependencyNames).toEqual(["undici", "zod"]); + expect(dependencyNames).toEqual(["zod"]); const packArgs = smoke.packDependencyArgs({ dependencyPath: dependencySource, diff --git a/src/provider.test.ts b/src/provider.test.ts index 00eb0d0e..592285b9 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -32,18 +32,9 @@ const { extractAcpxJson, extractCursorJson, extractClaudeStructuredOutput, - extractMinimaxJson, extractOpencodeJson, formatZodError, formatZodIssue, - minimaxBaseUrl, - minimaxDefaultModel, - minimaxDispatcher, - minimaxEndpoint, - minimaxExitCode, - minimaxFailureMessage, - minimaxRequestBody, - minimaxTimeoutMs, parseAcpxJsonOutput, parseAcpxAgent, parseClaudeVersion, @@ -54,7 +45,6 @@ const { piThinkingLevel, providerExitCode, providerJsonSchema, - readMinimaxResponseText, } = __testing; function makeFinding(overrides: Record = {}): Record { @@ -1854,6 +1844,11 @@ describe("providerByName", () => { expect(providerByName("mock").name).toBe("mock"); expect(providerByName("mock-fail").name).toBe("mock-fail"); }); + + it("rejects direct model API providers", () => { + expect(() => providerByName("minimax")).toThrow(/unsupported provider: minimax/u); + expect(() => providerByName("deepseek")).toThrow(/unsupported provider: deepseek/u); + }); }); function buildToleranceFinding(overrides: Record = {}): Record { @@ -2023,390 +2018,3 @@ describe("buildAcpxJsonArgs", () => { expect(args).toContain("gamma"); }); }); - -describe("minimax provider", () => { - const originalFetch = globalThis.fetch; - const originalEnv = { ...process.env }; - - afterEach(() => { - globalThis.fetch = originalFetch; - process.env = { ...originalEnv }; - }); - - it("dispatches via providerByName", () => { - const provider = providerByName("minimax"); - - expect(provider.name).toBe("minimax"); - expect(typeof provider.check).toBe("function"); - expect(typeof provider.review).toBe("function"); - }); - - it("uses minimax-specific timeout before the generic provider timeout", () => { - delete process.env["CLAWPATCH_MINIMAX_TIMEOUT_MS"]; - delete process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; - expect(minimaxTimeoutMs()).toBe(1_800_000); - expect(minimaxTimeoutMs()).toBeGreaterThan(300_000); - - process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"] = "45000"; - expect(minimaxTimeoutMs()).toBe(45_000); - - process.env["CLAWPATCH_MINIMAX_TIMEOUT_MS"] = "120000"; - expect(minimaxTimeoutMs()).toBe(120_000); - - process.env["CLAWPATCH_MINIMAX_TIMEOUT_MS"] = "bad"; - expect(minimaxTimeoutMs()).toBe(1_800_000); - }); - - it("caches the undici dispatcher while the configured timeout is unchanged", () => { - process.env["CLAWPATCH_MINIMAX_TIMEOUT_MS"] = "180000"; - const first = minimaxDispatcher(); - const second = minimaxDispatcher(); - - expect(first).toBe(second); - - process.env["CLAWPATCH_MINIMAX_TIMEOUT_MS"] = "240000"; - const providerDispatcher = minimaxDispatcher(); - expect(providerDispatcher).not.toBe(first); - - const doctorDispatcher = minimaxDispatcher(30_000); - expect(doctorDispatcher).not.toBe(providerDispatcher); - expect(minimaxDispatcher(30_000)).toBe(doctorDispatcher); - }); - - it("normalizes and validates the base URL", () => { - delete process.env["MINIMAX_BASE_URL"]; - expect(minimaxBaseUrl()).toBe("https://api.minimax.io/v1"); - - process.env["MINIMAX_BASE_URL"] = " https://proxy.example.com/v1/?unused=1#frag "; - expect(minimaxBaseUrl()).toBe("https://proxy.example.com/v1"); - expect(minimaxEndpoint("chat/completions")).toBe( - "https://proxy.example.com/v1/chat/completions", - ); - - process.env["MINIMAX_BASE_URL"] = "http://127.0.0.1:8787/v1"; - expect(minimaxBaseUrl()).toBe("http://127.0.0.1:8787/v1"); - - process.env["MINIMAX_BASE_URL"] = "http://proxy.example.com/v1"; - expect(() => minimaxBaseUrl()).toThrow(/https unless it targets loopback/u); - - process.env["MINIMAX_BASE_URL"] = "https://user:pass@api.minimax.io/v1"; - expect(() => minimaxBaseUrl()).toThrow(/must not include URL credentials/u); - - process.env["MINIMAX_BASE_URL"] = "file:///tmp/token"; - expect(() => minimaxBaseUrl()).toThrow(/http or https/u); - }); - - it("uses MiniMax-M3 as the default model and trims overrides", () => { - delete process.env["MINIMAX_MODEL"]; - expect(minimaxDefaultModel()).toBe("MiniMax-M3"); - - process.env["MINIMAX_MODEL"] = " MiniMax-M2.7-highspeed "; - expect(minimaxDefaultModel()).toBe("MiniMax-M2.7-highspeed"); - }); - - it("builds a prompt-validated chat request without unsupported response_format", () => { - const body = minimaxRequestBody( - "review prompt", - { model: null, reasoningEffort: null, skipGitRepoCheck: false }, - reviewJsonSchema, - true, - ) as Record; - - expect(body).toMatchObject({ - model: "MiniMax-M3", - temperature: 0, - thinking: { type: "disabled" }, - reasoning_split: true, - }); - expect(body).not.toHaveProperty("response_format"); - const messages = body["messages"] as Array>; - expect(messages[0]?.["content"]).toContain("Do not modify files"); - expect(messages[1]?.["content"]).toContain("Provider output schema"); - }); - - it("sends Bearer auth and parses a schema-valid review response", async () => { - process.env["MINIMAX_API_KEY"] = "sk-cp-test-secret"; - let capturedBody: Record | null = null; - - globalThis.fetch = (async (input: Parameters[0], init?: RequestInit) => { - expect(String(input)).toBe("https://api.minimax.io/v1/chat/completions"); - const headers = new Headers(init?.headers); - expect(headers.get("Authorization")).toBe("Bearer sk-cp-test-secret"); - capturedBody = JSON.parse(String(init?.body)) as Record; - return new Response( - JSON.stringify({ - choices: [ - { - message: { - content: JSON.stringify({ - findings: [], - inspected: { files: [], symbols: [], notes: ["minimax clean"] }, - }), - }, - }, - ], - base_resp: { status_code: 0, status_msg: "" }, - }), - { status: 200 }, - ); - }) as typeof fetch; - - const output = await providerByName("minimax").review("/repo", "prompt", { - model: null, - reasoningEffort: null, - skipGitRepoCheck: false, - }); - - expect(capturedBody).not.toHaveProperty("response_format"); - expect(output).toEqual({ - findings: [], - inspected: { files: [], symbols: [], notes: ["minimax clean"] }, - droppedFindings: [], - }); - }); - - it("classifies HTTP auth failures and redacts provider error messages", async () => { - process.env["MINIMAX_API_KEY"] = "sk-cp-test-secret"; - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - error: { type: "TimeoutError", message: "SECRET_OUTPUT_MUST_NOT_LEAK" }, - }), - { status: 401 }, - )) as typeof fetch; - - let authError: unknown; - try { - await providerByName("minimax").review("/repo", "prompt", { - model: null, - reasoningEffort: null, - skipGitRepoCheck: false, - }); - } catch (err) { - authError = err; - } - expect(authError).toMatchObject({ code: "provider-auth", exitCode: 4 }); - expect(String(authError)).not.toContain("SECRET_OUTPUT_MUST_NOT_LEAK"); - }); - - it("does not expose raw fetch setup errors", async () => { - process.env["MINIMAX_API_KEY"] = "sk-cp-test-secret"; - globalThis.fetch = (async () => { - throw new TypeError( - "bad Authorization: Bearer sk-cp-test-secret in https://user:pass@example.invalid/v1", - ); - }) as typeof fetch; - - let fetchError: unknown; - try { - await providerByName("minimax").review("/repo", "prompt", { - model: null, - reasoningEffort: null, - skipGitRepoCheck: false, - }); - } catch (err) { - fetchError = err; - } - - expect(fetchError).toMatchObject({ - code: "provider-failure", - exitCode: 1, - message: "minimax provider network error (TypeError)", - }); - expect(String(fetchError)).not.toContain("sk-cp-test-secret"); - expect(String(fetchError)).not.toContain("user:pass"); - }); - - it("keeps the timeout active while reading the response body", async () => { - process.env["MINIMAX_API_KEY"] = "sk-cp-test-secret"; - process.env["CLAWPATCH_MINIMAX_TIMEOUT_MS"] = "5"; - globalThis.fetch = (async (_input: Parameters[0], init?: RequestInit) => - new Response( - new ReadableStream({ - start(controller) { - init?.signal?.addEventListener("abort", () => { - controller.error(new DOMException("aborted", "AbortError")); - }); - }, - }), - { status: 200 }, - )) as typeof fetch; - - await expect( - providerByName("minimax").review("/repo", "prompt", { - model: null, - reasoningEffort: null, - skipGitRepoCheck: false, - }), - ).rejects.toMatchObject({ - code: "provider-failure", - exitCode: 1, - message: "minimax provider timed out after 5ms", - }); - }); - - it("validates the /models envelope during provider checks", async () => { - process.env["MINIMAX_API_KEY"] = "sk-cp-test-secret"; - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - object: "list", - data: [{ id: "MiniMax-M3", object: "model" }], - base_resp: { status_code: 0, status_msg: "" }, - }), - { status: 200 }, - )) as typeof fetch; - - await expect(providerByName("minimax").check("/repo")).resolves.toContain("provider=minimax"); - - globalThis.fetch = (async () => - new Response(JSON.stringify({ object: "not-list", data: [] }), { - status: 200, - })) as typeof fetch; - - await expect(providerByName("minimax").check("/repo")).rejects.toThrow(/not a model list/u); - }); - - it("fails fix before auth lookup or network side effects", async () => { - delete process.env["MINIMAX_API_KEY"]; - let called = false; - globalThis.fetch = (async () => { - called = true; - return new Response("{}"); - }) as typeof fetch; - - await expect( - providerByName("minimax").fix("/repo", "prompt", { - model: null, - reasoningEffort: null, - skipGitRepoCheck: false, - }), - ).rejects.toMatchObject({ code: "unsupported-provider", exitCode: 2 }); - expect(called).toBe(false); - }); - - it("maps HTTP statuses to provider exit codes", () => { - expect(minimaxExitCode(401)).toBe(4); - expect(minimaxExitCode(403)).toBe(4); - expect(minimaxExitCode(429)).toBe(5); - expect(minimaxExitCode(500)).toBe(1); - }); - - it("redacts provider error bodies while keeping safe status signals", () => { - const message = minimaxFailureMessage( - 401, - JSON.stringify({ - error: { - type: "authorized_error", - message: "SECRET_OUTPUT_MUST_NOT_LEAK", - }, - }), - ); - - expect(message).toContain("auth failed"); - expect(message).toContain("error.type=authorized_error"); - expect(message).not.toContain("SECRET_OUTPUT_MUST_NOT_LEAK"); - }); - - it("extracts JSON from chat-completions and responses envelopes", () => { - expect( - extractMinimaxJson( - JSON.stringify({ - choices: [{ message: { content: '{"features":[],"notes":[]}' } }], - base_resp: { status_code: 0, status_msg: "" }, - }), - ), - ).toEqual({ features: [], notes: [] }); - - expect( - extractMinimaxJson( - JSON.stringify({ - output_text: '{"outcome":"fixed","reasoning":"ok","commands":[]}', - }), - ), - ).toEqual({ outcome: "fixed", reasoning: "ok", commands: [] }); - }); - - it("throws malformed-output for empty choices or non-JSON content", () => { - expectMalformed( - () => extractMinimaxJson(JSON.stringify({ choices: [] })), - /missing choices\[0\]\.message\.content/u, - ); - expectMalformed( - () => extractMinimaxJson(JSON.stringify({ choices: [{ message: { content: "plain" } }] })), - /contained no Clawpatch JSON/u, - ); - }); - - it("treats a nonzero base_resp status as provider failure without leaking status_msg", () => { - expect(() => - extractMinimaxJson( - JSON.stringify({ - choices: [{ message: { content: '{"findings":[]}' } }], - base_resp: { status_code: 1001, status_msg: "SECRET_OUTPUT_MUST_NOT_LEAK" }, - }), - ), - ).toThrow(/base_resp\.status_code=1001/u); - expect(() => - extractMinimaxJson( - JSON.stringify({ - choices: [{ message: { content: '{"findings":[]}' } }], - base_resp: { status_code: 1001, status_msg: "SECRET_OUTPUT_MUST_NOT_LEAK" }, - }), - ), - ).not.toThrow(/SECRET_OUTPUT_MUST_NOT_LEAK/u); - - let authError: unknown; - try { - extractMinimaxJson( - JSON.stringify({ - choices: [{ message: { content: '{"findings":[]}' } }], - base_resp: { status_code: 1004, status_msg: "SECRET_OUTPUT_MUST_NOT_LEAK" }, - }), - ); - } catch (err) { - authError = err; - } - expect(authError).toMatchObject({ code: "provider-auth", exitCode: 4 }); - expect(String(authError)).not.toContain("SECRET_OUTPUT_MUST_NOT_LEAK"); - - let rateLimitError: unknown; - try { - extractMinimaxJson( - JSON.stringify({ - choices: [{ message: { content: '{"findings":[]}' } }], - base_resp: { status_code: 1002, status_msg: "SECRET_OUTPUT_MUST_NOT_LEAK" }, - }), - ); - } catch (err) { - rateLimitError = err; - } - expect(rateLimitError).toMatchObject({ code: "provider-failure", exitCode: 5 }); - - for (const code of [1008, 2056]) { - let quotaError: unknown; - try { - extractMinimaxJson( - JSON.stringify({ - choices: [{ message: { content: '{"findings":[]}' } }], - base_resp: { status_code: code, status_msg: "SECRET_OUTPUT_MUST_NOT_LEAK" }, - }), - ); - } catch (err) { - quotaError = err; - } - expect(quotaError).toMatchObject({ code: "provider-failure", exitCode: 5 }); - expect(String(quotaError)).not.toContain("SECRET_OUTPUT_MUST_NOT_LEAK"); - } - }); - - it("bounds response body reads", async () => { - await expect( - readMinimaxResponseText( - new Response("abcd"), - 3, - () => new ClawpatchError("too large", 8, "malformed-output"), - ), - ).rejects.toMatchObject({ code: "malformed-output", exitCode: 8 }); - }); -}); diff --git a/src/provider.ts b/src/provider.ts index 70f55526..3fe9c325 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,7 +1,6 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Agent, type Dispatcher } from "undici"; import { z, type ZodError, type ZodIssue, type ZodType } from "zod"; import { runCommandArgs } from "./exec.js"; import { ClawpatchError } from "./errors.js"; @@ -279,9 +278,6 @@ export function providerByName(name: string): Provider { if (name === "grok") { return grokProvider; } - if (name === "minimax") { - return minimaxProvider; - } if (name === "pi") { return piProvider; } @@ -454,612 +450,6 @@ const grokProvider: Provider = { }, }; -const MINIMAX_PROVIDER_NAME = "minimax"; -const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1"; -const MINIMAX_DEFAULT_MODEL = "MiniMax-M3"; -const MINIMAX_DEFAULT_TIMEOUT_MS = 1_800_000; -const MINIMAX_CHECK_TIMEOUT_MS = 30_000; -const MINIMAX_CONNECT_TIMEOUT_MS = 10_000; -const MINIMAX_MAX_REQUEST_BYTES = 64 * 1024 * 1024; -const MINIMAX_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; -const MINIMAX_MAX_ERROR_BYTES = 16 * 1024; -const MINIMAX_MAX_MODELS_BYTES = 1024 * 1024; - -let minimaxDispatcherCache: { timeoutMs: number; dispatcher: InstanceType } | null = - null; - -function minimaxTimeoutMs(): number { - const raw = - process.env["CLAWPATCH_MINIMAX_TIMEOUT_MS"] ?? process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; - if (raw === undefined) { - return MINIMAX_DEFAULT_TIMEOUT_MS; - } - const parsed = Number(raw); - return Number.isFinite(parsed) && parsed > 0 ? parsed : MINIMAX_DEFAULT_TIMEOUT_MS; -} - -function minimaxDispatcher(timeoutMs = minimaxTimeoutMs()): InstanceType { - if (minimaxDispatcherCache !== null && minimaxDispatcherCache.timeoutMs === timeoutMs) { - return minimaxDispatcherCache.dispatcher; - } - if (minimaxDispatcherCache !== null) { - void minimaxDispatcherCache.dispatcher.close(); - } - const dispatcher = new Agent({ - headersTimeout: timeoutMs, - bodyTimeout: timeoutMs, - connectTimeout: MINIMAX_CONNECT_TIMEOUT_MS, - }); - minimaxDispatcherCache = { timeoutMs, dispatcher }; - return dispatcher; -} - -function minimaxBaseUrl(): string { - const raw = process.env["MINIMAX_BASE_URL"]?.trim(); - return normalizeMinimaxBaseUrl(raw && raw.length > 0 ? raw : MINIMAX_DEFAULT_BASE_URL); -} - -function normalizeMinimaxBaseUrl(raw: string): string { - let url: URL; - try { - url = new URL(raw); - } catch { - throw new ClawpatchError( - "minimax provider MINIMAX_BASE_URL must be a valid http(s) URL", - 4, - "provider-auth", - ); - } - if (url.protocol !== "https:" && url.protocol !== "http:") { - throw new ClawpatchError( - "minimax provider MINIMAX_BASE_URL must use http or https", - 4, - "provider-auth", - ); - } - if (url.protocol === "http:" && !isLoopbackHostname(url.hostname)) { - throw new ClawpatchError( - "minimax provider MINIMAX_BASE_URL must use https unless it targets loopback", - 4, - "provider-auth", - ); - } - if (url.username.length > 0 || url.password.length > 0) { - throw new ClawpatchError( - "minimax provider MINIMAX_BASE_URL must not include URL credentials", - 4, - "provider-auth", - ); - } - url.hash = ""; - url.search = ""; - return url.toString().replace(/\/+$/u, ""); -} - -function isLoopbackHostname(hostname: string): boolean { - const normalized = hostname.toLowerCase(); - return ( - normalized === "localhost" || - normalized === "::1" || - normalized === "[::1]" || - /^127(?:\.\d{1,3}){3}$/u.test(normalized) - ); -} - -function minimaxEndpoint(path: string): string { - return new URL(path.replace(/^\/+/u, ""), `${minimaxBaseUrl()}/`).toString(); -} - -function minimaxDefaultModel(): string { - const raw = process.env["MINIMAX_MODEL"]?.trim(); - return raw && raw.length > 0 ? raw : MINIMAX_DEFAULT_MODEL; -} - -function minimaxExitCode(status: number): number { - if (status === 401 || status === 403) { - return 4; - } - if (status === 429) { - return 5; - } - return 1; -} - -function minimaxHttpErrorCode(status: number): "provider-auth" | "provider-failure" { - return status === 401 || status === 403 ? "provider-auth" : "provider-failure"; -} - -function minimaxFailureMessage(status: number, body: string): string { - const signal = minimaxFailureSignal(body); - const detail = - signal.length === 0 ? `body chars=${body.length}` : `${signal}; body chars=${body.length}`; - if (status === 401 || status === 403) { - return `minimax provider auth failed (HTTP ${status}). Check MINIMAX_API_KEY. ${detail}`; - } - if (status === 429) { - return `minimax provider rate-limited (HTTP 429). ${detail}`; - } - return `minimax provider failed (HTTP ${status}). ${detail}`; -} - -function minimaxFailureSignal(body: string): string { - let parsed: unknown; - try { - parsed = JSON.parse(body) as unknown; - } catch { - return ""; - } - const parts = minimaxSignalParts(parsed, ""); - return parts.length === 0 ? "" : parts.join("; "); -} - -function minimaxSignalParts(value: unknown, prefix: string): string[] { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return []; - } - const record = value as Record; - const parts = [ - stringPart(`${prefix}type`, record["type"]), - stringPart(`${prefix}code`, record["code"]), - stringPart(`${prefix}status`, record["status"]), - stringPart(`${prefix}status_code`, record["status_code"]), - ].filter((part) => part.length > 0); - for (const key of ["error", "base_resp"]) { - const nested = record[key]; - const nestedPrefix = prefix.length === 0 ? `${key}.` : `${prefix}${key}.`; - parts.push(...minimaxSignalParts(nested, nestedPrefix)); - } - return parts; -} - -function minimaxRequestBody( - prompt: string, - options: ProviderOptions, - schema: object, - readOnly: boolean, -): object { - const model = - options.model !== null && options.model.trim().length > 0 - ? options.model.trim() - : minimaxDefaultModel(); - // MiniMax M-series Chat Completions do not document response_format/json_schema. - // Keep schema enforcement in Clawpatch's parser instead of sending unsupported API fields. - return { - model, - messages: [ - { - role: "system", - content: readOnly - ? "You are a read-only Clawpatch provider. Do not modify files, run commands, call tools, or include prose outside the final JSON object." - : "You are a Clawpatch provider. Return only the requested JSON object.", - }, - { role: "user", content: minimaxPrompt(prompt, schema) }, - ], - temperature: 0, - thinking: { type: "disabled" }, - reasoning_split: true, - }; -} - -function minimaxPrompt(prompt: string, schema: object): string { - return `${prompt} - -Provider output schema: -${JSON.stringify(schema, null, 2)} - -Return exactly one JSON object matching the schema. Do not wrap it in Markdown.`; -} - -async function runMinimaxJson( - _root: string, - prompt: string, - options: ProviderOptions, - schema: object, - readOnly: boolean, -): Promise { - const apiKey = minimaxApiKey(); - const requestJson = JSON.stringify(minimaxRequestBody(prompt, options, schema, readOnly)); - const requestBytes = Buffer.byteLength(requestJson, "utf8"); - if (requestBytes > MINIMAX_MAX_REQUEST_BYTES) { - throw new ClawpatchError( - `minimax provider request body exceeds ${MINIMAX_MAX_REQUEST_BYTES} bytes (${requestBytes})`, - 1, - "provider-failure", - ); - } - const text = await withMinimaxResponse( - minimaxEndpoint("chat/completions"), - { - method: "POST", - headers: minimaxHeaders(apiKey), - body: requestJson, - }, - minimaxTimeoutMs(), - async (response) => { - const responseText = response.ok - ? await readMinimaxResponseText( - response, - MINIMAX_MAX_RESPONSE_BYTES, - () => - new ClawpatchError( - `minimax provider response exceeded ${MINIMAX_MAX_RESPONSE_BYTES} bytes`, - 8, - "malformed-output", - ), - ) - : await readMinimaxResponseText( - response, - MINIMAX_MAX_ERROR_BYTES, - () => - new ClawpatchError( - `minimax provider error body exceeded ${MINIMAX_MAX_ERROR_BYTES} bytes`, - minimaxExitCode(response.status), - minimaxHttpErrorCode(response.status), - ), - ); - if (!response.ok) { - throw new ClawpatchError( - minimaxFailureMessage(response.status, responseText), - minimaxExitCode(response.status), - minimaxHttpErrorCode(response.status), - ); - } - return responseText; - }, - ); - return extractMinimaxJson(text); -} - -function minimaxApiKey(): string { - const apiKey = process.env["MINIMAX_API_KEY"]; - if (apiKey === undefined || apiKey.length === 0) { - throw new ClawpatchError( - "minimax provider requires MINIMAX_API_KEY env var", - 4, - "provider-auth", - ); - } - return apiKey; -} - -function minimaxHeaders(apiKey: string): Record { - return { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }; -} - -async function withMinimaxResponse( - url: string, - init: Omit, - timeoutMs: number, - consume: (response: Response) => Promise, -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(url, { - ...init, - signal: controller.signal, - dispatcher: minimaxDispatcher(timeoutMs) as unknown as Dispatcher, - } as RequestInit & { dispatcher: Dispatcher }); - return await consume(response); - } catch (err) { - if (err instanceof ClawpatchError) { - throw err; - } - if (isMinimaxTimeoutError(err)) { - throw new ClawpatchError( - `minimax provider timed out after ${timeoutMs}ms`, - 1, - "provider-failure", - ); - } - const name = err instanceof Error ? safeProviderPreview(err.name, 40) : "unknown"; - throw new ClawpatchError(`minimax provider network error (${name})`, 1, "provider-failure"); - } finally { - clearTimeout(timer); - } -} - -function isMinimaxTimeoutError(err: unknown): boolean { - if (!(err instanceof Error)) { - return false; - } - const cause = - err.cause instanceof Error ? `${err.cause.name} ${err.cause.message}` : String(err.cause ?? ""); - return /AbortError|TimeoutError|timed out|timeout|UND_ERR_(?:HEADERS|BODY)_TIMEOUT/iu.test( - `${err.name} ${err.message} ${cause}`, - ); -} - -async function readMinimaxResponseText( - response: Response, - limitBytes: number, - overflowError: () => ClawpatchError, -): Promise { - if (response.body === null) { - return ""; - } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let bytes = 0; - let text = ""; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) { - return `${text}${decoder.decode()}`; - } - if (value === undefined) { - continue; - } - bytes += value.byteLength; - if (bytes > limitBytes) { - await reader.cancel().catch(() => {}); - throw overflowError(); - } - text += decoder.decode(value, { stream: true }); - } - } catch (err) { - if (err instanceof ClawpatchError) { - throw err; - } - if (isMinimaxTimeoutError(err)) { - throw err; - } - throw new ClawpatchError( - "minimax provider failed while reading response", - 1, - "provider-failure", - ); - } -} - -function extractMinimaxJson(text: string): unknown { - let envelope: unknown; - try { - envelope = JSON.parse(text) as unknown; - } catch { - throw new ClawpatchError( - `minimax provider produced a malformed JSON envelope (body chars=${text.length})`, - 8, - "malformed-output", - ); - } - const embeddedError = minimaxEmbeddedError(envelope); - if (embeddedError !== null) { - throw new ClawpatchError( - `minimax provider failed: ${embeddedError.signal}`, - embeddedError.exitCode, - embeddedError.exitCode === 4 ? "provider-auth" : "provider-failure", - ); - } - const content = minimaxEnvelopeText(envelope); - if (content === null || content.trim().length === 0) { - throw new ClawpatchError( - "minimax provider response missing choices[0].message.content or output_text", - 8, - "malformed-output", - ); - } - const parsed = extractJson(content); - if (parsed === null) { - throw new ClawpatchError( - `minimax provider content contained no Clawpatch JSON (content chars=${content.length})`, - 8, - "malformed-output", - ); - } - return parsed; -} - -type MinimaxEmbeddedError = { signal: string; exitCode: number }; - -function minimaxEmbeddedError(envelope: unknown): MinimaxEmbeddedError | null { - if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) { - return null; - } - const record = envelope as Record; - const baseResp = record["base_resp"]; - if (typeof baseResp === "object" && baseResp !== null && !Array.isArray(baseResp)) { - const base = baseResp as Record; - if (typeof base["status_code"] === "number" && base["status_code"] !== 0) { - const signal = minimaxSignalParts(base, "base_resp.").join("; "); - return { - signal: signal.length === 0 ? "base_resp.status_code nonzero" : signal, - exitCode: minimaxEmbeddedExitCode(base["status_code"]), - }; - } - } - if (record["status"] === "failed") { - const signal = minimaxSignalParts(record, "").join("; "); - return { - signal: signal.length === 0 ? "status=failed" : signal, - exitCode: minimaxEmbeddedExitCode(minimaxNumericErrorCode(record)), - }; - } - return null; -} - -function minimaxNumericErrorCode(record: Record): number | null { - for (const key of ["status_code", "code"]) { - const value = record[key]; - if (typeof value === "number") { - return value; - } - if (typeof value === "string" && /^-?\d+$/u.test(value)) { - return Number(value); - } - } - return null; -} - -function minimaxEmbeddedExitCode(code: number | null): number { - if (code === 1004 || code === 2049) { - return 4; - } - if (code === 1002 || code === 1008 || code === 2056) { - return 5; - } - return 1; -} - -function validateMinimaxModelsEnvelope(text: string): void { - let envelope: unknown; - try { - envelope = JSON.parse(text) as unknown; - } catch { - throw new ClawpatchError( - `minimax provider models response was malformed JSON (body chars=${text.length})`, - 1, - "provider-failure", - ); - } - const embeddedError = minimaxEmbeddedError(envelope); - if (embeddedError !== null) { - throw new ClawpatchError( - `minimax provider models check failed: ${embeddedError.signal}`, - embeddedError.exitCode, - embeddedError.exitCode === 4 ? "provider-auth" : "provider-failure", - ); - } - if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) { - throw new ClawpatchError( - "minimax provider models response was not an object", - 1, - "provider-failure", - ); - } - const record = envelope as Record; - if (record["object"] !== "list" || !Array.isArray(record["data"])) { - throw new ClawpatchError( - "minimax provider models response was not a model list", - 1, - "provider-failure", - ); - } -} - -function minimaxEnvelopeText(envelope: unknown): string | null { - if (typeof envelope === "string") { - return envelope; - } - if (typeof envelope !== "object" || envelope === null) { - return null; - } - const record = envelope as Record; - if (typeof record["output_text"] === "string") { - return record["output_text"]; - } - const choices = record["choices"]; - if (Array.isArray(choices)) { - const first = choices[0] as unknown; - if (typeof first === "object" && first !== null) { - const firstRecord = first as Record; - const message = firstRecord["message"]; - if (typeof message === "object" && message !== null) { - const content = (message as Record)["content"]; - if (typeof content === "string") { - return content; - } - } - if (typeof firstRecord["text"] === "string") { - return firstRecord["text"]; - } - } - } - const output = record["output"]; - if (Array.isArray(output)) { - for (const item of output) { - if (typeof item !== "object" || item === null) { - continue; - } - const content = (item as Record)["content"]; - if (!Array.isArray(content)) { - continue; - } - const texts = content - .map((part) => { - if (typeof part !== "object" || part === null) { - return ""; - } - const text = (part as Record)["text"]; - return typeof text === "string" ? text : ""; - }) - .filter((part) => part.length > 0); - if (texts.length > 0) { - return texts.join(""); - } - } - } - return null; -} - -const minimaxProvider: Provider = { - name: MINIMAX_PROVIDER_NAME, - async check(_root: string): Promise { - await withMinimaxResponse( - minimaxEndpoint("models"), - { - method: "GET", - headers: { Authorization: `Bearer ${minimaxApiKey()}` }, - }, - MINIMAX_CHECK_TIMEOUT_MS, - async (response) => { - const text = await readMinimaxResponseText( - response, - response.ok ? MINIMAX_MAX_MODELS_BYTES : MINIMAX_MAX_ERROR_BYTES, - () => - new ClawpatchError( - `minimax provider models response exceeded ${ - response.ok ? MINIMAX_MAX_MODELS_BYTES : MINIMAX_MAX_ERROR_BYTES - } bytes`, - minimaxExitCode(response.status), - response.ok ? "provider-failure" : minimaxHttpErrorCode(response.status), - ), - ); - if (!response.ok) { - throw new ClawpatchError( - minimaxFailureMessage(response.status, text), - minimaxExitCode(response.status), - minimaxHttpErrorCode(response.status), - ); - } - validateMinimaxModelsEnvelope(text); - }, - ); - return `provider=minimax default-model=${minimaxDefaultModel()} base=${minimaxBaseUrl()}`; - }, - async map(root: string, prompt: string, options: ProviderOptions): Promise { - const output = await runMinimaxJson(root, prompt, options, agentMapJsonSchema, true); - return parseOrThrow(agentMapOutputSchema, output, "minimax agent-map"); - }, - async review( - root: string, - prompt: string, - options: ProviderOptions, - ): Promise { - const output = await runMinimaxJson(root, prompt, options, reviewJsonSchema, true); - return parseReviewOutput(output); - }, - async fix(_root: string, _prompt: string, _options: ProviderOptions): Promise { - throw new ClawpatchError( - "minimax provider does not support clawpatch fix: the chat-completions API cannot edit the worktree. Use --provider codex, acpx, claude, opencode, or pi for fix; use --provider minimax for map, review, and revalidate.", - 2, - "unsupported-provider", - ); - }, - async revalidate( - root: string, - prompt: string, - options: ProviderOptions, - ): Promise { - const output = await runMinimaxJson(root, prompt, options, revalidateJsonSchema, true); - return parseOrThrow(revalidateOutputSchema, output, "minimax revalidate"); - }, -}; - const PI_DEFAULT_TIMEOUT_MS = 180_000; const CURSOR_DEFAULT_TIMEOUT_MS = 300_000; const CURSOR_MIN_SAFE_APP_VERSION = "2.5.0"; @@ -2957,16 +2347,6 @@ export const __testing = { parseCodexJson, parseReviewOutput, parseOrThrow, - extractMinimaxJson, - minimaxBaseUrl, - minimaxDefaultModel, - minimaxDispatcher, - minimaxEndpoint, - minimaxExitCode, - minimaxFailureMessage, - minimaxRequestBody, - minimaxTimeoutMs, - readMinimaxResponseText, piThinkingLevel, providerExitCode, providerJsonSchema, diff --git a/website/README.md b/website/README.md index 409c5d64..4101fbbb 100644 --- a/website/README.md +++ b/website/README.md @@ -24,4 +24,4 @@ Keep copy aligned with the implemented CLI: - review: sequential feature review - fix: `clawpatch fix --finding ` - no auto-commit, PR creation, or landing yet -- no direct OpenAI provider yet +- no direct model API providers; coding harnesses only diff --git a/website/index.html b/website/index.html index d8bbf0e2..bff1f4cf 100644 --- a/website/index.html +++ b/website/index.html @@ -1383,7 +1383,7 @@

Next Steps

  • CI/CD Integration — Run review and reporting in GitHub Actions
  • Custom Mappers — Support additional frameworks and languages
  • Finding Templates — Create custom review rules
  • -
  • Provider API — Implement custom AI providers
  • +
  • Coding harnesses — Integrate agent CLIs and ACP adapters
  • Made with care by the OpenClaw team. Released under MIT license.