Skip to content

feat: add Astra support and reliable SSE capture - #55

Open
dean0x wants to merge 16 commits into
mainfrom
feat/astra-sse-capture
Open

dean0x wants to merge 16 commits into
mainfrom
feat/astra-sse-capture

Conversation

@dean0x

@dean0x dean0x commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Summary

  • add gpt-6-astra / astra routing, discovery, reverse alias protection, and model-specific effort validation
  • capture eligible streamed Responses SSE traffic when the upstream omits Content-Type
  • provide an import-safe recorder server factory with local integration coverage

Verification

  • npm run check
  • npm run build
  • bash scripts/smoke-tarball.sh

Closes #50
Closes #21

@dean0x

dean0x commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

Code Review — Cycle 1

Full summary withheld (public repository).

Category CRITICAL HIGH MEDIUM LOW Total
Blocking 0 8 12 - 20
Should Fix - 1 6 - 7
Pre-existing - - 5 0 5

No CRITICAL findings in any focus.

Full report: /Users/dean/Sandbox/croxy/.devflow/docs/reviews/feat-astra-sse-capture/2026-09-13_2137/review-summary.md (not committed; ask the author)
Posted by devflow · cycle 1

Extract the two arms of the isSse branch into module-level siblings
recordSseStream() and pipeThrough(). handleRequest goes from 162 to 94
lines and max nesting from 6 to 4; both arm bodies move verbatim, so
behaviour is unchanged and the next batches get clean seams
(complexity-04).

Build the println/separator/indent helpers once per server with a
module-level createPrinter() under the existing Printing banner instead
of allocating three closures per request, drop the `const println =
output` rename alias, and name the bare 64 as SEPARATOR_WIDTH
(complexity-01).

Pin both with a transcript test asserting a separator rule of exactly
64 columns, the numbered event lines, and the SSE TOTAL count. The
byte-for-byte pass-through guard stays the three existing deepEqual
assertions, unchanged (ADR-010).
Resolve five triaged issues in the per-model reasoning-effort path.

consistency-01: reasoningEffortsForModel closed over the module-global
MODEL_REGISTRY while every other registry consumer in models.ts takes
`registry` as its first parameter. Take it explicitly and thread
MODEL_REGISTRY from the codex-request call site, so the per-model rule is
testable against a synthetic registry instead of whichever real ids
happen to declare reasoningEfforts. (applies ADR-005, ADR-006)

architecture-02: the effort vocabulary was spelled in two unlinked places
and translateEffort picked between them with a double-negated ternary.
Declare DEFAULT_REASONING_EFFORTS once next to the registry, make the
accessor total (per-model list, else the default set), drop
CODEX_EFFORT_VALUES, and collapse the ternary to a single positive
membership test. Wire input stays `string`: unknown values still degrade
with unsupported_effort_dropped rather than 400. (avoids PF-014, PF-004)

reliability-09: buildRoutingTable now reports entries whose
reasoningEfforts are not a subset of the default set, as a returned
diagnostic rather than a throw — it stays total. A typo like "xhig" is
otherwise silent: the model simply stops accepting an effort it should.

complexity-06: document the canonical-id precondition on translateEffort
and the accessor; an alias would fall back to the default set and
silently widen validation. (applies ADR-007)

consistency-03: record why gpt-6-astra keeps five efforts and does not
adopt the sixth (`ultra`) advertised by the native-CLI fixture — that
fixture describes the reverse-ingress leg, not the /responses leg.
isOpenaiModelName hand-listed the registry's family names (sol|terra|luna|
astra) in a regex with no structural link to MODEL_REGISTRY. validClaudeAlias
is the only gate on codexIngress.claude.aliases, so a future registry family
the regex never learned about could be claimed by a Claude alias and hijacked
on the reverse leg — the mirror image of PF-007 (applies ADR-005/ADR-006,
avoids PF-028).

