From a1268bbc3dd46e01e6cbb35d33c81be86f989970 Mon Sep 17 00:00:00 2001 From: Thomas Maerz <7740810+thomasmaerz@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:07:17 -0700 Subject: [PATCH 1/2] fix: strip Claude title reasoning effort --- src/plugin.ts | 113 ++++++++++++++++++++++++++++++++++++++----- test/plugin.test.mjs | 96 ++++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 11 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index 4071315..3de7d9f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -969,7 +969,7 @@ function createFetchInterceptor( headers.set('Authorization', `Bearer ${config.apiKey}`); headers.set('Content-Type', 'application/json'); - const sanitizedBody = await sanitizeGeminiToolSchemas(input, init, url); + const sanitizedBody = await sanitizeRequestPayload(input, init, url); // Clone init to avoid mutating original const modifiedInit: RequestInit = { @@ -1131,8 +1131,12 @@ function cloneMutableResponseHeaders(headers: Headers): Headers { } const GEMINI_SCHEMA_KEYS_TO_REMOVE = new Set(['$schema', '$ref', 'ref', 'additionalProperties']); +const TITLE_PROMPT_REQUIRED_MARKERS = [ + 'You are a title generator', + 'thread title', +]; -async function sanitizeGeminiToolSchemas( +async function sanitizeRequestPayload( input: RequestInfo | URL, init: RequestInit | undefined, url: string, @@ -1157,24 +1161,111 @@ async function sanitizeGeminiToolSchemas( return undefined; } + const clonedPayload = structuredClone(payload); + let changed = false; + + changed = stripClaudeTitleReasoningEffort(clonedPayload) || changed; + changed = sanitizeGeminiToolSchemas(clonedPayload) || changed; + + return changed ? JSON.stringify(clonedPayload) : undefined; +} + +function stripClaudeTitleReasoningEffort(payload: Record): boolean { + const model = payload.model; + if (!isClaudeModel(model) || !isOpenCodeTitlePrompt(payload)) { + return false; + } + + let changed = false; + if ('reasoning_effort' in payload) { + delete payload.reasoning_effort; + changed = true; + } + if ('reasoningEffort' in payload) { + delete payload.reasoningEffort; + changed = true; + } + + if (changed) { + debug('Removed reasoning effort from Claude title request'); + } + + return changed; +} + +function isClaudeModel(model: unknown): boolean { + if (typeof model !== 'string') return false; + const lower = model.toLowerCase(); + return ( + lower.startsWith('claude/') || + lower.startsWith('claude-') || + lower.startsWith('anthropic/') || + lower.startsWith('anthropic:') || + lower.includes('/claude-') + ); +} + +function isOpenCodeTitlePrompt(payload: Record): boolean { + if (contentContainsTitlePrompt(payload.instructions)) return true; + + const messages = payload.messages; + if (Array.isArray(messages)) { + return messages.some((message) => { + if (!isRecord(message) || message.role !== 'system') return false; + return contentContainsTitlePrompt(message.content); + }); + } + + const input = payload.input; + if (Array.isArray(input)) { + return input.some((item) => { + if (!isRecord(item) || item.role !== 'system') return false; + return contentContainsTitlePrompt(item.content); + }); + } + + return false; +} + +function contentContainsTitlePrompt(content: unknown): boolean { + const text = contentToText(content); + if (!text) return false; + return TITLE_PROMPT_REQUIRED_MARKERS.every((marker) => text.includes(marker)); +} + +function contentToText(content: unknown): string { + if (typeof content === 'string') return content; + + if (Array.isArray(content)) { + return content.map(contentToText).filter(Boolean).join('\n'); + } + + if (!isRecord(content)) return ''; + const text = content.text; + if (typeof text === 'string') return text; + const value = content.value; + if (typeof value === 'string') return value; + const contentValue = content.content; + if (contentValue !== undefined) return contentToText(contentValue); + return ''; +} + +function sanitizeGeminiToolSchemas(payload: Record): boolean { const model = payload.model; if (typeof model !== 'string' || !model.toLowerCase().includes('gemini')) { - return undefined; + return false; } const tools = payload.tools; if (!Array.isArray(tools) || tools.length === 0) { - return undefined; + return false; } - const clonedPayload = structuredClone(payload); - const changed = sanitizeToolSchemaContainer(clonedPayload); - if (!changed) { - return undefined; + const changed = sanitizeToolSchemaContainer(payload); + if (changed) { + debug('Sanitized Gemini tool schema keywords'); } - - debug('Sanitized Gemini tool schema keywords'); - return JSON.stringify(clonedPayload); + return changed; } async function getRawJsonBody( diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 37804ce..b36045a 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -710,6 +710,102 @@ test('non-gemini payload keeps original tool schema fields', async () => { ); }); +test('claude title requests strip reasoning_effort before forwarding', async () => { + const plugin = await OmniRouteAuthPlugin({}); + let forwardedBody; + + global.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response(JSON.stringify(createModelsResponse()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + forwardedBody = typeof init?.body === 'string' ? JSON.parse(init.body) : null; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const provider = { + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, + models: {}, + }; + + const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + const interceptedFetch = options.fetch; + + await interceptedFetch(`${getDummyBaseUrl()}/chat/completions`, { + method: 'POST', + body: JSON.stringify({ + model: 'claude/claude-haiku-4-5-20251001', + temperature: 0.5, + reasoning_effort: 'low', + messages: [ + { + role: 'system', + content: 'You are a title generator. You output ONLY a thread title. Nothing else.', + }, + ], + }), + }); + + assert.ok(forwardedBody); + assert.equal(forwardedBody.reasoning_effort, undefined); + assert.equal(forwardedBody.temperature, 0.5); +}); + +test('claude non-title requests keep reasoning_effort before forwarding', async () => { + const plugin = await OmniRouteAuthPlugin({}); + let forwardedBody; + + global.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response(JSON.stringify(createModelsResponse()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + forwardedBody = typeof init?.body === 'string' ? JSON.parse(init.body) : null; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const provider = { + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, + models: {}, + }; + + const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + const interceptedFetch = options.fetch; + + await interceptedFetch(`${getDummyBaseUrl()}/chat/completions`, { + method: 'POST', + body: JSON.stringify({ + model: 'claude/claude-sonnet-4-6', + temperature: 1, + reasoning_effort: 'low', + messages: [ + { + role: 'user', + content: 'Explain this bug.', + }, + ], + }), + }); + + assert.ok(forwardedBody); + assert.equal(forwardedBody.reasoning_effort, 'low'); + assert.equal(forwardedBody.temperature, 1); +}); + test('gemini schema sanitization applies to responses endpoint request objects', async () => { const plugin = await OmniRouteAuthPlugin({}); let forwardedBody; From f9897c24fc5f9b532645f846a40619833a3c9476 Mon Sep 17 00:00:00 2001 From: Sebastian Rumpf Date: Thu, 25 Jun 2026 18:44:21 +0200 Subject: [PATCH 2/2] fix(review): address PR #31 review feedback - Make Claude title-prompt detection case-insensitive - Check top-level Anthropic system field for title prompts - Add recursion depth guard to contentToText - Tighten isClaudeModel prefix matching and reduce false positives - Avoid structuredClone for non-Claude/non-Gemini requests - Log warning when request body JSON parsing fails - Add JSDoc to in-place sanitizeGeminiToolSchemas - Add tests for instructions, input array, system field, content arrays, camelCase reasoningEffort, non-Claude title requests, and pass-through - Update AGENTS.md fetch-interceptor documentation --- AGENTS.md | 1 + src/plugin.ts | 56 +++++--- test/plugin.test.mjs | 315 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 353 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 95fdcf8..d0a14d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,7 @@ The loader returns a `fetch` function that: 1. Adds `Authorization: Bearer ` and `Content-Type: application/json` headers. 2. Only intercepts requests to the configured OmniRoute base URL (with safe prefix matching). 3. Sanitizes Gemini tool schemas by stripping `$schema`, `$ref`, `ref`, and `additionalProperties` keywords when the model name includes "gemini". +4. Strips `reasoning_effort`/`reasoningEffort` from identified Claude title-generation requests (OpenCode's hidden title prompt) to avoid Anthropic OAuth temperature/thinking validation errors, while preserving reasoning options for normal Claude chat requests. ### Caching Strategy diff --git a/src/plugin.ts b/src/plugin.ts index 3de7d9f..a993a8b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1132,7 +1132,7 @@ function cloneMutableResponseHeaders(headers: Headers): Headers { const GEMINI_SCHEMA_KEYS_TO_REMOVE = new Set(['$schema', '$ref', 'ref', 'additionalProperties']); const TITLE_PROMPT_REQUIRED_MARKERS = [ - 'You are a title generator', + 'you are a title generator', 'thread title', ]; @@ -1153,7 +1153,8 @@ async function sanitizeRequestPayload( let payload: unknown; try { payload = JSON.parse(rawBody); - } catch { + } catch (error) { + warn(`Failed to parse request body as JSON; forwarding unchanged: ${sanitizeForLog(String(error))}`); return undefined; } @@ -1161,18 +1162,23 @@ async function sanitizeRequestPayload( return undefined; } - const clonedPayload = structuredClone(payload); + const mayMutate = isClaudeModel(payload.model) || isGeminiModel(payload.model); + const workingPayload = mayMutate ? structuredClone(payload) : payload; let changed = false; - changed = stripClaudeTitleReasoningEffort(clonedPayload) || changed; - changed = sanitizeGeminiToolSchemas(clonedPayload) || changed; + changed = stripClaudeTitleReasoningEffort(workingPayload) || changed; + changed = sanitizeGeminiToolSchemas(workingPayload) || changed; - return changed ? JSON.stringify(clonedPayload) : undefined; + return changed ? JSON.stringify(workingPayload) : undefined; } function stripClaudeTitleReasoningEffort(payload: Record): boolean { const model = payload.model; - if (!isClaudeModel(model) || !isOpenCodeTitlePrompt(payload)) { + if (!isClaudeModel(model)) { + return false; + } + if (!isOpenCodeTitlePrompt(payload)) { + debug('Claude request detected but title markers not found; preserving reasoning effort'); return false; } @@ -1196,17 +1202,21 @@ function stripClaudeTitleReasoningEffort(payload: Record): bool function isClaudeModel(model: unknown): boolean { if (typeof model !== 'string') return false; const lower = model.toLowerCase(); - return ( - lower.startsWith('claude/') || - lower.startsWith('claude-') || - lower.startsWith('anthropic/') || - lower.startsWith('anthropic:') || - lower.includes('/claude-') - ); + // OmniRoute canonical aliases: claude/ and anthropic/ + if (lower.startsWith('claude/') || lower.startsWith('anthropic/')) return true; + // Provider-prefixed IDs where the provider slug ends with the model family, + // e.g. aws/us-claude-sonnet-4-6 or openrouter/claude-3-5-sonnet + if (/\bclaude[-/]/.test(lower)) return true; + return false; +} + +function isGeminiModel(model: unknown): boolean { + return typeof model === 'string' && model.toLowerCase().includes('gemini'); } function isOpenCodeTitlePrompt(payload: Record): boolean { if (contentContainsTitlePrompt(payload.instructions)) return true; + if (contentContainsTitlePrompt(payload.system)) return true; const messages = payload.messages; if (Array.isArray(messages)) { @@ -1230,14 +1240,18 @@ function isOpenCodeTitlePrompt(payload: Record): boolean { function contentContainsTitlePrompt(content: unknown): boolean { const text = contentToText(content); if (!text) return false; - return TITLE_PROMPT_REQUIRED_MARKERS.every((marker) => text.includes(marker)); + const normalized = text.toLowerCase(); + return TITLE_PROMPT_REQUIRED_MARKERS.every((marker) => normalized.includes(marker)); } -function contentToText(content: unknown): string { +const MAX_CONTENT_DEPTH = 10; + +function contentToText(content: unknown, depth = 0): string { + if (depth > MAX_CONTENT_DEPTH) return ''; if (typeof content === 'string') return content; if (Array.isArray(content)) { - return content.map(contentToText).filter(Boolean).join('\n'); + return content.map((item) => contentToText(item, depth + 1)).filter(Boolean).join('\n'); } if (!isRecord(content)) return ''; @@ -1246,13 +1260,17 @@ function contentToText(content: unknown): string { const value = content.value; if (typeof value === 'string') return value; const contentValue = content.content; - if (contentValue !== undefined) return contentToText(contentValue); + if (contentValue !== undefined) return contentToText(contentValue, depth + 1); return ''; } +/** + * Sanitizes Gemini tool schemas in place. + * Mutates `payload` and returns `true` if any keys were removed. + */ function sanitizeGeminiToolSchemas(payload: Record): boolean { const model = payload.model; - if (typeof model !== 'string' || !model.toLowerCase().includes('gemini')) { + if (!isGeminiModel(model)) { return false; } diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index b36045a..8d76a9b 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -806,6 +806,321 @@ test('claude non-title requests keep reasoning_effort before forwarding', async assert.equal(forwardedBody.temperature, 1); }); +test('claude title requests strip reasoning_effort from instructions field', async () => { + const plugin = await OmniRouteAuthPlugin({}); + let forwardedBody; + + global.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response(JSON.stringify(createModelsResponse()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + forwardedBody = typeof init?.body === 'string' ? JSON.parse(init.body) : null; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const provider = { + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, + models: {}, + }; + + const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + const interceptedFetch = options.fetch; + + await interceptedFetch(`${getDummyBaseUrl()}/chat/completions`, { + method: 'POST', + body: JSON.stringify({ + model: 'claude/claude-haiku-4-5-20251001', + temperature: 0.5, + reasoning_effort: 'low', + instructions: 'You are a title generator. You output ONLY a thread title.', + }), + }); + + assert.ok(forwardedBody); + assert.equal(forwardedBody.reasoning_effort, undefined); + assert.equal(forwardedBody.temperature, 0.5); +}); + +test('claude title requests strip reasoning_effort from input array', async () => { + const plugin = await OmniRouteAuthPlugin({}); + let forwardedBody; + + global.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response(JSON.stringify(createModelsResponse()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + forwardedBody = typeof init?.body === 'string' ? JSON.parse(init.body) : null; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const provider = { + options: { baseURL: getDummyBaseUrl(), apiMode: 'responses' }, + models: {}, + }; + + const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + const interceptedFetch = options.fetch; + + await interceptedFetch(`${getDummyBaseUrl()}/responses`, { + method: 'POST', + body: JSON.stringify({ + model: 'claude/claude-haiku-4-5-20251001', + reasoning_effort: 'low', + input: [ + { + role: 'system', + content: 'You are a title generator. You output ONLY a thread title.', + }, + ], + }), + }); + + assert.ok(forwardedBody); + assert.equal(forwardedBody.reasoning_effort, undefined); +}); + +test('claude title requests strip reasoning_effort from top-level system field', async () => { + const plugin = await OmniRouteAuthPlugin({}); + let forwardedBody; + + global.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response(JSON.stringify(createModelsResponse()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + forwardedBody = typeof init?.body === 'string' ? JSON.parse(init.body) : null; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const provider = { + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, + models: {}, + }; + + const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + const interceptedFetch = options.fetch; + + await interceptedFetch(`${getDummyBaseUrl()}/chat/completions`, { + method: 'POST', + body: JSON.stringify({ + model: 'claude/claude-haiku-4-5-20251001', + reasoning_effort: 'low', + system: 'You are a title generator. You output ONLY a thread title.', + messages: [{ role: 'user', content: 'Hi' }], + }), + }); + + assert.ok(forwardedBody); + assert.equal(forwardedBody.reasoning_effort, undefined); +}); + +test('claude title requests detect title markers case-insensitively', async () => { + const plugin = await OmniRouteAuthPlugin({}); + let forwardedBody; + + global.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response(JSON.stringify(createModelsResponse()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + forwardedBody = typeof init?.body === 'string' ? JSON.parse(init.body) : null; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const provider = { + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, + models: {}, + }; + + const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + const interceptedFetch = options.fetch; + + await interceptedFetch(`${getDummyBaseUrl()}/chat/completions`, { + method: 'POST', + body: JSON.stringify({ + model: 'claude/claude-haiku-4-5-20251001', + reasoning_effort: 'low', + messages: [ + { + role: 'system', + content: [ + { type: 'text', text: 'you are a title generator' }, + { type: 'text', text: 'output a thread title' }, + ], + }, + ], + }), + }); + + assert.ok(forwardedBody); + assert.equal(forwardedBody.reasoning_effort, undefined); +}); + +test('claude title requests strip camelCase reasoningEffort', async () => { + const plugin = await OmniRouteAuthPlugin({}); + let forwardedBody; + + global.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response(JSON.stringify(createModelsResponse()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + forwardedBody = typeof init?.body === 'string' ? JSON.parse(init.body) : null; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const provider = { + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, + models: {}, + }; + + const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + const interceptedFetch = options.fetch; + + await interceptedFetch(`${getDummyBaseUrl()}/chat/completions`, { + method: 'POST', + body: JSON.stringify({ + model: 'anthropic/claude-haiku-4-5-20251001', + reasoningEffort: 'low', + messages: [ + { + role: 'system', + content: 'You are a title generator. You output ONLY a thread title.', + }, + ], + }), + }); + + assert.ok(forwardedBody); + assert.equal(forwardedBody.reasoningEffort, undefined); +}); + +test('non-claude title requests keep reasoning_effort before forwarding', async () => { + const plugin = await OmniRouteAuthPlugin({}); + let forwardedBody; + + global.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response(JSON.stringify(createModelsResponse()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + forwardedBody = typeof init?.body === 'string' ? JSON.parse(init.body) : null; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const provider = { + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, + models: {}, + }; + + const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + const interceptedFetch = options.fetch; + + await interceptedFetch(`${getDummyBaseUrl()}/chat/completions`, { + method: 'POST', + body: JSON.stringify({ + model: 'openai/gpt-4.1-mini', + reasoning_effort: 'low', + messages: [ + { + role: 'system', + content: 'You are a title generator. You output ONLY a thread title.', + }, + ], + }), + }); + + assert.ok(forwardedBody); + assert.equal(forwardedBody.reasoning_effort, 'low'); +}); + +test('non-claude payloads are not re-stringified unnecessarily', async () => { + const plugin = await OmniRouteAuthPlugin({}); + let rawBody; + + global.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response(JSON.stringify(createModelsResponse()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + rawBody = typeof init?.body === 'string' ? init.body : null; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const provider = { + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, + models: {}, + }; + + const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + const interceptedFetch = options.fetch; + + const originalBody = JSON.stringify({ + model: 'openai/gpt-4.1-mini', + temperature: 1, + reasoning_effort: 'low', + messages: [{ role: 'user', content: 'Explain this bug.' }], + }); + + await interceptedFetch(`${getDummyBaseUrl()}/chat/completions`, { + method: 'POST', + body: originalBody, + }); + + assert.equal(rawBody, originalBody); +}); + test('gemini schema sanitization applies to responses endpoint request objects', async () => { const plugin = await OmniRouteAuthPlugin({}); let forwardedBody;