diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 392ddc1a7b..02aabd756d 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -186,6 +186,11 @@ export function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): return controller.signal; } +function hasActiveClaudeThinking(body: Record): boolean { + const thinking = body.thinking as Record | 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" }], }; diff --git a/open-sse/services/claudeCodeConstraints.ts b/open-sse/services/claudeCodeConstraints.ts index e220a0272b..42f5cc07e8 100644 --- a/open-sse/services/claudeCodeConstraints.ts +++ b/open-sse/services/claudeCodeConstraints.ts @@ -25,6 +25,7 @@ export function disableThinkingIfToolChoiceForced(body: Record) if (isForced && body.thinking) { delete body.thinking; + delete body.context_management; } } diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts index db6f873cad..b0bdc04e77 100644 --- a/open-sse/services/claudeCodeToolRemapper.ts +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -41,6 +41,10 @@ for (const [k, v] of Object.entries(TOOL_RENAME_MAP)) { export function remapToolNamesInRequest(body: Record): boolean { let hasLowercase = false; let hasTitleCase = false; + const toolNameMap = + body._toolNameMap instanceof Map + ? (body._toolNameMap as Map) + : new Map(); // Remap tool definitions const tools = body.tools as Array> | undefined; @@ -48,7 +52,9 @@ export function remapToolNamesInRequest(body: Record): 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): 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): 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): 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 { 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}"`); diff --git a/open-sse/services/systemTransforms.ts b/open-sse/services/systemTransforms.ts index ed95fc0c32..665aa65546 100644 --- a/open-sse/services/systemTransforms.ts +++ b/open-sse/services/systemTransforms.ts @@ -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. { diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index f601d3427c..e243f9bd72 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -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", diff --git a/tests/unit/claude-code-parity.test.ts b/tests/unit/claude-code-parity.test.ts index 682f1486af..ecc205b977 100644 --- a/tests/unit/claude-code-parity.test.ts +++ b/tests/unit/claude-code-parity.test.ts @@ -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 = { tools: [{ name: "bash", description: "Run bash commands" }], messages: [], @@ -219,7 +218,7 @@ describe("remapToolNamesInRequest", () => { assert.equal((body.tools as Array>)[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", () => { diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index 057916bf6c..a8d3d6084d 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -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", () => { diff --git a/tests/unit/system-transforms.test.ts b/tests/unit/system-transforms.test.ts index 1269ecf748..d7513b36fe 100644 --- a/tests/unit/system-transforms.test.ts +++ b/tests/unit/system-transforms.test.ts @@ -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", ], }, {