- buildOpenaiModelNamePredicate(registry) derives the family/id alternation at
  module load, escaping metacharacters and deduping; the gpt-, o[134], codex:
  arms, the (?:$|\[) suffix handling and the i flag are unchanged. An empty
  registry emits no alternation arm rather than an empty one that would match
  every name.
- test/unit/claude-models.test.ts: registry invariant (every id and family is
  reserved), a synthetic-registry liveness control, and the validClaudeAlias
  test moved out of reverse-adapter.test.ts.
- test/unit/config.test.ts: consumer-level coverage for the
  codexIngress.claude.aliases refine, which was previously unasserted.
consistency-02: ModelRow mirrored every other ModelEntry field except
reasoningEfforts, so a model's narrowed effort vocabulary never reached
`subswitch models --json` — the machine-readable registry. A consumer
reading it would offer gpt-6-astra the two backend-wide efforts it
rejects. Add the field and emit it by conditional spread only when the
entry declares one: absent means DEFAULT_REASONING_EFFORTS applies, so
filling in the default would claim a narrowing that is not there and
re-spell a vocabulary that has one home (avoids PF-014, PF-004).
Additive, so schemaVersion stays 1. formatModelsReport is left alone —
its AliasTableRow shows neither family, preview nor retired, so it is
not the view that mirrors every optional field.

reliability-09: buildDeps destructured the five known diagnostics by
name, so 51ff365's unknownReasoningEfforts was returned and tested but
surfaced nowhere at runtime. Add the field and one loop mirroring its
neighbours, annotating the id with the offending values exactly as
ambiguous_family annotates its provider list.

testing-05: the routableModelCount canary compared the function against
an inline re-spelling of its own filter, so both sides moved together
and it stayed green when this PR took the count 4 -> 5. Pin the literal
5 instead — one side of a control must be independent of the code under
test (avoids PF-011). Proven RED at 4.

testing-03: the astra `models --json` test dropped the file's assertion
messages and left `gen` unpinned, though astra is the registry's only
single-element gen tuple. Both restored, plus the omission half of the
conditional spread on gpt-5.6-sol.

consistency-13: the Astra effort test bundled three behaviours into one
`it` and omitted the `result.value.effort` assertion its neighbours
make. Split into forward / drop / unnarrowed-neighbour, restore the
surrounding fixture style, and assert the outcome's effort on both the
accept and the drop path — the body alone does not pin what the handler
logs. Live ids stay: translateRequest threads MODEL_REGISTRY internally
and takes no registry parameter.
…nt-type

The headerless-SSE eligibility predicate re-decoded and re-parsed the whole
request body inside the upstream response callback, duplicating the parse
bodyShape had already done. Codex bodies carry entire conversation histories,
so that sat on the time-to-first-byte path. The body is now parsed exactly
once after it is read; the predicate consumes the resulting boolean.

new URL(path, ...) sat outside the try that wrapped only JSON.parse, inside
the response callback and therefore outside the promise handleRequest awaits:
a malformed request target (POST // HTTP/1.1) escaped the handler's .catch()
and killed the recorder on uncaughtException. The parse now lives inside the
guard - a malformed target is ineligible, not fatal - and the CLI entry point
registers an uncaughtException logger so an unattended capture survives a
stray throw. The factory stays import-safe and touches no process globals.

The four loose primitives - including two adjacent transposable strings -
collapse into one named StreamProbe, with RESPONSES_PATH_SUFFIX,
PATH_PARSE_BASE, SSE_CONTENT_TYPE and isSuccessStatus replacing the inlined
magic values, and the arm dispatch reading isSseResponse(probe).

Reading content-type through an Array.isArray branch narrowed to never, so
never[0] was any and the whole contentType expression collapsed to any,
leaving .toLowerCase() and === "" unchecked. IncomingHttpHeaders types that
header string | undefined, so the impossible branch is gone.

All four PF-013 clauses are preserved exactly and ADR-010 byte-transparency
is unchanged.

Refs: performance-02, complexity-02, security-02, typescript-01, complexity-07
…ailure

The capture arm is chosen from the REQUEST shape (PF-013), so a 2xx response
with no blank-line delimiter reached it and was retained whole by `lineBuf`,
with `split` re-scanning the accumulation per chunk. Measured: a 16 MiB
undelimited body took the recorder heap from 10 MiB to 232 MiB in 1002 ms.
MAX_SSE_EVENTS caps printing, not memory.

- Hold undelivered capture text as an array of segments, joined only on the
  chunk that completes an event, mirroring the production `createSseParser`.
  Bound it at MAX_SSE_BUFFER_CHARS (4 Mi UTF-16 code units); on overflow print
  one `<cap:sse-residual>` notice, drop the residual and stop parsing — while
  every byte keeps flowing to the client untouched (ADR-010, ADR-012).
  Same body now: 6.1-7.2 MiB growth in 33 ms, bytes byte-identical.
- Decode with one StringDecoder per response, so a multi-byte sequence split
  across two writes no longer becomes two replacement characters.
- Write to the client before any capture work and honour `res.write`'s return
  value, pausing the upstream until drain. Both drain and close detach the
  pair, so a socket that closes mid-drain cannot strand the upstream paused
  (PF-009 class).
- Give teardown its own predicate instead of the `headersSent` reply latch
  (PF-022): before the reply synthesize a status, after it destroy the socket.
  Applied to both arms' upstream-error paths and the outer catch — a
  mid-stream upstream death left the client's chunked body unterminated.
- Destroy the upstream request when the client disconnects mid-stream; the
  handler promise still settles exactly once.

All five controls proven RED against the prior implementation first (PF-011);
the seven pre-existing tests stayed green untouched.
Four recorder-hygiene defects, each proven RED first (PF-011); the twelve
pre-existing tests stayed green untouched.

- `event.type` and the `usage` numbers came off the UPSTREAM stream and were
  interpolated raw into the capture, contradicting the file's own header
  invariant that message text is never printed. One crafted event forged four
  capture lines: a fake `REQUEST HEADERS:` block carrying a plausible bearer
  token, a fake `[99] type=response.completed`, and an ANSI `\x1b[2K` that
  erases the real prefix in a terminal. Captures are hand-converted into
  fixtures, so a forged line is a live hazard. Both now pass through capture
  hygiene at the single print site: `renderCaptureToken` strips the C0/DEL/C1
  range and caps length at 128 chars, exactly as `renderToken` does in
  src/logger.ts (ADR-008), and `usage` is projected down to its finite numbers
  with bounded depth and field count, so a crafted string value is dropped
  rather than rendered. Nested `input_tokens_details.cached_tokens` survives.
- Hoist the nested ternary-in-template into `formatUsageSuffix`.
- `requestSeq` stayed module-global after the factory made the module
  multi-instance, so several recorders in one process shared one counter and
  their `REQUEST #n` labels depended on execution order. The counter now lives
  in the factory closure and the per-request `seq` is threaded into
  `handleRequest`.
- The factory accepted any upstream URL while `buildForwardHeaders` forwards
  `authorization`, `chatgpt-account-id` and `cookie` verbatim — the `REDACT_*`
  sets govern the transcript, not the wire — so a recorder pointed at
  `http://evil.test` shipped the user's ChatGPT OAuth token in cleartext to an
  arbitrary host. ADR-009 now applies at construction, before anything listens:
  https to chatgpt.com, or any scheme to loopback, judged by src/config.ts's
  exact-form `isLoopbackHost` rather than a hand-rolled prefix test (PF-026:
  `startsWith("127.")` admits `127.0.0.1.evil.test`). Any other https host
  needs CODEX_RECORDER_ALLOW_INSECURE_UPSTREAM=1; cleartext http off loopback
  is refused outright. The CLI catches the refusal and exits 1 rather than
  letting the `uncaughtException` guard swallow it.
- Add `e2e/capture/**/*.ts` to tsconfig.json `include`: the recorder was
  type-checked only transitively, via the test's import. tsconfig.build.json
  stays src-only, so it still never reaches dist. Its header claimed exclusion
  from the typecheck program and the npm test globs, false on both counts
  (PF-023); rewritten to state what is actually true, along with the CLI banner
  and header text describing the upstream override.
…pling

The headerless-SSE eligibility rule had no negative test for the method
clause: deleting `method === "POST"` left the suite green because every
other negative was stopped by a cheaper clause first. `recordEvent`'s
200-event print cap and its terminal-usage latch were prose only —
fixtures emitted one or two events, so the cap branch never ran and both
re-coupling the usage print to the cap and deleting the latch stayed
green.

- add a headerless 2xx GET /responses carrying `stream: true`, which
  clears every other clause so the method is the only one left refusing
- add a 205-event fixture (an independent literal — importing
  MAX_SSE_EVENTS would make the test agree with the code rather than pin
  it) with a completion on each side of the cap, asserting 200 printed
  lines, one cap notice, one TERMINAL USAGE carrying the first value, and
  byte-for-byte forwarding
- add the post-cap-only completion case, so usage that arrives past the
  print cap is pinned as terminal accounting rather than event detail
- bound every client/close helper at 5 s, so a hang fails as a named
  assertion at its call site instead of the anonymous 30 s suite timeout

Mutations proven RED, each reverted: `method === "POST"` → `true`;
`!terminalUsagePrinted` dropped (usage printed twice); usage print moved
inside the cap branch (post-cap usage lost).

Refs PF-011, PF-012, PF-013, ADR-010
The recorder section described a CLI that no longer tells the whole story.
Three drifts, each checked against the current code (PF-023):

- the output-format list never gained `TERMINAL USAGE:`, which the
  integration suite asserts literally, and its cap sentence claimed the
  200-event cap governs the usage print — the code deliberately decouples
  them, so a completion past the cap still reports usage
- `<cap:sse-residual>` had a Bounds row but no entry in the output list,
  and the `usage` object is not printed "verbatim": only finite numbers
  survive, keys and types pass capture hygiene, and booleans in a body
  shape render as `<bool:…>`
- the module is import-safe and exports `createCodexRecorderServer` /
  `RecorderOutput`, but only the `npx tsx` invocation was documented; add
  a "Using the recorder from a test" subsection covering the current
  three-argument signature, the ephemeral-port dance, and the output sink
- document the ADR-009 upstream rule the factory enforces: loopback on any
  scheme, `https://chatgpt.com` by default, any other https host behind
  `CODEX_RECORDER_ALLOW_INSECURE_UPSTREAM=1` / `allowInsecureUpstream`,
  and cleartext off-loopback refused outright, fatally and before listening

Refs ADR-009, ADR-010, PF-013, PF-023, PF-026
Behaviour-preserving cleanup across the twelve issue-resolution commits,
written by seven agents that each spelled the shared parts differently.

- recorder: both response arms ended a mid-stream upstream failure with the
  same three lines and the same client-visible message literal; name the rule
  once as `failUpstreamStream` so the two arms cannot drift apart on status or
  message. The connection-error path keeps its own wording and stays inline.
- recorder: `forward()` registered two byte-identical closures, one per event,
  plus a third to detach them. One listener on both events says the same thing
  and makes the accompanying comment literally true.
- recorder: drop three `err as Error` assertions. Node types an `"error"`
  listener as `(err: Error)` on both Readable and Writable, so the casts
  asserted a type the compiler already had.
- recorder: `const { println } = printer`, matching how `handleRequest`
  destructures the same object two functions away.
- comments: replace three file:line references with the symbol names they
  point at. All three were accurate today and silently rot on the next edit.
- tests: `send()` carried a `method = "POST"` default that neither call site
  uses — both pass the method explicitly, which is what `post()` exists for.
- tests: `SYNTHETIC` -> `SYNTHETIC_REGISTRY`, the name the sibling synthetic
  registry in claude-models.test.ts already uses for the same device.

No production behaviour, transcript output, log field, or assertion changes.
tsc clean; the eight in-scope files stay at 297 passing.
…order SSE capture

Documents gpt-6-astra's per-model reasoningEfforts, DEFAULT_REASONING_EFFORTS/
reasoningEffortsForModel/unknownReasoningEfforts, buildOpenaiModelNamePredicate
deriving the claude-ingress alias gate from the registry, and the PF-013 fix in
codex-recorder.ts (headerless-SSE capture eligibility, MAX_SSE_BUFFER_CHARS,
upstream vetting). Cross-references the same additions into the cli-ux KB.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add and validate GPT-6 Astra support for Claude Code → OpenAI e2e recorder cannot capture SSE from live backend (no content-type header)

1 participant