From be9573f7a3d49c64ad2f229afe0a2721ed0686b7 Mon Sep 17 00:00:00 2001 From: micookie2 Date: Mon, 3 Aug 2026 12:16:07 +0800 Subject: [PATCH] fix(transformers): translate reasoning object to reasoning_effort for chat completions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Responses-API client (e.g. Claude Code) routes through Plexus to a Chat Completions provider, the nested `reasoning: { effort, summary }` object was forwarded verbatim onto the outbound Chat Completions payload. Chat Completions does not accept that nested object — it expects the top-level `reasoning_effort` string — so strict upstreams rejected the request with `UNKNOWN_FIELD: reasoning.effort`. Translate the nested object to `reasoning_effort` on cross-format routes (responses -> chat). Same-format (chat -> chat) pass-through is left untouched: it starts from the client's originalBody, which already preserves whatever reasoning shape the chat client sent. Fixes #783 Co-Authored-By: Claude --- .../openai-reasoning-cross-format.test.ts | 95 +++++++++++++++++++ packages/backend/src/transformers/openai.ts | 21 +++- 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/transformers/__tests__/openai-reasoning-cross-format.test.ts diff --git a/packages/backend/src/transformers/__tests__/openai-reasoning-cross-format.test.ts b/packages/backend/src/transformers/__tests__/openai-reasoning-cross-format.test.ts new file mode 100644 index 000000000..53d068e27 --- /dev/null +++ b/packages/backend/src/transformers/__tests__/openai-reasoning-cross-format.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { OpenAITransformer } from '../openai'; + +/** + * Cross-format reasoning translation: a Responses-API client (e.g. Claude + * Code) sends `reasoning: { effort, summary }` (nested object). When Plexus + * routes that to a Chat Completions provider, the nested object must be + * translated to the Chat Completions top-level `reasoning_effort` string — + * not forwarded verbatim, or strict upstreams reject the unknown + * `reasoning.effort` field (UNKNOWN_FIELD). See issue #783. + */ +describe('OpenAITransformer.transformRequest — reasoning translation', () => { + it('translates a nested reasoning object to top-level reasoning_effort (responses -> chat)', async () => { + const transformer = new OpenAITransformer(); + const out = await transformer.transformRequest({ + model: 'gpt-5', + messages: [{ role: 'user', content: 'hi' }], + reasoning: { effort: 'medium', summary: 'auto' }, + incomingApiType: 'responses', + originalBody: { + model: 'gpt-5', + input: 'hi', + reasoning: { effort: 'medium', summary: 'auto' }, + }, + } as any); + + expect(out.reasoning_effort).toBe('medium'); + // The Responses-style nested object must NOT leak onto a Chat Completions + // payload — that is exactly the field strict upstreams reject. + expect(out.reasoning).toBeUndefined(); + }); + + it('maps each effort level through', async () => { + const transformer = new OpenAITransformer(); + for (const effort of ['minimal', 'low', 'medium', 'high', 'xhigh'] as const) { + const out = await transformer.transformRequest({ + model: 'gpt-5', + messages: [{ role: 'user', content: 'hi' }], + reasoning: { effort }, + incomingApiType: 'responses', + originalBody: {}, + } as any); + expect(out.reasoning_effort).toBe(effort); + expect(out.reasoning).toBeUndefined(); + } + }); + + it('drops reasoning_effort when effort is "none" (thinking disabled)', async () => { + const transformer = new OpenAITransformer(); + const out = await transformer.transformRequest({ + model: 'gpt-5', + messages: [{ role: 'user', content: 'hi' }], + reasoning: { effort: 'none' }, + incomingApiType: 'responses', + originalBody: {}, + } as any); + + expect(out.reasoning_effort).toBeUndefined(); + expect(out.reasoning).toBeUndefined(); + }); + + it('preserves a chat client originalBody verbatim on same-format (chat -> chat) pass-through', async () => { + const transformer = new OpenAITransformer(); + // A chat client that speaks the OpenRouter-style nested `reasoning` object. + const out = await transformer.transformRequest({ + model: 'or-model', + messages: [{ role: 'user', content: 'hi' }], + reasoning: { effort: 'high' }, + incomingApiType: 'chat', + originalBody: { + model: 'or-model', + messages: [{ role: 'user', content: 'hi' }], + reasoning: { effort: 'high' }, + }, + } as any); + + // Same-format keeps the client's nested object untouched (OpenRouter-style + // upstreams accept it; auto-compat rewrites it downstream if needed). + expect(out.reasoning).toEqual({ effort: 'high' }); + expect(out.reasoning_effort).toBeUndefined(); + }); + + it('omits reasoning entirely when the unified request carries none', async () => { + const transformer = new OpenAITransformer(); + const out = await transformer.transformRequest({ + model: 'gpt-4o', + messages: [{ role: 'user', content: 'hi' }], + incomingApiType: 'responses', + originalBody: { model: 'gpt-4o', input: 'hi' }, + } as any); + + expect(out.reasoning).toBeUndefined(); + expect(out.reasoning_effort).toBeUndefined(); + }); +}); diff --git a/packages/backend/src/transformers/openai.ts b/packages/backend/src/transformers/openai.ts index 256f56c8c..cbe9e058a 100644 --- a/packages/backend/src/transformers/openai.ts +++ b/packages/backend/src/transformers/openai.ts @@ -130,7 +130,26 @@ export class OpenAITransformer implements Transformer { } } - if (request.reasoning) { + // Chat Completions and the Responses API spell reasoning intent + // differently: Responses uses a nested `reasoning: { effort, summary }` + // object, while Chat Completions uses a top-level `reasoning_effort` + // string. A cross-format route (e.g. a Responses client such as Claude + // Code targeting a Chat Completions provider) carries the nested object on + // the unified request, and forwarding it verbatim makes strict upstreams + // reject the unknown `reasoning.effort` field (UNKNOWN_FIELD). Translate + // the nested object to the Chat Completions top-level field instead. + // + // Same-format (chat -> chat) pass-through is left untouched: `out` already + // started from `request.originalBody`, which preserves whatever reasoning + // shape the chat client sent (top-level `reasoning_effort` or a nested + // `reasoning` object for OpenRouter-style upstreams), and the unified + // `request.reasoning` mirrors that same value. + if (request.reasoning && !isSameApiType) { + const effort = request.reasoning.effort; + if (effort && effort !== 'none') { + out.reasoning_effort = effort; + } + } else if (request.reasoning) { out.reasoning = request.reasoning; }