Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
76 changes: 72 additions & 4 deletions docs/opencodez.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
41 changes: 36 additions & 5 deletions packages/llm/src/protocols/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -180,6 +180,7 @@ type OpenAIResponsesUsage = Schema.Schema.Type<typeof OpenAIResponsesUsage>
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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 }
: {}),
})
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
})
Expand Down
7 changes: 7 additions & 0 deletions packages/llm/src/protocols/utils/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down
59 changes: 59 additions & 0 deletions packages/llm/test/provider/openai-responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenAIResponses.OpenAIResponsesBody>(
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)
Expand Down
Loading
Loading