From 60e5a8dd66c9498830322a752be0f667c9b3ad89 Mon Sep 17 00:00:00 2001 From: alexanderludwig Date: Tue, 1 Sep 2026 11:49:51 +0200 Subject: [PATCH 1/4] fix seller reasoning profile preservation --- packages/api-adapter/src/canonical.ts | 21 +++++ packages/api-adapter/tests/adapter.test.ts | 32 ++++++++ .../src/index.test.ts | 81 +++++++++++++++++++ .../provider-openai-responses/src/index.ts | 47 ++++++++++- 4 files changed, 180 insertions(+), 1 deletion(-) diff --git a/packages/api-adapter/src/canonical.ts b/packages/api-adapter/src/canonical.ts index cb865c5af..6da46e9d9 100644 --- a/packages/api-adapter/src/canonical.ts +++ b/packages/api-adapter/src/canonical.ts @@ -20,6 +20,15 @@ export type CanonicalToolChoice = | 'required' | { type: 'function'; name: string }; +export type CanonicalReasoningEffort = + | 'none' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max'; + // Anthropic requires max_tokens; the OpenAI protocols treat it as optional. export const DEFAULT_ANTHROPIC_MAX_TOKENS = 16_384; @@ -63,6 +72,7 @@ export interface CanonicalLlmRequest { instructions?: string; input: CanonicalInputItem[]; maxOutputTokens?: number; + reasoningEffort?: CanonicalReasoningEffort; temperature?: number; topP?: number; stop?: string | string[]; @@ -178,6 +188,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 +245,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 +483,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 as CanonicalReasoningEffort; + } 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 +557,12 @@ export function normalizeOpenAIResponsesRequestBody(body: Record).effort; + if (typeof effort === 'string' && effort.length > 0) { + request.reasoningEffort = effort as CanonicalReasoningEffort; + } + } 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..4b3ea1bde 100644 --- a/plugins/provider-openai-responses/src/index.test.ts +++ b/plugins/provider-openai-responses/src/index.test.ts @@ -196,6 +196,87 @@ 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('rejects an invalid reasoning profile before contacting the upstream', async () => { + const authFile = writeAuthFile({ + tokens: { + access_token: makeJwt({}), + account_id: 'acct-file', + }, + }); + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const provider = plugin.createProvider({ + OPENAI_RESPONSES_AUTH_FILE: authFile, + ANTSEED_ALLOWED_SERVICES: 'gpt-5.6-sol', + }); + + const response = await provider.handleRequest({ + requestId: 'req-invalid-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: 'extreme' }, + stream: false, + })), + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(new TextDecoder().decode(response.body))).toEqual({ + error: { + type: 'invalid_request_error', + code: 'request_profile_incompatible', + message: "Unsupported reasoning effort: 'extreme'", + }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + rmSync(dirname(authFile), { recursive: true, force: true }); + }); + it('rewrites announced service names via alias map', async () => { const authFile = writeAuthFile({ tokens: { diff --git a/plugins/provider-openai-responses/src/index.ts b/plugins/provider-openai-responses/src/index.ts index 0d04d1450..a9720e694 100644 --- a/plugins/provider-openai-responses/src/index.ts +++ b/plugins/provider-openai-responses/src/index.ts @@ -35,6 +35,15 @@ const RESPONSE_PATH_PREFIX = '/v1/responses'; const RELAY_PATH = '/responses'; const AUTH_CLAIM_PATH = 'https://api.openai.com/auth'; const CLIENT_STREAM_REQUESTED_HEADER = 'x-antseed-client-stream-requested'; +const SUPPORTED_REASONING_EFFORTS = new Set([ + 'none', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', +]); function expandHome(path: string): string { if (path === '~') return homedir(); @@ -273,7 +282,7 @@ function extractAccountIdFromTokens(accessToken: string, idToken: string | undef function prepareRequestBody( request: SerializedHttpRequest, serviceRewriteMap: Record | undefined, -): { request: SerializedHttpRequest; forcedStream: boolean } { +): { request: SerializedHttpRequest; forcedStream: boolean } | { response: SerializedHttpResponse } { if (request.method === 'GET' || request.method === 'HEAD') { return { request, forcedStream: false }; } @@ -285,6 +294,10 @@ function prepareRequestBody( try { const parsed = JSON.parse(new TextDecoder().decode(request.body)) as Record; + const reasoningProfileError = validateReasoningProfile(parsed); + if (reasoningProfileError) { + return { response: buildRequestProfileError(request.requestId, reasoningProfileError) }; + } parsed.store ??= false; const clientStreamRequested = parseClientStreamRequestedHeader(request.headers); const forcedStream = clientStreamRequested === false || parsed.stream !== true; @@ -348,6 +361,20 @@ function prepareRequestBody( } } +function validateReasoningProfile(body: Record): string | null { + if (body.reasoning === undefined || body.reasoning === null) return null; + if (typeof body.reasoning !== 'object' || Array.isArray(body.reasoning)) { + return 'Reasoning profile must be an object'; + } + + const effort = (body.reasoning as Record).effort; + if (effort === undefined) return null; + if (typeof effort !== 'string' || !SUPPORTED_REASONING_EFFORTS.has(effort)) { + return `Unsupported reasoning effort: '${String(effort)}'`; + } + return null; +} + function parseClientStreamRequestedHeader(headers: Record): boolean | undefined { const value = getHeader(headers, CLIENT_STREAM_REQUESTED_HEADER); if (!value) return undefined; @@ -473,6 +500,21 @@ function buildError(requestId: string, statusCode: number, error: string): Seria }; } +function buildRequestProfileError(requestId: string, message: string): SerializedHttpResponse { + return { + requestId, + statusCode: 400, + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ + error: { + type: 'invalid_request_error', + code: 'request_profile_incompatible', + message, + }, + })), + }; +} + class CodexAuthTokenProvider implements TokenProvider { private authContext: AuthContext | null = null; private refreshPromise: Promise | null = null; @@ -634,6 +676,9 @@ class OpenAIResponsesProvider implements Provider { } const preparedBody = prepareRequestBody(req, this.serviceRewriteMap); + if ('response' in preparedBody) { + return preparedBody; + } return { request: { ...preparedBody.request, From 41fe2fa3449676b52e95cbefbda38b96ec9dc0dd Mon Sep 17 00:00:00 2001 From: alexanderludwig Date: Tue, 1 Sep 2026 11:53:23 +0200 Subject: [PATCH 2/4] docs: document seller reasoning profile fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0396a5203..84c0e89c1 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" }`. Unsupported reasoning profiles now fail with `request_profile_incompatible` instead of being silently removed and allowing the upstream to use a different default. - 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. From 4317c760acfa6d186467b8c0e9c37da6d058fdd4 Mon Sep 17 00:00:00 2001 From: alexanderludwig Date: Tue, 1 Sep 2026 11:57:33 +0200 Subject: [PATCH 3/4] refactor: keep reasoning adaptation model agnostic --- CHANGELOG.md | 2 +- packages/api-adapter/src/canonical.ts | 15 ++---- .../src/index.test.ts | 40 ---------------- .../provider-openai-responses/src/index.ts | 47 +------------------ 4 files changed, 5 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84c0e89c1..da27ef2a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +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" }`. Unsupported reasoning profiles now fail with `request_profile_incompatible` instead of being silently removed and allowing the upstream to use a different default. +- 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. - 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 6da46e9d9..97bfba5c8 100644 --- a/packages/api-adapter/src/canonical.ts +++ b/packages/api-adapter/src/canonical.ts @@ -20,15 +20,6 @@ export type CanonicalToolChoice = | 'required' | { type: 'function'; name: string }; -export type CanonicalReasoningEffort = - | 'none' - | 'minimal' - | 'low' - | 'medium' - | 'high' - | 'xhigh' - | 'max'; - // Anthropic requires max_tokens; the OpenAI protocols treat it as optional. export const DEFAULT_ANTHROPIC_MAX_TOKENS = 16_384; @@ -72,7 +63,7 @@ export interface CanonicalLlmRequest { instructions?: string; input: CanonicalInputItem[]; maxOutputTokens?: number; - reasoningEffort?: CanonicalReasoningEffort; + reasoningEffort?: string; temperature?: number; topP?: number; stop?: string | string[]; @@ -484,7 +475,7 @@ 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 as CanonicalReasoningEffort; + 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; @@ -560,7 +551,7 @@ export function normalizeOpenAIResponsesRequestBody(body: Record).effort; if (typeof effort === 'string' && effort.length > 0) { - request.reasoningEffort = effort as CanonicalReasoningEffort; + request.reasoningEffort = effort; } } if (typeof body.temperature === 'number') request.temperature = body.temperature; diff --git a/plugins/provider-openai-responses/src/index.test.ts b/plugins/provider-openai-responses/src/index.test.ts index 4b3ea1bde..039b448de 100644 --- a/plugins/provider-openai-responses/src/index.test.ts +++ b/plugins/provider-openai-responses/src/index.test.ts @@ -237,46 +237,6 @@ describe('provider-openai-responses plugin', () => { rmSync(dirname(authFile), { recursive: true, force: true }); }); - it('rejects an invalid reasoning profile before contacting the upstream', async () => { - const authFile = writeAuthFile({ - tokens: { - access_token: makeJwt({}), - account_id: 'acct-file', - }, - }); - const fetchMock = vi.fn(); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const provider = plugin.createProvider({ - OPENAI_RESPONSES_AUTH_FILE: authFile, - ANTSEED_ALLOWED_SERVICES: 'gpt-5.6-sol', - }); - - const response = await provider.handleRequest({ - requestId: 'req-invalid-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: 'extreme' }, - stream: false, - })), - }); - - expect(response.statusCode).toBe(400); - expect(JSON.parse(new TextDecoder().decode(response.body))).toEqual({ - error: { - type: 'invalid_request_error', - code: 'request_profile_incompatible', - message: "Unsupported reasoning effort: 'extreme'", - }, - }); - expect(fetchMock).not.toHaveBeenCalled(); - rmSync(dirname(authFile), { recursive: true, force: true }); - }); - it('rewrites announced service names via alias map', async () => { const authFile = writeAuthFile({ tokens: { diff --git a/plugins/provider-openai-responses/src/index.ts b/plugins/provider-openai-responses/src/index.ts index a9720e694..0d04d1450 100644 --- a/plugins/provider-openai-responses/src/index.ts +++ b/plugins/provider-openai-responses/src/index.ts @@ -35,15 +35,6 @@ const RESPONSE_PATH_PREFIX = '/v1/responses'; const RELAY_PATH = '/responses'; const AUTH_CLAIM_PATH = 'https://api.openai.com/auth'; const CLIENT_STREAM_REQUESTED_HEADER = 'x-antseed-client-stream-requested'; -const SUPPORTED_REASONING_EFFORTS = new Set([ - 'none', - 'minimal', - 'low', - 'medium', - 'high', - 'xhigh', - 'max', -]); function expandHome(path: string): string { if (path === '~') return homedir(); @@ -282,7 +273,7 @@ function extractAccountIdFromTokens(accessToken: string, idToken: string | undef function prepareRequestBody( request: SerializedHttpRequest, serviceRewriteMap: Record | undefined, -): { request: SerializedHttpRequest; forcedStream: boolean } | { response: SerializedHttpResponse } { +): { request: SerializedHttpRequest; forcedStream: boolean } { if (request.method === 'GET' || request.method === 'HEAD') { return { request, forcedStream: false }; } @@ -294,10 +285,6 @@ function prepareRequestBody( try { const parsed = JSON.parse(new TextDecoder().decode(request.body)) as Record; - const reasoningProfileError = validateReasoningProfile(parsed); - if (reasoningProfileError) { - return { response: buildRequestProfileError(request.requestId, reasoningProfileError) }; - } parsed.store ??= false; const clientStreamRequested = parseClientStreamRequestedHeader(request.headers); const forcedStream = clientStreamRequested === false || parsed.stream !== true; @@ -361,20 +348,6 @@ function prepareRequestBody( } } -function validateReasoningProfile(body: Record): string | null { - if (body.reasoning === undefined || body.reasoning === null) return null; - if (typeof body.reasoning !== 'object' || Array.isArray(body.reasoning)) { - return 'Reasoning profile must be an object'; - } - - const effort = (body.reasoning as Record).effort; - if (effort === undefined) return null; - if (typeof effort !== 'string' || !SUPPORTED_REASONING_EFFORTS.has(effort)) { - return `Unsupported reasoning effort: '${String(effort)}'`; - } - return null; -} - function parseClientStreamRequestedHeader(headers: Record): boolean | undefined { const value = getHeader(headers, CLIENT_STREAM_REQUESTED_HEADER); if (!value) return undefined; @@ -500,21 +473,6 @@ function buildError(requestId: string, statusCode: number, error: string): Seria }; } -function buildRequestProfileError(requestId: string, message: string): SerializedHttpResponse { - return { - requestId, - statusCode: 400, - headers: { 'content-type': 'application/json' }, - body: new TextEncoder().encode(JSON.stringify({ - error: { - type: 'invalid_request_error', - code: 'request_profile_incompatible', - message, - }, - })), - }; -} - class CodexAuthTokenProvider implements TokenProvider { private authContext: AuthContext | null = null; private refreshPromise: Promise | null = null; @@ -676,9 +634,6 @@ class OpenAIResponsesProvider implements Provider { } const preparedBody = prepareRequestBody(req, this.serviceRewriteMap); - if ('response' in preparedBody) { - return preparedBody; - } return { request: { ...preparedBody.request, From 4bfe8ea40cc0eb188d8c7cb1c22d6f5ed5fbb532 Mon Sep 17 00:00:00 2001 From: alexanderludwig Date: Tue, 1 Sep 2026 12:32:34 +0200 Subject: [PATCH 4/4] fix: preserve streamed response output items --- CHANGELOG.md | 2 +- .../src/index.test.ts | 27 ++++++++++++++++--- .../provider-openai-responses/src/index.ts | 19 +++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da27ef2a9..b3778bbbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +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. +- 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/plugins/provider-openai-responses/src/index.test.ts b/plugins/provider-openai-responses/src/index.test.ts index 039b448de..08fb7174d 100644 --- a/plugins/provider-openai-responses/src/index.test.ts +++ b/plugins/provider-openai-responses/src/index.test.ts @@ -378,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, @@ -415,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 }; }