Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
16 changes: 11 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove DeepSeek from the activation prefixes

For every deepseek-* model using one of the accepted Responses APIs, this prefix makes syncToolset remove write and edit and activate apply_patch, directly violating the repository constraint that the extension only activates for OpenAI GPT-family models. Keep non-GPT models on the standard editing tools unless that repository-wide constraint is intentionally changed.

AGENTS.md reference: AGENTS.md:L21-L25

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 1ecf162: the deepseek- prefix is intentional and the repository constraint is updated accordingly — AGENTS.md now states the extension activates for OpenAI GPT-family models and DeepSeek models (id prefix deepseek-) exposed through openai-responses / openai-codex-responses APIs. Non-GPT models on other APIs remain on the standard editing tools.

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;
Expand Down Expand Up @@ -338,10 +339,10 @@ eof_line: "*** End of File" LF
%import common.LF
`;

export function isOpenAIGptModel(
model: (Pick<Model<string>, "provider" | "id"> & Partial<Pick<Model<string>, "api">>) | undefined,
): boolean {
if (!model?.id.startsWith("gpt-")) {
export type ApplyPatchCandidateModel = Pick<Model<string>, "provider" | "id"> & Partial<Pick<Model<string>, "api">>;

export function isApplyPatchCapableModel(model: ApplyPatchCandidateModel | undefined): boolean {
if (!model || !APPLY_PATCH_MODEL_ID_PREFIXES.some((prefix) => model.id.startsWith(prefix))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a Responses API before activating DeepSeek

When a deepseek-* model uses a provider in GPT_APPLY_PATCH_PROVIDERS but a different API, such as { provider: "openai", id: "deepseek-custom", api: "openai-completions" }, this expanded prefix check admits it because the existing provider branch bypasses the API allowlist. syncToolset consequently removes write and edit, even though DeepSeek activation is restricted to openai-responses and openai-codex-responses; require an allowed API specifically for the DeepSeek prefix while retaining the provider shortcut for GPT models.

AGENTS.md reference: AGENTS.md:L24-L24

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7d910bc: the deepseek- prefix now requires an allowed Responses API (openai-responses / openai-codex-responses) and no longer falls through the provider-allowlist shortcut, which remains exclusive to GPT models. Regression test added for { provider: "openai", id: "deepseek-custom", api: "openai-completions" } → not activated.

return false;
}

Expand All @@ -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");
}
Expand Down Expand Up @@ -1377,7 +1383,7 @@ function syncToolset(
model: Model<string> | undefined,
): void {
const currentToolNames = pi.getActiveTools();
if (isOpenAIGptModel(model)) {
if (isApplyPatchCapableModel(model)) {
pi.setActiveTools(replaceEditToolsWithApplyPatch(currentToolNames));
return;
}
Expand Down
58 changes: 50 additions & 8 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
createApplyPatchTool,
extractPatchedPaths,
type FreeformToolFormat,
isApplyPatchCapableModel,
isOpenAIGptModel,
PatchParseError,
registerApplyPatchExtension,
Expand Down Expand Up @@ -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"]);
Expand Down Expand Up @@ -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);
});
});