diff --git a/CHANGELOG.md b/CHANGELOG.md index 0396a5203..b3778bbbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Fixed +- OpenAI Responses sellers now preserve explicit reasoning effort across Chat Completions and Responses request adaptation, so `reasoning_effort: "none"` reaches the upstream as `reasoning: { effort: "none" }` instead of being silently removed and allowing the upstream to use a different default. Non-stream responses also preserve completed streamed output items when an upstream's final event contains an empty `output`, preventing valid CatGPT answers from becoming blank responses. - Desktop telemetry launches now appear as sessions in PostHog. Events carried the launch id only as a custom `session_id` property and as a v4 UUID, so PostHog's Sessions explorer — which keys on `$session_id` and derives the session start from a UUIDv7 timestamp — showed nothing. The launch id is now a UUIDv7 and is sent as both `session_id` and `$session_id`, enabling per-launch analysis (session duration, events per launch, crash rate) without changing anything built on `session_id`. - Phones browsing the website in "Desktop site" mode no longer download desktop installers they can't run. Mobile detection for download CTAs previously relied on viewport width alone, so a phone requesting the desktop site (which widens the layout viewport and, in Samsung Internet, spoofs an `X11; Linux` user agent) was handed the Linux AppImage. The reroute to the `/get-started` flow now also checks touch-only hardware (`pointer: coarse` + `hover: none`) and the UA-CH mobile signal — neither of which desktop-site mode changes — and platform detection treats such devices as unknown, so an installer is never resolved for them. Analytics counts these taps as `get_started` funnel entries instead of download conversions, matching the behavior. Touchscreen laptops keep a fine, hover-capable primary pointer and still get the direct download. - Seller and transport failures returned through the buyer protocol now clearly explain that the selected peer failed, suggest choosing another peer or Auto routing, preserve the seller's original response and status for diagnostics, and identify pinned-peer failures so clients can surface them immediately without retrying the same peer. Buyer-side failures, payment-required responses, and actionable request errors remain unchanged. diff --git a/packages/api-adapter/src/canonical.ts b/packages/api-adapter/src/canonical.ts index cb865c5af..97bfba5c8 100644 --- a/packages/api-adapter/src/canonical.ts +++ b/packages/api-adapter/src/canonical.ts @@ -63,6 +63,7 @@ export interface CanonicalLlmRequest { instructions?: string; input: CanonicalInputItem[]; maxOutputTokens?: number; + reasoningEffort?: string; temperature?: number; topP?: number; stop?: string | string[]; @@ -178,6 +179,7 @@ export function renderCanonicalRequestToOpenAIChatBody( ...(request.stream ? { stream_options: { include_usage: true } } : {}), }; if (typeof request.maxOutputTokens === 'number') body.max_tokens = request.maxOutputTokens; + if (request.reasoningEffort !== undefined) body.reasoning_effort = request.reasoningEffort; if (typeof request.temperature === 'number') body.temperature = request.temperature; if (typeof request.topP === 'number') body.top_p = request.topP; if (request.stop !== undefined) body.stop = request.stop; @@ -234,6 +236,7 @@ export function renderCanonicalRequestToOpenAIResponsesBody( stream: request.stream, }; if (typeof request.maxOutputTokens === 'number') body.max_output_tokens = request.maxOutputTokens; + if (request.reasoningEffort !== undefined) body.reasoning = { effort: request.reasoningEffort }; if (typeof request.temperature === 'number') body.temperature = request.temperature; if (typeof request.topP === 'number') body.top_p = request.topP; if (request.stop !== undefined) body.stop = request.stop; @@ -471,6 +474,9 @@ export function normalizeOpenAIChatRequestBody(body: Record): C } if (typeof body.max_tokens === 'number') request.maxOutputTokens = body.max_tokens; + if (typeof body.reasoning_effort === 'string' && body.reasoning_effort.length > 0) { + request.reasoningEffort = body.reasoning_effort; + } if (typeof body.temperature === 'number') request.temperature = body.temperature; if (typeof body.top_p === 'number') request.topP = body.top_p; if (typeof body.stop === 'string' || Array.isArray(body.stop)) request.stop = body.stop as string | string[]; @@ -542,6 +548,12 @@ export function normalizeOpenAIResponsesRequestBody(body: Record).effort; + if (typeof effort === 'string' && effort.length > 0) { + request.reasoningEffort = effort; + } + } if (typeof body.temperature === 'number') request.temperature = body.temperature; if (typeof body.top_p === 'number') request.topP = body.top_p; if (typeof body.stop === 'string' || Array.isArray(body.stop)) request.stop = body.stop as string | string[]; diff --git a/packages/api-adapter/tests/adapter.test.ts b/packages/api-adapter/tests/adapter.test.ts index dcd494cb9..770fcc903 100644 --- a/packages/api-adapter/tests/adapter.test.ts +++ b/packages/api-adapter/tests/adapter.test.ts @@ -1098,6 +1098,20 @@ describe('transformRequest responses to chat', () => { expect(messages[1]).toEqual({ role: 'user', content: 'What is the capital of France?' }); }); + it('preserves reasoning effort when converting responses to chat completions', () => { + const request = makeResponsesRequest({ + body: new TextEncoder().encode(JSON.stringify({ + model: 'gpt-5.6-sol', + input: 'Return only 42', + reasoning: { effort: 'none' }, + })), + }); + const result = transformRequest(request, { from: 'openai-responses', to: 'openai-chat-completions' }); + + const body = JSON.parse(new TextDecoder().decode(result!.request.body)) as Record; + expect(body.reasoning_effort).toBe('none'); + }); + it('converts array input to messages', () => { const request = makeResponsesRequest({ body: new TextEncoder().encode(JSON.stringify({ @@ -1848,6 +1862,24 @@ describe('transformRequest chat to responses', () => { ]); }); + it('preserves reasoning effort when converting chat completions to responses', () => { + const request: SerializedHttpRequest = { + requestId: 'req-chat-reasoning-effort', + method: 'POST', + path: '/v1/chat/completions', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ + model: 'gpt-5.6-sol', + messages: [{ role: 'user', content: 'Return only 42' }], + reasoning_effort: 'none', + })), + }; + const result = transformRequest(request, { from: 'openai-chat-completions', to: 'openai-responses' }); + + const body = JSON.parse(new TextDecoder().decode(result!.request.body)) as Record; + expect(body.reasoning).toEqual({ effort: 'none' }); + }); + it('carries an explicit prompt_cache_key through to the responses body', () => { const request: SerializedHttpRequest = { requestId: 'req-chat-cache-key', diff --git a/plugins/provider-openai-responses/src/index.test.ts b/plugins/provider-openai-responses/src/index.test.ts index 6302e6b0a..08fb7174d 100644 --- a/plugins/provider-openai-responses/src/index.test.ts +++ b/plugins/provider-openai-responses/src/index.test.ts @@ -196,6 +196,47 @@ describe('provider-openai-responses plugin', () => { rmSync(dirname(authFile), { recursive: true, force: true }); }); + it('preserves an explicit reasoning profile when relaying to the Codex backend', async () => { + const authFile = writeAuthFile({ + tokens: { + access_token: makeJwt({}), + account_id: 'acct-file', + }, + }); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'resp_1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const provider = plugin.createProvider({ + OPENAI_RESPONSES_AUTH_FILE: authFile, + ANTSEED_ALLOWED_SERVICES: 'gpt-5.6-sol', + }); + + await provider.handleRequest({ + requestId: 'req-reasoning-profile', + method: 'POST', + path: '/v1/responses', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ + model: 'gpt-5.6-sol', + input: 'Return only 42', + reasoning: { effort: 'none' }, + stream: false, + })), + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const upstreamBody = JSON.parse( + new TextDecoder().decode((init.body as Uint8Array) ?? new Uint8Array(0)), + ) as Record; + expect(upstreamBody.reasoning).toEqual({ effort: 'none' }); + rmSync(dirname(authFile), { recursive: true, force: true }); + }); + it('rewrites announced service names via alias map', async () => { const authFile = writeAuthFile({ tokens: { @@ -337,9 +378,23 @@ describe('provider-openai-responses plugin', () => { const fetchMock = vi.fn().mockResolvedValue( new Response( 'event: response.created\n' - + 'data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.5","status":"in_progress","output":[]}}\n\n' + + 'data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_1","model":"gpt-5.5","status":"in_progress","output":[]}}\n\n' + + 'event: response.output_item.added\n' + + 'data: {"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"type":"message","id":"msg_1","role":"assistant","status":"in_progress","content":[]}}\n\n' + + 'event: response.content_part.added\n' + + 'data: {"type":"response.content_part.added","sequence_number":2,"item_id":"msg_1","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}}\n\n' + + 'event: response.output_text.delta\n' + + 'data: {"type":"response.output_text.delta","sequence_number":3,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"h","logprobs":[]}\n\n' + + 'event: response.output_text.delta\n' + + 'data: {"type":"response.output_text.delta","sequence_number":4,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"i","logprobs":[]}\n\n' + + 'event: response.output_text.done\n' + + 'data: {"type":"response.output_text.done","sequence_number":5,"item_id":"msg_1","output_index":0,"content_index":0,"text":"hi","logprobs":[]}\n\n' + + 'event: response.content_part.done\n' + + 'data: {"type":"response.content_part.done","sequence_number":6,"item_id":"msg_1","output_index":0,"content_index":0,"part":{"type":"output_text","text":"hi","annotations":[]}}\n\n' + + 'event: response.output_item.done\n' + + 'data: {"type":"response.output_item.done","sequence_number":7,"output_index":0,"item":{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"hi","annotations":[]}]}}\n\n' + 'event: response.completed\n' - + 'data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.5","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"hi","annotations":[]}]}],"output_text":"hi","usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n' + + 'data: {"type":"response.completed","sequence_number":8,"response":{"id":"resp_1","object":"response","model":"gpt-5.5","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n' + 'data: [DONE]\n\n', { status: 200, @@ -374,7 +429,14 @@ describe('provider-openai-responses plugin', () => { expect(response.headers['content-type']).toBe('application/json'); const body = JSON.parse(new TextDecoder().decode(response.body)) as Record; expect(body.id).toBe('resp_1'); - expect(body.output_text).toBe('hi'); + expect(body.output).toEqual([{ + type: 'message', + id: 'msg_1', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hi', annotations: [] }], + }]); + expect(body.output_text).toBeUndefined(); rmSync(dirname(authFile), { recursive: true, force: true }); }); diff --git a/plugins/provider-openai-responses/src/index.ts b/plugins/provider-openai-responses/src/index.ts index 0d04d1450..722466eed 100644 --- a/plugins/provider-openai-responses/src/index.ts +++ b/plugins/provider-openai-responses/src/index.ts @@ -389,6 +389,8 @@ function collapseResponsesSseResponse(response: SerializedHttpResponse): Seriali } function parseResponsesSse(text: string): { body: Record; statusCode?: number } | null { + const outputItems: Array | undefined> = []; + for (const block of text.replace(/\r\n/g, '\n').split('\n\n')) { const lines = block.split('\n'); const event = lines @@ -399,6 +401,7 @@ function parseResponsesSse(text: string): { body: Record; statu event !== 'response.completed' && event !== 'response.failed' && event !== 'error' + && event !== 'response.output_item.done' ) { continue; } @@ -412,6 +415,16 @@ function parseResponsesSse(text: string): { body: Record; statu try { const parsed = JSON.parse(data) as Record; + if (event === 'response.output_item.done') { + const item = parsed.item; + const outputIndex = parsed.output_index; + if (typeof outputIndex === 'number' && Number.isInteger(outputIndex) && outputIndex >= 0 + && item && typeof item === 'object' && !Array.isArray(item)) { + outputItems[outputIndex] = item as Record; + } + continue; + } + if (event === 'error') { return { body: normalizeUpstreamStreamError(parsed), statusCode: 502 }; } @@ -422,6 +435,12 @@ function parseResponsesSse(text: string): { body: Record; statu if (event === 'response.failed') { return { body: normalizeUpstreamStreamError(body), statusCode: 502 }; } + const completedOutput = outputItems.filter( + (item): item is Record => item !== undefined, + ); + if (completedOutput.length > 0 && (!Array.isArray(body.output) || body.output.length === 0)) { + return { body: { ...body, output: completedOutput } }; + } return { body }; }