fix(claude): improve Pi and OpenCode compatibility (#2621)

Integrated into release/v3.8.3
This commit is contained in:
Halil Tezcan KARABULUT
2026-05-23 23:07:02 +03:00
committed by GitHub
parent 5726db4af1
commit 149b493d1e
8 changed files with 122 additions and 12 deletions

View File

@@ -186,6 +186,11 @@ export function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal):
return controller.signal;
}
function hasActiveClaudeThinking(body: Record<string, unknown>): boolean {
const thinking = body.thinking as Record<string, unknown> | undefined;
return thinking?.type === "enabled" || thinking?.type === "adaptive";
}
/**
* Sanitize reasoning_effort for providers that don't accept all values.
*
@@ -732,7 +737,7 @@ export class BaseExecutor {
// Real CLI always pairs context_management with thinking. Mirror
// that invariant so long sessions don't accumulate thinking blocks
// toward the context cap.
if (tb.thinking && !tb.context_management) {
if (hasActiveClaudeThinking(tb) && !tb.context_management) {
tb.context_management = {
edits: [{ type: "clear_thinking_20251015", keep: "all" }],
};

View File

@@ -25,6 +25,7 @@ export function disableThinkingIfToolChoiceForced(body: Record<string, unknown>)
if (isForced && body.thinking) {
delete body.thinking;
delete body.context_management;
}
}

View File

@@ -41,6 +41,10 @@ for (const [k, v] of Object.entries(TOOL_RENAME_MAP)) {
export function remapToolNamesInRequest(body: Record<string, unknown>): boolean {
let hasLowercase = false;
let hasTitleCase = false;
const toolNameMap =
body._toolNameMap instanceof Map
? (body._toolNameMap as Map<string, string>)
: new Map<string, string>();
// Remap tool definitions
const tools = body.tools as Array<Record<string, unknown>> | undefined;
@@ -48,7 +52,9 @@ export function remapToolNamesInRequest(body: Record<string, unknown>): boolean
for (const tool of tools) {
const name = String(tool.name || "");
if (TOOL_RENAME_MAP[name]) {
tool.name = TOOL_RENAME_MAP[name];
const mapped = TOOL_RENAME_MAP[name];
tool.name = mapped;
toolNameMap.set(mapped, name);
hasLowercase = true;
} else if (REVERSE_MAP[name]) {
hasTitleCase = true;
@@ -66,6 +72,7 @@ export function remapToolNamesInRequest(body: Record<string, unknown>): boolean
if (block.type === "tool_use" && typeof block.name === "string") {
const mapped = TOOL_RENAME_MAP[block.name];
if (mapped) {
toolNameMap.set(mapped, block.name);
block.name = mapped;
hasLowercase = true;
} else if (REVERSE_MAP[block.name]) {
@@ -81,6 +88,7 @@ export function remapToolNamesInRequest(body: Record<string, unknown>): boolean
if (toolChoice?.type === "tool" && typeof toolChoice.name === "string") {
const mapped = TOOL_RENAME_MAP[toolChoice.name];
if (mapped) {
toolNameMap.set(mapped, toolChoice.name);
toolChoice.name = mapped;
hasLowercase = true;
} else if (REVERSE_MAP[toolChoice.name]) {
@@ -93,13 +101,32 @@ export function remapToolNamesInRequest(body: Record<string, unknown>): boolean
// request body, causing HTTP 400 (Extra inputs are not permitted).
// The response-side remap is unconditional via remapToolNamesInResponse.
if (toolNameMap.size > 0) {
Object.defineProperty(body, "_toolNameMap", {
value: toolNameMap,
enumerable: false,
configurable: true,
writable: true,
});
}
return hasLowercase && !hasTitleCase;
}
export function remapToolNamesInResponse(text: string, forceLowercase = true): string {
export function remapToolNamesInResponse(
text: string,
forceLowercase = true,
toolNameMap?: Map<string, string>
): string {
if (!forceLowercase) return text;
// Replace TitleCase tool names back to lowercase in SSE chunks
if (toolNameMap?.size) {
for (const [mapped, original] of toolNameMap.entries()) {
text = text.replaceAll(`"name":"${mapped}"`, `"name":"${original}"`);
text = text.replaceAll(`"name": "${mapped}"`, `"name": "${original}"`);
}
}
for (const [titleCase, lower] of Object.entries(REVERSE_MAP)) {
// Match in "name":"ToolName" patterns
text = text.replaceAll(`"name":"${titleCase}"`, `"name":"${lower}"`);

View File

@@ -112,6 +112,12 @@ export const OPENWEBUI_PARAGRAPH_ANCHORS = [
/** Open WebUI identity paragraph prefixes. */
export const OPENWEBUI_IDENTITY_PREFIXES = ["You are Open WebUI"];
export const PI_PARAGRAPH_ANCHORS = [
"@earendil-works/pi-coding-agent",
"/.pi/",
"Pi documentation (read only when the user asks about pi itself",
];
// ────────────────────────────────────────────────────────────────────────────
// Per-provider defaults.
// ────────────────────────────────────────────────────────────────────────────
@@ -141,7 +147,7 @@ export const PROVIDER_CC_BRIDGE = "anthropic-compatible-cc";
*
* Plugin parity (`@ex-machina/opencode-anthropic-auth`): drops 3rd-party-agent
* anchor paragraphs (anomalyco/opencode, cline, getcursor/cursor, continue.dev,
* Open WebUI), drops "You are OpenCode" / "You are Open WebUI" identity
* Open WebUI, Pi docs), drops "You are OpenCode" / "You are Open WebUI" identity
* paragraphs, replaces the "Here is some useful information about the
* environment you are running in:" billing-gate trigger phrase, and ZWJ
* obfuscates sensitive client words. Without these, the native OAuth path
@@ -150,11 +156,15 @@ export const PROVIDER_CC_BRIDGE = "anthropic-compatible-cc";
* verified against opencode→OmniRoute→Anthropic with claude-opus-4-7 OAuth.
*/
export const DEFAULT_CLAUDE_PIPELINE: TransformOp[] = [
// Drop paragraphs containing 3rd-party-agent anchor URLs (anomalyco/opencode,
// opencode.ai/docs, cline, getcursor/cursor, continue.dev) and Open WebUI URLs.
// Drop paragraphs containing 3rd-party-agent anchors (anomalyco/opencode,
// opencode.ai/docs, cline, getcursor/cursor, continue.dev, Open WebUI, Pi docs).
{
kind: "drop_paragraph_if_contains",
needles: [...DEFAULT_PARAGRAPH_REMOVAL_ANCHORS, ...OPENWEBUI_PARAGRAPH_ANCHORS],
needles: [
...DEFAULT_PARAGRAPH_REMOVAL_ANCHORS,
...OPENWEBUI_PARAGRAPH_ANCHORS,
...PI_PARAGRAPH_ANCHORS,
],
},
// Drop "You are OpenCode" + "You are Open WebUI" identity paragraphs.
{

View File

@@ -48,6 +48,12 @@ const DEFAULT_PARAGRAPH_REMOVAL_ANCHORS = [
"continue.dev",
];
const PI_PARAGRAPH_ANCHORS = [
"@earendil-works/pi-coding-agent",
"/.pi/",
"Pi documentation (read only when the user asks about pi itself",
];
const DEFAULT_IDENTITY_PREFIXES = ["You are OpenCode"];
const DEFAULT_TEXT_REPLACEMENTS = [
@@ -85,7 +91,11 @@ const DEFAULT_SYSTEM_TRANSFORMS_CLIENT = {
pipeline: [
{
kind: "drop_paragraph_if_contains",
needles: [...DEFAULT_PARAGRAPH_REMOVAL_ANCHORS, ...OPENWEBUI_PARAGRAPH_ANCHORS],
needles: [
...DEFAULT_PARAGRAPH_REMOVAL_ANCHORS,
...OPENWEBUI_PARAGRAPH_ANCHORS,
...PI_PARAGRAPH_ANCHORS,
],
},
{
kind: "drop_paragraph_if_starts_with",

View File

@@ -190,7 +190,7 @@ describe("remapToolNamesInRequest", () => {
_claudeCodeRequiresLowercaseToolNames?: boolean;
};
// remapToolNamesInRequest remaps in-place; no _toolNameMap stored on body (removed API)
// remapToolNamesInRequest remaps in-place and stores _toolNameMap as non-enumerable.
assert.equal(body.tools[0].name, "Bash");
assert.equal(body.tools[1].name, "Glob");
assert.equal(body.tool_choice.name, "Glob");
@@ -205,8 +205,7 @@ describe("remapToolNamesInRequest", () => {
});
it("remaps known tools and does not throw with extra unknown fields on body", () => {
// _toolNameMap merging was removed from the API; verify that extra properties
// on the body do not interfere with remapping and no error is thrown.
// Verify unrelated extra properties do not interfere with remapping and no error is thrown.
const body: Record<string, unknown> = {
tools: [{ name: "bash", description: "Run bash commands" }],
messages: [],
@@ -219,7 +218,7 @@ describe("remapToolNamesInRequest", () => {
assert.equal((body.tools as Array<Record<string, unknown>>)[0].name, "Bash");
// Extra fields are left intact (not stripped, not used for map lookup)
assert.equal(body._someExtraField, "irrelevant");
// No _toolNameMap is stored on body
// _toolNameMap is non-enumerable and will not leak through Object.keys/JSON.stringify.
assert.equal(Object.keys(body).includes("_toolNameMap"), false);
});
@@ -267,6 +266,7 @@ describe("disableThinkingIfToolChoiceForced", () => {
it("removes thinking when tool_choice forces a specific tool", () => {
const body = {
thinking: { type: "enabled", budget_tokens: 1000 },
context_management: { edits: [{ type: "clear_thinking_20251015", keep: "all" }] },
tool_choice: { type: "tool", name: "Bash" },
tools: [{ name: "Bash" }],
};
@@ -277,6 +277,7 @@ describe("disableThinkingIfToolChoiceForced", () => {
!thinkingType || thinkingType === "disabled" || thinkingType === "none",
"thinking must be disabled when tool_choice forces a specific tool"
);
assert.equal(body.context_management, undefined);
});
it("does not modify thinking when tool_choice is auto", () => {

View File

@@ -627,6 +627,28 @@ test("DefaultExecutor.execute only injects adaptive thinking defaults for Claude
},
extendedContext: false,
});
await claude.execute({
model: "claude-sonnet-4-6",
body: {
model: "claude-sonnet-4-6",
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
thinking: { type: "disabled" },
},
stream: false,
credentials: {
apiKey: "cc-key",
providerSpecificData: {
ccSessionId: "session-1",
},
},
clientHeaders: {
"x-app": "cli",
"user-agent": "claude-cli/2.1.116 (external, cli)",
},
extendedContext: false,
});
} finally {
globalThis.fetch = originalFetch;
}
@@ -640,6 +662,9 @@ test("DefaultExecutor.execute only injects adaptive thinking defaults for Claude
assert.equal((requestBodies[1] as any).thinking, undefined);
assert.equal((requestBodies[1] as any).context_management, undefined);
assert.equal((requestBodies[1] as any).output_config, undefined);
assert.deepEqual((requestBodies[2] as any).thinking, { type: "disabled" });
assert.equal((requestBodies[2] as any).context_management, undefined);
});
test("DefaultExecutor.transformRequest injects OpenAI stream usage and preserves model ids with slashes", () => {

View File

@@ -13,6 +13,7 @@ const {
DEFAULT_OBFUSCATE_WORDS,
OPENWEBUI_PARAGRAPH_ANCHORS,
OPENWEBUI_IDENTITY_PREFIXES,
PI_PARAGRAPH_ANCHORS,
PROVIDER_CLAUDE,
PROVIDER_CC_BRIDGE,
} = await import("../../open-sse/services/systemTransforms.ts");
@@ -59,6 +60,10 @@ test("defaults: OpenWebUI anchors include canonical URLs", () => {
assert.ok(OPENWEBUI_IDENTITY_PREFIXES.includes("You are Open WebUI"));
});
test("defaults: Pi anchors include package documentation paths", () => {
assert.ok(PI_PARAGRAPH_ANCHORS.includes("@earendil-works/pi-coding-agent"));
});
// ────────────────────────────────────────────────────────────────────────────
// obfuscate_words op
// ────────────────────────────────────────────────────────────────────────────
@@ -218,6 +223,29 @@ test("applySystemTransformPipeline: claude provider runs its default pipeline",
assert.ok(!result.appliedOpKinds.includes("inject_billing_header"));
});
test("applySystemTransformPipeline: claude provider drops Pi documentation paragraph", () => {
const body = {
system: [
{
type: "text",
text: [
"You are an expert coding assistant operating inside pi, a coding agent harness.",
"Guidelines:\n- Be concise.",
"Pi documentation (read only when the user asks about pi itself):\n- Main documentation: /Users/test/.nvm/versions/node/v24.11.1/lib/node_modules/@earendil-works/pi-coding-agent/README.md",
].join("\n\n"),
},
],
messages: [{ role: "user", content: "hi" }],
};
const result = applySystemTransformPipeline(PROVIDER_CLAUDE, body);
const out = (body.system as Array<{ text: string }>)[0].text;
assert.ok(result.appliedOpKinds.includes("drop_paragraph_if_contains"));
assert.ok(out.includes("expert coding assistant operating inside pi"));
assert.ok(out.includes("Guidelines:"));
assert.ok(!out.includes("@earendil-works/pi-coding-agent"));
assert.ok(!out.includes("Pi documentation"));
});
test("applySystemTransformPipeline: anthropic-compatible-cc-* falls back to PROVIDER_CC_BRIDGE config", () => {
const body = {
system: [{ type: "text", text: "I am OpenCode\n\nThird-party agent" }],
@@ -415,6 +443,9 @@ const UI_DEFAULTS_SNAPSHOT = {
"github.com/open-webui/open-webui",
"openwebui.com",
"docs.openwebui.com",
"@earendil-works/pi-coding-agent",
"/.pi/",
"Pi documentation (read only when the user asks about pi itself",
],
},
{