Skip to content

Fix OpenAI OAuth compaction routing, stop compaction failures from killing turns, and anchor estimation on real usage - #190

Merged
pbdeuchler merged 3 commits into
masterfrom
compaction-fixes
Jul 26, 2026
Merged

Fix OpenAI OAuth compaction routing, stop compaction failures from killing turns, and anchor estimation on real usage#190
pbdeuchler merged 3 commits into
masterfrom
compaction-fixes

Conversation

@pbdeuchler

Copy link
Copy Markdown
Owner

Three related compaction fixes, one per commit, each independently green.

Originated from comparing halter's compaction against two reference implementations — earendil-works/pi and openai/codex — after a report that OpenAI OAuth compaction was failing with Store must be set to false.

1. Route ChatGPT OAuth compaction to /responses/compact (245ff6d)

The reported bug, but the root cause was upstream of the reported symptom.

provider_url returned a single constant for every Responses-shaped path under OAuth:

if is_chatgpt_codex_rewrite_path(path) {
    CHATGPT_CODEX_RESPONSES_URL.to_owned()   // ignores `path`
}

is_responses_path_or_child matches /v1/responses and every child, so /v1/responses/compact resolved to .../backend-api/codex/responses with the /compact suffix silently dropped. Dedicated compaction bodies were posted to the streaming turn endpoint — which is exactly the endpoint that rejects them with Store must be set to false. An existing test asserted the collapsed behavior, so it locked the bug in.

Now rewritten per-endpoint, matching the reference Codex client's .../backend-api/codex/responses/compact. The explicit match subsumes both helper predicates, so they're deleted; unmapped paths still fall through to the configured base_url.

store is deliberately not added to the compaction body. The dedicated endpoint doesn't take it — codex's CompactionInput omits it too — and adding it would have masked the routing defect while leaving /compact unreachable.

Also adds the wire-level coverage the OpenAI compaction codec never had (only the OpenRouter encoder was tested): encode shape, store/stream absence, prefix carry-forward, wrong-api-kind rejection, decode success and failure.

2. Degrade instead of failing the turn when auto-compaction cannot run (c0b9570)

plan() propagated every compaction failure via ?. Once a session crossed the compaction threshold, any provider-side problem failed the turn — and since context only grows, the next turn failed identically. Combined with #1, affected OAuth sessions were unrecoverable.

Automatic compaction is now best-effort. An unsupported provider, a missing compaction window, or a failing Provider::compact returns the uncompacted state carrying the reason, which run_turn publishes as SessionEventPayload::Warning. Manual compact() still propagates — the caller asked for compaction explicitly, so a silent no-op would misreport the result.

The three failure exits route through one degrade_or_fail, and the four repeated "nothing compacted" outcome literals collapse into uncompacted_outcome, so the engine body is shorter than before despite the added behavior.

3. Anchor context estimation on provider-reported usage (f59ae19)

Compaction triggering ran entirely off a character heuristic (chars * 10/37, documented as ±20% for code-heavy text) applied to the whole transcript — so error compounded with context size, exactly where the threshold decision matters most. Both reference implementations instead drive off real provider usage.

estimate_context_tokens now scans back for the most recent usable report, takes it as ground truth, and estimates only the messages after it. The report already covers the prompt, summaries, and compacted prefix, so those are not added on top of an anchor. Sessions with no usable report fall back to the previous whole-transcript estimate. Interrupted turns, errored turns, and zero-token reports are rejected — they describe a partial request and would peg the estimate below the real context, stopping compaction from ever firing.

Two hazards surfaced while implementing this:

A compaction loop. Compaction preserves a message tail, and the assistant message in that tail still reports the pre-compaction context. Anchoring on it would make every following turn see the old figure and compact again, forever. SessionState::usage_anchor_floor bounds the scan; compaction advances it past the preserved tail in both the runtime and the event fold, so replayed and resumed sessions agree. Forked subagents start with no anchor, since the parent's reports describe a different system prompt and tool set.

Opposite cache-token conventions. OpenAI's input_tokens includes cached tokens (cached_tokens is a subset); Anthropic's excludes them (separate sibling counters). No single formula is correct for both — plain input_tokens under-counts a mostly-cached Anthropic prompt badly enough to delay compaction past the context window.

⚠️ Semantics change worth a look during review. Usage::input_tokens is now defined as total input including cache traffic, with the cache counters as breakdown subsets, and the Anthropic decoder normalized to match. This changes reported Anthropic input totals — including accumulated usage_so_far — for turns recorded after this change. Historical values in existing sessions are unaffected. Blast radius is two tracing lines plus that telemetry counter; nothing computes billing or displays it. The alternative was threading provider identity into the estimator, which I judged worse. Happy to switch if you disagree.

Testing

18 new tests. Every suite green (26 binaries), cargo fmt --check and cargo clippy --all-features --all-targets clean.

I verified the two load-bearing guards by deliberately reintroducing each bug:

  • reverting the /compact suffix → the routing test fails
  • reverting degrade_or_fail to always bail! → both degradation tests fail
  • zeroing the usage_anchor_floor advance → the compaction-loop regression test fails

