A Codex backend 400 on the richer Coach tool-call request (and the daily
briefing) propagated out of the provider chain runner unwrapped: a 4xx other
than 401/403/429 was not classified as a hard provider failure, so it bypassed
AllProvidersFailedError and rethrew from the chat route as an HTTP 500. Plain
status-card completions, which never send a tools array, were unaffected.
Harden the failure path so a provider error can never surface as a 500:
- Treat any 4xx a provider wire returns as a hard failure so it cascades and is
wrapped in AllProvidersFailedError. InsightSchemaError (the strict-JSON 422
surface) stays the only non-hard class, so that diagnostic is preserved.
- The chat route now also classifies a tagged provider error that reaches its
catch unwrapped and emits the graceful coach.provider.* SSE frame instead of
rethrowing. Only an untagged error keeps the 500 + error-reporter path.
Make the Codex 400 diagnosable: fold the redacted upstream response body into
the thrown error message and the chain wide-event, so the field the backend
rejects is visible in logs (it was previously dropped).
Fix the Codex request shape behind the tool-call 400:
- Send the required strict flag on each Responses-API function tool.
- Replay prior assistant turns as output_text, not input_text, per the
Responses API multi-turn contract — the violation only surfaced on round two
of the tool loop, which is the path that broke.
Codex-only; the OpenAI, Anthropic, local and mock providers are untouched.
Replace the CompletionParams { systemPrompt, userPrompt } string pair with a
structured { system, messages[] } request across all five AI clients. One
refactor unlocks three things: provider prompt-caching (Anthropic cache_control
on the stable system prefix, OpenAI/Codex automatic prefix cache, with
cachedInputTokens surfaced for observability), the tool-calling wire surface,
and clean multi-turn.
Each client maps the new shape to its own wire and degrades unsupported
features silently: the local OpenAI-compatible server never receives a tools
field (most reject unknown keys), and prompt-caching is best-effort everywhere.
CompletionResult gains toolCalls, finishReason and cachedInputTokens; the tool
types and per-provider mapping ship now, the tool-use loop does not.
Vision inputs fold into the user turn's content parts via singleUserTurn, with
each provider's pre-existing block ordering preserved. The corrective-retry
paths append to the last user message rather than a flat string. The Coach
keeps assembling one user turn so its snapshot-once and grounding behaviour is
unchanged. No wire schema visible to API consumers changed.
Behaviour is unchanged across every surface; the full suite stays green.
Image input rides the ChatGPT-OAuth codex/responses endpoint via the
input_image content block (protocol spec 2b) — no vision API key. The
codex client folds params.images into input_image blocks alongside the
input_text; text-only calls keep the prior byte-identical wire. The
vision gate now resolves codex capability against the working slug
(GPT-5.x / gpt-4o-class multimodal) instead of a blanket false, with the
OCR capability resolver feeding the codex slug rather than the user's
aiModel. A non-multimodal slug or an image-block rejection degrades to a
clean extract failure / local-OCR text mode.
Stop iterating on individual 400 errors and implement the full Codex
Responses-API protocol once, from the source. Wrote a 713-line
reference at docs/codex-protocol-spec.md from the official
openai/codex Rust client; this commit is the TS implementation
against that spec.
Required pieces that were missing in v1.4.11:
- `ChatGPT-Account-ID` header — extracted from the `chatgpt_account_id`
claim of the OAuth id_token (also accepts the URI-namespaced
variant under `https://api.openai.com/auth`). Without it: 401.
- `originator`, `User-Agent`, `Accept: text/event-stream`,
`session_id`/`session-id` (both spellings), `thread_id`/`thread-id`.
- Body fields `reasoning: null` and `include: []` are required even
when no reasoning is requested.
- Model slug is `gpt-5-codex`, not `gpt-5.3-codex` (the latter is a
test placeholder upstream).
- Refresh endpoint takes a JSON body, not form-urlencoded.
Storage codec rewrite: `codexAccessTokenEncrypted` now holds an
encrypted JSON blob `{ accessToken, accountId, expiresAt }` instead
of just the raw access token, so the account id stays available
across requests. v1.4.7-v1.4.11 records that lack the field cannot
be revived — `decryptCodexCreds` returns null for those rows and
`resolveCodexProvider` flips the connection to `expired`, prompting
the user to re-link once.
Removed the dead browser-flow routes (`/api/auth/codex/authorize`
and `/callback`). They never worked on hosted domains because
Hydra's redirect-URI allow-list for the public Codex client ID
covers only localhost; device-code is the only viable path.
CodexClient SSE parsing extended to handle `response.failed` and
`response.incomplete` terminal events, and to read the actual
routed model from the `OpenAI-Model` response header.
Tests rebuilt: codex-oauth covers the full device-code flow with a
synthesised id_token JWT, the JSON-body refresh, and the
storage-codec round-trip. CodexClient tests carry an `accountId`
through the constructor. All 720 tests pass; typecheck clean.
Refs: docs/codex-protocol-spec.md §1-§6 for every claim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After v1.4.10 fixed the request body, the Codex backend started
returning 400:
{ "detail": "Stream must be set to true" }
The endpoint is streaming-only — `stream: false` is rejected
outright. The official Codex CLI consumes a Server-Sent-Events
stream (`codex-rs/codex-api/src/sse/responses.rs`); we have to do
the same.
CodexClient now requests `stream: true` and reads the response body
incrementally via the standard `ReadableStream` reader. Each SSE
message ends in a blank line; the `data:` payload is JSON. We listen
for three event types from the OpenAI Responses API protocol:
- `response.output_item.done` with an assistant `message` item — the
final text is read directly from the message's `output_text`
content. Preferred path because the backend has already stitched
any deltas together.
- `response.output_text.delta` — incremental text chunks. Fallback
when no `output_item.done` arrived (e.g. older models or
reasoning-only responses), all deltas are concatenated.
- `response.completed` — picks up `usage.total_tokens`.
- `response.error` / `error` — bubbled as a thrown Error with the
upstream message.
Tests rebuilt to drive the client with synthetic SSE bodies, plus
new coverage for the delta-fallback path. All 720 unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TDD implementation of CodexClient that calls the Codex responses
endpoint with OAuth Bearer token and automatically retries once
after refreshing the token on 401 responses.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>