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
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,55 @@ log.
and state-only intermediate changes ride the next event-ful flush or the
final turn commit.

### Compaction

#### Fixed

- OpenAI OAuth (ChatGPT Codex) compaction reached the wrong endpoint. The
OAuth URL rewrite collapsed every Responses-shaped path onto a single
constant, so `/v1/responses/compact` resolved to `.../codex/responses` —
the streaming turn endpoint — which rejects compaction bodies with
`Store must be set to false`. Each ChatGPT-served endpoint now keeps its
own path, matching the reference Codex client
(`.../codex/responses/compact`). Automatic compaction was therefore
unreachable for OAuth sessions, which combined with the issue below made
every turn past the compaction threshold fail outright.

#### Changed

- Context estimation now anchors on provider-reported usage instead of
estimating the whole transcript with a character heuristic. When the
transcript contains a usable report from a completed assistant turn,
`estimate_context_tokens` takes that figure as ground truth and estimates
only the messages after it, bounding heuristic error (documented at ±20%
for code-heavy text) to one turn's tail rather than letting it compound
across the entire context. Interrupted turns, errored turns, and zero-usage
reports are rejected as anchors, and a session with none falls back to the
previous whole-transcript estimate.
- `SessionState` gained `usage_anchor_floor`. Compaction preserves a tail of
real messages whose usage describes the *pre*-compaction context; without a
floor, that stale figure re-triggers compaction on every following turn
indefinitely. Compaction advances the floor past the preserved tail, in
both the runtime (`CompactionEffects::apply`) 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.
- **`Usage::input_tokens` now uniformly means total input including cache
traffic.** OpenAI already reported it that way; Anthropic reports cache
reads and writes as separate counters, and its decoder now folds them in,
keeping `cache_read_input_tokens` / `cache_creation_input_tokens` as
breakdown fields. Without this, a mostly-cached Anthropic prompt reported a
small `input_tokens` and context budgeting under-counted the live context.
This changes reported Anthropic input totals — including the accumulated
`usage_so_far` — for turns recorded after the upgrade; historical values in
existing sessions are unaffected.
- Automatic compaction is now best-effort. A provider that cannot compact —
failing endpoint, missing capability, or no compaction window — degrades
the turn to an uncompacted context and emits
`SessionEventPayload::Warning` instead of failing the turn. `ContextPlan`
gained `compaction_warning` to carry the reason. Manual `compact()` is
unchanged and still propagates its errors, since the caller asked for
compaction explicitly.