Each commit was checked out individually and the full suite run against it, so the history bisects cleanly rather than only being green at the tip.

Open risk

Codex sends originator, session_id, x-codex-installation-id, and attestation headers to the ChatGPT backend; halter sends only the bearer token. If /responses/compact is gated on request provenance, OAuth compaction will now fail differently rather than succeed. I can't verify this without a live OAuth token.

Commit 2 is what makes that acceptable: the failure degrades to a warning instead of taking the turn down. #189 tracks the fallback — a streaming compaction-trigger strategy that needs no dedicated endpoint.

Not included

docs/ is gitignored in this repo, so the call-graph documentation I wrote (token estimation, failure handling, OAuth endpoint routing) is not in these commits. Happy to open a separate change if you want those tracked.

🤖 Generated with Claude Code

pbdeuchler and others added 3 commits July 26, 2026 00:45
The OAuth URL rewrite returned a single constant for every
Responses-shaped path, so `/v1/responses/compact` resolved to
`.../backend-api/codex/responses` with the `/compact` suffix silently
dropped. Dedicated compaction bodies were therefore posted to the
streaming turn endpoint, which rejects them with `Store must be set to
false` — making automatic compaction unreachable for OAuth sessions.

Rewrite per-endpoint instead, matching the reference Codex client's
`.../backend-api/codex/responses/compact`. The explicit match subsumes
`is_chatgpt_codex_rewrite_path` and `is_responses_path_or_child`, so
both helpers are gone and unmapped paths still fall through to the
configured base_url.

`store` is deliberately not added to the compaction body: the dedicated
endpoint does not take it, and the reference client omits it too.
Adding it would have masked the routing defect while leaving /compact
unreachable.

The existing test asserted the collapsed behavior; it now asserts each
endpoint keeps its own path. Also adds the wire-level coverage the
OpenAI compaction codec never had — only the OpenRouter encoder was
tested — including encode shape, `store`/`stream` absence, prefix
carry-forward, wrong-api-kind rejection, and decode success/failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`plan()` propagated every compaction failure, so once a session crossed
the compaction threshold any provider-side problem failed the turn — and
context only grows, so the next turn failed the same way. Combined with
the OAuth routing defect this made affected sessions unrecoverable.

Automatic compaction is now best-effort: an unsupported provider, a
missing compaction window, or a failing `Provider::compact` returns the
uncompacted state carrying the reason, which `run_turn` publishes as
`SessionEventPayload::Warning`. Manual `compact()` still propagates —
the caller asked for compaction explicitly, so a silent no-op would
misreport the result.

`ContextPlan` gained `compaction_warning` (serde-defaulted, so existing
event logs still deserialize) and `CompactionOutcome` gained
`compaction_error` to carry it out of the engine.

The three failure exits now route through one `degrade_or_fail`, and
the four repeated "nothing compacted" outcome literals collapse into
`uncompacted_outcome`, so the engine body is shorter than before.

Tests cover both sides of each branch: degrade on provider error,
unsupported provider, and absent window; no warning on success; and
propagation on both manual paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Compaction triggering ran entirely off a character heuristic
(chars * 10/37, documented as +/-20% for code-heavy text) applied to the
whole transcript, so the error compounded with context size — exactly
where the threshold decision matters most.

Providers report the true context size with every assistant response.
`estimate_context_tokens` now scans back for the most recent usable
report, takes it as ground truth, and estimates only the messages after
it, bounding heuristic error to one turn's tail. The report already
covers the prompt, summaries, and compacted prefix, so those are not
added on top of an anchor. Sessions with no usable report — fresh, or
all turns failed — fall back to the previous whole-transcript estimate.

Interrupted turns, errored turns, and zero-token reports are rejected:
they describe a partial request and would peg the estimate far below the
real context, stopping compaction from ever firing.

Two hazards this surfaced:

Compaction preserves a message tail, and the assistant message in that
tail still reports the *pre*-compaction context. Anchoring on it would
make every following turn see the old figure and compact again,
indefinitely. `SessionState::usage_anchor_floor` bounds the scan;
compaction advances it past the preserved tail in both the runtime and
the event fold so replayed and resumed sessions agree. Forked subagents
start with no anchor, since the parent's reports describe a different
system prompt and tool set.

The providers used opposite cache-token conventions: OpenAI's
`input_tokens` includes cached tokens, Anthropic's excludes them. No
single formula is correct for both, and plain `input_tokens` under-counts
a mostly-cached Anthropic prompt badly enough to delay compaction past
the context window. `Usage::input_tokens` is now defined as total input
including cache traffic, with the cache counters kept as breakdown
subsets, and the Anthropic decoder normalized to match. This changes
reported Anthropic input totals, including accumulated `usage_so_far`,
for turns recorded after this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pbdeuchler
pbdeuchler merged commit 2056e4c into master Jul 26, 2026
10 checks passed
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.

1 participant