diff --git a/AGENTS.md b/AGENTS.md index fd1750c..b8284b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ Conventions for human contributors and AI agents working on this repository. ## Constraints - No Bun APIs. Runtime is Node only. -- This extension registers the `apply_patch` tool and only activates it for OpenAI GPT-family models. +- This extension registers the `apply_patch` tool and only activates it for OpenAI GPT-family models and DeepSeek models (id prefix `deepseek-`) exposed through `openai-responses` / `openai-codex-responses` APIs. - Keep the tool schema, grammar, and descriptions byte-for-byte compatible with Codex unless intentionally updating the golden source. - No dependency on pi-coding-agent internal modules outside the documented public extension API in `@mariozechner/pi-coding-agent`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c4305e..30ed1de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,3 +6,4 @@ - Initial standalone `apply_patch` pi extension. - Support GPT models exposed through custom `openai-responses` and `openai-codex-responses` providers. +- Support DeepSeek models (`deepseek-` id prefix, e.g. `deepseek-v4-flash` via OpenCode Go Responses) exposed through `openai-responses` and `openai-codex-responses` providers. diff --git a/README.md b/README.md index 6d8cb12..419a039 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ The extension registers one LLM-callable tool: `apply_patch`. The tool accepts C |------|--------| | OpenAI GPT provider active | replaces `write` and `edit` with `apply_patch` | | Custom `openai-responses` GPT provider active | replaces `write` and `edit` with `apply_patch` | -| Non-GPT model active | restores the original `write` and `edit` toolset | +| DeepSeek model on `openai-responses` provider (e.g. `deepseek-v4-flash` via OpenCode Go Responses) | replaces `write` and `edit` with `apply_patch` | +| Non-GPT / non-DeepSeek model active | restores the original `write` and `edit` toolset | | Raw freeform patch input | accepted and applied | | JSON `{ "input": "..." }` patch input | accepted and applied | | Absolute or parent-escaping path | accepted and resolved by Node path semantics | @@ -35,7 +36,7 @@ Use this tool to edit files with the Codex patch format. Pi exposes this as a freeform grammar tool. Models with `compat.supportsOpenAIGrammarTools` enabled receive an OpenAI custom grammar tool; other Responses-compatible models fall back to a function tool with an `input` string. -Custom provider names are supported when the model id starts with `gpt-` and its Pi API is `openai-responses` or `openai-codex-responses`. For example, a model registered as `my-proxy/gpt-5` with `api: "openai-responses"` activates `apply_patch` without adding the provider name to a hard-coded allowlist. +Custom provider names are supported when the model id starts with `gpt-` or `deepseek-` and its Pi API is `openai-responses` or `openai-codex-responses`. For example, a model registered as `my-proxy/gpt-5` or `opencode-go-responses/deepseek-v4-flash` with `api: "openai-responses"` activates `apply_patch` without adding the provider name to a hard-coded allowlist. ## Installation diff --git a/src/index.ts b/src/index.ts index c47d4f5..03dad93 100644 --- a/src/index.ts +++ b/src/index.ts @@ -178,6 +178,7 @@ function hasErrorCode(error: unknown, code: string): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === code); } +const APPLY_PATCH_MODEL_ID_PREFIXES = ["gpt-", "deepseek-"] as const; const GPT_APPLY_PATCH_PROVIDERS = new Set(["openai", "openai-codex", "azure-openai-responses", "github-copilot"]); const GPT_APPLY_PATCH_APIS = new Set(["openai-responses", "openai-codex-responses"]); export const PATCH_PREVIEW_MAX_LINES = 16; @@ -338,10 +339,10 @@ eof_line: "*** End of File" LF %import common.LF `; -export function isOpenAIGptModel( - model: (Pick, "provider" | "id"> & Partial, "api">>) | undefined, -): boolean { - if (!model?.id.startsWith("gpt-")) { +export type ApplyPatchCandidateModel = Pick, "provider" | "id"> & Partial, "api">>; + +export function isApplyPatchCapableModel(model: ApplyPatchCandidateModel | undefined): boolean { + if (!model || !APPLY_PATCH_MODEL_ID_PREFIXES.some((prefix) => model.id.startsWith(prefix))) { return false; } @@ -350,6 +351,11 @@ export function isOpenAIGptModel( ); } +/** @deprecated Use {@link isApplyPatchCapableModel}. */ +export function isOpenAIGptModel(model: ApplyPatchCandidateModel | undefined): boolean { + return isApplyPatchCapableModel(model); +} + function normalizePatchText(patchText: string): string { return patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } @@ -1377,7 +1383,7 @@ function syncToolset( model: Model | undefined, ): void { const currentToolNames = pi.getActiveTools(); - if (isOpenAIGptModel(model)) { + if (isApplyPatchCapableModel(model)) { pi.setActiveTools(replaceEditToolsWithApplyPatch(currentToolNames)); return; } diff --git a/test/index.test.ts b/test/index.test.ts index 97aaab2..08773b8 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -10,6 +10,7 @@ import { createApplyPatchTool, extractPatchedPaths, type FreeformToolFormat, + isApplyPatchCapableModel, isOpenAIGptModel, PatchParseError, registerApplyPatchExtension, @@ -173,6 +174,22 @@ describe("pi-apply-patch", () => { expect(harness.getActiveTools()).toEqual(["read", "apply_patch"]); }); + it("#given DeepSeek model on custom Responses provider #when session starts #then enables apply_patch", async () => { + // given + const harness = createToolsetTestApi(["read", "edit", "write"]); + registerApplyPatchExtension(harness.api); + + // when + await harness.trigger("session_start", { + provider: "opencode-go-responses", + id: "deepseek-v4-flash", + api: "openai-responses", + }); + + // then + expect(harness.getActiveTools()).toEqual(["read", "apply_patch"]); + }); + it("#given non GPT model and no original edit tools #when session starts #then restores standard edit tools", async () => { // given const harness = createToolsetTestApi(["read", "apply_patch"]); @@ -1097,14 +1114,39 @@ EOF`; expect(extractPatchedPaths(patch)).toEqual(["src/app.ts", "src/new.ts", "src/old.ts", "src/moved.ts"]); }); - it("#given model metadata #when checking GPT activation #then matches known providers and Responses APIs", () => { + it("#given model metadata #when checking apply_patch activation #then matches GPT and DeepSeek models on Responses APIs", () => { + expect(isApplyPatchCapableModel({ provider: "openai", id: "gpt-5" })).toBe(true); + expect(isApplyPatchCapableModel({ provider: "openai-codex", id: "gpt-5.5" })).toBe(true); + expect(isApplyPatchCapableModel({ provider: "my-proxy", id: "gpt-5", api: "openai-responses" })).toBe(true); + expect(isApplyPatchCapableModel({ provider: "codex-proxy", id: "gpt-5", api: "openai-codex-responses" })).toBe( + true, + ); + expect( + isApplyPatchCapableModel({ + provider: "opencode-go-responses", + id: "deepseek-v4-flash", + api: "openai-responses", + }), + ).toBe(true); + expect( + isApplyPatchCapableModel({ provider: "my-proxy", id: "deepseek-v4-flash", api: "openai-codex-responses" }), + ).toBe(true); + expect(isApplyPatchCapableModel({ provider: "openai", id: "o1" })).toBe(false); + expect(isApplyPatchCapableModel({ provider: "anthropic", id: "gpt-5" })).toBe(false); + expect(isApplyPatchCapableModel({ provider: "my-proxy", id: "claude-sonnet", api: "openai-responses" })).toBe( + false, + ); + expect(isApplyPatchCapableModel({ provider: "anthropic-proxy", id: "gpt-5", api: "anthropic-messages" })).toBe( + false, + ); + expect(isApplyPatchCapableModel({ provider: "my-proxy", id: "deepseek-chat", api: "anthropic-messages" })).toBe( + false, + ); + + // legacy alias stays consistent + expect( + isOpenAIGptModel({ provider: "opencode-go-responses", id: "deepseek-v4-flash", api: "openai-responses" }), + ).toBe(true); expect(isOpenAIGptModel({ provider: "openai", id: "gpt-5" })).toBe(true); - expect(isOpenAIGptModel({ provider: "openai-codex", id: "gpt-5.5" })).toBe(true); - expect(isOpenAIGptModel({ provider: "my-proxy", id: "gpt-5", api: "openai-responses" })).toBe(true); - expect(isOpenAIGptModel({ provider: "codex-proxy", id: "gpt-5", api: "openai-codex-responses" })).toBe(true); - expect(isOpenAIGptModel({ provider: "openai", id: "o1" })).toBe(false); - expect(isOpenAIGptModel({ provider: "anthropic", id: "gpt-5" })).toBe(false); - expect(isOpenAIGptModel({ provider: "my-proxy", id: "claude-sonnet", api: "openai-responses" })).toBe(false); - expect(isOpenAIGptModel({ provider: "anthropic-proxy", id: "gpt-5", api: "anthropic-messages" })).toBe(false); }); });