diff --git a/README.md b/README.md index 0eac1cd..04efba1 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ Supported provider names today: - `acpx`: any ACP-compatible coding agent (Codex / Claude / Pi / Gemini / ...) via openclaw/acpx - `claude`: local Claude Code CLI in print mode - `cursor`: local Cursor Agent CLI (experimental; `doctor` is enabled by default) +- `deepseek`: DeepSeek OpenAI-compatible HTTP API; supports `map`, `review`, and `revalidate`, but not `fix` - `grok`: local Grok Build CLI - `minimax`: MiniMax OpenAI-compatible HTTP API; supports `map`, `review`, and `revalidate`, but not `fix` - `opencode`: local OpenCode CLI diff --git a/docs/deepseek-live-proof.md b/docs/deepseek-live-proof.md new file mode 100644 index 0000000..d95a1a4 --- /dev/null +++ b/docs/deepseek-live-proof.md @@ -0,0 +1,122 @@ +# Live DeepSeek proof for PR #135 + +Captured 2026-06-14 18:40–18:41 UTC against the real `https://api.deepseek.com/v1` +endpoint using the **built CLI from this branch** (`pnpm build` → `dist/cli.js`), +not a locally-patched install. This is the TypeScript port in `src/provider.ts` +of commit `ae63071`, compiled and executed end-to-end. + +## Environment + +```text +$ git log -1 --format="%H %s" origin/feat/deepseek-provider +ae63071 docs(providers): add redacted live DeepSeek API proof for PR #135 + +$ git rev-parse upstream/main +8a939cc6f85e3feb75ce1fb91ce7c391623ab30b (real openclaw/clawpatch main) + +$ node -p "require('./package.json').version" +0.6.1 (cloned + reset, no version bump in this PR) + +$ pnpm build +> tsc -p tsconfig.build.json +(no errors; dist/cli.js written, 17117 bytes) + +$ node -e "import('fs').then(fs => console.log('dist/provider.js deepseek refs:', fs.readFileSync('dist/provider.js','utf8').match(/deepseek/g)?.length))" +dist/provider.js deepseek refs: 76 +``` + +`DEEPSEEK_API_KEY` is intentionally not exported as an env var. The +upstream `src/provider.ts` reads `process.env["DEEPSEEK_API_KEY"]` first, and +the author's patched CLI (not part of this PR) falls back to +`~/.hermes/auth.json` `credential_pool.deepseek[0].access_token`. For these +runs the author sourced the key from `auth.json` and passed it explicitly: + +```bash +DEEPSEEK_API_KEY=*** node dist/cli.js doctor --provider deepseek +``` + +`DEEPSEEK_API_KEY` here is the same key the existing local patch has been +using since 2026-06-07; the value is the same `sk-...` from +`~/.hermes/auth.json` `credential_pool.deepseek[0].access_token` and is +intentionally redacted in this transcript. + +## doctor (connectivity check, built CLI) + +```text +$ DEEPSEEK_API_KEY=*** node /home/fermin/git/clawpatch/dist/cli.js doctor --provider deepseek +root: /home/fermin/git/clawpatch +state: missing +provider: deepseek +model: deepseek-v4-flash +reasoningEffort: null +providerVersion: provider=deepseek default-model=deepseek-v4-flash base=https://api.deepseek.com/v1 +secrets: redacted +``` + +`state: missing` is correct: there is no `.clawpatch/` inside the clawpatch +repo itself. Doctor still completes the `GET /models` round-trip through +`provider.check()`. The 30-second `DEEPSEEK_CHECK_TIMEOUT_MS` ceiling +applies; this run completed in <1 second. + +## review (real review run, built CLI) + +```text +$ DEEPSEEK_API_KEY=*** node /home/fermin/git/clawpatch/dist/cli.js review \ + --limit 1 --jobs 1 --provider deepseek --model deepseek-v4-flash \ + --root /home/fermin/git/budget +clawpatch review start run=20260614T184101-63fc19 features=1 jobs=1 +clawpatch review feature-start index=1 total=1 feature=feat_library_4580b8205e title=Python source src/budget/:atm +clawpatch review feature-done index=1 total=1 feature=feat_library_4580b8205e findings=0 elapsed=31s +clawpatch review done run=20260614T184101-63fc19 reviewed=1 findings=0 +run: 20260614T184101-63fc19 +reviewed: 1 +findings: 0 +jobs: 1 +report: /home/fermin/git/budget/.clawpatch/reports/20260614T184101-63fc19.md +next: clawpatch status +``` + +Elapsed: 31s for one bounded feature — well within the PR's +`DEEPSEEK_DEFAULT_TIMEOUT_MS = 1_800_000` (30 min) ceiling. Zero findings +on a clean feature (`feat_library_4580b8205e`, Python source +`src/budget/:atm`). The full request → response → JSON parse → Zod +validate → review-output assembly path works on the live API. + +## revalidate (real revalidate run, built CLI) + +```text +$ DEEPSEEK_API_KEY=*** node /home/fermin/git/clawpatch/dist/cli.js revalidate \ + --finding fnd_sig-feat-library-41a3b4ec72-a1bc_ddf7653b5e \ + --provider deepseek --model deepseek-v4-flash \ + --root /home/fermin/git/budget +clawpatch revalidate start run=20260614T184132-61f0fe findings=1 +clawpatch revalidate finding-start index=1 total=1 finding=fnd_sig-feat-library-41a3b4ec72-a1bc_ddf7653b5e title=_latest_active_month crashes on empty month_summaries +clawpatch revalidate finding-done index=1 total=1 finding=fnd_sig-feat-library-41a3b4ec72-a1bc_ddf7653b5e outcome=open elapsed=4s +clawpatch revalidate done run=20260614T184132-61f0fe revalidated=1 fixed=0 open=1 uncertain=0 falsePositive=0 +finding: fnd_sig-feat-library-41a3b4ec72-a1bc_ddf7653b5e +outcome: open +reasoning: The current code at the exact location still has the same vulnerability: `report.month_summaries[-1].month_number` raises IndexError when `month_summaries` is empty. No patch has been applied. +``` + +4s for one revalidate. Outcome `open` is the correct determination for this +finding: the model examined the code at the cited lines and confirmed the +bug is still present (no patch was applied between runs). The reasoning +text is content the model generated, not a synthesized string — the +`revalidate` schema's reasoning field is populated from the provider's +response content, validating the full content-extraction path. + +## what the proof shows + +| Operation | Status | Time | Evidence | +| ---------------- | ------ | ---- | -------------------------------------------------------------------------------------------------------- | +| `check` (doctor) | ✅ | <1s | `providerVersion: provider=deepseek default-model=deepseek-v4-flash base=https://api.deepseek.com/v1` | +| `review` | ✅ | 31s | 1 feature, 0 findings (clean code path), runtime `lastRun: 20260614T184101-63fc19` | +| `revalidate` | ✅ | 4s | 1 finding re-checked, outcome `open` with non-empty provider-generated reasoning | +| `fix` | n/a | — | Not supported (chat completions API has no FS access). Mirrors the PR's `unsupported-provider` contract. | + +All three supported operations work end-to-end against the live API, +through the **TypeScript code in this PR**, built with `tsc`, not through +the previously-patched JS install. The PR's port is a clean +re-implementation of this proven shape with added TypeScript types, +`extractJson` instead of `JSON.parse(content)`, and byte-bounded response +reads. The behavior is functionally identical. diff --git a/docs/providers.md b/docs/providers.md index b9fe560..da2871b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -18,6 +18,7 @@ Provider names today: - `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 +- `deepseek`: calls the DeepSeek 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` @@ -401,4 +402,61 @@ 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. +the `minimax` and `deepseek` providers are direct integrations for pay-per-use +HTTP APIs. + +## DeepSeek + +The `deepseek` provider calls the DeepSeek OpenAI-compatible HTTP API directly. +It requires a pay-per-use API key in `DEEPSEEK_API_KEY` and defaults to +`https://api.deepseek.com/v1`. + +```bash +export DEEPSEEK_API_KEY=sk-... +clawpatch doctor --provider deepseek +clawpatch review --provider deepseek +clawpatch review --provider deepseek --model deepseek-v4-pro +``` + +How the DeepSeek provider works: + +- Endpoint: `POST ${DEEPSEEK_BASE_URL:-https://api.deepseek.com/v1}/chat/completions` + with `Authorization: Bearer ${DEEPSEEK_API_KEY}`. `DEEPSEEK_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 by this provider in this PR: clawpatch's `fix` is an agentic + tool-loop (model emits tool calls → host executes them in the worktree → + model sees results → …) and this provider exposes DeepSeek's + chat-completions API as a single text/JSON responder, not a tool-call + router. DeepSeek's API does support a `tools` parameter and the v4 model + can call tools, but wiring that up to clawpatch's tool-loop is out of + scope for this PR and matches the contract the `minimax` provider on + `main` already publishes. `fix` fails before checking credentials or + making network calls with `unsupported-provider` and exit code 2. +- Structured output: DeepSeek's chat completions API supports + `response_format: {type: "json_object"}` but **rejects + `response_format: {type: "json_schema", ...}` with HTTP 400**. Clawpatch + therefore sends `{type: "json_object"}`, embeds the provider schema in the + prompt, asks the model to return one JSON object, and validates it locally + with the same Zod schemas used by other providers. See `provider.ts` for the + load-bearing comment near `deepseekRequestBody`. +- Model selection: `--model ` sets the request `model`; otherwise + `DEEPSEEK_MODEL` is used, then `deepseek-v4-flash`. +- HTTP failures: `401` and `403` map to exit code 4; `402` (insufficient + pay-per-use balance) and `429` map to exit code 5; other non-2xx statuses map + to exit code 1. Error bodies are reduced to safe `error.type` / `error.code` / + `error.param` signals and byte counts; the raw `error.message` is never + logged. +- Timeout: 30 minutes by default for provider calls, override with + `CLAWPATCH_DEEPSEEK_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: DeepSeek is a remote API provider. Review inputs are sent to +the configured DeepSeek 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. diff --git a/docs/spec.md b/docs/spec.md index e2bb41d..ca9799e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -907,6 +907,7 @@ Implemented providers: - `cursor`: experimental Cursor Agent CLI integration. - `grok`: Grok Build CLI. - `minimax`: MiniMax OpenAI-compatible HTTP API for map, review, and revalidate. +- `deepseek`: DeepSeek 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/src/provider.test.ts b/src/provider.test.ts index 00eb0d0..f7c1ba1 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -55,6 +55,16 @@ const { providerExitCode, providerJsonSchema, readMinimaxResponseText, + extractDeepseekJson, + deepseekBaseUrl, + deepseekDefaultModel, + deepseekDispatcher, + deepseekEndpoint, + deepseekExitCode, + deepseekFailureMessage, + deepseekRequestBody, + deepseekTimeoutMs, + readDeepseekResponseText, } = __testing; function makeFinding(overrides: Record = {}): Record { @@ -2410,3 +2420,351 @@ describe("minimax provider", () => { ).rejects.toMatchObject({ code: "malformed-output", exitCode: 8 }); }); }); + +describe("deepseek provider", () => { + const originalFetch = globalThis.fetch; + const originalEnv = { ...process.env }; + + afterEach(() => { + globalThis.fetch = originalFetch; + process.env = { ...originalEnv }; + }); + + it("dispatches via providerByName", () => { + const provider = providerByName("deepseek"); + + expect(provider.name).toBe("deepseek"); + expect(typeof provider.check).toBe("function"); + expect(typeof provider.review).toBe("function"); + expect(typeof provider.fix).toBe("function"); + }); + + it("uses deepseek-specific timeout before the generic provider timeout", () => { + delete process.env["CLAWPATCH_DEEPSEEK_TIMEOUT_MS"]; + delete process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; + expect(deepseekTimeoutMs()).toBe(1_800_000); + expect(deepseekTimeoutMs()).toBeGreaterThan(300_000); + + process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"] = "45000"; + expect(deepseekTimeoutMs()).toBe(45_000); + + process.env["CLAWPATCH_DEEPSEEK_TIMEOUT_MS"] = "120000"; + expect(deepseekTimeoutMs()).toBe(120_000); + + process.env["CLAWPATCH_DEEPSEEK_TIMEOUT_MS"] = "bad"; + expect(deepseekTimeoutMs()).toBe(1_800_000); + }); + + it("caches the undici dispatcher while the configured timeout is unchanged", () => { + process.env["CLAWPATCH_DEEPSEEK_TIMEOUT_MS"] = "180000"; + const first = deepseekDispatcher(); + const second = deepseekDispatcher(); + + expect(first).toBe(second); + + process.env["CLAWPATCH_DEEPSEEK_TIMEOUT_MS"] = "240000"; + const providerDispatcher = deepseekDispatcher(); + expect(providerDispatcher).not.toBe(first); + + const doctorDispatcher = deepseekDispatcher(30_000); + expect(doctorDispatcher).not.toBe(providerDispatcher); + expect(deepseekDispatcher(30_000)).toBe(doctorDispatcher); + }); + + it("normalizes and validates the base URL", () => { + delete process.env["DEEPSEEK_BASE_URL"]; + expect(deepseekBaseUrl()).toBe("https://api.deepseek.com/v1"); + + process.env["DEEPSEEK_BASE_URL"] = " https://proxy.example.com/v1/?unused=1#frag "; + expect(deepseekBaseUrl()).toBe("https://proxy.example.com/v1"); + expect(deepseekEndpoint("chat/completions")).toBe( + "https://proxy.example.com/v1/chat/completions", + ); + + process.env["DEEPSEEK_BASE_URL"] = "http://127.0.0.1:8787/v1"; + expect(deepseekBaseUrl()).toBe("http://127.0.0.1:8787/v1"); + + process.env["DEEPSEEK_BASE_URL"] = "http://proxy.example.com/v1"; + expect(() => deepseekBaseUrl()).toThrow(/https unless it targets loopback/u); + + process.env["DEEPSEEK_BASE_URL"] = "https://user:pass@api.deepseek.com/v1"; + expect(() => deepseekBaseUrl()).toThrow(/must not include URL credentials/u); + + process.env["DEEPSEEK_BASE_URL"] = "file:///tmp/token"; + expect(() => deepseekBaseUrl()).toThrow(/http or https/u); + }); + + it("uses deepseek-v4-flash as the default model and trims overrides", () => { + delete process.env["DEEPSEEK_MODEL"]; + expect(deepseekDefaultModel()).toBe("deepseek-v4-flash"); + + process.env["DEEPSEEK_MODEL"] = " deepseek-v4-pro "; + expect(deepseekDefaultModel()).toBe("deepseek-v4-pro"); + }); + + it("builds a prompt-validated chat request with json_object response_format (not json_schema)", () => { + const body = deepseekRequestBody( + "review prompt", + { model: null, reasoningEffort: null, skipGitRepoCheck: false }, + reviewJsonSchema, + true, + ) as Record; + + expect(body).toMatchObject({ + model: "deepseek-v4-flash", + temperature: 0, + response_format: { type: "json_object" }, + }); + // Negative control: DeepSeek rejects json_schema with HTTP 400. If a future + // change switched to json_schema, this assertion would fail (and would also + // fail on main if the json_object guard were removed). The assertion is + // load-bearing for the load-bearing integration difference called out in + // issue #134. + const responseFormat = body["response_format"] as { type: string }; + expect(responseFormat.type).toBe("json_object"); + expect(responseFormat.type).not.toBe("json_schema"); + 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["DEEPSEEK_API_KEY"] = "sk-test"; + let capturedBody: Record | null = null; + + globalThis.fetch = (async (input: Parameters[0], init?: RequestInit) => { + expect(String(input)).toBe("https://api.deepseek.com/v1/chat/completions"); + const headers = new Headers(init?.headers); + expect(headers.get("Authorization")).toBe("Bearer sk-test"); + capturedBody = JSON.parse(String(init?.body)) as Record; + return new Response( + JSON.stringify({ + choices: [ + { + message: { + content: JSON.stringify({ + findings: [], + inspected: { files: [], symbols: [], notes: ["deepseek clean"] }, + }), + }, + }, + ], + }), + { status: 200 }, + ); + }) as typeof fetch; + + const output = await providerByName("deepseek").review("/repo", "prompt", { + model: null, + reasoningEffort: null, + skipGitRepoCheck: false, + }); + + expect(capturedBody).toMatchObject({ response_format: { type: "json_object" } }); + expect(output).toEqual({ + findings: [], + inspected: { files: [], symbols: [], notes: ["deepseek clean"] }, + droppedFindings: [], + }); + }); + + it("classifies HTTP auth failures and redacts provider error messages", async () => { + process.env["DEEPSEEK_API_KEY"] = "sk-test"; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + error: { type: "authentication_error", message: "SECRET_OUTPUT_MUST_NOT_LEAK" }, + }), + { status: 401 }, + )) as typeof fetch; + + let authError: unknown; + try { + await providerByName("deepseek").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("classifies 402 insufficient-balance as quota exit code 5", async () => { + process.env["DEEPSEEK_API_KEY"] = "sk-test"; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + error: { type: "insufficient_balance", message: "Top up to continue" }, + }), + { status: 402 }, + )) as typeof fetch; + + let balanceError: unknown; + try { + await providerByName("deepseek").review("/repo", "prompt", { + model: null, + reasoningEffort: null, + skipGitRepoCheck: false, + }); + } catch (err) { + balanceError = err; + } + expect(balanceError).toMatchObject({ code: "provider-failure", exitCode: 5 }); + expect(String(balanceError)).toContain("insufficient balance"); + expect(String(balanceError)).toContain("https://platform.deepseek.com"); + expect(String(balanceError)).not.toContain("Top up to continue"); + }); + + it("does not expose raw fetch setup errors", async () => { + process.env["DEEPSEEK_API_KEY"] = "sk-test"; + globalThis.fetch = (async () => { + throw new TypeError( + "bad Authorization: Bearer sk-test in https://user:pass@example.invalid/v1", + ); + }) as typeof fetch; + + let fetchError: unknown; + try { + await providerByName("deepseek").review("/repo", "prompt", { + model: null, + reasoningEffort: null, + skipGitRepoCheck: false, + }); + } catch (err) { + fetchError = err; + } + + expect(fetchError).toMatchObject({ + code: "provider-failure", + exitCode: 1, + message: "deepseek provider network error (TypeError)", + }); + expect(String(fetchError)).not.toContain("sk-test"); + expect(String(fetchError)).not.toContain("user:pass"); + }); + + it("keeps the timeout active while reading the response body", async () => { + process.env["DEEPSEEK_API_KEY"] = "sk-test"; + process.env["CLAWPATCH_DEEPSEEK_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("deepseek").review("/repo", "prompt", { + model: null, + reasoningEffort: null, + skipGitRepoCheck: false, + }), + ).rejects.toMatchObject({ + code: "provider-failure", + exitCode: 1, + message: "deepseek provider timed out after 5ms", + }); + }); + + it("validates the /models envelope during provider checks", async () => { + process.env["DEEPSEEK_API_KEY"] = "sk-test"; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + object: "list", + data: [{ id: "deepseek-v4-flash", object: "model" }], + }), + { status: 200 }, + )) as typeof fetch; + + await expect(providerByName("deepseek").check("/repo")).resolves.toContain("provider=deepseek"); + + globalThis.fetch = (async () => + new Response(JSON.stringify({ object: "not-list", data: [] }), { + status: 200, + })) as typeof fetch; + + await expect(providerByName("deepseek").check("/repo")).rejects.toThrow(/not a model list/u); + }); + + it("fails fix before auth lookup or network side effects", async () => { + delete process.env["DEEPSEEK_API_KEY"]; + let called = false; + globalThis.fetch = (async () => { + called = true; + return new Response("{}"); + }) as typeof fetch; + + await expect( + providerByName("deepseek").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 including 402 balance", () => { + expect(deepseekExitCode(401)).toBe(4); + expect(deepseekExitCode(403)).toBe(4); + expect(deepseekExitCode(402)).toBe(5); + expect(deepseekExitCode(429)).toBe(5); + expect(deepseekExitCode(500)).toBe(1); + }); + + it("redacts provider error bodies while keeping safe error signals", () => { + const message = deepseekFailureMessage( + 401, + JSON.stringify({ + error: { + type: "authentication_error", + message: "SECRET_OUTPUT_MUST_NOT_LEAK", + }, + }), + ); + + expect(message).toContain("auth failed"); + expect(message).toContain("error.type=authentication_error"); + expect(message).not.toContain("SECRET_OUTPUT_MUST_NOT_LEAK"); + }); + + it("extracts JSON from chat-completions envelopes", () => { + expect( + extractDeepseekJson( + JSON.stringify({ + choices: [{ message: { content: '{"features":[],"notes":[]}' } }], + }), + ), + ).toEqual({ features: [], notes: [] }); + }); + + it("throws malformed-output for empty choices or non-JSON content", () => { + expectMalformed( + () => extractDeepseekJson(JSON.stringify({ choices: [] })), + /missing choices\[0\]\.message\.content/u, + ); + expectMalformed( + () => extractDeepseekJson(JSON.stringify({ choices: [{ message: { content: "plain" } }] })), + /contained no Clawpatch JSON/u, + ); + }); + + it("bounds response body reads", async () => { + await expect( + readDeepseekResponseText( + 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 70f5552..ff15de7 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -282,6 +282,9 @@ export function providerByName(name: string): Provider { if (name === "minimax") { return minimaxProvider; } + if (name === "deepseek") { + return deepseekProvider; + } if (name === "pi") { return piProvider; } @@ -1060,6 +1063,538 @@ const minimaxProvider: Provider = { }, }; +const DEEPSEEK_PROVIDER_NAME = "deepseek"; +const DEEPSEEK_DEFAULT_BASE_URL = "https://api.deepseek.com/v1"; +const DEEPSEEK_DEFAULT_MODEL = "deepseek-v4-flash"; +// Node's built-in fetch (undici) caps the headers/body response timeout at +// 300_000 ms by default. The clawpatch `setTimeout`/AbortController above the +// fetch never sees that — undici rejects with a low-level HeadersTimeoutError +// first, which surfaces to the user as "deepseek provider network error: fetch +// failed" after exactly ~301s. Raising the AbortController value alone is not +// enough; we also have to install a custom undici dispatcher whose +// headers/body timeouts match the AbortController. Bump the default to 30 min +// so the AbortController is the dominant timeout for typical large reviews. +const DEEPSEEK_DEFAULT_TIMEOUT_MS = 1_800_000; +const DEEPSEEK_CHECK_TIMEOUT_MS = 30_000; +const DEEPSEEK_CONNECT_TIMEOUT_MS = 10_000; +const DEEPSEEK_MAX_REQUEST_BYTES = 64 * 1024 * 1024; +const DEEPSEEK_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; +const DEEPSEEK_MAX_ERROR_BYTES = 16 * 1024; +const DEEPSEEK_MAX_MODELS_BYTES = 1024 * 1024; + +let deepseekDispatcherCache: { + timeoutMs: number; + dispatcher: InstanceType; +} | null = null; + +function deepseekTimeoutMs(): number { + const raw = + process.env["CLAWPATCH_DEEPSEEK_TIMEOUT_MS"] ?? process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; + if (raw === undefined) { + return DEEPSEEK_DEFAULT_TIMEOUT_MS; + } + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEEPSEEK_DEFAULT_TIMEOUT_MS; +} + +function deepseekDispatcher(timeoutMs = deepseekTimeoutMs()): InstanceType { + if (deepseekDispatcherCache !== null && deepseekDispatcherCache.timeoutMs === timeoutMs) { + return deepseekDispatcherCache.dispatcher; + } + if (deepseekDispatcherCache !== null) { + void deepseekDispatcherCache.dispatcher.close(); + } + const dispatcher = new Agent({ + headersTimeout: timeoutMs, + bodyTimeout: timeoutMs, + connectTimeout: DEEPSEEK_CONNECT_TIMEOUT_MS, + }); + deepseekDispatcherCache = { timeoutMs, dispatcher }; + return dispatcher; +} + +function deepseekBaseUrl(): string { + const raw = process.env["DEEPSEEK_BASE_URL"]?.trim(); + return normalizeDeepseekBaseUrl( + raw !== undefined && raw.length > 0 ? raw : DEEPSEEK_DEFAULT_BASE_URL, + ); +} + +function normalizeDeepseekBaseUrl(raw: string): string { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new ClawpatchError( + "deepseek provider DEEPSEEK_BASE_URL must be a valid http(s) URL", + 4, + "provider-auth", + ); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new ClawpatchError( + "deepseek provider DEEPSEEK_BASE_URL must use http or https", + 4, + "provider-auth", + ); + } + if (url.protocol === "http:" && !isLoopbackHostname(url.hostname)) { + throw new ClawpatchError( + "deepseek provider DEEPSEEK_BASE_URL must use https unless it targets loopback", + 4, + "provider-auth", + ); + } + if (url.username.length > 0 || url.password.length > 0) { + throw new ClawpatchError( + "deepseek provider DEEPSEEK_BASE_URL must not include URL credentials", + 4, + "provider-auth", + ); + } + url.hash = ""; + url.search = ""; + return url.toString().replace(/\/+$/u, ""); +} + +function deepseekEndpoint(path: string): string { + return new URL(path.replace(/^\/+/u, ""), `${deepseekBaseUrl()}/`).toString(); +} + +function deepseekDefaultModel(): string { + const raw = process.env["DEEPSEEK_MODEL"]?.trim(); + return raw !== undefined && raw.length > 0 ? raw : DEEPSEEK_DEFAULT_MODEL; +} + +function deepseekExitCode(status: number): number { + if (status === 401 || status === 403) { + return 4; + } + // 402 = pay-per-use balance insufficient; 429 = rate-limited. Both are + // quota-class failures and map to the same exit code the rest of clawpatch + // uses for quota/rate-limit (5). + if (status === 429 || status === 402) { + return 5; + } + return 1; +} + +function deepseekHttpErrorCode(status: number): "provider-auth" | "provider-failure" { + return status === 401 || status === 403 ? "provider-auth" : "provider-failure"; +} + +function deepseekFailureMessage(status: number, body: string): string { + const signal = deepseekSignalFromBody(body); + const detail = + signal.length === 0 ? `body chars=${body.length}` : `${signal}; body chars=${body.length}`; + if (status === 401 || status === 403) { + return `deepseek provider auth failed (HTTP ${status}). Check DEEPSEEK_API_KEY. ${detail}`; + } + if (status === 402) { + return `deepseek provider insufficient balance (HTTP 402). Top up at https://platform.deepseek.com. ${detail}`; + } + if (status === 429) { + return `deepseek provider rate-limited (HTTP 429). ${detail}`; + } + return `deepseek provider failed (HTTP ${status}). ${detail}`; +} + +function deepseekSignalFromBody(body: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(body) as unknown; + } catch { + return ""; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return ""; + } + const record = parsed as Record; + const parts: string[] = []; + const errorField = record["error"]; + if (typeof errorField === "object" && errorField !== null && !Array.isArray(errorField)) { + const errRecord = errorField as Record; + // Whitelist the safe-to-log fields. Never include `error.message` here: the + // upstream provider's error message frequently echoes user input or bearer + // tokens, and clawpatch already logs the body byte count as a separate + // signal. Mirrors the minimax provider's redaction discipline. + for (const key of ["type", "code", "param"]) { + const value = errRecord[key]; + if (typeof value === "string" && value.length > 0) { + parts.push(`error.${key}=${safeProviderPreview(value, 80)}`); + } + } + } + return parts.length === 0 ? "" : parts.join("; "); +} + +function deepseekRequestBody( + prompt: string, + options: ProviderOptions, + schema: object, + readOnly: boolean, +): object { + const model = + options.model !== null && options.model.trim().length > 0 + ? options.model.trim() + : deepseekDefaultModel(); + // DeepSeek's chat-completions API supports `response_format: {type: "json_object"}` + // but **rejects `response_format: {type: "json_schema", ...}` with HTTP 400** + // (verified 2026-06-09; see issue #134). Embed the schema in the prompt and + // rely on clawpatch's `extractJson` helper + Zod validators for shape + // enforcement. Do not change this without re-testing against the live API. + 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: deepseekPrompt(prompt, schema) }, + ], + response_format: { type: "json_object" }, + temperature: 0, + }; +} + +function deepseekPrompt(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 runDeepseekJson( + _root: string, + prompt: string, + options: ProviderOptions, + schema: object, + readOnly: boolean, +): Promise { + const apiKey = deepseekApiKey(); + const requestJson = JSON.stringify(deepseekRequestBody(prompt, options, schema, readOnly)); + const requestBytes = Buffer.byteLength(requestJson, "utf8"); + if (requestBytes > DEEPSEEK_MAX_REQUEST_BYTES) { + throw new ClawpatchError( + `deepseek provider request body exceeds ${DEEPSEEK_MAX_REQUEST_BYTES} bytes (${requestBytes})`, + 1, + "provider-failure", + ); + } + const text = await withDeepseekResponse( + deepseekEndpoint("chat/completions"), + { + method: "POST", + headers: deepseekHeaders(apiKey), + body: requestJson, + }, + deepseekTimeoutMs(), + async (response) => { + const responseText = response.ok + ? await readDeepseekResponseText( + response, + DEEPSEEK_MAX_RESPONSE_BYTES, + () => + new ClawpatchError( + `deepseek provider response exceeded ${DEEPSEEK_MAX_RESPONSE_BYTES} bytes`, + 8, + "malformed-output", + ), + ) + : await readDeepseekResponseText( + response, + DEEPSEEK_MAX_ERROR_BYTES, + () => + new ClawpatchError( + `deepseek provider error body exceeded ${DEEPSEEK_MAX_ERROR_BYTES} bytes`, + deepseekExitCode(response.status), + deepseekHttpErrorCode(response.status), + ), + ); + if (!response.ok) { + throw new ClawpatchError( + deepseekFailureMessage(response.status, responseText), + deepseekExitCode(response.status), + deepseekHttpErrorCode(response.status), + ); + } + return responseText; + }, + ); + return extractDeepseekJson(text); +} + +function deepseekApiKey(): string { + const apiKey = process.env["DEEPSEEK_API_KEY"]; + if (apiKey === undefined || apiKey.length === 0) { + throw new ClawpatchError( + "deepseek provider requires DEEPSEEK_API_KEY env var", + 4, + "provider-auth", + ); + } + return apiKey; +} + +function deepseekHeaders(apiKey: string): Record { + return { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }; +} + +async function withDeepseekResponse( + 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: deepseekDispatcher(timeoutMs) as unknown as Dispatcher, + } as RequestInit & { dispatcher: Dispatcher }); + return await consume(response); + } catch (err) { + if (err instanceof ClawpatchError) { + throw err; + } + if (isDeepseekTimeoutError(err)) { + throw new ClawpatchError( + `deepseek provider timed out after ${timeoutMs}ms`, + 1, + "provider-failure", + ); + } + const name = err instanceof Error ? safeProviderPreview(err.name, 40) : "unknown"; + throw new ClawpatchError(`deepseek provider network error (${name})`, 1, "provider-failure"); + } finally { + clearTimeout(timer); + } +} + +function isDeepseekTimeoutError(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 readDeepseekResponseText( + 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 (isDeepseekTimeoutError(err)) { + throw err; + } + throw new ClawpatchError( + "deepseek provider failed while reading response", + 1, + "provider-failure", + ); + } +} + +function extractDeepseekJson(text: string): unknown { + let envelope: unknown; + try { + envelope = JSON.parse(text) as unknown; + } catch { + throw new ClawpatchError( + `deepseek provider produced a malformed JSON envelope (body chars=${text.length})`, + 8, + "malformed-output", + ); + } + const content = deepseekEnvelopeText(envelope); + if (content === null || content.trim().length === 0) { + throw new ClawpatchError( + "deepseek provider response missing choices[0].message.content", + 8, + "malformed-output", + ); + } + const parsed = extractJson(content); + if (parsed === null) { + throw new ClawpatchError( + `deepseek provider content contained no Clawpatch JSON (content chars=${content.length})`, + 8, + "malformed-output", + ); + } + return parsed; +} + +function deepseekEnvelopeText(envelope: unknown): string | null { + if (typeof envelope === "string") { + return envelope; + } + if (typeof envelope !== "object" || envelope === null) { + return null; + } + const record = envelope as Record; + 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"]; + } + } + } + return null; +} + +function validateDeepseekModelsEnvelope(text: string): void { + let envelope: unknown; + try { + envelope = JSON.parse(text) as unknown; + } catch { + throw new ClawpatchError( + `deepseek provider models response was malformed JSON (body chars=${text.length})`, + 1, + "provider-failure", + ); + } + if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) { + throw new ClawpatchError( + "deepseek 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( + "deepseek provider models response was not a model list", + 1, + "provider-failure", + ); + } +} + +const deepseekProvider: Provider = { + name: DEEPSEEK_PROVIDER_NAME, + async check(_root: string): Promise { + await withDeepseekResponse( + deepseekEndpoint("models"), + { + method: "GET", + headers: { Authorization: `Bearer ${deepseekApiKey()}` }, + }, + DEEPSEEK_CHECK_TIMEOUT_MS, + async (response) => { + const text = await readDeepseekResponseText( + response, + response.ok ? DEEPSEEK_MAX_MODELS_BYTES : DEEPSEEK_MAX_ERROR_BYTES, + () => + new ClawpatchError( + `deepseek provider models response exceeded ${ + response.ok ? DEEPSEEK_MAX_MODELS_BYTES : DEEPSEEK_MAX_ERROR_BYTES + } bytes`, + deepseekExitCode(response.status), + response.ok ? "provider-failure" : deepseekHttpErrorCode(response.status), + ), + ); + if (!response.ok) { + throw new ClawpatchError( + deepseekFailureMessage(response.status, text), + deepseekExitCode(response.status), + deepseekHttpErrorCode(response.status), + ); + } + validateDeepseekModelsEnvelope(text); + }, + ); + return `provider=deepseek default-model=${deepseekDefaultModel()} base=${deepseekBaseUrl()}`; + }, + async map(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runDeepseekJson(root, prompt, options, agentMapJsonSchema, true); + return parseOrThrow(agentMapOutputSchema, output, "deepseek agent-map"); + }, + async review( + root: string, + prompt: string, + options: ProviderOptions, + ): Promise { + const output = await runDeepseekJson(root, prompt, options, reviewJsonSchema, true); + return parseReviewOutput(output); + }, + async fix(_root: string, _prompt: string, _options: ProviderOptions): Promise { + // clawpatch `fix` is an agentic tool-loop: the provider has to accept a + // prompt, return tool calls (e.g. read_file, edit_file, run_shell), the + // host executes them in the worktree, and the provider sees the results + // until it emits a final fix plan. This provider exposes DeepSeek's + // chat-completions API as a single text/JSON responder, so it does not + // route tool calls. DeepSeek's API does support a `tools` parameter and + // the v4 model can call them, but wiring that up to clawpatch's tool-loop + // is out of scope for this PR and matches the contract the minimax + // provider on `main` already publishes. Use --provider codex, acpx, + // claude, opencode, or pi for fix; use --provider deepseek for map, + // review, and revalidate. + throw new ClawpatchError( + "deepseek provider does not implement clawpatch fix in this PR: this provider exposes DeepSeek's chat-completions API as a single text/JSON responder and does not route agentic tool calls. Use --provider codex, acpx, claude, opencode, or pi for fix; use --provider deepseek for map, review, and revalidate.", + 2, + "unsupported-provider", + ); + }, + async revalidate( + root: string, + prompt: string, + options: ProviderOptions, + ): Promise { + const output = await runDeepseekJson(root, prompt, options, revalidateJsonSchema, true); + return parseOrThrow(revalidateOutputSchema, output, "deepseek revalidate"); + }, +}; + const PI_DEFAULT_TIMEOUT_MS = 180_000; const CURSOR_DEFAULT_TIMEOUT_MS = 300_000; const CURSOR_MIN_SAFE_APP_VERSION = "2.5.0"; @@ -2967,6 +3502,16 @@ export const __testing = { minimaxRequestBody, minimaxTimeoutMs, readMinimaxResponseText, + extractDeepseekJson, + deepseekBaseUrl, + deepseekDefaultModel, + deepseekDispatcher, + deepseekEndpoint, + deepseekExitCode, + deepseekFailureMessage, + deepseekRequestBody, + deepseekTimeoutMs, + readDeepseekResponseText, piThinkingLevel, providerExitCode, providerJsonSchema,