From a0ce1316c4d37faefa3a15f836f20c87a7143458 Mon Sep 17 00:00:00 2001 From: G626 <103378730+Krablante@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:09:36 +0000 Subject: [PATCH] fix(opencodez): handle Responses lifecycle and empty continuations --- README.md | 12 +- docs/opencodez.md | 76 ++- .../llm/src/protocols/openai-responses.ts | 41 +- packages/llm/src/protocols/utils/lifecycle.ts | 7 + .../test/provider/openai-responses.test.ts | 59 ++ .../src/opencodez/codex-responses/attempt.ts | 53 ++ .../src/opencodez/codex-responses/compact.ts | 8 +- .../opencodez/codex-responses/continuation.ts | 45 +- .../codex-responses/session-compaction.ts | 30 +- .../opencodez/codex-responses/transport.ts | 17 +- packages/opencode/src/plugin/openai/README.md | 22 +- packages/opencode/src/plugin/openai/codex.ts | 12 + packages/opencode/src/session/message-v2.ts | 21 +- packages/opencode/src/session/processor.ts | 17 +- packages/opencode/src/session/prompt.ts | 16 +- .../test/session/processor-effect.test.ts | 96 ++- .../test/session/remote-compaction.test.ts | 51 +- .../test/session/responses-lifecycle.test.ts | 108 ++++ patches/@ai-sdk%2Fopenai@3.0.88.patch | 604 +++++++++++++++++- 19 files changed, 1240 insertions(+), 55 deletions(-) create mode 100644 packages/opencode/test/session/responses-lifecycle.test.ts diff --git a/README.md b/README.md index 68c5813c2..af0397ab0 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,9 @@ After one full canonical request, it sends incremental input with `previous_response_id` whenever the next request is a compatible extension, including across logical user turns. A new user turn resets its sticky `x-codex-turn-state` routing token without discarding a compatible continuation -or warm WebSocket. Reconnects, interruptions, context changes, and relevant +or warm WebSocket. Compatible follow-ups may send an empty input delta. +Server-controlled interruptions keep continuation; user cancellation, +reconnects, context changes, and relevant model-setting changes return safely to a full request. Set the value to `legacy` to restore the previous OpenCode request lifecycle: unchanged full request bodies, upstream retry and partial-output handling, and @@ -276,7 +278,11 @@ session that already contains durable opaque OpenAI compaction state continues that state over authenticated HTTP so changing the setting cannot discard context. API-key OpenAI access, alternate OpenAI model adapters, and other providers bypass Codex request lowering and keep their -existing behavior. +existing behavior. Codex OAuth preserves `partial_answer` phases and continues +when the server reports `end_turn: false` or a controlled interruption. Other +incomplete responses stop without automatic retry, compaction, or model fallback, +while retaining partial output and reported usage. See the bilingual +[response lifecycle guide](docs/opencodez.md#response-lifecycle--жизненный-цикл-ответа). OpenCodez reads ChatGPT model capabilities from the authenticated Codex model catalog and refreshes them on the catalog ETag. GPT-5.6 and GPT-6 models that @@ -306,7 +312,7 @@ catalog `comp_hash` changes between turns, or a model switch reduces the effective context window below the active token state, it compacts the previous history with the previous model before sampling the new turn. It retries once with the current model only when the previous model can no longer complete the -compact. +compact. Terminal incomplete responses never trigger this model fallback. The authenticated catalog profile is frozen for the complete logical turn, so an ETag refresh cannot change `comp_hash`, Responses Lite lowering, or context limits halfway through a tool loop. Switching ChatGPT login during an active diff --git a/docs/opencodez.md b/docs/opencodez.md index c5ae359b8..cab1cf05e 100644 --- a/docs/opencodez.md +++ b/docs/opencodez.md @@ -209,12 +209,13 @@ rewrites personal prompts or assignments. ## Responses Wire Mode ChatGPT OAuth sessions can use the stateful Responses WebSocket protocol aligned -with Codex `rust-v0.153.4`. The first request in a fresh transport chain sends the +with Codex `rust-v0.153.4`, with response lifecycle and empty-delta continuation +behavior updated against `rust-v0.163.0`. The first request in a fresh transport chain sends the full conversation. Later compatible requests send only new input items together with `previous_response_id` when the conversation prefix and model settings still match. This continuation can cross a logical user-turn boundary, while the turn-scoped `x-codex-turn-state` routing token is reset. A reconnect, -interruption, incompatible history, or relevant request-setting change starts a +cancelled request, incompatible history, or relevant request-setting change starts a new full chain without requiring a different local session. The default is `codex`. Set `opencodez.responses.wire` to `legacy` to restore the @@ -268,8 +269,8 @@ routing hints, and remote compaction use the same lowered value. This adds no probe, entitlement check, warning, or persistent store; legacy mode and API-key requests do not inherit it. -OpenCodez automatically sends a full request after a reconnect, interrupted -response, context compaction, history edit, relevant model-setting change, or +OpenCodez automatically sends a full request after a reconnect, cancelled +request, terminal incomplete response, context compaction, history edit, relevant model-setting change, or failed compatibility check. A new logical user turn alone resets sticky routing but does not invalidate an otherwise compatible continuation. The existing HTTP fallback also receives the original full request rather than @@ -321,6 +322,73 @@ retry-exhausted HTTP fallback remains sticky for that session-and-account entry until the session is removed or the bounded pool evicts it, avoiding periodic WebSocket reprobes after a known transport failure. +### Response lifecycle / Жизненный цикл ответа + +The SDK and native Responses parsers preserve `commentary`, `partial_answer`, +and `final_answer` phases in text metadata and replay them in history. A partial +answer may precede more reasoning or tool calls. For Codex OAuth, a completed +response with `end_turn: false`, or `response.incomplete` with reason +`interrupted`, requests another model turn through the existing session loop. +A server-controlled interruption keeps a compatible WebSocket continuation; +the user's Stop action still cancels the request and invalidates it. + +Other incomplete responses, including `max_output_tokens`, `content_filter`, +and missing or unknown reasons, stop with a non-retryable error. Received text, +reasoning, token usage, and cost remain stored. They trigger neither automatic +compaction nor model fallback. Their usage counts toward cost but does not +replace the last completed context baseline for compaction decisions. An +incomplete compact preserves its reported usage and leaves the active compacted +state unchanged; it never installs a partial result or retries on another model. +An interrupted compact also stops safely, allowing an explicit later retry. + +Continuation accepts an empty delta when the full current input exactly matches +the previous request plus its output. It sends `input: []` with +`previous_response_id`. Debug logs named `continuation unavailable` report only +a bounded reason: missing previous response/id, invalid input, changed model, +tools, instructions, reasoning or other settings, shortened input, or mismatched +history. They never contain request content. Reconnect and HTTP fallback still +send the full current working context. These policies apply only to Codex OAuth; +API-key, legacy-wire, and other-provider lifecycle behavior is unchanged. + +SDK и встроенный клиент Responses сохраняют фазы `commentary`, `partial_answer` +и `final_answer` в метаданных текста и передают их обратно с историей. После +частичного ответа модель может продолжить рассуждение или вызвать инструменты. +В Codex OAuth признак `end_turn: false` и неполный ответ с причиной `interrupted` +запускают следующий запрос через обычный цикл сессии. Управляемое сервером +прерывание сохраняет совместимый WebSocket continuation. Кнопка Stop отменяет +запрос и сбрасывает continuation. + +Другие неполные ответы, включая выходной лимит, фильтр содержимого и неизвестную +или отсутствующую причину, завершаются ошибкой без автоматического повтора, +compact или смены модели. Полученный текст, рассуждение, токены и стоимость +сохраняются. Расход учитывается в стоимости, но не заменяет последнее завершённое +состояние контекста при решении о compact. Неполный compact сохраняет известный +расход и прежнее рабочее состояние; частичный результат не устанавливается. +Прерванный compact тоже останавливается, после чего его можно повторить явно. + +Если текущий контекст точно совпадает с предыдущим запросом и его ответом, +continuation отправляет пустой `input` с `previous_response_id`. Debug-сообщение +`continuation unavailable` содержит только короткую причину: нет предыдущего +ответа или его id, некорректный input, изменились настройки, история сократилась +или перестала совпадать. Содержимое запросов в этот лог не попадает. После +переподключения и при HTTP fallback отправляется полный текущий рабочий контекст. +Эти правила жизненного цикла относятся только к Codex OAuth. + +Maintenance: the pinned `@ai-sdk/openai` patch changes parsing and exposes +`endTurn`/`incompleteReason` metadata; it does not decide session policy. +`attempt.ts` owns Codex outcome classification, `processor.ts` persists output +and usage before surfacing terminal errors, and `continuation.ts` owns prefix +compatibility. Keep the SDK source, ESM/CJS bundles, and declarations aligned +when refreshing the patch. Regression coverage lives in the Responses lifecycle, +processor, remote-compaction, and native Responses provider tests. + +Для поддержки: патч SDK отвечает за разбор и метаданные, `attempt.ts` — за +классификацию результата Codex, `processor.ts` — за сохранение ответа и расхода +до показа ошибки, `continuation.ts` — за совместимость истории. При обновлении +патча нужно согласованно менять исходники SDK, ESM/CJS и декларации типов. +Поведение защищают тесты жизненного цикла Responses, процессора сессии, +удалённого compact и встроенного клиента Responses. + Continuation state is keyed by both the local session and the ChatGPT account. Changing accounts therefore opens a fresh chain and sends one full request instead of reusing account-scoped response or reasoning IDs. When the login does diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 36424d3ac..3d025bccd 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -80,7 +80,7 @@ const OpenAIResponsesInputItem = Schema.Union([ Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText), - phase: Schema.optionalKey(Schema.Literals(["commentary", "final_answer"])), + phase: Schema.optionalKey(Schema.Literals(["commentary", "partial_answer", "final_answer"])), }), OpenAIResponsesReasoningItem, OpenAIResponsesItemReference, @@ -180,6 +180,7 @@ type OpenAIResponsesUsage = Schema.Schema.Type const OpenAIResponsesStreamItem = Schema.Struct({ type: Schema.String, id: Schema.optional(Schema.String), + phase: optionalNull(Schema.Literals(["commentary", "partial_answer", "final_answer"])), call_id: Schema.optional(Schema.String), name: Schema.optional(Schema.String), arguments: Schema.optional(Schema.String), @@ -222,6 +223,7 @@ const OpenAIResponsesEvent = Schema.Struct({ Schema.StructWithRest( Schema.Struct({ id: Schema.optional(Schema.String), + end_turn: optionalNull(Schema.Boolean), service_tier: optionalNull(Schema.String), incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })), usage: optionalNull(OpenAIResponsesUsage), @@ -381,7 +383,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ input.push({ role: "assistant", content: [{ type: "output_text", text: part.text }], - ...(ProviderShared.isRecord(openai) && (openai.phase === "commentary" || openai.phase === "final_answer") + ...(ProviderShared.isRecord(openai) && + (openai.phase === "commentary" || openai.phase === "partial_answer" || openai.phase === "final_answer") ? { phase: openai.phase } : {}), }) @@ -662,6 +665,16 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) => // best-effort, not guaranteed. const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { const item = event.item + if (item?.type === "message" && item.id) { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.textStart( + state.lifecycle, + events, + item.id, + openaiMetadata({ itemId: item.id, ...(item.phase != null ? { phase: item.phase } : {}) }), + ) + return [{ ...state, lifecycle }, events] + } if (item && isReasoningItem(item)) { const events: LLMEvent[] = [] return [ @@ -850,6 +863,17 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* return [{ ...state, lifecycle }, events] satisfies StepResult } + if (item.type === "message" && item.id) { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.textEnd( + state.lifecycle, + events, + item.id, + openaiMetadata({ itemId: item.id, ...(item.phase != null ? { phase: item.phase } : {}) }), + ) + return [{ ...state, lifecycle }, events] satisfies StepResult + } + if (isReasoningItem(item)) { const events: LLMEvent[] = [] const providerMetadata = reasoningMetadata(item) @@ -885,10 +909,17 @@ const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): Step reason: mapFinishReason(event, state.hasFunctionCall), usage: mapUsage(event.response?.usage), providerMetadata: - event.response?.id || event.response?.service_tier + event.response?.id || + event.response?.service_tier || + typeof event.response?.end_turn === "boolean" || + event.type === "response.incomplete" ? openaiMetadata({ - responseId: event.response.id, - serviceTier: event.response.service_tier, + responseId: event.response?.id, + serviceTier: event.response?.service_tier, + ...(event.response?.end_turn != null ? { endTurn: event.response.end_turn } : {}), + ...(event.type === "response.incomplete" + ? { incompleteReason: event.response?.incomplete_details?.reason ?? "unknown" } + : {}), }) : undefined, }) diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts index eb6c95dfb..c3696f4dc 100644 --- a/packages/llm/src/protocols/utils/lifecycle.ts +++ b/packages/llm/src/protocols/utils/lifecycle.ts @@ -24,6 +24,13 @@ export const textDelta = (state: State, events: LLMEvent[], id: string, text: st return { ...stepped, text: new Set([...stepped.text, id]) } } +export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { + if (state.text.has(id)) return state + const stepped = stepStart(state, events) + events.push(LLMEvent.textStart({ id, providerMetadata })) + return { ...stepped, text: new Set([...stepped.text, id]) } +} + export const reasoningStart = ( state: State, events: LLMEvent[], diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index cd8bad51a..25eb6b4a6 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -40,6 +40,65 @@ const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAITool } describe("OpenAI Responses route", () => { + for (const terminal of [ + { type: "response.completed", end_turn: false }, + { type: "response.incomplete", reason: "interrupted" }, + { type: "response.incomplete", reason: "max_output_tokens" }, + { type: "response.incomplete", reason: "content_filter" }, + { type: "response.incomplete" }, + ]) { + it.effect(`retains partial answer and lifecycle metadata ${JSON.stringify(terminal)}`, () => + Effect.gen(function* () { + const item = { type: "message", id: "msg_partial", phase: "partial_answer" } + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_item.added", item }, + { type: "response.output_text.delta", item_id: item.id, delta: "Partial answer" }, + { type: "response.output_item.done", item }, + { + type: terminal.type, + response: { + id: "resp_partial", + end_turn: terminal.end_turn, + incomplete_details: terminal.reason ? { reason: terminal.reason } : null, + usage: { input_tokens: 100, output_tokens: 5 }, + }, + }, + ), + ), + ), + ) + expect(response.text).toBe("Partial answer") + expect(response.events.find((event) => event.type === "text-start")).toMatchObject({ + providerMetadata: { openai: { phase: "partial_answer" } }, + }) + const finish = response.events.find((event) => event.type === "step-finish") + expect(finish?.usage).toMatchObject({ inputTokens: 100, outputTokens: 5 }) + expect(finish?.providerMetadata?.openai).toMatchObject( + terminal.type === "response.incomplete" + ? { incompleteReason: terminal.reason ?? "unknown" } + : { endTurn: false }, + ) + const replay = yield* LLMClient.prepare( + LLM.updateRequest(request, { + messages: [ + Message.assistant([ + { type: "text", text: "Partial answer", providerMetadata: { openai: { phase: "partial_answer" } } }, + ]), + ], + }), + ) + expect(replay.body.input).toContainEqual({ + role: "assistant", + content: [{ type: "output_text", text: "Partial answer" }], + phase: "partial_answer", + }) + }), + ) + } + it.effect("prepares OpenAI Responses target", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare(request) diff --git a/packages/opencode/src/opencodez/codex-responses/attempt.ts b/packages/opencode/src/opencodez/codex-responses/attempt.ts index db8df38c9..628137bd8 100644 --- a/packages/opencode/src/opencodez/codex-responses/attempt.ts +++ b/packages/opencode/src/opencodez/codex-responses/attempt.ts @@ -6,6 +6,59 @@ export type RequestKind = "sampling" | "compaction" export const REQUEST_KIND_HEADER = "x-opencodez-responses-request-kind" export const SESSION_POOL_LIMIT = 256 +// Transport metadata is shared by SDK and native Responses. Session policy is +// applied only after the request has been identified as Codex OAuth. +export function outcome(metadata: unknown) { + const openai = isRecord(metadata) && isRecord(metadata.openai) ? metadata.openai : undefined + const reason = typeof openai?.incompleteReason === "string" ? openai.incompleteReason : undefined + return { + error: reason !== undefined && reason !== "interrupted" ? new IncompleteResponseError(reason) : undefined, + continue: reason === "interrupted" || openai?.endTurn === false, + } +} + +export class IncompleteResponseError extends Error { + constructor( + readonly reason: string, + readonly usage?: Record, + ) { + super( + reason === "max_output_tokens" + ? "OpenAI response reached its output token limit. The partial response was preserved." + : reason === "content_filter" + ? "OpenAI response was stopped by the content filter. The partial response was preserved." + : "OpenAI returned an incomplete response. The partial response was preserved.", + ) + this.name = "IncompleteResponseError" + } +} + +export function incompleteError(cause: unknown): IncompleteResponseError | undefined { + const seen = new Set() + for (let error = cause; error instanceof Error && !seen.has(error); error = error.cause) { + if (error instanceof IncompleteResponseError) return error + seen.add(error) + } +} + +export function incompleteEvent(event: Record) { + if (event.type !== "response.incomplete") return undefined + const response = isRecord(event.response) ? event.response : undefined + const details = isRecord(response?.incomplete_details) ? response.incomplete_details : undefined + return new IncompleteResponseError( + typeof details?.reason === "string" ? details.reason : "unknown", + isRecord(response?.usage) ? response.usage : undefined, + ) +} + +export function completedEvent(event: Record) { + return ( + event.type === "response.completed" || + event.type === "response.done" || + incompleteEvent(event)?.reason === "interrupted" + ) +} + export type Rollback = { readonly partIDs: readonly PartID[] readonly durableOutput: boolean diff --git a/packages/opencode/src/opencodez/codex-responses/compact.ts b/packages/opencode/src/opencodez/codex-responses/compact.ts index 4c428a63e..37ba8cdb9 100644 --- a/packages/opencode/src/opencodez/codex-responses/compact.ts +++ b/packages/opencode/src/opencodez/codex-responses/compact.ts @@ -139,7 +139,8 @@ export function compact(input: { ? cause : new RemoteCompactionError( "OpenAI remote compaction request failed", - !(cause instanceof DOMException && cause.name === "AbortError"), + !CodexResponsesAttempt.incompleteError(cause) && + !(cause instanceof DOMException && cause.name === "AbortError"), undefined, { cause }, ), @@ -331,6 +332,10 @@ async function parseCompactionResponse(response: Response, input: unknown[]) { let buffer = "" const consume = (event: Record) => { + // Compaction only installs state after a successful completed response. + // Even a controlled interruption must leave the old working state intact. + const incomplete = CodexResponsesAttempt.incompleteEvent(event) + if (incomplete) throw incomplete if ( ["error", "response.failed", "response.incomplete"].includes(typeof event.type === "string" ? event.type : "") ) { @@ -469,6 +474,7 @@ export function estimateText(value: string) { } export function canRetryWithCurrentModel(cause: unknown) { + if (CodexResponsesAttempt.incompleteError(cause)) return false const seen = new Set() let current = cause while (current && !seen.has(current)) { diff --git a/packages/opencode/src/opencodez/codex-responses/continuation.ts b/packages/opencode/src/opencodez/codex-responses/continuation.ts index 354dfe10c..ca661e960 100644 --- a/packages/opencode/src/opencodez/codex-responses/continuation.ts +++ b/packages/opencode/src/opencodez/codex-responses/continuation.ts @@ -6,26 +6,52 @@ type CompletedResponse = { outputItems: unknown[] } +export type RejectionReason = + | "missing_previous_response" + | "missing_response_id" + | "invalid_input" + | "model_changed" + | "tools_changed" + | "instructions_changed" + | "reasoning_changed" + | "settings_changed" + | "input_shortened" + | "history_mismatch" + export * as CodexResponsesContinuation from "./continuation" export class Continuation { private previous?: CompletedResponse private generation = 0 + private unavailable: RejectionReason = "missing_previous_response" - prepare(request: Record): Record { + prepare(request: Record, rejected?: (reason: RejectionReason) => void): Record { + const full = (reason: RejectionReason) => { + rejected?.(reason) + return request + } const input = request.input - if (!this.previous || !Array.isArray(input)) return request + if (!this.previous) return full(this.unavailable) + if (!Array.isArray(input)) return full("invalid_input") const previousInput = this.previous.request.input - if (!Array.isArray(previousInput) || !propertiesMatch(this.previous.request, request)) return request + if (!Array.isArray(previousInput)) return full("invalid_input") + if (!propertiesMatch(this.previous.request, request)) { + for (const key of ["model", "tools", "instructions", "reasoning"] as const) { + if (!jsonEqual(this.previous.request[key], request[key])) return full(`${key}_changed`) + } + return full("settings_changed") + } - const prefix = [...previousInput, ...this.previous.outputItems] - if (input.length <= prefix.length) return request - for (let index = 0; index < prefix.length; index++) { - if (!responseItemsEqual(input[index], prefix[index])) return request + const prefixLength = previousInput.length + this.previous.outputItems.length + if (input.length < prefixLength) return full("input_shortened") + for (let index = 0; index < prefixLength; index++) { + const previous = + index < previousInput.length ? previousInput[index] : this.previous.outputItems[index - previousInput.length] + if (!responseItemsEqual(input[index], previous)) return full("history_mismatch") } - const delta = input.slice(prefix.length) + const delta = input.slice(prefixLength) return { ...request, input: delta, @@ -51,6 +77,7 @@ export class Continuation { const responseID = typeof response?.id === "string" ? response.id : undefined if (!responseID) { this.previous = undefined + this.unavailable = "missing_response_id" return } const items = outputItems.length > 0 ? outputItems : Array.isArray(response?.output) ? response.output : [] @@ -61,6 +88,7 @@ export class Continuation { done = true if (generation !== this.generation) return this.previous = undefined + this.unavailable = "missing_previous_response" }, } } @@ -68,6 +96,7 @@ export class Continuation { reset() { this.generation++ this.previous = undefined + this.unavailable = "missing_previous_response" } } diff --git a/packages/opencode/src/opencodez/codex-responses/session-compaction.ts b/packages/opencode/src/opencodez/codex-responses/session-compaction.ts index 3fb674fd7..8da09c495 100644 --- a/packages/opencode/src/opencodez/codex-responses/session-compaction.ts +++ b/packages/opencode/src/opencodez/codex-responses/session-compaction.ts @@ -10,6 +10,7 @@ import { InstanceState } from "@/effect/instance-state" import { EventV2 } from "@opencode-ai/core/event" import { RuntimeFlags } from "@/effect/runtime-flags" import { CodexResponsesCompact } from "./compact" +import { CodexResponsesAttempt } from "./attempt" import { CodexResponsesCompaction } from "./compaction" import { CodexResponsesCatalog } from "./catalog" import { SystemPrompt } from "@/session/system" @@ -558,7 +559,7 @@ export function make(deps: Dependencies) { const attempt = (attemptModel: Provider.Model, snapshot?: LLMRequestPrep.Prepared) => run(attemptModel, snapshot).pipe( Effect.match({ - onFailure: (error) => ({ ok: false as const, error }), + onFailure: (error) => ({ ok: false as const, error, model: attemptModel }), onSuccess: (value) => ({ ok: true as const, value, model: attemptModel }), }), ) @@ -577,7 +578,7 @@ export function make(deps: Dependencies) { error: errorMessage(primary.error), }) const fallback = yield* attempt(input.targetModel) - if (fallback.ok) return fallback + if (fallback.ok || CodexResponsesAttempt.incompleteError(fallback.error)) return fallback yield* Effect.logError("current-model remote compaction fallback failed", { "session.id": input.sessionID, previousModel: input.sourceModel.api.id, @@ -594,6 +595,31 @@ export function make(deps: Dependencies) { error: errorMessage(compacted.error), }) msg.error = MessageV2.fromError(compacted.error, { providerID: input.sourceModel.providerID }) + const raw = CodexResponsesAttempt.incompleteError(compacted.error)?.usage + if (raw) { + const number = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : undefined) + const cached = isRecord(raw.input_tokens_details) ? number(raw.input_tokens_details.cached_tokens) : undefined + const usage = Session.getUsage({ + model: compacted.model, + usage: new Usage({ + inputTokens: number(raw.input_tokens), + outputTokens: number(raw.output_tokens), + totalTokens: number(raw.total_tokens), + cacheReadInputTokens: cached, + nonCachedInputTokens: + number(raw.input_tokens) !== undefined + ? Math.max(0, (number(raw.input_tokens) ?? 0) - (cached ?? 0)) + : undefined, + reasoningTokens: isRecord(raw.output_tokens_details) + ? number(raw.output_tokens_details.reasoning_tokens) + : undefined, + }), + }) + msg.tokens = usage.tokens + msg.cost = usage.cost + msg.modelID = compacted.model.id + msg.providerID = compacted.model.providerID + } msg.finish = "error" msg.time.completed = Date.now() yield* deps.session.updateMessage(msg) diff --git a/packages/opencode/src/opencodez/codex-responses/transport.ts b/packages/opencode/src/opencodez/codex-responses/transport.ts index 76cd51726..caaa7bfa7 100644 --- a/packages/opencode/src/opencodez/codex-responses/transport.ts +++ b/packages/opencode/src/opencodez/codex-responses/transport.ts @@ -1,7 +1,7 @@ import WebSocket from "ws" import { ProviderError } from "@/provider/error" import { isRecord } from "@/util/record" -import { Continuation } from "./continuation" +import { Continuation, type RejectionReason } from "./continuation" import { OpenAIWebSocket } from "@/plugin/openai/ws" import { CodexResponsesAttempt } from "./attempt" import { CodexResponsesProtocol } from "./protocol" @@ -26,6 +26,7 @@ export interface CreateWebSocketFetchOptions { streamRetries?: number wire?: Mode onModelsEtag?: (etag: string, accountKey: string | undefined) => void + onContinuationUnavailable?: (reason: RejectionReason) => void } interface PoolEntry { @@ -157,7 +158,12 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { resolveFirstEvent = resolve rejectFirstEvent = reject }) - const prepared = options?.wire === "codex" ? entry.continuation.prepare(body) : undefined + const prepared = + options?.wire === "codex" + ? entry.continuation.prepare(body, (reason) => { + options.onContinuationUnavailable?.(reason) + }) + : undefined let transaction = options?.wire === "codex" ? entry.continuation.transaction(body) : undefined const requestBody = options?.wire === "codex" ? withTurnState(prepared ?? body, entry.turnState) : body let recoveredPreviousResponse = false @@ -186,7 +192,12 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { onTerminal: (event) => { entry.busy = false entry.lastUsedAt = Date.now() - if (event.type === "response.completed" || event.type === "response.done") { + if ( + (options?.wire === "codex" && CodexResponsesAttempt.completedEvent(event)) || + event.type === "response.completed" || + event.type === "response.done" + ) { + transaction?.complete(event) entry.streamFailures = 0 return } diff --git a/packages/opencode/src/plugin/openai/README.md b/packages/opencode/src/plugin/openai/README.md index fc2ce5c17..fe8ac9a34 100644 --- a/packages/opencode/src/plugin/openai/README.md +++ b/packages/opencode/src/plugin/openai/README.md @@ -25,9 +25,9 @@ The upstream full-request WebSocket transport is enabled by default on `local`, 7. In `legacy` mode, send the complete `response.create` body. WebSocket frames retain the explicit `stream: true` request field and do not acquire Codex continuation, turn-state, catalog, or replay semantics. -8. In `codex` mode, send the first request of each logical user turn in full. - Later matching requests inside that turn send only new input items with - `previous_response_id`; a healthy socket may remain open across the boundary. +8. In `codex` mode, send the first request of a fresh transport chain in full. + Later matching requests send only new input items with `previous_response_id`, + including empty deltas and compatible requests across user turns. 9. Capture `x-codex-turn-state` from response metadata or HTTP fallback headers, plus WebSocket upgrade headers when the runtime exposes them. Bun's client exposes neither rejected-upgrade status nor headers, so the standalone @@ -35,8 +35,8 @@ The upstream full-request WebSocket transport is enabled by default on `local`, waits one minute before trying WebSocket again, and uses backend metadata without adding another socket stack. Keep the state in `client_metadata` for follow-ups within the same logical user turn, - including compaction, then clear it together with continuation at the next - turn without discarding a healthy socket. + including compaction, then clear the routing token at the next turn while + retaining compatible continuation and a healthy socket. 10. Return WebSocket events as SSE. Preserve the handshake's `x-reasoning-included` signal on the synthetic response so normal usage accounting can distinguish server-counted historical reasoning. @@ -80,8 +80,9 @@ message starts a fresh lane while durable encrypted compaction remains portable. - Reuse a socket for up to 55 minutes, then replace it on the next request. - Cool down WebSocket for one minute after an opaque Bun upgrade rejection. - Reset Codex continuation state whenever the socket is replaced, aborted, or - fails, and at every logical user-turn boundary. Turn changes do not close a - healthy socket. + fails. A logical user-turn boundary resets routing state only. +- A server-controlled `interrupted` response is continuable; user cancellation + and terminal incomplete responses invalidate continuation. - Scope pool entries to both session ID and ChatGPT account ID. A login change therefore starts a full request chain instead of reusing account-scoped response or reasoning IDs. If the account id claim is absent, the OAuth @@ -124,6 +125,12 @@ message starts a fresh lane while durable encrypted compaction remains portable. remain authoritative. - Abort or cancel closes the socket. +The SDK and native parsers pass phase and termination metadata to the session +processor. Codex outcome classification lives in `codex-responses/attempt.ts`. +The [bilingual lifecycle guide](../../../../../docs/opencodez.md#response-lifecycle--жизненный-цикл-ответа) +defines incomplete-response policy, usage accounting, empty-delta continuation, +debug rejection reasons, and the SDK patch maintenance boundary. + ## Concurrency Concurrent requests in one session use HTTP. Before that fallback starts, the @@ -235,6 +242,7 @@ A login change discards account-scoped continuation and sends a fresh full request. The encrypted compaction item remains in that request as durable session history, matching Codex without carrying response or reasoning IDs across the account boundary. + ## Model catalog defaults GPT-6 Sol and Luna use the same account-scoped catalog and turn snapshot as diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index 6475b453e..3d0e44b70 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -371,6 +371,18 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug const websocketFetch = options.experimentalWebSockets || codexWire ? CodexResponsesTransport.createWebSocketFetch({ + onContinuationUnavailable: (reason) => { + void input.client?.app + .log({ + body: { + service: "codex-responses", + level: "debug", + message: "continuation unavailable", + extra: { reason }, + }, + }) + .catch(() => {}) + }, httpFetch: fetch, wire: codexWire ? "codex" : "legacy", onModelsEtag(etag, accountKey) { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index cda725d8e..66e5d9b57 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -36,6 +36,7 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { Effect, Schema } from "effect" import { CodexResponsesCompaction } from "@/opencodez/codex-responses/compaction" +import { CodexResponsesAttempt } from "@/opencodez/codex-responses/attempt" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { @@ -252,6 +253,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( if ( msg.info.error && + !isIncompleteResponse(msg.info.error) && !( AbortedError.isInstance(msg.info.error) && msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") @@ -609,18 +611,21 @@ export function latest(msgs: WithParts[]) { let user: User | undefined let assistant: Assistant | undefined let finished: Assistant | undefined + let context: Assistant | undefined for (const msg of msgs) { const info = msg.info if (info.role === "user" && isAfter(info, user)) user = info if (info.role === "assistant" && isAfter(info, assistant)) assistant = info if (info.role === "assistant" && info.finish && isAfter(info, finished)) finished = info + if (info.role === "assistant" && info.finish && !isIncompleteResponse(info.error) && isAfter(info, context)) + context = info } const tasks = msgs.flatMap((m) => finished && !isAfter(m.info, finished) ? [] : m.parts.filter((p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask"), ) - return { user, assistant, finished, tasks } + return { user, assistant, finished, context, tasks } } function isAfter(info: Info, other?: Info) { @@ -629,10 +634,24 @@ function isAfter(info: Info, other?: Info) { return info.id > other.id } +function isIncompleteResponse(error: Assistant["error"]) { + return error?.name === "APIError" && error.data.metadata?.code === "response_incomplete" +} + export function fromError( e: unknown, ctx: { providerID: ProviderV2.ID; aborted?: boolean }, ): NonNullable { + const incomplete = CodexResponsesAttempt.incompleteError(e) + if (incomplete) + return new APIError( + { + message: incomplete.message, + isRetryable: false, + metadata: { code: "response_incomplete", reason: incomplete.reason }, + }, + { cause: e }, + ).toObject() switch (true) { case e instanceof DOMException && e.name === "AbortError": return new AbortedError( diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 52c7a6082..e1d3d7585 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -472,12 +472,14 @@ const layer = Layer.effect( usage: value.usage ?? new Usage({}), metadata: value.providerMetadata, }) - ctx.assistantMessage.finish = value.reason + const outcome = attempt.active() ? CodexResponsesAttempt.outcome(value.providerMetadata) : undefined + const reason = outcome?.error ? "error" : outcome?.continue ? "unknown" : value.reason + ctx.assistantMessage.finish = reason ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens yield* session.updatePart({ id: PartID.ascending(), - reason: value.reason, + reason, snapshot: completedSnapshot, messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, @@ -486,6 +488,13 @@ const layer = Layer.effect( cost: usage.cost, }) yield* session.updateMessage(ctx.assistantMessage) + if (outcome?.error) { + // A terminal provider response has real output and usage. Commit + // it before raising the error so retry cleanup cannot erase it. + attempt.commit() + ctx.needsCompaction = false + return yield* Effect.fail(outcome.error) + } if (ctx.snapshot) { const patch = yield* snapshot.patch(ctx.snapshot) if (patch.files.length) { @@ -507,7 +516,7 @@ const layer = Layer.effect( }) .pipe(Effect.ignore, Effect.forkIn(scope)) const needsFollowUp = - ctx.sawToolCall || value.reason === "tool-calls" || (attempt.active() && value.reason === "unknown") + ctx.sawToolCall || reason === "tool-calls" || (attempt.active() && reason === "unknown") if ( (!attempt.active() || needsFollowUp) && !ctx.assistantMessage.summary && @@ -663,7 +672,7 @@ const layer = Layer.effect( error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined, }) - if (!aborted) yield* rollbackAttempt() + if (!aborted && !CodexResponsesAttempt.incompleteError(e)) yield* rollbackAttempt() const error = parse(e) if (SessionV1.ContextOverflowError.isInstance(error)) { if ( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 789ded055..faefebaf0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1157,7 +1157,13 @@ const layer = Layer.effect( ) } - const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs) + const { + user: lastUser, + assistant: lastAssistant, + finished: lastFinished, + context, + tasks, + } = MessageV2.latest(msgs) if (!lastUser) throw new Error("No user message found in stream. This should never happen.") @@ -1300,7 +1306,7 @@ const layer = Layer.effect( turnID: activeTurnID, messages: msgs, model, - tokens: lastFinished?.tokens, + tokens: context?.tokens, }) if (remoteTransition) { yield* Effect.logInfo("transitioning remote compaction backend snapshot", { @@ -1352,10 +1358,10 @@ const layer = Layer.effect( }, 0) ?? 0) : 0 if ( - lastFinished && - lastFinished.summary !== true && + context && + context.summary !== true && (yield* compaction.isOverflow({ - tokens: lastFinished.tokens, + tokens: context.tokens, model, additionalTokens, turn: remoteTurn, diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index caf64cca8..683324729 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -25,7 +25,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { SessionProjector } from "@opencode-ai/core/session/projector" -import { LLMEvent } from "@opencode-ai/llm" +import { LLMEvent, Usage } from "@opencode-ai/llm" const summary = Layer.succeed( SessionSummary.Service, @@ -272,6 +272,100 @@ const boot = Effect.fn("test.boot")(function* () { return { processors, session, provider } }) +for (const scenario of [ + { codex: true, metadata: { endTurn: false }, followUp: true }, + { codex: true, metadata: { incompleteReason: "interrupted" }, followUp: true }, + { codex: true, metadata: { incompleteReason: "max_output_tokens" }, terminal: true }, + { codex: true, metadata: { incompleteReason: "content_filter" }, terminal: true }, + { codex: true, metadata: { incompleteReason: "future_reason" }, terminal: true }, + { codex: false, metadata: { endTurn: false }, followUp: false }, + { codex: false, metadata: { incompleteReason: "max_output_tokens" }, followUp: false }, +]) { + let calls = 0 + const inputTokens = scenario.terminal ? 95000 : 100 + const llm = Layer.succeed( + LLM.Service, + LLM.Service.of({ + stream: (input) => { + calls++ + input.onPrepared?.({ + codexResponses: scenario.codex, + system: input.system, + messages: input.messages, + tools: input.tools, + params: { options: {} }, + messageTransformOptions: {}, + headers: {}, + }) + return Stream.make( + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "partial", providerMetadata: { openai: { phase: "partial_answer" } } }), + LLMEvent.textDelta({ id: "partial", text: "Retained partial answer" }), + LLMEvent.textEnd({ id: "partial" }), + LLMEvent.stepFinish({ + index: 0, + reason: "stop", + usage: new Usage({ inputTokens, outputTokens: 5, totalTokens: inputTokens + 5 }), + providerMetadata: { openai: scenario.metadata }, + }), + LLMEvent.finish({ reason: "stop" }), + ) + }, + }), + ) + const lifecycle = testEffect(LayerNode.compile(root, [...replacements, [LLM.node, llm]])) + lifecycle.live(`session.processor lifecycle ${JSON.stringify(scenario)}`, () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + calls = 0 + const { processors, session, provider } = yield* boot() + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "Continue the task") + const baseline = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl }) + const result = yield* handle.process({ + user: parent, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "Continue the task" }], + tools: {}, + }) + expect(calls).toBe(1) + expect(result).toBe(scenario.terminal ? "stop" : "continue") + expect(handle.needsFollowUp).toBe(scenario.followUp === true) + const stored = yield* MessageV2.get({ sessionID: chat.id, messageID: msg.id }) + expect(stored.parts.find((part) => part.type === "text")).toMatchObject({ + text: "Retained partial answer", + metadata: { openai: { phase: "partial_answer" } }, + }) + expect(stored.info.role === "assistant" && stored.info.tokens).toMatchObject({ + input: inputTokens, + output: 5, + total: inputTokens + 5, + }) + if (scenario.terminal) { + expect(stored.info.role === "assistant" && stored.info.error).toMatchObject({ + name: "APIError", + data: { isRetryable: false }, + }) + const latest = MessageV2.latest([{ info: baseline, parts: [] }, stored]) + expect(latest.finished?.id).toBe(msg.id) + expect(latest.context?.id).toBe(baseline.id) + const replay = yield* MessageV2.toModelMessagesEffect([stored], mdl) + expect(JSON.stringify(replay)).toContain("Retained partial answer") + expect(JSON.stringify(replay)).toContain("partial_answer") + } + }), + { config: cfg }, + ), + ) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/packages/opencode/test/session/remote-compaction.test.ts b/packages/opencode/test/session/remote-compaction.test.ts index 124a7000a..80640d856 100644 --- a/packages/opencode/test/session/remote-compaction.test.ts +++ b/packages/opencode/test/session/remote-compaction.test.ts @@ -16,6 +16,7 @@ import { CodexResponsesTransport } from "@/opencodez/codex-responses/transport" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { isRecord } from "@/util/record" +import { CodexResponsesAttempt } from "@/opencodez/codex-responses/attempt" const model: Provider.Model = { id: ModelV2.ID.make("gpt-6.1-sol"), @@ -125,12 +126,30 @@ async function sdkBody(input: LLMRequestPrep.Prepared) { return body } -async function fixture(options: { overflows?: number; streamOverflow?: boolean; httpOnly?: boolean } = {}) { +async function fixture( + options: { + overflows?: number + streamOverflow?: boolean + httpOnly?: boolean + incomplete?: string + interrupted?: boolean + } = {}, +) { const captures: Capture[] = [] const id = crypto.randomUUID() let count = 0 const events = (capture: Capture) => { const compact = capture.input.some((item) => isRecord(item) && item.type === "compaction_trigger") + if (compact && options.incomplete) + return [ + { + type: "response.incomplete", + response: { + incomplete_details: { reason: options.incomplete }, + usage: { input_tokens: 200, output_tokens: 10, total_tokens: 210 }, + }, + }, + ] const failed = compact && ++count <= (options.overflows ?? 0) if (failed) return [ @@ -143,9 +162,10 @@ async function fixture(options: { overflows?: number; streamOverflow?: boolean; return [ { type: "response.output_item.done", item }, { - type: "response.completed", + type: options.interrupted ? "response.incomplete" : "response.completed", response: { id: `resp_${crypto.randomUUID()}`, + ...(options.interrupted ? { incomplete_details: { reason: "interrupted" } } : {}), output: [item], usage: { input_tokens: 200, output_tokens: 10, total_tokens: 210 }, }, @@ -289,6 +309,33 @@ test("compact reuses the sampling wire and replaces the working window before co ]) }) +test("controlled interruption preserves WebSocket continuation with an empty delta", async () => { + await using server = await fixture({ interrupted: true }) + await server.send(await sdkBody(prepared([user]))) + await server.send(await sdkBody(prepared([user, assistant]))) + expect(server.captures.at(-1)?.via).toBe("ws") + expect(server.captures.at(-1)?.body.previous_response_id).toBeString() + expect(server.captures.at(-1)?.input).toEqual([]) +}) + +for (const reason of ["max_output_tokens", "content_filter", "unknown", "interrupted"]) { + test(`incomplete compact (${reason}) preserves usage and never retries or changes models`, async () => { + await using server = await fixture({ incomplete: reason }) + const error = await server.compact(prepared([user])).then( + () => undefined, + (error: unknown) => error, + ) + expect(CodexResponsesAttempt.incompleteError(error)?.reason).toBe(reason) + expect(CodexResponsesAttempt.incompleteError(error)?.usage).toEqual({ + input_tokens: 200, + output_tokens: 10, + total_tokens: 210, + }) + expect(CodexResponsesCompact.canRetryWithCurrentModel(error)).toBe(false) + expect(server.captures).toHaveLength(1) + }) +} + test("large encrypted state cannot veto compact or preemptively clear tool results", async () => { await using server = await fixture() const messages: ModelMessage[] = [ diff --git a/packages/opencode/test/session/responses-lifecycle.test.ts b/packages/opencode/test/session/responses-lifecycle.test.ts new file mode 100644 index 000000000..bdd11795f --- /dev/null +++ b/packages/opencode/test/session/responses-lifecycle.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from "bun:test" +import { createOpenAI } from "@ai-sdk/openai" +import { streamText } from "ai" +import { CodexResponsesAttempt } from "@/opencodez/codex-responses/attempt" +import { CodexResponsesContinuation } from "@/opencodez/codex-responses/continuation" +import { CodexResponsesCompact } from "@/opencodez/codex-responses/compact" +import { MessageV2 } from "@/session/message-v2" +import { ProviderV2 } from "@opencode-ai/core/provider" + +for (const terminal of [ + { type: "response.completed", end_turn: false }, + { type: "response.incomplete", reason: "interrupted" }, + { type: "response.incomplete", reason: "max_output_tokens" }, + { type: "response.incomplete", reason: "content_filter" }, + { type: "response.incomplete", reason: "future_reason" }, + { type: "response.incomplete" }, + { type: "response.incomplete", reason: "max_output_tokens", noUsage: true }, +]) { + test(`Responses SDK preserves partial_answer and ${JSON.stringify(terminal)}`, async () => { + const item = { id: "msg_partial", type: "message", role: "assistant", phase: "partial_answer", content: [] } + const events = [ + { type: "response.created", response: { id: "resp_partial", created_at: 1, model: "test-model" } }, + { type: "response.output_item.added", output_index: 0, item }, + { + type: "response.output_text.delta", + item_id: item.id, + output_index: 0, + content_index: 0, + delta: "Partial answer", + }, + { + type: "response.output_item.done", + output_index: 0, + item: { ...item, content: [{ type: "output_text", text: "Partial answer", annotations: [] }] }, + }, + { + type: terminal.type, + response: { + id: "resp_partial", + end_turn: terminal.end_turn, + incomplete_details: terminal.reason ? { reason: terminal.reason } : null, + usage: terminal.noUsage ? null : { input_tokens: 100, output_tokens: 5 }, + }, + }, + ] + const sdk = createOpenAI({ + apiKey: "test-key", + fetch: Object.assign( + async () => + new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }), + { preconnect: fetch.preconnect }, + ), + }) + const result = streamText({ model: sdk.responses("test-model"), prompt: "Continue", maxRetries: 0 }) + const parts = await Array.fromAsync(result.fullStream) + expect(parts.filter((part) => part.type === "error")).toEqual([]) + expect(await result.text).toBe("Partial answer") + expect(parts.find((part) => part.type === "text-start")?.providerMetadata?.openai?.phase).toBe("partial_answer") + const finish = parts.find((part) => part.type === "finish-step") + expect(finish?.usage.inputTokens).toBe(terminal.noUsage ? undefined : 100) + expect(finish?.usage.outputTokens).toBe(terminal.noUsage ? undefined : 5) + const outcome = CodexResponsesAttempt.outcome(finish?.providerMetadata) + const controlled = terminal.type === "response.completed" || terminal.reason === "interrupted" + expect(outcome.continue).toBe(controlled) + if (controlled) { + expect(outcome.error).toBeUndefined() + return + } + expect(outcome.error?.reason).toBe(terminal.reason ?? "unknown") + const error = MessageV2.fromError(outcome.error, { providerID: ProviderV2.ID.make("openai") }) + expect(error).toMatchObject({ + name: "APIError", + data: { isRetryable: false, metadata: { code: "response_incomplete" } }, + }) + expect(CodexResponsesCompact.canRetryWithCurrentModel(new Error("compact failed", { cause: outcome.error }))).toBe( + false, + ) + }) +} + +test("continuation accepts an empty delta and reports bounded rejection reasons", () => { + const state = new CodexResponsesContinuation.Continuation() + const reasons: CodexResponsesContinuation.RejectionReason[] = [] + const request = { + model: "test-model", + instructions: "private instructions", + input: [{ role: "user", content: "private user text" }], + } + expect(state.prepare(request, (reason) => reasons.push(reason))).toBe(request) + const output = { + type: "message", + role: "assistant", + phase: "partial_answer", + content: [{ type: "output_text", text: "partial" }], + } + const transaction = state.transaction(request) + transaction.event({ type: "response.output_item.done", item: output }) + transaction.complete({ type: "response.completed", response: { id: "resp_test" } }) + const next = { ...request, input: [...request.input, output] } + expect(state.prepare(next)).toEqual({ ...next, input: [], previous_response_id: "resp_test" }) + state.prepare({ ...next, model: "changed" }, (reason) => reasons.push(reason)) + state.prepare({ ...next, input: request.input }, (reason) => reasons.push(reason)) + state.prepare({ ...next, input: [{ role: "user", content: "changed" }, output] }, (reason) => reasons.push(reason)) + expect(reasons).toEqual(["missing_previous_response", "model_changed", "input_shortened", "history_mismatch"]) + expect(JSON.stringify(reasons)).not.toContain("private") +}) diff --git a/patches/@ai-sdk%2Fopenai@3.0.88.patch b/patches/@ai-sdk%2Fopenai@3.0.88.patch index 5d0f3f7f8..45cf299d8 100644 --- a/patches/@ai-sdk%2Fopenai@3.0.88.patch +++ b/patches/@ai-sdk%2Fopenai@3.0.88.patch @@ -1,5 +1,88 @@ +diff --git a/node_modules/@opencode-ai/core/node_modules/@ai-sdk/openai/.bun-tag-bdbf21b7771638b6 b/.bun-tag-bdbf21b7771638b6 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/dist/index.d.mts b/dist/index.d.mts +index 779b17d7d44b5ee58de033756d1156571b73eb70..5259bd16d94bfdd6770a8059c97b6c422fa5f899 100644 +--- a/dist/index.d.mts ++++ b/dist/index.d.mts +@@ -309,7 +309,7 @@ declare const openaiResponsesChunkSchema: _ai_sdk_provider_utils.LazySchema<{ + item: { + type: "message"; + id: string; +- phase?: "commentary" | "final_answer" | null | undefined; ++ phase?: "commentary" | "partial_answer" | "final_answer" | null | undefined; + } | { + type: "reasoning"; + id: string; +@@ -426,7 +426,7 @@ declare const openaiResponsesChunkSchema: _ai_sdk_provider_utils.LazySchema<{ + item: { + type: "message"; + id: string; +- phase?: "commentary" | "final_answer" | null | undefined; ++ phase?: "commentary" | "partial_answer" | "final_answer" | null | undefined; + } | { + type: "reasoning"; + id: string; +@@ -1222,6 +1222,8 @@ type ResponsesOutputTextAnnotationProviderMetadata = Extract['annotation']; + type ResponsesProviderMetadata = { ++ endTurn?: boolean; ++ incompleteReason?: string; + responseId: string | null | undefined; + logprobs?: Array; + serviceTier?: string; +@@ -1239,7 +1241,7 @@ type OpenaiResponsesProviderMetadata = { + }; + type ResponsesTextProviderMetadata = { + itemId: string; +- phase?: 'commentary' | 'final_answer' | null; ++ phase?: 'commentary' | 'partial_answer' | 'final_answer' | null; + annotations?: Array; + }; + type OpenaiResponsesTextProviderMetadata = { +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 779b17d7d44b5ee58de033756d1156571b73eb70..5259bd16d94bfdd6770a8059c97b6c422fa5f899 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -309,7 +309,7 @@ declare const openaiResponsesChunkSchema: _ai_sdk_provider_utils.LazySchema<{ + item: { + type: "message"; + id: string; +- phase?: "commentary" | "final_answer" | null | undefined; ++ phase?: "commentary" | "partial_answer" | "final_answer" | null | undefined; + } | { + type: "reasoning"; + id: string; +@@ -426,7 +426,7 @@ declare const openaiResponsesChunkSchema: _ai_sdk_provider_utils.LazySchema<{ + item: { + type: "message"; + id: string; +- phase?: "commentary" | "final_answer" | null | undefined; ++ phase?: "commentary" | "partial_answer" | "final_answer" | null | undefined; + } | { + type: "reasoning"; + id: string; +@@ -1222,6 +1222,8 @@ type ResponsesOutputTextAnnotationProviderMetadata = Extract['annotation']; + type ResponsesProviderMetadata = { ++ endTurn?: boolean; ++ incompleteReason?: string; + responseId: string | null | undefined; + logprobs?: Array; + serviceTier?: string; +@@ -1239,7 +1241,7 @@ type OpenaiResponsesProviderMetadata = { + }; + type ResponsesTextProviderMetadata = { + itemId: string; +- phase?: 'commentary' | 'final_answer' | null; ++ phase?: 'commentary' | 'partial_answer' | 'final_answer' | null; + annotations?: Array; + }; + type OpenaiResponsesTextProviderMetadata = { diff --git a/dist/index.js b/dist/index.js -index 5fc82d7ca0f5fba1da3b24c15bb714f51b5aa178..e1a3e2c97774af3a5688c7052bd9a07d692fcc8f 100644 +index 5fc82d7ca0f5fba1da3b24c15bb714f51b5aa178..9f5422107d8b846dd3ef120dc3f29d770c52e05f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1035,22 +1035,6 @@ var OpenAIChatLanguageModel = class { @@ -25,7 +108,51 @@ index 5fc82d7ca0f5fba1da3b24c15bb714f51b5aa178..e1a3e2c97774af3a5688c7052bd9a07d const { tools: openaiTools2, toolChoice: openaiToolChoice, -@@ -5454,22 +5438,6 @@ var OpenAIResponsesLanguageModel = class { +@@ -3762,6 +3746,7 @@ var openaiResponsesChunkSchema = (0, import_provider_utils27.lazySchema)( + import_v422.z.object({ + type: import_v422.z.enum(["response.completed", "response.incomplete"]), + response: import_v422.z.object({ ++ end_turn: import_v422.z.boolean().nullish(), + incomplete_details: import_v422.z.object({ reason: import_v422.z.string() }).nullish(), + usage: import_v422.z.object({ + input_tokens: import_v422.z.number(), +@@ -3776,7 +3761,7 @@ var openaiResponsesChunkSchema = (0, import_provider_utils27.lazySchema)( + reasoning_tokens: import_v422.z.number().nullish(), + orchestration_output_tokens: import_v422.z.number().nullish() + }).nullish() +- }), ++ }).nullish(), + reasoning: import_v422.z.object({ + context: import_v422.z.string().nullish() + }).nullish(), +@@ -3828,7 +3813,7 @@ var openaiResponsesChunkSchema = (0, import_provider_utils27.lazySchema)( + import_v422.z.object({ + type: import_v422.z.literal("message"), + id: import_v422.z.string(), +- phase: import_v422.z.enum(["commentary", "final_answer"]).nullish() ++ phase: import_v422.z.enum(["commentary", "partial_answer", "final_answer"]).nullish() + }), + import_v422.z.object({ + type: import_v422.z.literal("reasoning"), +@@ -3970,7 +3955,7 @@ var openaiResponsesChunkSchema = (0, import_provider_utils27.lazySchema)( + import_v422.z.object({ + type: import_v422.z.literal("message"), + id: import_v422.z.string(), +- phase: import_v422.z.enum(["commentary", "final_answer"]).nullish() ++ phase: import_v422.z.enum(["commentary", "partial_answer", "final_answer"]).nullish() + }), + import_v422.z.object({ + type: import_v422.z.literal("reasoning"), +@@ -4306,7 +4291,7 @@ var openaiResponsesResponseSchema = (0, import_provider_utils27.lazySchema)( + type: import_v422.z.literal("message"), + role: import_v422.z.literal("assistant"), + id: import_v422.z.string(), +- phase: import_v422.z.enum(["commentary", "final_answer"]).nullish(), ++ phase: import_v422.z.enum(["commentary", "partial_answer", "final_answer"]).nullish(), + content: import_v422.z.array( + import_v422.z.object({ + type: import_v422.z.literal("output_text"), +@@ -5454,22 +5439,6 @@ var OpenAIResponsesLanguageModel = class { }); } } @@ -48,8 +175,37 @@ index 5fc82d7ca0f5fba1da3b24c15bb714f51b5aa178..e1a3e2c97774af3a5688c7052bd9a07d const shellToolEnvType = (_k = (_j = (_i = tools == null ? void 0 : tools.find( (tool) => tool.type === "provider" && tool.id === "openai.shell" )) == null ? void 0 : _i.args) == null ? void 0 : _j.environment) == null ? void 0 : _k.type; +@@ -6018,6 +5987,8 @@ var OpenAIResponsesLanguageModel = class { + const activeReasoning = {}; + let serviceTier; + let reasoningContext; ++ let endTurn; ++ let incompleteReason; + const hostedToolSearchCallIds = []; + let encounteredStreamError = false; + const result = { +@@ -6765,7 +6736,9 @@ var OpenAIResponsesLanguageModel = class { + }), + raw: (_x = (_w = value.response.incomplete_details) == null ? void 0 : _w.reason) != null ? _x : void 0 + }; +- usage = value.response.usage; ++ usage = value.response.usage ?? void 0; ++ endTurn = value.response.end_turn ?? void 0; ++ incompleteReason = value.type === "response.incomplete" ? value.response.incomplete_details?.reason ?? "unknown" : void 0; + if (typeof value.response.service_tier === "string") { + serviceTier = value.response.service_tier; + } +@@ -6869,6 +6842,8 @@ var OpenAIResponsesLanguageModel = class { + const providerMetadata = { + [providerOptionsName]: { + responseId, ++ ...endTurn !== void 0 ? { endTurn } : {}, ++ ...incompleteReason !== void 0 ? { incompleteReason } : {}, + ...logprobs.length > 0 ? { logprobs } : {}, + ...serviceTier !== void 0 ? { serviceTier } : {}, + ...reasoningContext !== void 0 ? { reasoningContext } : {} diff --git a/dist/index.mjs b/dist/index.mjs -index 589c4f76955bee692b8af36ea1d9a15121695d4a..6838ea0a362c8fe774527bb1126b8031ebfd3fab 100644 +index 589c4f76955bee692b8af36ea1d9a15121695d4a..9069bf0b0abb1b4b4cfe7816cbcb63d8958f75c0 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -1031,22 +1031,6 @@ var OpenAIChatLanguageModel = class { @@ -75,7 +231,51 @@ index 589c4f76955bee692b8af36ea1d9a15121695d4a..6838ea0a362c8fe774527bb1126b8031 const { tools: openaiTools2, toolChoice: openaiToolChoice, -@@ -5557,22 +5541,6 @@ var OpenAIResponsesLanguageModel = class { +@@ -3860,6 +3844,7 @@ var openaiResponsesChunkSchema = lazySchema20( + z22.object({ + type: z22.enum(["response.completed", "response.incomplete"]), + response: z22.object({ ++ end_turn: z22.boolean().nullish(), + incomplete_details: z22.object({ reason: z22.string() }).nullish(), + usage: z22.object({ + input_tokens: z22.number(), +@@ -3874,7 +3859,7 @@ var openaiResponsesChunkSchema = lazySchema20( + reasoning_tokens: z22.number().nullish(), + orchestration_output_tokens: z22.number().nullish() + }).nullish() +- }), ++ }).nullish(), + reasoning: z22.object({ + context: z22.string().nullish() + }).nullish(), +@@ -3926,7 +3911,7 @@ var openaiResponsesChunkSchema = lazySchema20( + z22.object({ + type: z22.literal("message"), + id: z22.string(), +- phase: z22.enum(["commentary", "final_answer"]).nullish() ++ phase: z22.enum(["commentary", "partial_answer", "final_answer"]).nullish() + }), + z22.object({ + type: z22.literal("reasoning"), +@@ -4068,7 +4053,7 @@ var openaiResponsesChunkSchema = lazySchema20( + z22.object({ + type: z22.literal("message"), + id: z22.string(), +- phase: z22.enum(["commentary", "final_answer"]).nullish() ++ phase: z22.enum(["commentary", "partial_answer", "final_answer"]).nullish() + }), + z22.object({ + type: z22.literal("reasoning"), +@@ -4404,7 +4389,7 @@ var openaiResponsesResponseSchema = lazySchema20( + type: z22.literal("message"), + role: z22.literal("assistant"), + id: z22.string(), +- phase: z22.enum(["commentary", "final_answer"]).nullish(), ++ phase: z22.enum(["commentary", "partial_answer", "final_answer"]).nullish(), + content: z22.array( + z22.object({ + type: z22.literal("output_text"), +@@ -5557,22 +5542,6 @@ var OpenAIResponsesLanguageModel = class { }); } } @@ -98,8 +298,117 @@ index 589c4f76955bee692b8af36ea1d9a15121695d4a..6838ea0a362c8fe774527bb1126b8031 const shellToolEnvType = (_k = (_j = (_i = tools == null ? void 0 : tools.find( (tool) => tool.type === "provider" && tool.id === "openai.shell" )) == null ? void 0 : _i.args) == null ? void 0 : _j.environment) == null ? void 0 : _k.type; +@@ -6121,6 +6090,8 @@ var OpenAIResponsesLanguageModel = class { + const activeReasoning = {}; + let serviceTier; + let reasoningContext; ++ let endTurn; ++ let incompleteReason; + const hostedToolSearchCallIds = []; + let encounteredStreamError = false; + const result = { +@@ -6868,7 +6839,9 @@ var OpenAIResponsesLanguageModel = class { + }), + raw: (_x = (_w = value.response.incomplete_details) == null ? void 0 : _w.reason) != null ? _x : void 0 + }; +- usage = value.response.usage; ++ usage = value.response.usage ?? void 0; ++ endTurn = value.response.end_turn ?? void 0; ++ incompleteReason = value.type === "response.incomplete" ? value.response.incomplete_details?.reason ?? "unknown" : void 0; + if (typeof value.response.service_tier === "string") { + serviceTier = value.response.service_tier; + } +@@ -6972,6 +6945,8 @@ var OpenAIResponsesLanguageModel = class { + const providerMetadata = { + [providerOptionsName]: { + responseId, ++ ...endTurn !== void 0 ? { endTurn } : {}, ++ ...incompleteReason !== void 0 ? { incompleteReason } : {}, + ...logprobs.length > 0 ? { logprobs } : {}, + ...serviceTier !== void 0 ? { serviceTier } : {}, + ...reasoningContext !== void 0 ? { reasoningContext } : {} +diff --git a/dist/internal/index.d.mts b/dist/internal/index.d.mts +index 07fcd092f1ffdbecc13557a05e851ab6756a19da..a7d33185a0284ead31d9ab28eeed39f501dab2b7 100644 +--- a/dist/internal/index.d.mts ++++ b/dist/internal/index.d.mts +@@ -363,7 +363,7 @@ declare const openaiResponsesChunkSchema: _ai_sdk_provider_utils.LazySchema<{ + item: { + type: "message"; + id: string; +- phase?: "commentary" | "final_answer" | null | undefined; ++ phase?: "commentary" | "partial_answer" | "final_answer" | null | undefined; + } | { + type: "reasoning"; + id: string; +@@ -480,7 +480,7 @@ declare const openaiResponsesChunkSchema: _ai_sdk_provider_utils.LazySchema<{ + item: { + type: "message"; + id: string; +- phase?: "commentary" | "final_answer" | null | undefined; ++ phase?: "commentary" | "partial_answer" | "final_answer" | null | undefined; + } | { + type: "reasoning"; + id: string; +@@ -744,6 +744,8 @@ type ResponsesOutputTextAnnotationProviderMetadata = Extract['annotation']; + type ResponsesProviderMetadata = { ++ endTurn?: boolean; ++ incompleteReason?: string; + responseId: string | null | undefined; + logprobs?: Array; + serviceTier?: string; +@@ -761,7 +763,7 @@ type OpenaiResponsesProviderMetadata = { + }; + type ResponsesTextProviderMetadata = { + itemId: string; +- phase?: 'commentary' | 'final_answer' | null; ++ phase?: 'commentary' | 'partial_answer' | 'final_answer' | null; + annotations?: Array; + }; + type OpenaiResponsesTextProviderMetadata = { +diff --git a/dist/internal/index.d.ts b/dist/internal/index.d.ts +index 07fcd092f1ffdbecc13557a05e851ab6756a19da..a7d33185a0284ead31d9ab28eeed39f501dab2b7 100644 +--- a/dist/internal/index.d.ts ++++ b/dist/internal/index.d.ts +@@ -363,7 +363,7 @@ declare const openaiResponsesChunkSchema: _ai_sdk_provider_utils.LazySchema<{ + item: { + type: "message"; + id: string; +- phase?: "commentary" | "final_answer" | null | undefined; ++ phase?: "commentary" | "partial_answer" | "final_answer" | null | undefined; + } | { + type: "reasoning"; + id: string; +@@ -480,7 +480,7 @@ declare const openaiResponsesChunkSchema: _ai_sdk_provider_utils.LazySchema<{ + item: { + type: "message"; + id: string; +- phase?: "commentary" | "final_answer" | null | undefined; ++ phase?: "commentary" | "partial_answer" | "final_answer" | null | undefined; + } | { + type: "reasoning"; + id: string; +@@ -744,6 +744,8 @@ type ResponsesOutputTextAnnotationProviderMetadata = Extract['annotation']; + type ResponsesProviderMetadata = { ++ endTurn?: boolean; ++ incompleteReason?: string; + responseId: string | null | undefined; + logprobs?: Array; + serviceTier?: string; +@@ -761,7 +763,7 @@ type OpenaiResponsesProviderMetadata = { + }; + type ResponsesTextProviderMetadata = { + itemId: string; +- phase?: 'commentary' | 'final_answer' | null; ++ phase?: 'commentary' | 'partial_answer' | 'final_answer' | null; + annotations?: Array; + }; + type OpenaiResponsesTextProviderMetadata = { diff --git a/dist/internal/index.js b/dist/internal/index.js -index ad78b5df9359509091f4b8a1751dc34c03c74e32..ed92f56d8fe9c1442f741ef8d42d592ce9b28643 100644 +index ad78b5df9359509091f4b8a1751dc34c03c74e32..c476de3f16a64bd0d269a91d54cb539841f1aafd 100644 --- a/dist/internal/index.js +++ b/dist/internal/index.js @@ -1070,22 +1070,6 @@ var OpenAIChatLanguageModel = class { @@ -125,7 +434,51 @@ index ad78b5df9359509091f4b8a1751dc34c03c74e32..ed92f56d8fe9c1442f741ef8d42d592c const { tools: openaiTools, toolChoice: openaiToolChoice, -@@ -5724,22 +5708,6 @@ var OpenAIResponsesLanguageModel = class { +@@ -3711,6 +3695,7 @@ var openaiResponsesChunkSchema = (0, import_provider_utils25.lazySchema)( + import_v418.z.object({ + type: import_v418.z.enum(["response.completed", "response.incomplete"]), + response: import_v418.z.object({ ++ end_turn: import_v418.z.boolean().nullish(), + incomplete_details: import_v418.z.object({ reason: import_v418.z.string() }).nullish(), + usage: import_v418.z.object({ + input_tokens: import_v418.z.number(), +@@ -3725,7 +3710,7 @@ var openaiResponsesChunkSchema = (0, import_provider_utils25.lazySchema)( + reasoning_tokens: import_v418.z.number().nullish(), + orchestration_output_tokens: import_v418.z.number().nullish() + }).nullish() +- }), ++ }).nullish(), + reasoning: import_v418.z.object({ + context: import_v418.z.string().nullish() + }).nullish(), +@@ -3777,7 +3762,7 @@ var openaiResponsesChunkSchema = (0, import_provider_utils25.lazySchema)( + import_v418.z.object({ + type: import_v418.z.literal("message"), + id: import_v418.z.string(), +- phase: import_v418.z.enum(["commentary", "final_answer"]).nullish() ++ phase: import_v418.z.enum(["commentary", "partial_answer", "final_answer"]).nullish() + }), + import_v418.z.object({ + type: import_v418.z.literal("reasoning"), +@@ -3919,7 +3904,7 @@ var openaiResponsesChunkSchema = (0, import_provider_utils25.lazySchema)( + import_v418.z.object({ + type: import_v418.z.literal("message"), + id: import_v418.z.string(), +- phase: import_v418.z.enum(["commentary", "final_answer"]).nullish() ++ phase: import_v418.z.enum(["commentary", "partial_answer", "final_answer"]).nullish() + }), + import_v418.z.object({ + type: import_v418.z.literal("reasoning"), +@@ -4255,7 +4240,7 @@ var openaiResponsesResponseSchema = (0, import_provider_utils25.lazySchema)( + type: import_v418.z.literal("message"), + role: import_v418.z.literal("assistant"), + id: import_v418.z.string(), +- phase: import_v418.z.enum(["commentary", "final_answer"]).nullish(), ++ phase: import_v418.z.enum(["commentary", "partial_answer", "final_answer"]).nullish(), + content: import_v418.z.array( + import_v418.z.object({ + type: import_v418.z.literal("output_text"), +@@ -5724,22 +5709,6 @@ var OpenAIResponsesLanguageModel = class { }); } } @@ -148,8 +501,37 @@ index ad78b5df9359509091f4b8a1751dc34c03c74e32..ed92f56d8fe9c1442f741ef8d42d592c const shellToolEnvType = (_k = (_j = (_i = tools == null ? void 0 : tools.find( (tool) => tool.type === "provider" && tool.id === "openai.shell" )) == null ? void 0 : _i.args) == null ? void 0 : _j.environment) == null ? void 0 : _k.type; +@@ -6288,6 +6257,8 @@ var OpenAIResponsesLanguageModel = class { + const activeReasoning = {}; + let serviceTier; + let reasoningContext; ++ let endTurn; ++ let incompleteReason; + const hostedToolSearchCallIds = []; + let encounteredStreamError = false; + const result = { +@@ -7035,7 +7006,9 @@ var OpenAIResponsesLanguageModel = class { + }), + raw: (_x = (_w = value.response.incomplete_details) == null ? void 0 : _w.reason) != null ? _x : void 0 + }; +- usage = value.response.usage; ++ usage = value.response.usage ?? void 0; ++ endTurn = value.response.end_turn ?? void 0; ++ incompleteReason = value.type === "response.incomplete" ? value.response.incomplete_details?.reason ?? "unknown" : void 0; + if (typeof value.response.service_tier === "string") { + serviceTier = value.response.service_tier; + } +@@ -7139,6 +7112,8 @@ var OpenAIResponsesLanguageModel = class { + const providerMetadata = { + [providerOptionsName]: { + responseId, ++ ...endTurn !== void 0 ? { endTurn } : {}, ++ ...incompleteReason !== void 0 ? { incompleteReason } : {}, + ...logprobs.length > 0 ? { logprobs } : {}, + ...serviceTier !== void 0 ? { serviceTier } : {}, + ...reasoningContext !== void 0 ? { reasoningContext } : {} diff --git a/dist/internal/index.mjs b/dist/internal/index.mjs -index db7d6741c9ea615d577ea07722b469c144066268..f6b41b2832d2c8392072e0ecc1612e9e18b1b48e 100644 +index db7d6741c9ea615d577ea07722b469c144066268..26ed8ca1b191986805c5ee7dc5fbf5049ea5ffbb 100644 --- a/dist/internal/index.mjs +++ b/dist/internal/index.mjs @@ -1023,22 +1023,6 @@ var OpenAIChatLanguageModel = class { @@ -175,7 +557,51 @@ index db7d6741c9ea615d577ea07722b469c144066268..f6b41b2832d2c8392072e0ecc1612e9e const { tools: openaiTools, toolChoice: openaiToolChoice, -@@ -5802,22 +5786,6 @@ var OpenAIResponsesLanguageModel = class { +@@ -3756,6 +3740,7 @@ var openaiResponsesChunkSchema = lazySchema16( + z18.object({ + type: z18.enum(["response.completed", "response.incomplete"]), + response: z18.object({ ++ end_turn: z18.boolean().nullish(), + incomplete_details: z18.object({ reason: z18.string() }).nullish(), + usage: z18.object({ + input_tokens: z18.number(), +@@ -3770,7 +3755,7 @@ var openaiResponsesChunkSchema = lazySchema16( + reasoning_tokens: z18.number().nullish(), + orchestration_output_tokens: z18.number().nullish() + }).nullish() +- }), ++ }).nullish(), + reasoning: z18.object({ + context: z18.string().nullish() + }).nullish(), +@@ -3822,7 +3807,7 @@ var openaiResponsesChunkSchema = lazySchema16( + z18.object({ + type: z18.literal("message"), + id: z18.string(), +- phase: z18.enum(["commentary", "final_answer"]).nullish() ++ phase: z18.enum(["commentary", "partial_answer", "final_answer"]).nullish() + }), + z18.object({ + type: z18.literal("reasoning"), +@@ -3964,7 +3949,7 @@ var openaiResponsesChunkSchema = lazySchema16( + z18.object({ + type: z18.literal("message"), + id: z18.string(), +- phase: z18.enum(["commentary", "final_answer"]).nullish() ++ phase: z18.enum(["commentary", "partial_answer", "final_answer"]).nullish() + }), + z18.object({ + type: z18.literal("reasoning"), +@@ -4300,7 +4285,7 @@ var openaiResponsesResponseSchema = lazySchema16( + type: z18.literal("message"), + role: z18.literal("assistant"), + id: z18.string(), +- phase: z18.enum(["commentary", "final_answer"]).nullish(), ++ phase: z18.enum(["commentary", "partial_answer", "final_answer"]).nullish(), + content: z18.array( + z18.object({ + type: z18.literal("output_text"), +@@ -5802,22 +5787,6 @@ var OpenAIResponsesLanguageModel = class { }); } } @@ -198,6 +624,35 @@ index db7d6741c9ea615d577ea07722b469c144066268..f6b41b2832d2c8392072e0ecc1612e9e const shellToolEnvType = (_k = (_j = (_i = tools == null ? void 0 : tools.find( (tool) => tool.type === "provider" && tool.id === "openai.shell" )) == null ? void 0 : _i.args) == null ? void 0 : _j.environment) == null ? void 0 : _k.type; +@@ -6366,6 +6335,8 @@ var OpenAIResponsesLanguageModel = class { + const activeReasoning = {}; + let serviceTier; + let reasoningContext; ++ let endTurn; ++ let incompleteReason; + const hostedToolSearchCallIds = []; + let encounteredStreamError = false; + const result = { +@@ -7113,7 +7084,9 @@ var OpenAIResponsesLanguageModel = class { + }), + raw: (_x = (_w = value.response.incomplete_details) == null ? void 0 : _w.reason) != null ? _x : void 0 + }; +- usage = value.response.usage; ++ usage = value.response.usage ?? void 0; ++ endTurn = value.response.end_turn ?? void 0; ++ incompleteReason = value.type === "response.incomplete" ? value.response.incomplete_details?.reason ?? "unknown" : void 0; + if (typeof value.response.service_tier === "string") { + serviceTier = value.response.service_tier; + } +@@ -7217,6 +7190,8 @@ var OpenAIResponsesLanguageModel = class { + const providerMetadata = { + [providerOptionsName]: { + responseId, ++ ...endTurn !== void 0 ? { endTurn } : {}, ++ ...incompleteReason !== void 0 ? { incompleteReason } : {}, + ...logprobs.length > 0 ? { logprobs } : {}, + ...serviceTier !== void 0 ? { serviceTier } : {}, + ...reasoningContext !== void 0 ? { reasoningContext } : {} diff --git a/src/chat/openai-chat-language-model.ts b/src/chat/openai-chat-language-model.ts index 009a58deea236c2be03adc5cd03f98ed9050fd7b..a606675d82fc72a0bab8d52ac99866100e7ffb1d 100644 --- a/src/chat/openai-chat-language-model.ts @@ -237,8 +692,77 @@ index 009a58deea236c2be03adc5cd03f98ed9050fd7b..a606675d82fc72a0bab8d52ac9986610 const { tools: openaiTools, toolChoice: openaiToolChoice, +diff --git a/src/responses/convert-to-openai-responses-input.ts b/src/responses/convert-to-openai-responses-input.ts +index 5941962b8de3ce66d2d02ea2fd0c3326fc2954cc..e9a9f284c9b7463ff9960bd09fc01cdb15fa3ea9 100644 +--- a/src/responses/convert-to-openai-responses-input.ts ++++ b/src/responses/convert-to-openai-responses-input.ts +@@ -254,6 +254,7 @@ export async function convertToOpenAIResponsesInput({ + const id = providerOpts?.itemId as string | undefined; + const phase = providerOpts?.phase as + | 'commentary' ++ | 'partial_answer' + | 'final_answer' + | null + | undefined; +diff --git a/src/responses/openai-responses-api.ts b/src/responses/openai-responses-api.ts +index 9ab482d7417c2e547398846b2e44e305d095ef25..32a0c03d468fb9fda9f42a14f53aa50f6b9866d6 100644 +--- a/src/responses/openai-responses-api.ts ++++ b/src/responses/openai-responses-api.ts +@@ -122,7 +122,7 @@ export type OpenAIResponsesAssistantMessage = { + role: 'assistant'; + content: Array<{ type: 'output_text'; text: string }>; + id?: string; +- phase?: 'commentary' | 'final_answer' | null; ++ phase?: 'commentary' | 'partial_answer' | 'final_answer' | null; + }; + + export type OpenAIResponsesFunctionCall = { +@@ -569,6 +569,7 @@ export const openaiResponsesChunkSchema = lazySchema(() => + z.object({ + type: z.enum(['response.completed', 'response.incomplete']), + response: z.object({ ++ end_turn: z.boolean().nullish(), + incomplete_details: z.object({ reason: z.string() }).nullish(), + usage: z.object({ + input_tokens: z.number(), +@@ -587,7 +588,7 @@ export const openaiResponsesChunkSchema = lazySchema(() => + orchestration_output_tokens: z.number().nullish(), + }) + .nullish(), +- }), ++ }).nullish(), + reasoning: z + .object({ + context: z.string().nullish(), +@@ -651,7 +652,7 @@ export const openaiResponsesChunkSchema = lazySchema(() => + z.object({ + type: z.literal('message'), + id: z.string(), +- phase: z.enum(['commentary', 'final_answer']).nullish(), ++ phase: z.enum(['commentary', 'partial_answer', 'final_answer']).nullish(), + }), + z.object({ + type: z.literal('reasoning'), +@@ -795,7 +796,7 @@ export const openaiResponsesChunkSchema = lazySchema(() => + z.object({ + type: z.literal('message'), + id: z.string(), +- phase: z.enum(['commentary', 'final_answer']).nullish(), ++ phase: z.enum(['commentary', 'partial_answer', 'final_answer']).nullish(), + }), + z.object({ + type: z.literal('reasoning'), +@@ -1171,7 +1172,7 @@ export const openaiResponsesResponseSchema = lazySchema(() => + type: z.literal('message'), + role: z.literal('assistant'), + id: z.string(), +- phase: z.enum(['commentary', 'final_answer']).nullish(), ++ phase: z.enum(['commentary', 'partial_answer', 'final_answer']).nullish(), + content: z.array( + z.object({ + type: z.literal('output_text'), diff --git a/src/responses/openai-responses-language-model.ts b/src/responses/openai-responses-language-model.ts -index 926e7ec713480353b969901f19c5d1d8cc883dab..ed457ce8f07a60ed48c47a3cbf11b2a7c6cf6415 100644 +index 926e7ec713480353b969901f19c5d1d8cc883dab..ad22c459b4a42f28807261173b30dbfaf7085d66 100644 --- a/src/responses/openai-responses-language-model.ts +++ b/src/responses/openai-responses-language-model.ts @@ -433,36 +433,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { @@ -278,3 +802,65 @@ index 926e7ec713480353b969901f19c5d1d8cc883dab..ed457ce8f07a60ed48c47a3cbf11b2a7 const shellToolEnvType = ( tools?.find( tool => tool.type === 'provider' && tool.id === 'openai.shell', +@@ -1143,7 +1113,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { + > = []; + + // track the phase of the current message being streamed +- let activeMessagePhase: 'commentary' | 'final_answer' | undefined; ++ let activeMessagePhase: 'commentary' | 'partial_answer' | 'final_answer' | undefined; + + // flag that checks if there have been client-side tool calls (not executed by openai) + let hasFunctionCall = false; +@@ -1159,6 +1129,8 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { + + let serviceTier: string | undefined; + let reasoningContext: ResponsesProviderMetadata['reasoningContext']; ++ let endTurn: boolean | undefined; ++ let incompleteReason: string | undefined; + const hostedToolSearchCallIds: string[] = []; + let encounteredStreamError = false; + +@@ -2073,7 +2045,11 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { + }), + raw: value.response.incomplete_details?.reason ?? undefined, + }; +- usage = value.response.usage; ++ usage = value.response.usage ?? undefined; ++ endTurn = value.response.end_turn ?? undefined; ++ incompleteReason = value.type === 'response.incomplete' ++ ? value.response.incomplete_details?.reason ?? 'unknown' ++ : undefined; + if (typeof value.response.service_tier === 'string') { + serviceTier = value.response.service_tier; + } +@@ -2191,6 +2167,8 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { + const providerMetadata: SharedV3ProviderMetadata = { + [providerOptionsName]: { + responseId: responseId, ++ ...(endTurn !== undefined ? { endTurn } : {}), ++ ...(incompleteReason !== undefined ? { incompleteReason } : {}), + ...(logprobs.length > 0 ? { logprobs } : {}), + ...(serviceTier !== undefined ? { serviceTier } : {}), + ...(reasoningContext !== undefined ? { reasoningContext } : {}), +diff --git a/src/responses/openai-responses-provider-metadata.ts b/src/responses/openai-responses-provider-metadata.ts +index 3c6a3ce4eb023c234c2379b7bb4be3b0b80c528e..92072006af53db6b71e155074d8c31828330f3be 100644 +--- a/src/responses/openai-responses-provider-metadata.ts ++++ b/src/responses/openai-responses-provider-metadata.ts +@@ -12,6 +12,8 @@ type ResponsesOutputTextAnnotationProviderMetadata = Extract< + >['annotation']; + + export type ResponsesProviderMetadata = { ++ endTurn?: boolean; ++ incompleteReason?: string; + responseId: string | null | undefined; + logprobs?: Array; + serviceTier?: string; +@@ -33,7 +35,7 @@ export type OpenaiResponsesProviderMetadata = { + + export type ResponsesTextProviderMetadata = { + itemId: string; +- phase?: 'commentary' | 'final_answer' | null; ++ phase?: 'commentary' | 'partial_answer' | 'final_answer' | null; + annotations?: Array; + }; +