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
113 changes: 102 additions & 11 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,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 = {
Expand All @@ -918,8 +918,12 @@ function createFetchInterceptor(
}

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,
Expand All @@ -944,24 +948,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;
Comment on lines +952 to +957

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Updating the changed flag using logical OR (||) with function calls on the left-hand side is correct but highly fragile and non-idiomatic. If a future refactoring reorders the operands (e.g., changed = changed || ...), the function call will be short-circuited and skipped when changed is already true. It is much safer and more readable to evaluate the functions independently first.

Suggested change
let changed = false;
changed = stripClaudeTitleReasoningEffort(clonedPayload) || changed;
changed = sanitizeGeminiToolSchemas(clonedPayload) || changed;
return changed ? JSON.stringify(clonedPayload) : undefined;
const changedClaude = stripClaudeTitleReasoningEffort(clonedPayload);
const changedGemini = sanitizeGeminiToolSchemas(clonedPayload);
const changed = changedClaude || changedGemini;
return changed ? JSON.stringify(clonedPayload) : undefined;

}

function stripClaudeTitleReasoningEffort(payload: Record<string, unknown>): 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<string, unknown>): 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;
}
Comment on lines +995 to +1015

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If payload.messages is an array, the function immediately returns the result of messages.some(...). If it does not match, the function returns false and completely skips checking payload.input (even if payload.input is present and contains the title prompt). It is more robust to only return true if a match is found, and otherwise continue checking the remaining fields.

function isOpenCodeTitlePrompt(payload: Record<string, unknown>): boolean {
  if (contentContainsTitlePrompt(payload.instructions)) return true;

  const messages = payload.messages;
  if (Array.isArray(messages)) {
    const hasPrompt = messages.some((message) => {
      if (!isRecord(message) || message.role !== 'system') return false;
      return contentContainsTitlePrompt(message.content);
    });
    if (hasPrompt) return true;
  }

  const input = payload.input;
  if (Array.isArray(input)) {
    const hasPrompt = input.some((item) => {
      if (!isRecord(item) || item.role !== 'system') return false;
      return contentContainsTitlePrompt(item.content);
    });
    if (hasPrompt) return true;
  }

  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<string, unknown>): 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(
Expand Down
96 changes: 96 additions & 0 deletions test/plugin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,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;
Expand Down