Blank-slate review fixes on top of the provider resilience primitive
(issue #183). Highlights:

Expand Down
12 changes: 9 additions & 3 deletions crates/halter-protocol/src/fold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@
//! provider-chaining fields) are deliberately *not* event-covered: they are
//! carried by the checkpoint, which the runtime writes on every
//! state-changing commit. The one exception is that `ContextCompacted`
//! effects also reset `last_response_id` / `messages_seen_by_provider`,
//! mirroring the runtime's compaction rules so a mid-replay view is not left
//! pointing at a provider response chain that predates the rewrite.
//! effects also reset `last_response_id` / `messages_seen_by_provider` and
//! advance `usage_anchor_floor`, mirroring the runtime's compaction rules so
//! a mid-replay view is not left pointing at a provider response chain — or
//! at reported token usage — that predates the rewrite.
//!
//! The store conformance suite locks the invariant in: after any sequence of
//! commits, folding the full log over a default state must agree with the
Expand Down Expand Up @@ -58,6 +59,8 @@ pub fn apply_event(state: &mut SessionState, payload: &SessionEventPayload) {
// runtime so replayed views never chain onto pre-rewrite context.
state.last_response_id = None;
state.messages_seen_by_provider = 0;
// Usage reported by the preserved tail predates this rewrite.
state.usage_anchor_floor = state.messages.len();
}
SessionEventPayload::ContextCompacted { effects: None, .. }
| SessionEventPayload::SessionStarted
Expand Down Expand Up @@ -216,6 +219,9 @@ mod tests {
assert_eq!(state.compacted_prefix, vec![json!({"kind": "prefix"})]);
assert_eq!(state.last_response_id, None);
assert_eq!(state.messages_seen_by_provider, 0);
// Replay must reach the same anchor floor the runtime sets, or a
// resumed session would re-anchor on pre-compaction usage.
assert_eq!(state.usage_anchor_floor, window.len());
}

#[test]
Expand Down
31 changes: 31 additions & 0 deletions crates/halter-protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,13 +314,31 @@ pub enum PanelIsolation {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
/// Token accounting reported by providers and accumulated by sessions.
pub struct Usage {
/// Total input tokens for the request, **including** tokens served from
/// or written to the prompt cache. Provider codecs normalize to this
/// convention: OpenAI already reports the total, while Anthropic reports
/// cache traffic separately and its decoder folds it in.
pub input_tokens: u64,
pub output_tokens: u64,
/// Portion of `input_tokens` written to the prompt cache. A breakdown of
/// `input_tokens`, not an addition to it.
pub cache_creation_input_tokens: u64,
/// Portion of `input_tokens` served from the prompt cache. A breakdown of
/// `input_tokens`, not an addition to it.
pub cache_read_input_tokens: u64,
}

impl Usage {
/// Total tokens the model had in context when it produced this response:
/// everything it read plus everything it wrote. This is the size the
/// *next* request's input starts from, which is what context budgeting
/// needs — as opposed to the per-request cost, which is what the
/// individual fields report.
#[must_use]
pub const fn context_tokens(&self) -> u64 {
self.input_tokens.saturating_add(self.output_tokens)
}

/// Accumulate `delta` into `self`, saturating at `u64::MAX` so lifetime
/// counters can never overflow. Both the session runtime and the event
/// fold ([`fold::apply_event`]) use this, keeping the persisted
Expand Down Expand Up @@ -1534,6 +1552,14 @@ pub struct SessionState {
/// Messages at indices `[0..messages_seen_by_provider)` don't need re-sending.
#[serde(default)]
pub messages_seen_by_provider: usize,
/// Index into `messages` before which reported `Usage` no longer
/// describes the live context. Compaction rewrites history but preserves
/// a tail of real messages, and any assistant message in that tail still
/// carries the usage from *before* the rewrite. Anchoring context
/// estimates on that stale figure would re-trigger compaction every turn,
/// so compaction moves this floor past the preserved tail.
#[serde(default)]
pub usage_anchor_floor: usize,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
Expand Down Expand Up @@ -1768,6 +1794,11 @@ pub struct ContextPlan {
/// If the planner compacted messages this turn, the result is here.
/// The caller should apply it to `SessionState` after using the plan.
pub compaction: Option<CompactionResult>,
/// Set when automatic compaction was due but could not run. The plan is
/// still valid and uncompacted, so the turn proceeds; the caller should
/// surface this so a degraded context does not look like a healthy one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction_warning: Option<String>,
/// When set, the codec should chain via `previous_response_id`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
Expand Down
44 changes: 27 additions & 17 deletions crates/halter-providers/src/anthropic_codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -645,24 +645,29 @@ fn decode_stop_reason(response: &Value) -> StopReason {
)
}

/// Anthropic reports `input_tokens` *excluding* cache traffic, with cache
/// reads and writes as separate sibling counters. [`Usage::input_tokens`] is
/// defined as the total including cache traffic (OpenAI's convention), so
/// fold the cache counters in here and keep them as breakdown fields. Without
/// this, a mostly-cached prompt reports a tiny `input_tokens` and context
/// budgeting badly under-counts the live context.
fn decode_usage(response: &Value) -> Usage {
Usage {
input_tokens: response
.pointer("/usage/input_tokens")
.and_then(Value::as_u64)
.unwrap_or_default(),
output_tokens: response
.pointer("/usage/output_tokens")
.and_then(Value::as_u64)
.unwrap_or_default(),
cache_creation_input_tokens: response
.pointer("/usage/cache_creation_input_tokens")
.and_then(Value::as_u64)
.unwrap_or_default(),
cache_read_input_tokens: response
.pointer("/usage/cache_read_input_tokens")
let field = |name: &str| {
response
.pointer(&format!("/usage/{name}"))
.and_then(Value::as_u64)
.unwrap_or_default(),
.unwrap_or_default()
};
let cache_creation_input_tokens = field("cache_creation_input_tokens");
let cache_read_input_tokens = field("cache_read_input_tokens");

Usage {
input_tokens: field("input_tokens")
.saturating_add(cache_creation_input_tokens)
.saturating_add(cache_read_input_tokens),
output_tokens: field("output_tokens"),
cache_creation_input_tokens,
cache_read_input_tokens,
}
}

Expand Down Expand Up @@ -1295,9 +1300,14 @@ mod tests {
event,
StreamEvent::ToolArgsDelta { delta, .. } if delta.contains("README.md")
)));
// input_tokens folds in cache traffic: 11 uncached + 2 written + 3 read.
assert!(events.iter().any(|event| matches!(
event,
StreamEvent::UsageUpdate { usage } if usage.input_tokens == 11 && usage.output_tokens == 7
StreamEvent::UsageUpdate { usage }
if usage.input_tokens == 16
&& usage.output_tokens == 7
&& usage.cache_creation_input_tokens == 2
&& usage.cache_read_input_tokens == 3
)));
assert!(events.iter().any(|event| matches!(
event,
Expand Down
66 changes: 66 additions & 0 deletions crates/halter-providers/src/openai_codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1921,6 +1921,72 @@ mod tests {
assert!(body.get("temperature").is_none());
}

/// The dedicated `/v1/responses/compact` endpoint takes the compaction
/// input shape only. `store` and `stream` belong to the streaming turn
/// endpoint and must stay absent here, matching the reference Codex
/// client's compaction payload.
#[test]
fn openai_compaction_request_uses_compact_endpoint_shape() {
let request = sample_compaction_request(ProviderKind::OpenAi);
let body = encode_responses_compact_request(&request).expect("encode compaction request");
let input = body["input"].as_array().expect("input");

assert_eq!(body["model"], "gpt-5");
assert_eq!(body["instructions"], "Summarize the session");
assert!(body.get("store").is_none());
assert!(body.get("stream").is_none());
// The carried compacted prefix leads the input so successive
// compactions fold into one another instead of restarting.
assert_eq!(input[0]["role"], "developer");
assert!(
input[0]["content"][0]["text"]
.as_str()
.expect("prefix text")
.contains("Earlier summary")
);
assert!(input.iter().any(|item| item["role"] == "user"));
assert!(input.iter().any(|item| item["type"] == "function_call"));
assert!(
input
.iter()
.any(|item| item["type"] == "function_call_output")
);
}

#[test]
fn openai_compaction_request_rejects_non_responses_api_kind() {
let mut request = sample_compaction_request(ProviderKind::OpenAi);
request.model.api_kind = ApiKind::OpenAiChat;

let error =
encode_responses_compact_request(&request).expect_err("chat api kind must not encode");

assert!(error.to_string().contains("unsupported api kind"));
}

#[test]
fn openai_compaction_response_decodes_output_items() {
let response = json!({
"output": [{"type": "compaction", "id": "cmp_1", "encrypted_content": "opaque"}],
"usage": {"input_tokens": 120, "output_tokens": 30},
});

let decoded = decode_responses_compact_response(&response).expect("decode compaction");

assert_eq!(decoded.output.len(), 1);
assert_eq!(decoded.output[0]["type"], "compaction");
assert_eq!(decoded.usage.input_tokens, 120);
assert_eq!(decoded.usage.output_tokens, 30);
}

#[test]
fn openai_compaction_response_without_output_array_is_an_error() {
let error = decode_responses_compact_response(&json!({"usage": {}}))
.expect_err("missing output must fail");

assert!(error.to_string().contains("missing output array"));
}

#[test]
fn openrouter_compaction_request_uses_responses_shape() {
let request = sample_compaction_request(ProviderKind::OpenRouter);
Expand Down
Loading