From a6a3b7c5416e85d517a748f8dabf497a7b07f2d5 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 15:20:47 -0500 Subject: [PATCH 01/26] Docs: Add the plan for praxis mode step 2 (gateway and ingestion) This plan divides #1719 into 11 tasks. Each task has its files, its tests and the tasks that it depends on. The plan also records the decisions that the spec does not make. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- ...-11-09-praxis-mode-2-gateway-and-ingest.md | 411 ++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md diff --git a/docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md b/docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md new file mode 100644 index 000000000..0ab0714db --- /dev/null +++ b/docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md @@ -0,0 +1,411 @@ +# Praxis mode 2/8: cortex-gateway and the Cortex ingestion endpoint — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Praxis records each request and response row of a gateway request and streams it to Cortex, and Cortex accepts, settles and stores the rows through `POST /v1/ingest`. The endpoint lands with its producer. + +**Architecture:** In `praxis/filters`, a pure module `record` builds rows and lines from plain values (no Praxis types), so its tests need no `HttpFilterContext`. Two thin Praxis filters (`cortex_attribution`, `cortex_observe`) adapt the Praxis hooks to `record` and `parse`. One emitter thread per process streams NDJSON to Cortex. A new binary crate `praxis/server` (`cortex-gateway`) registers the filters with `register_filters!`. In Go, `core/sessionapi/ingest.go` serves `POST /v1/ingest`, and `cmd/cortex` turns it on only when `praxis.enabled` and `listener.bind_loopback_only` are both set. + +**Tech Stack:** Rust 1.96.0 (edition 2024), `praxis-proxy =0.8.0` and `praxis-proxy-filter =0.8.0`, `tokio`, `hyper` 1 (client, http1), `http-body-util`, `base64`, `chrono`, `metrics`, `yaml_serde`. Go 1.26.10. + +**Spec:** `docs/superpowers/specs/2026-11-07-praxis-mode-design.md`, Sections 1, 2, 3 and 5. Issue: #1719. Tracker: #1714. Previous step: #1718 (plan `docs/superpowers/plans/2026-11-09-praxis-mode-1-inference-parser.md`, merged as PR #1741). + +## Decisions this plan takes + +Each one closes a question that the spec or the issue leaves open. + +1. **No Praxis test helper exists outside Praxis** (`praxis_filter::test_utils` is `pub(crate)`), and the spec forbids an `HttpFilterContext` struct literal [T F10]. So the filters stay thin, and all logic is in `record` and `parse`, which take plain values. The filter behavior is tested end to end: the tests start the real `cortex-gateway` binary with a mock origin and a mock ingest endpoint. +2. **The token file path.** Cortex reads it from the env var `CORTEX_GATEWAY_TOKEN_FILE`. The gateway reads it from the `token_file` key of its filter config. The supervisor (#1720) creates the file, sets the env var and writes `gateway.yaml`. In this PR, tests create the file. Cortex never creates it. +3. **The `praxis` config block has only `enabled` in this PR.** `listen` and `egress` land with their callers (#1720, #1722). This PR also adds the two startup refusals (`praxis.enabled` with `mtls`, or without `bind_loopback_only`) and the restart rule for `praxis.*`, because the field lands here. +4. **The ingest counters** are served at `GET /v1/ingest/stats` on the session API, in praxis mode only. The spec names `/stats`, but `/stats` serves `auth.Stats` from plugins and is built before the session API. The route uses the loopback guards of the session API. +5. **`X-Cortex-Attribution` JSON**, base64url without padding. Keys: `agent`, `proc`, `clientProc`, `session`, `source`, `sessionHeader`, `chainSession`, `recordSession`. All are strings, all optional. The Go encoder (`attribution.Wire`) lands with the hop (#1722). A missing or bad header with a valid token gives `Attribution::default()`. Then `session` is empty, and ingestion files the row with `attribution.FiledSession` (the active session, then the default bucket), as a listener does. +6. **Fields the gateway cannot know in v1** stay unset: `identity` (no workload identity on a laptop), `tls` (the TLS bridge state is in Cortex; #1722 decides if the hop carries it), `invocations` (no plugins run), `requestedHost` (no router), `upgradeStripped` (Cortex strips upgrades before the hop), `tunnel`, `tunnelReason`, `dialAddr` (Cortex's own tunnel rows), `a2a`, `mcp` (step 6). The drift test lists each one with this reason. `plugins` is set by ingestion (cost). +7. **`client`, `subagent` and `parentSubagent` are ported to Rust** (`model.ParseUserAgent`, `subagentHeader`), because the end state needs them in Rust. A Go test writes a golden table that the Rust test reads. +8. **Messages of `client_canceled` and `upstream_error` rows** are Rust text (`"client closed the connection"`, `"upstream closed the connection"`). Go puts the Go error text there. Only the message differs. This is a known difference, as the 413 text is not one: the 413 text is copied exactly. +9. **The response-capture guard at ingestion** keeps the request ids of kept request captures until the response line arrives, and removes entries older than 1 hour. A 60 s window would drop the response capture of every long stream. +10. **Tunnel joins** (`requestId → tunnel`, Section 1 "Joins") land with the hop (#1722). This PR has no join to release when a line is dropped as erased. #1722 adds the release at that drop point (`ingest.go`, the `erased` branch). +11. **The replay test.** The "live run" is a direct-mode forward proxy with `inference-parser` and a rate table, over every `claude-code` fixture. A Go test converts its rows and captures to NDJSON lines, as the producer writes them, and commits the file. The same file is the golden for the Rust row builder (Task 10). So one file pins both sides. +12. **The emitter has its own thread** with a current-thread Tokio runtime, started once per process. It does not depend on the Praxis runtime, and `from_config` can run outside a runtime. +13. **`praxis-proxy` without default features**: `features = ["pingora", "admin-api", "forward-proxy"]`. This drops the policy engine and `aws-lc-sys` from the build. OpenSSL is vendored now (spec Section 4, "A standalone binary"), so CI needs no system OpenSSL. +14. **The measurement** (Done-when, last item) is a script and numbers in the PR body, not a CI gate. + +## Global Constraints + +- **Direct mode does not change.** With `praxis.enabled: false` (the default): `/v1/ingest` and `/v1/ingest/stats` answer 404, no erase mark is kept, no token file is read or created, no new error kind appears. Every existing Go test passes with no change to its expectations. +- Line format v1 (spec Section 1). Exact JSON keys: `v` (1), `schemaVersion` (1, `sessionapi.SchemaVersion`), `op` (`"append"`), `requestId`, `startedAt` (RFC 3339 with nanoseconds, UTC), `session`, `event` (SessionEvent wire JSON), `capture`, `cost`. +- `capture` JSON: `{"original": , "forwarded": null, "header": {"Name": ["v"]}, "query": "", "response": {"status", "header", "body", "decoded", "truncated", "ended", "incomplete"}, "dropped": false}`. A request row carries `original`, `header`, `query`. A response row carries `response` only. A dropped capture is exactly `{"dropped": true}`. Header names are canonical (Go `http.CanonicalHeaderKey`). +- `cost` JSON: `{"headers": {...}}`, response rows only, with only these headers when present: `X-Litellm-Response-Cost`, `X-Litellm-Response-Cost-Original`, `X-Litellm-Response-Cost-Discount-Amount`, `X-Litellm-Response-Cost-Margin-Amount`, `X-Litellm-Model-Name`, `Content-Type`. +- Caps: line 64 MiB (`64 << 20`); emitter queue 4096 lines and 256 MiB (`256 << 20`); request body 32 MiB (`32 << 20` = 33554432, `maxRequestBodySize`); buffered decoded response and SSE frame 10 MiB (`parse::MAX_BODY_SIZE`); response capture 10 MiB (`model.MaxResponseCapture`); erase mark 10 minutes. +- Error texts, exact: 413 `"request body of %d bytes is over the 33554432-byte buffer limit"` when `Content-Length` is known, else `"request body over the 33554432-byte buffer limit"`; buffered `"response body over the 10485760-byte buffer limit"`; frame `"single SSE frame over the 10485760-byte frame cap"`. +- Hop headers. The seven `wire.go` headers: `X-Cortex-Agent`, `X-Cortex-Proc`, `X-Cortex-Client-Proc`, `X-Cortex-Session`, `X-Cortex-Source`, `X-Cortex-Session-Header`, `X-Cortex-Chain-Session`. Plus `X-Cortex-Attribution`, `X-Cortex-Request-Id`, `X-Cortex-Probe`, `X-Cortex-Dest`, `X-Cortex-Token`. Before the upstream, remove the first ten and keep `X-Cortex-Dest` and `X-Cortex-Token`. The request capture removes all twelve. Any other `X-Cortex-…` header goes through. +- Error kinds from `X-Cortex-Egress-Error`: `upstream_dns`, `upstream_tls`, `upstream_timeout`, `upstream_refused`. Any other value becomes `upstream_error`. +- Rust lint gate, from `praxis/`: `cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`, `cargo test --locked`. Use the rustup `cargo` (`$HOME/.cargo/bin`), not Homebrew's, so `rust-toolchain.toml` applies. +- Go gate: `GOWORK=off go vet ./...` and `gofmt -l ` in each module touched (`core`, `cmd/cortex`). +- Go is the source of truth for every port. When Rust and Go differ, fix Rust. +- Commits: `git commit -s`, message in ASD-STE100, ending with `Assisted-By: Claude (Anthropic AI) `. No `Co-Authored-By`. PR title `Feat: Build cortex-gateway and the Cortex ingestion endpoint`, body with `Fixes #1719`. + +## Review Focus + +1. **A gzip response whose capture and byte counts must look like direct mode.** Expected: capture `decoded: true`, without `Content-Encoding` and `Content-Length`, `bytesDown` = decoded bytes; a `br` or uncoded response is captured as it came. Test: Task 5 `gzip_response_capture_is_decoded`, `identity_response_capture_is_raw`. +2. **A slow or dead Cortex.** Expected: traffic is never held up; the queue stays under 256 MiB, captures go first. Test: Task 4 `queue_drops_captures_before_rows`, `submit_never_blocks_when_ingest_is_down`. +3. **A client that sends its own hop headers or `X-Cortex-Custom`.** Expected: hop headers never reach the upstream (except Dest and Token), the custom header does. Test: Task 6 `hop_headers_removed_custom_kept`. +4. **An erase while lines are queued.** Expected: the erased session does not come back; a request that started after the erase starts a new session. Test: Task 8 `TestIngest_EraseDropsOlderLines`, `TestIngest_ClearDropsOlderLines`. +5. **A producer that sends its own cost record or an unredacted credential.** Expected: Cortex removes the cost record and settles again, and removes the credential again. Test: Task 8 `TestIngest_ProducerCostIsReplaced`, `TestIngest_CaptureRedactedAgain`. + +--- + +## File Structure + +``` +praxis/ + Cargo.toml members = ["filters", "server"] + Cargo.lock + README.md + the gateway, how to run it, the decisions above in one line each + filters/ + Cargo.toml + praxis-proxy-filter, async-trait, bytes, tokio, hyper, http-body-util, + base64, chrono, metrics, yaml_serde (as serde_yaml) + src/lib.rs pub mod parse; pub mod record; pub mod filters; + src/record/mod.rs pub mod attribution, client, emitter, line, redact, row; + src/record/redact.rs port of core/redact headers.go (Task 2) + src/record/client.rs port of model.ParseUserAgent and subagentHeader (Task 3) + src/record/emitter.rs Emitter (Task 4) + src/record/line.rs Line, Event, Capture, Cost wire types; drift test (Task 5) + src/record/attribution.rs Attribution, decode, token_matches, hop header lists (Task 5) + src/record/row.rs Exchange: the row builder (Task 5) + src/filters/mod.rs pub mod attribution, observe; + src/filters/attribution.rs CortexAttribution (Task 6) + src/filters/observe.rs CortexObserve (Task 6) + tests/row_parity.rs the replay NDJSON against the Rust builder (Task 10) + server/ + Cargo.toml package cortex-gateway, bin cortex-gateway + src/main.rs register_filters!, run the server (Task 1, filled in Task 6) + tests/gateway.rs end-to-end tests with the real binary (Task 6) +core/config/config.go, validate.go Praxis block and refusals (Task 7) +core/reloader/reloader.go praxis.* needs a restart (Task 7) +core/model/client_golden_test.go writes core/model/testdata/user-agent.golden.json (Task 3) +core/sessionapi/ingest.go, ingest_test.go, erase_marks.go (Task 8) +cmd/cortex/ingest.go, ingest_test.go, main.go (Task 9) +core/listener/forwardproxy/ingest_replay_test.go (Task 10) +core/sessionapi/testdata/ingest/replay.ndjson (Task 10, generated) +scripts/dev/praxis-measure.sh (Task 11) +.github/workflows/praxis.yaml, docs/session-api.md, CLAUDE.md +``` + +--- + +### Task 1: Scaffold the workspace, the server crate and CI + +Depends on: none + +All later Rust tasks only fill files that this task creates, so parallel tasks do not touch `Cargo.toml`, `Cargo.lock` or a `mod` list. + +**Files:** +- Modify: `praxis/Cargo.toml`, `praxis/filters/Cargo.toml`, `praxis/filters/src/lib.rs`, `praxis/Cargo.lock`, `praxis/README.md`, `.github/workflows/praxis.yaml`, `CLAUDE.md` (Layout row `praxis/`: add "and the `cortex-gateway` binary") +- Create: `praxis/filters/src/record/{mod,attribution,client,emitter,line,redact,row}.rs`, `praxis/filters/src/filters/{mod,attribution,observe}.rs` (each a one-line `//!` doc comment; `emitter.rs` also holds `Queued`, below), `praxis/server/Cargo.toml`, `praxis/server/src/main.rs` + +**Interfaces:** +- Produces: the module tree in File Structure. `praxis/server` is package `cortex-gateway` (`publish = false`, edition 2024, `rust-version = "1.96"`), with `[[bin]] name = "cortex-gateway"`. +- Produces, in `record/emitter.rs`, the one type that Tasks 4 and 5 share, so they can run at the same time: `pub struct Queued { pub row: Vec, pub with_capture: Option> }`. These are two serializations of one line, without and with its capture, each ending in `\n`. `row` holds `"capture":{"dropped":true}` when the line has a capture, and no `capture` key otherwise. + +- [ ] **Step 1: Add the dependencies.** Filters crate: `praxis-proxy-filter = { version = "=0.8.0", default-features = false }`, `async-trait = "0.1"`, `bytes = "1"`, `tokio = { version = "1", features = ["rt", "net", "sync", "time", "io-util", "macros"] }`, `hyper = { version = "1", features = ["client", "http1"] }`, `http-body-util = "0.1"`, `base64 = "0.22"`, `chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }`, `metrics = "0.24"`, `serde_yaml = { package = "yaml_serde", version = "0.10.7" }`. Server crate: `praxis-proxy = { version = "=0.8.0", default-features = false, features = ["pingora", "admin-api", "forward-proxy"] }`, `cortex-praxis-filters = { path = "../filters" }`, `openssl = { version = "0.10", features = ["vendored"] }`. If cargo reports a `links = "zstd"` conflict, raise the filters crate's `zstd` to the version Praxis uses (`0.14`); the API it uses does not change. +- [ ] **Step 2: Write `server/src/main.rs`**: install the crypto provider (`praxis::install_crypto_provider()`), read the config path from `-c ` or `PRAXIS_CONFIG`, load it (`praxis::load_config`), init tracing, and call `praxis::run_server_with_registry(config, custom_registry(), …)` with an empty `praxis_filter::register_filters! {}` for now. Copy the shape of Praxis's own `crates/server/src/main.rs` at v0.8.0. +- [ ] **Step 3: Build.** `cargo build --locked` after `cargo generate-lockfile` → OK. Record the cold build time and `du -sh target`. +- [ ] **Step 4: Update `praxis.yaml`.** `timeout-minutes: 45`; target cap 3072 MB in place of 2048; add `core/sessionapi/ingest*.go`, `core/sessionapi/testdata/ingest/**`, `core/redact/**`, `core/model/testdata/**`, `core/pipeline/testdata/session-event.golden.json` to both `paths` lists. Keep the blocking rule. +- [ ] **Step 5: Lint.** fmt, clippy, test → clean. +- [ ] **Step 6: Commit.** "Feat: Add the cortex-gateway crate and the record module scaffold". + +### Task 2: Port credential removal (Rust) + +Depends on: Task 1 + +**Files:** +- Modify: `praxis/filters/src/record/redact.rs` + +**Interfaces:** +- Produces: `pub fn credential_name(name: &str) -> bool`, `pub fn headers(h: &http::HeaderMap) -> http::HeaderMap` (a copy without credential headers), `pub fn query(raw: &str) -> String`. Ports of `core/redact/headers.go`, same word list, same split on `-` and `_`, same rule that a query that does not parse is dropped whole. + +- [ ] **Step 1: Write the failing tests**, one per Go test in `core/redact/headers_test.go`, with the same inputs and expected outputs: `headers_drops_credentials_and_copies`, `query_drops_credential_parameters`, `headers_drops_cloud_and_proxy_credentials`, `headers_and_query_drop_names_with_a_credential_word`, `credential_name_documented_examples`. Copy each Go case table verbatim. +- [ ] **Step 2: Run.** `cargo test -p cortex-praxis-filters redact` → FAIL. +- [ ] **Step 3: Implement.** For `query`, "does not parse" means what Go's `url.ParseQuery` refuses: a bad `%` escape or a `;`. Keep kept pairs in order and in their original escaping. +- [ ] **Step 4: Run** → PASS; fmt and clippy clean. +- [ ] **Step 5: Commit.** "Feat: Port core/redact header and query credential removal to Rust". + +### Task 3: Port the user-agent and subagent rules (Rust, with a Go golden) + +Depends on: Task 1 + +**Files:** +- Create: `core/model/client_golden_test.go`, `core/model/testdata/user-agent.golden.json` +- Modify: `praxis/filters/src/record/client.rs` + +**Interfaces:** +- Produces: `pub struct Client` (serde, camelCase, the JSON of `model.EventClient` with its omit rules), `pub fn parse_user_agent(ua: &str) -> Option` (port of `model.ParseUserAgent`, `core/model/client.go:234-590` with `capUA`, `productVersion`, `trailingKnownClient`), `pub fn subagent_header(v: Option<&str>) -> String` (port of `pipeline.subagentHeader`: trim SP and HTAB only, then refuse an id with control runes or longer than `maxClientLen`). Header names `X-Claude-Code-Agent-Id`, `X-Claude-Code-Parent-Agent-Id`. +- Consumes: `parse::inference::text` (`is_control_rune`, from #1718). + +- [ ] **Step 1: Write the Go golden test** `TestParseUserAgent_Golden`: a list of user-agent strings → `ParseUserAgent` JSON, written with `-update` and compared otherwise. Inputs: every input string in `core/model/client_test.go` cases, every `User-Agent` in `core/plugins/inferenceparser/testdata/*/*/request.headers`, plus `""`, a 600-byte string and one with a control rune. Also a `subagent` section: header value → `subagentHeader` result (from the cases in `core/pipeline/subagent_test.go`). Run with `-update`, then without → PASS. +- [ ] **Step 2: Write the failing Rust test** `user_agent_golden_matches_go`: read `../../core/model/testdata/user-agent.golden.json`; for each case, `serde_json::to_value(parse_user_agent(ua))` equals the Go JSON; for each subagent case, `subagent_header` equals the Go string. Run → FAIL. +- [ ] **Step 3: Implement** the port, function by function. +- [ ] **Step 4: Run** → PASS; Go `GOWORK=off go test ./model/ -run Golden` from `core` → PASS. +- [ ] **Step 5: Commit.** "Feat: Port the user-agent and subagent rules to Rust". + +### Task 4: The emitter (Rust) + +Depends on: Task 1 + +**Files:** +- Modify: `praxis/filters/src/record/emitter.rs` + +**Interfaces:** +- Produces: + - Consumes `Queued` (Task 1). Task 5 builds it. + - `pub struct Emitter`; `pub fn Emitter::start(ingest_addr: std::net::SocketAddr, token: String) -> Arc` starts the thread; `pub fn submit(&self, q: Queued)` never blocks and never fails; `pub fn stats(&self) -> EmitterStats`. + - `pub struct EmitterStats { pub rows_emitted: u64, pub rows_dropped: u64, pub captures_dropped_line_cap: u64, pub captures_dropped_queue_bytes: u64, queued_lines: usize, queued_bytes: usize }` (all `pub`). + - Constants `MAX_QUEUE_LINES = 4096`, `MAX_QUEUE_BYTES = 256 << 20`, `MAX_LINE = 64 << 20`. + - `pub fn global(ingest_addr, token) -> Arc`: one emitter per process (a `OnceLock`); a second call with other arguments returns the first one and logs a warning. + +`submit` rules, in this order: +1. `with_capture` longer than `MAX_LINE` → use `row`, count `captures_dropped_line_cap`. +2. `queued_lines >= MAX_QUEUE_LINES` → drop, count `rows_dropped`. +3. Chosen bytes would take `queued_bytes` over `MAX_QUEUE_BYTES` and the chosen bytes are `with_capture` → use `row`, count `captures_dropped_queue_bytes`. +4. Still over → drop, count `rows_dropped`. +5. Else queue it and wake the sender. + +The sender: a thread with a current-thread Tokio runtime. It connects with `TcpStream`, `hyper::client::conn::http1::handshake`, and sends `POST /v1/ingest` with `Authorization: Bearer `, `Content-Type: application/x-ndjson` and a streaming body (`http_body_util::StreamBody` over a `tokio::sync::mpsc` channel of `Bytes`). It moves lines from the queue to the body. On any error, it counts the line in flight as `rows_dropped`, waits (100 ms, doubling to 5 s, reset on success) and reconnects. `rows_emitted` counts lines written to the body. Each counter also increments the `metrics` counters `cortex_rows_emitted_total`, `cortex_rows_dropped_total`, `cortex_captures_dropped_total{reason="line_cap"|"queue_bytes"}`. + +- [ ] **Step 1: Write the failing tests** (a mock ingest server on `127.0.0.1:0` in the test, reading lines): + - `lines_arrive_in_order_with_token`: 3 lines → the server reads them in order, with `Authorization: Bearer t`. + - `capture_over_line_cap_is_dropped_row_kept`: `with_capture` of `MAX_LINE + 1` bytes → the server reads `row`; `captures_dropped_line_cap == 1`. + - `queue_drops_captures_before_rows`: no server listening (a closed port); submit 30 lines with a 10 MiB `with_capture` each → `queued_bytes <= MAX_QUEUE_BYTES`, `captures_dropped_queue_bytes > 0`, `rows_dropped == 0`. + - `line_limit_drops_whole_lines`: no server; submit 4097 small lines → `rows_dropped == 1`. + - `submit_never_blocks_when_ingest_is_down`: no server; 1000 submits finish in under 1 s. + - `reconnects_after_ingest_restarts`: the server closes the connection after line 1, then accepts again → line 3 arrives. +- [ ] **Step 2: Run** `cargo test -p cortex-praxis-filters emitter` → FAIL. +- [ ] **Step 3: Implement** with a `std::sync::Mutex>` plus a `tokio::sync::Notify`, so `submit` takes only a short lock. +- [ ] **Step 4: Run** → PASS; fmt and clippy clean. +- [ ] **Step 5: Commit.** "Feat: Add the bounded NDJSON emitter for cortex-gateway". + +### Task 5: The row builder (Rust) + +Depends on: Task 2, Task 3 + +**Files:** +- Modify: `praxis/filters/src/record/{line,attribution,row}.rs` + +**Interfaces:** +- Consumes: `record::emitter::Queued` (Task 1), `record::redact::{headers, query}` (Task 2), `record::client::{parse_user_agent, subagent_header, Client}` (Task 3), `parse::inference::InferenceObserver`, `parse::decode::{Coding, Decoder, decode_request_body}`, `parse::sse::FrameReader`, `parse::MAX_BODY_SIZE` (#1718). The mislabeled-SSE rule (`status / 100 == 2 && host == "chatgpt.com" && path == "/backend-api/codex/responses"`) moves here as `is_known_mislabeled_sse(status: u16, host: &str, path: &str) -> bool`, with the `mislabeled` test that #1741 removed (spec Section 3). +- Produces, in `record::attribution`: + - `pub struct Attribution` (Decision 5 keys, `#[serde(default, rename_all = "camelCase")]`). + - `pub fn decode(header: Option<&str>) -> Attribution` (bad base64url or bad JSON → default). + - `pub fn token_matches(given: Option<&[u8]>, token: &[u8]) -> bool` (constant time; false on empty token). + - `pub const UPSTREAM_REMOVE: [&str; 10]`, `pub const CAPTURE_REMOVE: [&str; 12]` (Global Constraints). +- Produces, in `record::line`: `Line`, `Event`, `EventError { kind, code, message }`, `Capture`, `ResponseCapture`, `Cost`, all `Serialize`, with the JSON of Global Constraints and of `core/pipeline/testdata/session-event.golden.json` (camelCase, Go's omit rules, `durationMs` and `ttfbMs` as integer milliseconds). `pub fn Line::queued(&self) -> emitter::Queued` writes the two serializations. +- Produces, in `record::row`: + - `pub struct Exchange`, one per request. + - `pub fn Exchange::new(method: &str, host: &str, path_and_query: &str, headers: &http::HeaderMap, attribution: Attribution, request_id: String, started: std::time::SystemTime, start: std::time::Instant) -> Exchange`. + - `pub fn on_request_body(&mut self, body: &[u8]) -> Line`: the request row. It parses with `InferenceObserver::on_request` (after `decode_request_body`), sets `bytesUp = body.len()`, `client`, `subagent`, `parentSubagent`, `proc`, `clientProc`, `sessionHeader`, and carries the capture only when `inference` is set: `original` = body, `header` = `redact::headers` of the request headers without `CAPTURE_REMOVE`, `query` = `redact::query`. + - `pub fn on_response_head(&mut self, status: u16, headers: &mut http::HeaderMap) -> Result<(), Abort>`. It records the TTFB, takes and removes `X-Cortex-Egress-Error` (Global Constraints mapping), and picks the SSE mode (`text/event-stream`, or `is_known_mislabeled_sse`). It returns `Err(Abort::BufferedOverCap)` for a non-SSE response with no `Content-Encoding` and a `Content-Length` over `MAX_BODY_SIZE`. + - `pub fn on_response_chunk(&mut self, chunk: &[u8]) -> Result<(), Abort>`: decodes for parsing, counts and captures, and returns `Err(Abort::FrameOverCap)` or `Err(Abort::BufferedOverCap)` at a cap. + - `pub fn on_response_end(&mut self) -> Line`: the response row (`ended: true`). + - `pub fn on_abort(&mut self, a: Abort) -> Line`: the cap rows. + - `pub fn deferred(&mut self, status: u16, downstream_gone: bool, upstream_gone: bool) -> Vec`: the deferred rows. It first gives the request row when none was built. + - `pub enum Abort { BufferedOverCap, FrameOverCap }`. + +Response row rules (each from direct mode, cited in spec Sections 1 and 3): +- `inference` = `InferenceObserver::extension()` (the request and response facts together), after `settle_served_model` with the response headers. +- Capture only when the request capture was kept. When `Content-Encoding` is `gzip` alone: body = the decoder output, `decoded: true`, without `Content-Encoding` and `Content-Length` in the header. Otherwise: wire bytes, `decoded: false`, headers kept. Header = `redact::headers`. Up to 10 MiB, `truncated` past it. `incomplete = !ended`. +- `bytesDown` = decoded bytes for gzip, wire bytes otherwise. +- `cost` = the six headers when present. +- Kinds: `Abort::BufferedOverCap` → `statusCode` 502, `proxy_error`, `code` = the upstream status as a decimal string, the buffered text. `Abort::FrameOverCap` → `statusCode` = the upstream status, `proxy_error`, the frame text. `deferred`: status 413 → `statusCode` 413, `proxy_error`, the 413 text with the request's `Content-Length`; `downstream_gone` or status 499 → `statusCode` 499, `client_canceled`, Decision 8 text; `upstream_gone` → `statusCode` = status, `upstream_error`, Decision 8 text; otherwise `statusCode` = status, `gateway_refused`. + +- [ ] **Step 1: Write the failing drift test** in `line.rs`: `every_session_event_field_is_set_or_listed`. Take the top-level keys of `../../core/pipeline/testdata/session-event.golden.json`, and the keys of `serde_json::to_value(full_event())`, where `full_event()` is a test helper with every field set. Assert golden ⊆ rust ∪ `NOT_SET_IN_PRAXIS_MODE`, and rust ⊆ golden. The list is `sessionId`, `seq`, `at` ("the store sets it") and the Decision 6 fields, with each reason as a comment. +- [ ] **Step 2: Write the failing row tests** in `row.rs`, on the Claude Code fixture `core/plugins/inferenceparser/testdata/claude-code/` (fed whole and in 4096-byte chunks): + - `request_row_has_inference_and_capture` + - `capture_headers_have_no_hop_or_credential_headers`: `Authorization` and `X-Cortex-Token` absent, `X-Cortex-Custom` present. + - `non_inference_request_has_no_capture`: `POST /oauth/token`. + - `gzip_response_capture_is_decoded` + - `identity_response_capture_is_raw` + - `br_response_capture_is_raw`: `decoded: false`, headers kept. + - `egress_error_header_becomes_kind_and_is_removed` + - `unknown_egress_error_is_upstream_error` + - `buffered_over_cap_by_content_length_aborts_at_head` + - `chunked_over_cap_row_is_502_proxy_error`: `code: "200"`. + - `frame_over_cap_row_keeps_upstream_status` + - `deferred_413_gives_request_and_response_rows`: the exact text with `Content-Length` 34603008. + - `deferred_499_is_client_canceled` + - `deferred_upstream_gone_is_upstream_error` + - `deferred_other_is_gateway_refused` + - `mislabeled_sse_streams_only_on_2xx` + - `attribution_decode_bad_input_is_default` + - `token_matches_rules` + - `line_with_capture_over_cap_has_dropped_row`: a request with 30 MiB of ASCII message text. `Line::queued().with_capture` is over 64 MiB, and `row` parses with `capture == {"dropped": true}` and keeps `inference`. +- [ ] **Step 3: Run** `cargo test -p cortex-praxis-filters record` → FAIL. +- [ ] **Step 4: Implement** `line.rs`, `attribution.rs`, `row.rs`. +- [ ] **Step 5: Run** → PASS; fmt and clippy clean. +- [ ] **Step 6: Commit.** "Feat: Add the row builder for praxis-mode rows". + +### Task 6: The filters, the binary and its end-to-end tests (Rust) + +Depends on: Task 4, Task 5 + +**Files:** +- Modify: `praxis/filters/src/filters/{attribution,observe}.rs`, `praxis/server/src/main.rs` +- Create: `praxis/server/tests/gateway.rs`, `praxis/server/tests/testdata/gateway.yaml.tmpl` + +**Interfaces:** +- Consumes: `record::attribution`, `record::row::Exchange`, `record::emitter::global` (Tasks 4, 5). +- Produces: filter names `cortex_attribution` (config `token_file`) and `cortex_observe` (config `token_file`, `ingest` as `host:port`), registered in `main.rs` with `register_filters! { http "cortex_attribution" => CortexAttribution::from_config, http "cortex_observe" => CortexObserve::from_config }`. A private newtype `TokenValid(Attribution)` goes into `ctx.extensions`. + +Filter behavior: +- `CortexAttribution::on_request`: when `token_matches(X-Cortex-Token)` is false → `Reject(Rejection::status(403))`. Else insert `TokenValid(decode(X-Cortex-Attribution))` (unless `X-Cortex-Probe` is present: then insert nothing and continue), and push `UPSTREAM_REMOVE` to `ctx.request_headers_to_remove`. +- `CortexObserve`: does nothing when `TokenValid` is absent. `request_body_access` → `ReadOnly`, `request_body_mode` → `StreamBuffer`, `response_body_access` → `ReadOnly`, `response_body_mode` → `Stream`. It keeps its `Exchange` in `ctx.insert_filter_state`. `on_request` builds the `Exchange` (request id from `X-Cortex-Request-Id`, else `ctx.request_id()`). `on_request_body` at end of stream submits the request row. `on_response` calls `on_response_head` on `ctx.response_header` (an `Abort` there → `Reject(Rejection::status(502))`, and the row is submitted). `on_response_body` feeds chunks (an `Abort` → submit `on_abort` and return `Err`, which aborts the connection), and at end of stream submits `on_response_end`. `emit_deferred_record(ctx, status)` submits `deferred(status, …)` and returns true, unless the exchange already submitted its response row (then false). `downstream_gone` and `upstream_gone` come from `ctx.upstream_reached` and `ctx.stream_termination()`; Step 3 sets the exact mapping from what the tests see. + +The test harness: each test writes `gateway.yaml` from the template into a temp dir and starts `env!("CARGO_BIN_EXE_cortex-gateway")` with it. The YAML has one listener on a free port; the chain `cortex_attribution`, `cortex_observe`, `access_log`, router to a cluster on a mock origin; `body_limits.max_request_bytes: 33554432`, `max_response_bytes: null`; `insecure_options.allow_unbounded_body: true`, `allow_private_upstreams: true`, `allow_private_endpoints: true`. A mock origin (std `TcpListener`, one thread per connection) and a mock ingest (reads NDJSON lines into a channel) run in the test. A test waits for readiness by polling the port. + +- [ ] **Step 1: Write the failing tests** in `server/tests/gateway.rs`: + - `tokenless_request_gets_403_and_no_line`: the mock origin sees nothing, and the ingest sees no line in 1 s. + - `probe_request_records_nothing` + - `hop_headers_removed_custom_kept`: the origin sees `X-Cortex-Dest`, `X-Cortex-Token` and `X-Cortex-Custom`, and none of the other ten. + - `inference_request_gives_request_and_response_lines`: the Claude Code fixture → two lines with one `requestId`, `session` = `recordSession`, response row with tokens and `cost.headers`. + - `gzip_sse_streams_as_frames_and_wire_bytes_unchanged`: the client receives the gzip bytes unchanged, and receives the first frame before the origin sends the last one. + - `mid_body_abort_is_client_canceled`: the client sends half of a body with `Content-Length`, then closes → a response row with `client_canceled`, 499. + - `body_over_limit_is_413_proxy_error`: a 33 MiB body → the client gets 413; lines: request row and response row with `proxy_error` and the 413 text. + - `buffered_over_cap_with_content_length_is_502`: an uncoded 12 MB JSON response → the client gets 502; the row is 502 `proxy_error`. + - `frame_over_cap_aborts_stream` + - `egress_error_header_becomes_kind`: the origin answers 502 with `X-Cortex-Egress-Error: upstream_dns` → the client does not get the header; the row kind is `upstream_dns`. An origin `X-Cortex-Upstream-Error` header reaches the client. + - `without_access_log_no_deferred_row`: the same chain without `access_log` and a mid-body abort → no response line. This guards the upstream coupling (praxis#1228). +- [ ] **Step 2: Run** `cargo test -p cortex-gateway` → FAIL. +- [ ] **Step 3: Implement** the two filters and register them. +- [ ] **Step 4: Run** → PASS; fmt and clippy clean. When Praxis gives a status or termination cause that the `deferred` mapping does not expect, change the adapter in `observe.rs`, not the kinds in Task 5. +- [ ] **Step 5: Update `praxis/README.md`**: the binary, how to run the tests, and decisions 1, 2, 5 and 12 in one line each. +- [ ] **Step 6: Commit.** "Feat: Add the cortex_attribution and cortex_observe filters and the cortex-gateway binary". + +### Task 7: The `praxis` config block (Go) + +Depends on: none + +**Files:** +- Modify: `core/config/config.go`, `core/config/validate.go`, `core/reloader/reloader.go`, `CLAUDE.md` (the "Needs a restart" list: add `praxis`) +- Test: `core/config/praxis_test.go`, `core/reloader/reloader_test.go` + +**Interfaces:** +- Produces: `type PraxisConfig struct { Enabled bool \`yaml:"enabled" json:"enabled"\` }`; field `Praxis *PraxisConfig \`yaml:"praxis,omitempty" json:"praxis,omitempty"\`` on `Config`; `func (c *Config) PraxisEnabled() bool` (false on a nil block). + +- [ ] **Step 1: Write the failing tests:** + - `TestPraxis_DefaultOff`: no block → `PraxisEnabled()` false. + - `TestPraxis_NeedsLoopbackOnly`: `praxis.enabled` without `listener.bind_loopback_only` → `Validate` error naming `listener.bind_loopback_only`. + - `TestPraxis_RefusedWithMTLS`: with `mtls` set → error naming `mtls`. + - `TestValidateReloadable_PraxisNeedsRestart`: a change of `praxis.enabled` → the diff names `praxis.*`. +- [ ] **Step 2: Run** `GOWORK=off go test ./config/ ./reloader/ -run 'Praxis'` from `core` → FAIL. +- [ ] **Step 3: Implement**: a `validatePraxis(cfg)` called from `Validate`, and one `reflect.DeepEqual(active.Praxis, next.Praxis)` branch in `validateReloadable`. +- [ ] **Step 4: Run** → PASS; `go vet ./...` and `gofmt -l config reloader` clean. +- [ ] **Step 5: Commit.** "Feat: Add the praxis.enabled config switch". + +### Task 8: `POST /v1/ingest` (Go) + +Depends on: none + +**Files:** +- Create: `core/sessionapi/ingest.go`, `core/sessionapi/erase_marks.go`, `core/sessionapi/ingest_test.go` +- Modify: `core/sessionapi/server.go` (register the routes when ingest is configured), `docs/session-api.md` (a section for `POST /v1/ingest` and `GET /v1/ingest/stats`: auth, guards, line format v1, refusal reasons, the counters) + +**Interfaces:** +- Produces: + - `type Ingest struct { Token string; Rates pricing.Resolver }` + - `func WithIngest(in Ingest) Option` + - `POST /v1/ingest` and `GET /v1/ingest/stats`, registered only when `WithIngest` was given a non-empty `Token`. + - `type IngestStats struct { Lines uint64 \`json:"ingest_lines_total"\`; Refused map[string]uint64 \`json:"ingest_refused_total"\`; CapturesDropped uint64 \`json:"ingest_captures_dropped_total"\` }`. + - Refusal reasons: `bad_json`, `version`, `schema`, `op`, `line_cap`, `erased`. +- Consumes: `session.Store.AppendCapture`, `session.Store.AddRecorder`, `attribution.FiledSession`, `settle.SettleInput`, `settle.NewRecord`, `inference.SettleServedModel`, `event.Key`, `event.PluginName`, `redact.Headers`, `redact.Query`, `httpx.LoopbackHost`. + +Handler rules, in order: +1. A non-loopback `Host`, or an `Origin` header → 403 (the `clearRefusal` texts). +2. No `Authorization: Bearer ` (`subtle.ConstantTimeCompare`) → 401. +3. Read the body line by line with a `bufio.Reader` and a 64 MiB cap per line. A line over the cap is discarded up to its `\n` and refused as `line_cap`. The connection stays open after any refusal. The answer, when the body ends, is 204. +4. For each line: unmarshal (`bad_json`); `v != 1` → `version`; `schemaVersion != SchemaVersion` → `schema`; `op != "append"` → `op`. +5. `sid := attribution.FiledSession(line.Session, store.ActiveSession)`. When the erase marks hold a mark for `sid` (or a clear) later than `startedAt` → `erased`. (#1722 releases the tunnel join here: Decision 10.) +6. Remove `Plugins["cost"]` and `Plugins["litellm-budget-track"]`. Zero `Seq` and `SessionID`. Set `At = time.Now()`. +7. A response row (`Phase == SessionResponse`): `inference.SettleServedModel(ev.Inference, hdr)`, then `settle.SettleInput(settle.Input{Host: ev.Host, Path: ev.HTTPPath, Inference: ev.Inference, ResponseHeaders: hdr}, Rates)`, with `hdr` built from `cost.headers`. When the result is `Priced`, `HasPrompt` or has a `RejectedReason`, write `json.Marshal(settle.NewRecord(s, nil))` to `Plugins[event.Key]` and `Plugins[event.PluginName]`. This is the gate of `inferenceparser/cost.go`. +8. Capture: `dropped` → count `CapturesDropped`, no capture. No `ev.Inference` → no capture. A request capture: redact `Header` and `Query` again, keep it, and remember `requestId`. A response capture: keep it only when `requestId` is remembered (then forget it), with `Header` redacted again. Entries older than 1 hour are removed when a response line is read (Decision 9). +9. `store.AppendCapture(sid, ev, capture)`; count `Lines`. + +`erase_marks.go`: `type eraseMarks struct` implements `session.Recorder` (a no-op `Record`), `session.Deleter` and `session.Clearer`. It keeps the id → time and the last clear time for 10 minutes. `func (m *eraseMarks) erasedAfter(sid string, startedAt time.Time) bool`. It has an injectable clock. It is registered with `store.AddRecorder` in `New`, only when `WithIngest` was given. + +- [ ] **Step 1: Write the failing tests** (a `session.Store` with a capture recorder, as in `forwardproxy/capture_test.go`): + - `TestIngest_NotRegisteredWithoutOption`: `POST /v1/ingest` and `GET /v1/ingest/stats` → 404, and the store has no `eraseMarks` recorder. + - `TestIngest_Unauthorized`: no token, wrong token → 401. + - `TestIngest_RefusesOriginAndForeignHost` → 403. + - `TestIngest_RefusalsCountedConnectionStays`: one body with a bad JSON line, `v: 2`, `schemaVersion: 2`, `op: "pair"`, a line over 64 MiB, then a good line → the good row is stored; `/v1/ingest/stats` counts each reason once. + - `TestIngest_StoreAssignsSeqAndAt`: a producer's `seq: 99` and an old `at` → `seq` 1, `at` within 1 s of now. + - `TestIngest_ProducerCostIsReplaced`: a response row with `plugins.cost` of 999 USD and `cost.headers` `X-Litellm-Response-Cost: 0.01` → `plugins.cost` is the settled 0.01 record, in both keys. + - `TestIngest_CaptureRedactedAgain`: an `Authorization` header and `?api_key=` in a capture → both removed. + - `TestIngest_CaptureOnlyForInference` + - `TestIngest_DroppedCaptureKeepsRow` + - `TestIngest_NoResponseCaptureWithoutRequestCapture` + - `TestIngest_EraseDropsOlderLines`: append, `DELETE /v1/sessions/{id}`, a line with `startedAt` before the erase → refused `erased` and the session does not come back; a line with `startedAt` after → a new session. + - `TestIngest_ClearDropsOlderLines`: the same with `DELETE /v1/sessions`. + - `TestIngest_EraseMarkExpires`: with the clock 11 minutes on, the older line is kept. + - `TestIngest_EmptySessionIsFiled`: `session: ""` → filed under the active session. +- [ ] **Step 2: Run** `GOWORK=off go test ./sessionapi/ -run Ingest` from `core` → FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run** → PASS; also the whole `./sessionapi/ ./session/...` suite; `go vet ./...` and `gofmt -l sessionapi` clean. +- [ ] **Step 5: Commit.** "Feat: Add the praxis-mode ingestion endpoint to the session API". + +### Task 9: Turn ingestion on in `cmd/cortex` (Go) + +Depends on: Task 7, Task 8 + +**Files:** +- Create: `cmd/cortex/ingest.go`, `cmd/cortex/ingest_test.go` +- Modify: `cmd/cortex/main.go` (the `sessionapi.New` call at about line 1194) + +**Interfaces:** +- Consumes: `config.Config.PraxisEnabled` (Task 7), `sessionapi.WithIngest`, `sessionapi.Ingest` (Task 8). +- Produces: `func ingestOptions(cfg *config.Config, rates pricing.Resolver, getenv func(string) string, readFile func(string) ([]byte, error)) ([]sessionapi.Option, error)`. It returns nil options when `!cfg.PraxisEnabled() || !cfg.Listener.BindLoopbackOnly`. Otherwise it reads the token from the file named by `CORTEX_GATEWAY_TOKEN_FILE`, trims white space, and returns `WithIngest{Token, rates}`. It returns an error when the variable is unset, the file cannot be read, or the token is empty. `main.go` calls `fatalf` on the error and appends the options to the `sessionapi.New` call, with `pricingRegistry` as `rates`. + +- [ ] **Step 1: Write the failing tests:** + - `TestIngestOptions_DirectModeNone`: praxis off → no options, and neither `getenv` nor `readFile` is called. + - `TestIngestOptions_NeedsTokenFile`: praxis on, variable unset → error naming `CORTEX_GATEWAY_TOKEN_FILE`. + - `TestIngestOptions_EmptyToken` → error. + - `TestIngestOptions_ReadsToken`: `"abc\n"` → one option. A server built with it accepts `Bearer abc`. +- [ ] **Step 2: Run** `GOWORK=off go test -run IngestOptions .` from `cmd/cortex` → FAIL. +- [ ] **Step 3: Implement** and wire it into `main.go`. +- [ ] **Step 4: Run** → PASS; `GOWORK=off go test ./...` from `cmd/cortex` → PASS; vet and gofmt clean. +- [ ] **Step 5: Commit.** "Feat: Turn on ingestion in cortex when praxis mode is on". + +### Task 10: The replay test and the row parity test + +Depends on: Task 5, Task 8 + +**Files:** +- Create: `core/listener/forwardproxy/ingest_replay_test.go` (package `forwardproxy_test`, so it can import `sessionapi`), `core/sessionapi/testdata/ingest/replay.ndjson`, `praxis/filters/tests/row_parity.rs` + +**Interfaces:** +- Consumes: `sessionapi.WithIngest` (Task 8); `record::row::Exchange`, `record::line::Line` (Task 5). + +- [ ] **Step 1: Write the Go test** `TestIngestReplay_MatchesLiveRun`: + - **Live run:** a forward proxy with `inference-parser` and a fixed `pricing.Table`, a store with a usage aggregator and a capture recorder, and a mock origin that answers each `claude-code` fixture with its response file and headers, plus `X-Litellm-Response-Cost` on half the cases. One request per fixture, in fixture order, all with one session header. + - **Lines:** convert each stored event, with its capture, into a v1 line (`startedAt` = the event's `At`, `cost.headers` = the cost headers the origin sent). With `-update`, write them to `testdata/ingest/replay.ndjson`. Otherwise read that file. + - **Replay:** post the file to a second session API with `WithIngest` and an empty store with its own aggregator. + - **Compare:** `/v1/sessions/{id}` events field by field without `at`; the `/v1/sessions` summaries without their time fields; the `/v1/usage` totals (tokens, cost, requests) without the buckets (spec Section 5, [DR 4]). +- [ ] **Step 2: Run** with `-update`, then without → PASS. If they differ, fix ingestion (Task 8 code), not the comparison. +- [ ] **Step 3: Write the Rust test** `row_parity_matches_replay`: for each request and response line in `replay.ndjson`, build the same exchange from the same fixture files with `Exchange` (the headers from `request.headers`, the response fed in 4096-byte chunks), and compare `event` and `capture` JSON. Leave out `durationMs`, `ttfbMs`, `requestId` and `plugins` (ingestion owns it). A known difference is allowed only for the captured request headers: Go captures the agent's headers at entry (spec Section 1, "Captures", [F 2]). +- [ ] **Step 4: Run** `cargo test -p cortex-praxis-filters --test row_parity` → PASS. If it fails, fix Rust. +- [ ] **Step 5: Commit.** "Test: Pin praxis-mode rows against a direct-mode replay". + +### Task 11: Measure both processes + +Depends on: Task 6, Task 9 + +**Files:** +- Create: `scripts/dev/praxis-measure.sh` + +- [ ] **Step 1: Write the script.** It starts a scratch Cortex (a temp `HOME`, never the shared `:47600` proxy: other ports, `praxis.enabled: true`, `bind_loopback_only: true`, and `CORTEX_GATEWAY_TOKEN_FILE`), `cortex-gateway` with a generated `gateway.yaml`, and a Python 3 stdlib mock origin that answers with the largest `claude-code` fixture response. It sends N requests (default 200, alternating 7 MiB and 12 MiB request bodies, two `X-Claude-Code-Agent-Id` values for subagents) to the gateway with the token. Then it runs the same traffic through a direct-mode scratch Cortex. It prints, for each process: CPU time (`ps -o time`), peak RSS, and the heap from pprof where it is served (`listener.bind_loopback_only` serves `/debug/pprof/heap`). +- [ ] **Step 2: Run it** on macOS. Put the table in the PR body. +- [ ] **Step 3: Commit.** "Chore: Add a script that measures praxis mode against direct mode". + +--- + +## Self-review notes + +- Spec coverage: Section 1 → Tasks 4, 5, 8, 10. Section 2 → Tasks 5, 6. Section 3 (chain, record, emitter, drift test, binary) → Tasks 1, 4, 5, 6. Section 4 parts in scope (config refusals, vendored OpenSSL) → Tasks 1, 7. Section 5 Rust filter tests and Go contract tests in scope → Tasks 6, 8, 9, 10. Out of scope, by delivery step: tunnel pairing, Cortex cuts, the hop, the egress listener (#1722); supervision, the token file, `gateway.yaml` (#1720); agentop and install (step 5); MCP and A2A (step 6); the release job (step 7). +- Known edge cases from the PR #1717 review are accepted as the issue lists them: an in-memory erase mark, a check that is not atomic with the append, streams longer than 10 minutes, and `At` set at append. From 0f02f49b9d7b47998ee96f9688bd7bcc432b24f2 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 15:26:29 -0500 Subject: [PATCH 02/26] Docs: Correct the praxis mode step 2 plan after review - Move the user-agent golden test to core/pipeline. subagentHeader is unexported there, and the ParseUserAgent cases are there. - Make the text module of the Rust parser visible to the crate in Task 1. - Do not decode the request body twice, and do not settle the served model twice. - Give decisions for the Praxis 0.8.0 names, the filter state, the ingest texts, the shutdown of an open stream, the emitter connect order and the replay fixtures. - Add ./pipeline/... to the gate. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- ...-11-09-praxis-mode-2-gateway-and-ingest.md | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md b/docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md index 0ab0714db..833e74a94 100644 --- a/docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md +++ b/docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md @@ -28,6 +28,9 @@ Each one closes a question that the spec or the issue leaves open. 12. **The emitter has its own thread** with a current-thread Tokio runtime, started once per process. It does not depend on the Praxis runtime, and `from_config` can run outside a runtime. 13. **`praxis-proxy` without default features**: `features = ["pingora", "admin-api", "forward-proxy"]`. This drops the policy engine and `aws-lc-sys` from the build. OpenSSL is vendored now (spec Section 4, "A standalone binary"), so CI needs no system OpenSSL. 14. **The measurement** (Done-when, last item) is a script and numbers in the PR body, not a CI gate. +15. **The Praxis 0.8.0 API names.** The filter hooks and context members that Task 6 names (`request_headers_to_remove`, `insert_filter_state`, `upstream_reached`, `stream_termination()`, `emit_deferred_record`, `Rejection::status`) were read in 0.7.5. After Task 1 fetches 0.8.0, read `~/.cargo/registry/src/*/praxis-proxy-filter-0.8.0/src`. When a name differs, use the 0.8.0 member with the same meaning. Do not add a Praxis fork or a patch. +16. **`emit_deferred_record` gets `&HttpFilterContext`, not `&mut`.** So `CortexObserve` keeps its exchange in filter state as `Arc>`, and each hook locks it. +17. **The ingest stream ends on shutdown** (spec [R 18], restarts stay quick). `New` registers `s.server.RegisterOnShutdown`. It sets a read deadline of now on each open ingest stream (`http.NewResponseController(w).SetReadDeadline`), so a blocked read returns and the handler ends. ## Global Constraints @@ -41,6 +44,8 @@ Each one closes a question that the spec or the issue leaves open. - Error kinds from `X-Cortex-Egress-Error`: `upstream_dns`, `upstream_tls`, `upstream_timeout`, `upstream_refused`. Any other value becomes `upstream_error`. - Rust lint gate, from `praxis/`: `cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`, `cargo test --locked`. Use the rustup `cargo` (`$HOME/.cargo/bin`), not Homebrew's, so `rust-toolchain.toml` applies. - Go gate: `GOWORK=off go vet ./...` and `gofmt -l ` in each module touched (`core`, `cmd/cortex`). +- The full gate, from the worktree root: `export PATH="$HOME/.cargo/bin:$PATH" && (cd praxis && cargo fmt --check && cargo clippy --all-targets --locked -- -D warnings && cargo test --locked) && (cd core && GOWORK=off go vet ./... && GOWORK=off go test ./config/... ./reloader/... ./model/... ./pipeline/... ./redact/... ./session/... ./sessionapi/... ./cost/... ./listener/forwardproxy/...) && (cd cmd/cortex && GOWORK=off go vet ./... && GOWORK=off go test ./...) && test -z "$(gofmt -l core cmd/cortex)"`. +- Tasks 1 and 7 both edit `CLAUDE.md` and can run at the same time. Each edits only its own line (Task 1 the Layout row `praxis/`, Task 7 the "Needs a restart" list), so the merge is clean. Do not reflow other lines. - Go is the source of truth for every port. When Rust and Go differ, fix Rust. - Commits: `git commit -s`, message in ASD-STE100, ending with `Assisted-By: Claude (Anthropic AI) `. No `Co-Authored-By`. PR title `Feat: Build cortex-gateway and the Cortex ingestion endpoint`, body with `Fixes #1719`. @@ -82,7 +87,7 @@ praxis/ tests/gateway.rs end-to-end tests with the real binary (Task 6) core/config/config.go, validate.go Praxis block and refusals (Task 7) core/reloader/reloader.go praxis.* needs a restart (Task 7) -core/model/client_golden_test.go writes core/model/testdata/user-agent.golden.json (Task 3) +core/pipeline/client_golden_test.go writes core/pipeline/testdata/user-agent.golden.json (Task 3) core/sessionapi/ingest.go, ingest_test.go, erase_marks.go (Task 8) cmd/cortex/ingest.go, ingest_test.go, main.go (Task 9) core/listener/forwardproxy/ingest_replay_test.go (Task 10) @@ -100,7 +105,7 @@ Depends on: none All later Rust tasks only fill files that this task creates, so parallel tasks do not touch `Cargo.toml`, `Cargo.lock` or a `mod` list. **Files:** -- Modify: `praxis/Cargo.toml`, `praxis/filters/Cargo.toml`, `praxis/filters/src/lib.rs`, `praxis/Cargo.lock`, `praxis/README.md`, `.github/workflows/praxis.yaml`, `CLAUDE.md` (Layout row `praxis/`: add "and the `cortex-gateway` binary") +- Modify: `praxis/Cargo.toml`, `praxis/filters/Cargo.toml`, `praxis/filters/src/lib.rs`, `praxis/filters/src/parse/inference/mod.rs`, `praxis/Cargo.lock`, `praxis/README.md`, `.github/workflows/praxis.yaml`, `CLAUDE.md` (Layout row `praxis/`: add "and the `cortex-gateway` binary") - Create: `praxis/filters/src/record/{mod,attribution,client,emitter,line,redact,row}.rs`, `praxis/filters/src/filters/{mod,attribution,observe}.rs` (each a one-line `//!` doc comment; `emitter.rs` also holds `Queued`, below), `praxis/server/Cargo.toml`, `praxis/server/src/main.rs` **Interfaces:** @@ -109,8 +114,9 @@ All later Rust tasks only fill files that this task creates, so parallel tasks d - [ ] **Step 1: Add the dependencies.** Filters crate: `praxis-proxy-filter = { version = "=0.8.0", default-features = false }`, `async-trait = "0.1"`, `bytes = "1"`, `tokio = { version = "1", features = ["rt", "net", "sync", "time", "io-util", "macros"] }`, `hyper = { version = "1", features = ["client", "http1"] }`, `http-body-util = "0.1"`, `base64 = "0.22"`, `chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }`, `metrics = "0.24"`, `serde_yaml = { package = "yaml_serde", version = "0.10.7" }`. Server crate: `praxis-proxy = { version = "=0.8.0", default-features = false, features = ["pingora", "admin-api", "forward-proxy"] }`, `cortex-praxis-filters = { path = "../filters" }`, `openssl = { version = "0.10", features = ["vendored"] }`. If cargo reports a `links = "zstd"` conflict, raise the filters crate's `zstd` to the version Praxis uses (`0.14`); the API it uses does not change. - [ ] **Step 2: Write `server/src/main.rs`**: install the crypto provider (`praxis::install_crypto_provider()`), read the config path from `-c ` or `PRAXIS_CONFIG`, load it (`praxis::load_config`), init tracing, and call `praxis::run_server_with_registry(config, custom_registry(), …)` with an empty `praxis_filter::register_filters! {}` for now. Copy the shape of Praxis's own `crates/server/src/main.rs` at v0.8.0. +- [ ] **Step 2b: Open `text` to the crate.** In `praxis/filters/src/parse/inference/mod.rs`, change `mod text;` to `pub(crate) mod text;`. Task 3 calls `is_control_rune` from `record::client`, and a private module is not visible there. - [ ] **Step 3: Build.** `cargo build --locked` after `cargo generate-lockfile` → OK. Record the cold build time and `du -sh target`. -- [ ] **Step 4: Update `praxis.yaml`.** `timeout-minutes: 45`; target cap 3072 MB in place of 2048; add `core/sessionapi/ingest*.go`, `core/sessionapi/testdata/ingest/**`, `core/redact/**`, `core/model/testdata/**`, `core/pipeline/testdata/session-event.golden.json` to both `paths` lists. Keep the blocking rule. +- [ ] **Step 4: Update `praxis.yaml`.** `timeout-minutes: 45`; target cap 3072 MB in place of 2048; add `core/sessionapi/ingest*.go`, `core/sessionapi/testdata/ingest/**`, `core/redact/**`, `core/pipeline/subagent.go`, `core/listener/forwardproxy/ingest_replay_test.go` to both `paths` lists (`core/pipeline/testdata/**` is already there, and it holds the Task 3 golden). Keep the blocking rule. - [ ] **Step 5: Lint.** fmt, clippy, test → clean. - [ ] **Step 6: Commit.** "Feat: Add the cortex-gateway crate and the record module scaffold". @@ -135,17 +141,17 @@ Depends on: Task 1 Depends on: Task 1 **Files:** -- Create: `core/model/client_golden_test.go`, `core/model/testdata/user-agent.golden.json` +- Create: `core/pipeline/client_golden_test.go` (package `pipeline`, because `subagentHeader` is unexported there), `core/pipeline/testdata/user-agent.golden.json` - Modify: `praxis/filters/src/record/client.rs` **Interfaces:** - Produces: `pub struct Client` (serde, camelCase, the JSON of `model.EventClient` with its omit rules), `pub fn parse_user_agent(ua: &str) -> Option` (port of `model.ParseUserAgent`, `core/model/client.go:234-590` with `capUA`, `productVersion`, `trailingKnownClient`), `pub fn subagent_header(v: Option<&str>) -> String` (port of `pipeline.subagentHeader`: trim SP and HTAB only, then refuse an id with control runes or longer than `maxClientLen`). Header names `X-Claude-Code-Agent-Id`, `X-Claude-Code-Parent-Agent-Id`. -- Consumes: `parse::inference::text` (`is_control_rune`, from #1718). +- Consumes: `parse::inference::text::is_control_rune` (from #1718, made `pub(crate)` in Task 1). -- [ ] **Step 1: Write the Go golden test** `TestParseUserAgent_Golden`: a list of user-agent strings → `ParseUserAgent` JSON, written with `-update` and compared otherwise. Inputs: every input string in `core/model/client_test.go` cases, every `User-Agent` in `core/plugins/inferenceparser/testdata/*/*/request.headers`, plus `""`, a 600-byte string and one with a control rune. Also a `subagent` section: header value → `subagentHeader` result (from the cases in `core/pipeline/subagent_test.go`). Run with `-update`, then without → PASS. -- [ ] **Step 2: Write the failing Rust test** `user_agent_golden_matches_go`: read `../../core/model/testdata/user-agent.golden.json`; for each case, `serde_json::to_value(parse_user_agent(ua))` equals the Go JSON; for each subagent case, `subagent_header` equals the Go string. Run → FAIL. +- [ ] **Step 1: Write the Go golden test** `TestParseUserAgent_Golden` in package `pipeline`: a list of user-agent strings → `ParseUserAgent` JSON, written when the existing `-update` flag is set (it is declared in `fields_test.go`; do not declare a second one) and compared otherwise. Inputs: copy every user-agent string literal from the cases in `core/pipeline/client_test.go` into the test's own list; add every `User-Agent` read at test time from `../plugins/inferenceparser/testdata/*/*/request.headers`; add `""`, a 600-byte string and one with a control rune. Also a `subagent` section: header value → `subagentHeader` result, with the header values copied from `core/pipeline/subagent_test.go`. Run `GOWORK=off go test ./pipeline/ -run Golden -update` from `core`, then without `-update` → PASS. +- [ ] **Step 2: Write the failing Rust test** `user_agent_golden_matches_go`: read `../../core/pipeline/testdata/user-agent.golden.json`; for each case, `serde_json::to_value(parse_user_agent(ua))` equals the Go JSON; for each subagent case, `subagent_header` equals the Go string. Run → FAIL. - [ ] **Step 3: Implement** the port, function by function. -- [ ] **Step 4: Run** → PASS; Go `GOWORK=off go test ./model/ -run Golden` from `core` → PASS. +- [ ] **Step 4: Run** → PASS; Go `GOWORK=off go test ./pipeline/ -run Golden` from `core` → PASS. - [ ] **Step 5: Commit.** "Feat: Port the user-agent and subagent rules to Rust". ### Task 4: The emitter (Rust) @@ -159,7 +165,7 @@ Depends on: Task 1 - Produces: - Consumes `Queued` (Task 1). Task 5 builds it. - `pub struct Emitter`; `pub fn Emitter::start(ingest_addr: std::net::SocketAddr, token: String) -> Arc` starts the thread; `pub fn submit(&self, q: Queued)` never blocks and never fails; `pub fn stats(&self) -> EmitterStats`. - - `pub struct EmitterStats { pub rows_emitted: u64, pub rows_dropped: u64, pub captures_dropped_line_cap: u64, pub captures_dropped_queue_bytes: u64, queued_lines: usize, queued_bytes: usize }` (all `pub`). + - `pub struct EmitterStats { pub rows_emitted: u64, pub rows_dropped: u64, pub captures_dropped_line_cap: u64, pub captures_dropped_queue_bytes: u64, pub queued_lines: usize, pub queued_bytes: usize }`. - Constants `MAX_QUEUE_LINES = 4096`, `MAX_QUEUE_BYTES = 256 << 20`, `MAX_LINE = 64 << 20`. - `pub fn global(ingest_addr, token) -> Arc`: one emitter per process (a `OnceLock`); a second call with other arguments returns the first one and logs a warning. @@ -170,7 +176,7 @@ Depends on: Task 1 4. Still over → drop, count `rows_dropped`. 5. Else queue it and wake the sender. -The sender: a thread with a current-thread Tokio runtime. It connects with `TcpStream`, `hyper::client::conn::http1::handshake`, and sends `POST /v1/ingest` with `Authorization: Bearer `, `Content-Type: application/x-ndjson` and a streaming body (`http_body_util::StreamBody` over a `tokio::sync::mpsc` channel of `Bytes`). It moves lines from the queue to the body. On any error, it counts the line in flight as `rows_dropped`, waits (100 ms, doubling to 5 s, reset on success) and reconnects. `rows_emitted` counts lines written to the body. Each counter also increments the `metrics` counters `cortex_rows_emitted_total`, `cortex_rows_dropped_total`, `cortex_captures_dropped_total{reason="line_cap"|"queue_bytes"}`. +The sender: a thread with a current-thread Tokio runtime. It connects first, and takes a line from the queue only on an open connection, so a failed connect drops no line (the no-server tests rely on this). It connects with `TcpStream`, `hyper::client::conn::http1::handshake`, and sends `POST /v1/ingest` with `Authorization: Bearer `, `Content-Type: application/x-ndjson` and a streaming body (`http_body_util::StreamBody` over a `tokio::sync::mpsc` channel of `Bytes`). It moves lines from the queue to the body. On any error, it counts the line in flight as `rows_dropped`, waits (100 ms, doubling to 5 s, reset on success) and reconnects. `rows_emitted` counts lines written to the body. Each counter also increments the `metrics` counters `cortex_rows_emitted_total`, `cortex_rows_dropped_total`, `cortex_captures_dropped_total{reason="line_cap"|"queue_bytes"}`. - [ ] **Step 1: Write the failing tests** (a mock ingest server on `127.0.0.1:0` in the test, reading lines): - `lines_arrive_in_order_with_token`: 3 lines → the server reads them in order, with `Authorization: Bearer t`. @@ -202,7 +208,7 @@ Depends on: Task 2, Task 3 - Produces, in `record::row`: - `pub struct Exchange`, one per request. - `pub fn Exchange::new(method: &str, host: &str, path_and_query: &str, headers: &http::HeaderMap, attribution: Attribution, request_id: String, started: std::time::SystemTime, start: std::time::Instant) -> Exchange`. - - `pub fn on_request_body(&mut self, body: &[u8]) -> Line`: the request row. It parses with `InferenceObserver::on_request` (after `decode_request_body`), sets `bytesUp = body.len()`, `client`, `subagent`, `parentSubagent`, `proc`, `clientProc`, `sessionHeader`, and carries the capture only when `inference` is set: `original` = body, `header` = `redact::headers` of the request headers without `CAPTURE_REMOVE`, `query` = `redact::query`. + - `pub fn on_request_body(&mut self, body: &[u8]) -> Line`: the request row. It parses with `InferenceObserver::on_request` on the raw body (`on_request` calls `decode_request_body` itself, so do not decode twice), sets `bytesUp = body.len()`, `client`, `subagent`, `parentSubagent`, `proc`, `clientProc`, `sessionHeader`, and carries the capture only when `inference` is set: `original` = body, `header` = `redact::headers` of the request headers without `CAPTURE_REMOVE`, `query` = `redact::query`. - `pub fn on_response_head(&mut self, status: u16, headers: &mut http::HeaderMap) -> Result<(), Abort>`. It records the TTFB, takes and removes `X-Cortex-Egress-Error` (Global Constraints mapping), and picks the SSE mode (`text/event-stream`, or `is_known_mislabeled_sse`). It returns `Err(Abort::BufferedOverCap)` for a non-SSE response with no `Content-Encoding` and a `Content-Length` over `MAX_BODY_SIZE`. - `pub fn on_response_chunk(&mut self, chunk: &[u8]) -> Result<(), Abort>`: decodes for parsing, counts and captures, and returns `Err(Abort::FrameOverCap)` or `Err(Abort::BufferedOverCap)` at a cap. - `pub fn on_response_end(&mut self) -> Line`: the response row (`ended: true`). @@ -211,14 +217,14 @@ Depends on: Task 2, Task 3 - `pub enum Abort { BufferedOverCap, FrameOverCap }`. Response row rules (each from direct mode, cited in spec Sections 1 and 3): -- `inference` = `InferenceObserver::extension()` (the request and response facts together), after `settle_served_model` with the response headers. +- `inference` = `InferenceObserver::extension()` (the request and response facts together), read after the last `on_response_frame(headers, …, true)` call. That call already runs `settle_served_model`, so do not call it again. - Capture only when the request capture was kept. When `Content-Encoding` is `gzip` alone: body = the decoder output, `decoded: true`, without `Content-Encoding` and `Content-Length` in the header. Otherwise: wire bytes, `decoded: false`, headers kept. Header = `redact::headers`. Up to 10 MiB, `truncated` past it. `incomplete = !ended`. - `bytesDown` = decoded bytes for gzip, wire bytes otherwise. - `cost` = the six headers when present. - Kinds: `Abort::BufferedOverCap` → `statusCode` 502, `proxy_error`, `code` = the upstream status as a decimal string, the buffered text. `Abort::FrameOverCap` → `statusCode` = the upstream status, `proxy_error`, the frame text. `deferred`: status 413 → `statusCode` 413, `proxy_error`, the 413 text with the request's `Content-Length`; `downstream_gone` or status 499 → `statusCode` 499, `client_canceled`, Decision 8 text; `upstream_gone` → `statusCode` = status, `upstream_error`, Decision 8 text; otherwise `statusCode` = status, `gateway_refused`. -- [ ] **Step 1: Write the failing drift test** in `line.rs`: `every_session_event_field_is_set_or_listed`. Take the top-level keys of `../../core/pipeline/testdata/session-event.golden.json`, and the keys of `serde_json::to_value(full_event())`, where `full_event()` is a test helper with every field set. Assert golden ⊆ rust ∪ `NOT_SET_IN_PRAXIS_MODE`, and rust ⊆ golden. The list is `sessionId`, `seq`, `at` ("the store sets it") and the Decision 6 fields, with each reason as a comment. -- [ ] **Step 2: Write the failing row tests** in `row.rs`, on the Claude Code fixture `core/plugins/inferenceparser/testdata/claude-code/` (fed whole and in 4096-byte chunks): +- [ ] **Step 1: Write the failing drift test** in `line.rs`: `every_session_event_field_is_set_or_listed`. Take the top-level keys of `../../core/pipeline/testdata/session-event.golden.json`, and the keys of `serde_json::to_value(full_event())`, where `full_event()` is a test helper with every field set. Assert golden ⊆ rust ∪ `NOT_SET_IN_PRAXIS_MODE`, and rust ⊆ golden. The list is `pub const NOT_SET_IN_PRAXIS_MODE: &[&str]` (public, because Task 10's integration test reads it): `sessionId`, `seq`, `at` ("the store sets it") and the Decision 6 fields, with each reason as a comment. +- [ ] **Step 2: Write the failing row tests** in `row.rs`, on the Claude Code fixture `core/plugins/inferenceparser/testdata/claude-code/main-stream-thinking-tool-use` (fed whole and in 4096-byte chunks). The gzip tests gzip that response in the test with `flate2` and set `Content-Encoding: gzip`. The `br` test sets `Content-Encoding: br` on the raw bytes: - `request_row_has_inference_and_capture` - `capture_headers_have_no_hop_or_credential_headers`: `Authorization` and `X-Cortex-Token` absent, `X-Cortex-Custom` present. - `non_inference_request_has_no_capture`: `POST /oauth/token`. @@ -253,7 +259,7 @@ Depends on: Task 4, Task 5 **Interfaces:** - Consumes: `record::attribution`, `record::row::Exchange`, `record::emitter::global` (Tasks 4, 5). -- Produces: filter names `cortex_attribution` (config `token_file`) and `cortex_observe` (config `token_file`, `ingest` as `host:port`), registered in `main.rs` with `register_filters! { http "cortex_attribution" => CortexAttribution::from_config, http "cortex_observe" => CortexObserve::from_config }`. A private newtype `TokenValid(Attribution)` goes into `ctx.extensions`. +- Produces: filter names `cortex_attribution` (config `token_file`) and `cortex_observe` (config `token_file`, `ingest` as `host:port`), registered in `main.rs` with `register_filters! { http "cortex_attribution" => CortexAttribution::from_config, http "cortex_observe" => CortexObserve::from_config }`. A newtype `pub(crate) struct TokenValid(pub Attribution)`, defined in `filters/attribution.rs`, goes into `ctx.extensions`. The exchange is kept as Decision 16 says. Filter behavior: - `CortexAttribution::on_request`: when `token_matches(X-Cortex-Token)` is false → `Reject(Rejection::status(403))`. Else insert `TokenValid(decode(X-Cortex-Attribution))` (unless `X-Cortex-Probe` is present: then insert nothing and continue), and push `UPSTREAM_REMOVE` to `ctx.request_headers_to_remove`. @@ -265,7 +271,7 @@ The test harness: each test writes `gateway.yaml` from the template into a temp - `tokenless_request_gets_403_and_no_line`: the mock origin sees nothing, and the ingest sees no line in 1 s. - `probe_request_records_nothing` - `hop_headers_removed_custom_kept`: the origin sees `X-Cortex-Dest`, `X-Cortex-Token` and `X-Cortex-Custom`, and none of the other ten. - - `inference_request_gives_request_and_response_lines`: the Claude Code fixture → two lines with one `requestId`, `session` = `recordSession`, response row with tokens and `cost.headers`. + - `inference_request_gives_request_and_response_lines`: the fixture `claude-code/main-stream-thinking-tool-use`, with the mock origin adding `X-Litellm-Response-Cost: 0.01` → two lines with one `requestId`, `session` = `recordSession`, response row with tokens and `cost.headers`. - `gzip_sse_streams_as_frames_and_wire_bytes_unchanged`: the client receives the gzip bytes unchanged, and receives the first frame before the origin sends the last one. - `mid_body_abort_is_client_canceled`: the client sends half of a body with `Content-Length`, then closes → a response row with `client_canceled`, 499. - `body_over_limit_is_413_proxy_error`: a 33 MiB body → the client gets 413; lines: request row and response row with `proxy_error` and the 413 text. @@ -318,7 +324,7 @@ Depends on: none - Consumes: `session.Store.AppendCapture`, `session.Store.AddRecorder`, `attribution.FiledSession`, `settle.SettleInput`, `settle.NewRecord`, `inference.SettleServedModel`, `event.Key`, `event.PluginName`, `redact.Headers`, `redact.Query`, `httpx.LoopbackHost`. Handler rules, in order: -1. A non-loopback `Host`, or an `Origin` header → 403 (the `clearRefusal` texts). +1. A non-loopback `Host` (`httpx.LoopbackHost`), or an `Origin` header → 403, with the texts `"ingest is accepted only for a loopback Host (localhost, 127.0.0.1 or [::1])"` and `"ingest is refused from a browser: the request carries an Origin header"`. Do not call `clearRefusal`: its texts say "erasing", and its third check is `allowClear`. 2. No `Authorization: Bearer ` (`subtle.ConstantTimeCompare`) → 401. 3. Read the body line by line with a `bufio.Reader` and a 64 MiB cap per line. A line over the cap is discarded up to its `\n` and refused as `line_cap`. The connection stays open after any refusal. The answer, when the body ends, is 204. 4. For each line: unmarshal (`bad_json`); `v != 1` → `version`; `schemaVersion != SchemaVersion` → `schema`; `op != "append"` → `op`. @@ -328,7 +334,7 @@ Handler rules, in order: 8. Capture: `dropped` → count `CapturesDropped`, no capture. No `ev.Inference` → no capture. A request capture: redact `Header` and `Query` again, keep it, and remember `requestId`. A response capture: keep it only when `requestId` is remembered (then forget it), with `Header` redacted again. Entries older than 1 hour are removed when a response line is read (Decision 9). 9. `store.AppendCapture(sid, ev, capture)`; count `Lines`. -`erase_marks.go`: `type eraseMarks struct` implements `session.Recorder` (a no-op `Record`), `session.Deleter` and `session.Clearer`. It keeps the id → time and the last clear time for 10 minutes. `func (m *eraseMarks) erasedAfter(sid string, startedAt time.Time) bool`. It has an injectable clock. It is registered with `store.AddRecorder` in `New`, only when `WithIngest` was given. +`erase_marks.go`: `type eraseMarks struct` implements `session.Recorder` (a no-op `Record`), `session.Deleter` and `session.Clearer`. It keeps the id → time and the last clear time for 10 minutes. `func (m *eraseMarks) erasedAfter(sid string, startedAt time.Time) bool`. It has an injectable clock. `Deleted` and `Cleared` run under the store's write lock, so they take only the marks' own mutex and never call the store. It is registered with `store.AddRecorder` in `New`, only when `WithIngest` was given. Tests reach the clock through an unexported field set in the test (package `sessionapi`), not through a new exported option. - [ ] **Step 1: Write the failing tests** (a `session.Store` with a capture recorder, as in `forwardproxy/capture_test.go`): - `TestIngest_NotRegisteredWithoutOption`: `POST /v1/ingest` and `GET /v1/ingest/stats` → 404, and the store has no `eraseMarks` recorder. @@ -345,6 +351,7 @@ Handler rules, in order: - `TestIngest_ClearDropsOlderLines`: the same with `DELETE /v1/sessions`. - `TestIngest_EraseMarkExpires`: with the clock 11 minutes on, the older line is kept. - `TestIngest_EmptySessionIsFiled`: `session: ""` → filed under the active session. + - `TestIngest_ShutdownEndsStream`: an open ingest stream with no line in flight; `Server().Shutdown` with a 2 s context returns nil within 1 s (Decision 17). - [ ] **Step 2: Run** `GOWORK=off go test ./sessionapi/ -run Ingest` from `core` → FAIL. - [ ] **Step 3: Implement.** - [ ] **Step 4: Run** → PASS; also the whole `./sessionapi/ ./session/...` suite; `go vet ./...` and `gofmt -l sessionapi` clean. @@ -377,18 +384,19 @@ Depends on: Task 7, Task 8 Depends on: Task 5, Task 8 **Files:** -- Create: `core/listener/forwardproxy/ingest_replay_test.go` (package `forwardproxy_test`, so it can import `sessionapi`), `core/sessionapi/testdata/ingest/replay.ndjson`, `praxis/filters/tests/row_parity.rs` +- Create: `core/listener/forwardproxy/ingest_replay_test.go` (package `forwardproxy`, the same as `capture_test.go`. `sessionapi` does not import `forwardproxy`, so there is no cycle. Reuse `captures` and `proxyWithCaptures` from `capture_test.go`), `core/sessionapi/testdata/ingest/replay.ndjson`, `praxis/filters/tests/row_parity.rs`, `praxis/filters/tests/common/mod.rs` +- Modify: `praxis/filters/tests/parity.rs` (move its fixture loader, `Case`, `cases`, `load`, `header_map`, `read_body`, into `tests/common/mod.rs` and use `mod common;` from both tests) **Interfaces:** - Consumes: `sessionapi.WithIngest` (Task 8); `record::row::Exchange`, `record::line::Line` (Task 5). - [ ] **Step 1: Write the Go test** `TestIngestReplay_MatchesLiveRun`: - - **Live run:** a forward proxy with `inference-parser` and a fixed `pricing.Table`, a store with a usage aggregator and a capture recorder, and a mock origin that answers each `claude-code` fixture with its response file and headers, plus `X-Litellm-Response-Cost` on half the cases. One request per fixture, in fixture order, all with one session header. - - **Lines:** convert each stored event, with its capture, into a v1 line (`startedAt` = the event's `At`, `cost.headers` = the cost headers the origin sent). With `-update`, write them to `testdata/ingest/replay.ndjson`. Otherwise read that file. + - **Live run:** a forward proxy with `inference-parser` and a fixed `pricing.Table`, a store with a usage aggregator and a capture recorder, and a mock origin that answers each `claude-code` fixture with its response file and headers (the `parsefixture` rules: `response.headers` when present, else the Content-Type of the response file's type; status 502 for `error-502-html`, else 200), plus `X-Litellm-Response-Cost` on half the cases. One request per fixture, in fixture order, all with one session header. + - **Lines:** convert each stored event, with its capture, into a v1 line (`seq` and `sessionId` zeroed as a producer sends them, `startedAt` = the event's `At`, `cost.headers` = the cost headers the origin sent). With `-update`, write them to `testdata/ingest/replay.ndjson`. Otherwise read that file. - **Replay:** post the file to a second session API with `WithIngest` and an empty store with its own aggregator. - **Compare:** `/v1/sessions/{id}` events field by field without `at`; the `/v1/sessions` summaries without their time fields; the `/v1/usage` totals (tokens, cost, requests) without the buckets (spec Section 5, [DR 4]). - [ ] **Step 2: Run** with `-update`, then without → PASS. If they differ, fix ingestion (Task 8 code), not the comparison. -- [ ] **Step 3: Write the Rust test** `row_parity_matches_replay`: for each request and response line in `replay.ndjson`, build the same exchange from the same fixture files with `Exchange` (the headers from `request.headers`, the response fed in 4096-byte chunks), and compare `event` and `capture` JSON. Leave out `durationMs`, `ttfbMs`, `requestId` and `plugins` (ingestion owns it). A known difference is allowed only for the captured request headers: Go captures the agent's headers at entry (spec Section 1, "Captures", [F 2]). +- [ ] **Step 3: Write the Rust test** `row_parity_matches_replay`: for each request and response line in `replay.ndjson`, build the same exchange from the same fixture files with `Exchange` (the headers from `request.headers`, the response fed in 4096-byte chunks), and compare `event` and `capture` JSON. Leave out `durationMs`, `ttfbMs`, `requestId`, `plugins` (ingestion owns it) and every key in `NOT_SET_IN_PRAXIS_MODE` (direct mode sets `invocations`, and the store sets `at`). A known difference is allowed only for the captured request headers: Go captures the agent's headers at entry (spec Section 1, "Captures", [F 2]). - [ ] **Step 4: Run** `cargo test -p cortex-praxis-filters --test row_parity` → PASS. If it fails, fix Rust. - [ ] **Step 5: Commit.** "Test: Pin praxis-mode rows against a direct-mode replay". From c0c92903b75062834b94045e08f1650a4944d4aa Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 15:30:35 -0500 Subject: [PATCH 03/26] Feat: Add the praxis.enabled config switch Add the top-level praxis block with one field, enabled, and Config.PraxisEnabled. The value is false when the block is absent. Validate refuses praxis.enabled with an mtls block, and refuses praxis.enabled without listener.bind_loopback_only. A change to praxis.* needs a restart. Refs #1719 Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- CLAUDE.md | 2 +- core/config/config.go | 14 +++++++++ core/config/praxis_test.go | 54 ++++++++++++++++++++++++++++++++++ core/config/validate.go | 19 ++++++++++++ core/reloader/reloader.go | 4 +++ core/reloader/reloader_test.go | 12 ++++++++ 6 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 core/config/praxis_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 44c621aab..26c0a7e0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -275,7 +275,7 @@ on the old pipeline. - **Reloads:** any plugin list change and any plugin `config:` subtree edit. - **Needs a restart:** `mode` and every field of `listener`, `session`, `cost_ledger`, - `mtls`, `tls_bridge`, `spiffe`, `memory` and `stats` (`validateReloadable` in + `mtls`, `tls_bridge`, `spiffe`, `memory`, `stats` and `praxis` (`validateReloadable` in `core/reloader/reloader.go`). - **Bad YAML stays safe:** the active pipeline keeps serving, and `/reload/status` shows the error with `reloads_failed` incremented. diff --git a/core/config/config.go b/core/config/config.go index 5346bfc2e..3f26928e2 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -48,6 +48,9 @@ type Config struct { // Memory, when set, applies a Go runtime soft memory limit // (debug.SetMemoryLimit) at boot. See MemoryConfig. Memory *MemoryConfig `yaml:"memory,omitempty" json:"memory,omitempty"` + // Praxis, when Enabled, turns on praxis mode: cortex-gateway forwards the + // traffic and Cortex ingests its rows. Absent means direct mode. + Praxis *PraxisConfig `yaml:"praxis,omitempty" json:"praxis,omitempty"` // Pricing configures model rates for the whole process — one section rather // than a knob per plugin, so cost is consistent wherever it is reported. // @@ -332,6 +335,17 @@ func (b *TLSBridgeConfig) Validate() error { return nil } +// PraxisConfig is the top-level `praxis:` block. listen and egress land with +// their callers (#1720, #1722). +type PraxisConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` +} + +// PraxisEnabled reports whether praxis mode is on. False on a nil block. +func (c *Config) PraxisEnabled() bool { + return c.Praxis != nil && c.Praxis.Enabled +} + // MTLSMode names the inbound + outbound TLS posture. Vocabulary // borrows from Istio's PeerAuthentication.mtls.mode for familiarity. type MTLSMode string diff --git a/core/config/praxis_test.go b/core/config/praxis_test.go new file mode 100644 index 000000000..96e8c5a4e --- /dev/null +++ b/core/config/praxis_test.go @@ -0,0 +1,54 @@ +package config + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestPraxis_DefaultOff(t *testing.T) { + var c Config + if err := yaml.Unmarshal([]byte("mode: proxy-sidecar\n"), &c); err != nil { + t.Fatalf("yaml: %v", err) + } + if c.PraxisEnabled() { + t.Fatal("PraxisEnabled() is true with no praxis block") + } + if err := yaml.Unmarshal([]byte("mode: proxy-sidecar\npraxis:\n enabled: true\n"), &c); err != nil { + t.Fatalf("yaml: %v", err) + } + if !c.PraxisEnabled() { + t.Fatal("praxis.enabled: true did not parse") + } +} + +func TestPraxis_NeedsLoopbackOnly(t *testing.T) { + c := praxisConfig() + err := Validate(c) + if err == nil || !strings.Contains(err.Error(), "listener.bind_loopback_only") { + t.Fatalf("Validate = %v, want an error naming listener.bind_loopback_only", err) + } + c.Listener.BindLoopbackOnly = true + if err := Validate(c); err != nil { + t.Fatalf("Validate with bind_loopback_only: %v", err) + } +} + +func TestPraxis_RefusedWithMTLS(t *testing.T) { + c := praxisConfig() + c.MTLS = &MTLSConfig{} + c.Listener.BindLoopbackOnly = true + err := Validate(c) + if err == nil || !strings.Contains(err.Error(), "mtls") { + t.Fatalf("Validate = %v, want an error naming mtls", err) + } +} + +// praxisConfig is a proxy-sidecar config that passes the listener checks, so +// only the praxis rules can refuse it. +func praxisConfig() *Config { + c := &Config{Mode: ModeProxySidecar, Praxis: &PraxisConfig{Enabled: true}} + c.Listener.ReverseProxyBackend = "127.0.0.1:8000" + return c +} diff --git a/core/config/validate.go b/core/config/validate.go index d342fb139..d5184cdab 100644 --- a/core/config/validate.go +++ b/core/config/validate.go @@ -38,6 +38,9 @@ func Validate(cfg *Config) error { if err := validateSessionArchive(cfg); err != nil { return err } + if err := validatePraxis(cfg); err != nil { + return err + } return validatePricing(cfg) } @@ -73,6 +76,22 @@ func validateCostLedger(cfg *Config) error { "or drop cost_ledger.enabled") } +// validatePraxis refuses praxis mode outside a loopback-only laptop install, and +// beside mtls: the ingest endpoint and the hop to cortex-gateway are loopback +// plaintext, so neither combination can work. +func validatePraxis(cfg *Config) error { + if !cfg.PraxisEnabled() { + return nil + } + if cfg.MTLS != nil { + return fmt.Errorf("praxis.enabled: true cannot be used with mtls: drop the mtls block or praxis.enabled") + } + if !cfg.Listener.BindLoopbackOnly { + return fmt.Errorf("praxis.enabled: true requires listener.bind_loopback_only: true") + } + return nil +} + // validatePricing builds the rate table and discards it, so a fault in the // `pricing:` section fails at startup beside every other config error. // diff --git a/core/reloader/reloader.go b/core/reloader/reloader.go index f93f0c03a..146d0efb2 100644 --- a/core/reloader/reloader.go +++ b/core/reloader/reloader.go @@ -647,6 +647,10 @@ func validateReloadable(active, next *config.Config) error { if !reflect.DeepEqual(active.Stats, next.Stats) { diffs = append(diffs, "stats.*") } + // praxis.* too: praxis mode decides at boot which listeners and routes exist. + if !reflect.DeepEqual(active.Praxis, next.Praxis) { + diffs = append(diffs, "praxis.*") + } if len(diffs) > 0 { return fmt.Errorf("unreloadable field changed, pod restart required: %v", diffs) } diff --git a/core/reloader/reloader_test.go b/core/reloader/reloader_test.go index ed8c5b4c9..1bc0fee30 100644 --- a/core/reloader/reloader_test.go +++ b/core/reloader/reloader_test.go @@ -1254,3 +1254,15 @@ func TestNew_DrainWindowDefaultsToTenMinutes(t *testing.T) { t.Errorf("drainWindow = %v, want 10m", r.drainWindow) } } + +func TestValidateReloadable_PraxisNeedsRestart(t *testing.T) { + active := &config.Config{} + next := &config.Config{Praxis: &config.PraxisConfig{Enabled: true}} + if err := validateReloadable(active, next); err == nil || !strings.Contains(err.Error(), "praxis.*") { + t.Fatalf("validateReloadable = %v, want an error naming praxis.*", err) + } + active = &config.Config{Praxis: &config.PraxisConfig{Enabled: true}} + if err := validateReloadable(active, next); err != nil { + t.Errorf("unchanged praxis block was refused: %v", err) + } +} From 6786867860f9274ac6b1c3fe37558309f9e11ab6 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 15:33:54 -0500 Subject: [PATCH 04/26] Feat: Add the praxis-mode ingestion endpoint to the session API POST /v1/ingest accepts the NDJSON rows that cortex-gateway records. GET /v1/ingest/stats shows the ingestion counters. The server registers the two routes only when WithIngest has a token. In direct mode, both routes answer 404 and the server keeps no erase marks. For each line, Cortex does these steps: - It refuses a line with bad JSON, an unknown v, a different schemaVersion, an op that is not append, or a length over 64 MiB. It counts each refusal. The stream stays open. - It drops a line that started before an erase of its session in the last 10 minutes. - It removes the cost record of the producer and settles the response row again. - It assigns seq, sessionId and at. - It keeps a capture only for an inference row and removes credentials again. It keeps a response capture only after its request capture. On shutdown, the server ends each open stream immediately. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- core/sessionapi/erase_marks.go | 68 +++++ core/sessionapi/ingest.go | 339 ++++++++++++++++++++++++ core/sessionapi/ingest_test.go | 470 +++++++++++++++++++++++++++++++++ core/sessionapi/server.go | 13 + docs/session-api.md | 68 +++++ 5 files changed, 958 insertions(+) create mode 100644 core/sessionapi/erase_marks.go create mode 100644 core/sessionapi/ingest.go create mode 100644 core/sessionapi/ingest_test.go diff --git a/core/sessionapi/erase_marks.go b/core/sessionapi/erase_marks.go new file mode 100644 index 000000000..eec337699 --- /dev/null +++ b/core/sessionapi/erase_marks.go @@ -0,0 +1,68 @@ +package sessionapi + +import ( + "sync" + "time" + + "github.com/rossoctl/cortex/core/model" +) + +// eraseMarkTTL is how long an erase mark is kept: longer than a line can wait in the +// gateway's emitter queue. +const eraseMarkTTL = 10 * time.Minute + +// eraseMarks remembers recent erases, so a line queued before an erase cannot bring the +// erased session back (praxis mode only). It is a store recorder: Deleted and Cleared run +// under the store's write lock, so they take only mu and never call the store. +type eraseMarks struct { + mu sync.Mutex + now func() time.Time + ids map[string]time.Time + lastClear time.Time +} + +func newEraseMarks() *eraseMarks { + return &eraseMarks{now: time.Now, ids: map[string]time.Time{}} +} + +func (m *eraseMarks) Record(string, *model.SessionEvent) {} + +func (m *eraseMarks) Deleted(sessionID string) { + m.mu.Lock() + defer m.mu.Unlock() + now := m.now() + for id, at := range m.ids { + if now.Sub(at) > eraseMarkTTL { + delete(m.ids, id) + } + } + m.ids[sessionID] = now +} + +func (m *eraseMarks) Cleared() { + m.mu.Lock() + defer m.mu.Unlock() + m.lastClear = m.now() + clear(m.ids) // the clear's mark is later than each of them +} + +// erasedAfter reports whether sid was erased, by a delete or a clear, after startedAt and +// within the last eraseMarkTTL. +func (m *eraseMarks) erasedAfter(sid string, startedAt time.Time) bool { + m.mu.Lock() + defer m.mu.Unlock() + now := m.now() + for _, at := range []time.Time{m.ids[sid], m.lastClear} { + if !at.IsZero() && now.Sub(at) <= eraseMarkTTL && at.After(startedAt) { + return true + } + } + return false +} + +// setClock replaces the clock, for tests. +func (m *eraseMarks) setClock(now func() time.Time) { + m.mu.Lock() + m.now = now + m.mu.Unlock() +} diff --git a/core/sessionapi/ingest.go b/core/sessionapi/ingest.go new file mode 100644 index 000000000..d35f01f95 --- /dev/null +++ b/core/sessionapi/ingest.go @@ -0,0 +1,339 @@ +package sessionapi + +import ( + "bufio" + "bytes" + "crypto/subtle" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/rossoctl/cortex/core/attribution" + "github.com/rossoctl/cortex/core/cost/event" + "github.com/rossoctl/cortex/core/cost/pricing" + "github.com/rossoctl/cortex/core/cost/settle" + "github.com/rossoctl/cortex/core/listener/httpx" + "github.com/rossoctl/cortex/core/model" + "github.com/rossoctl/cortex/core/parse/inference" + "github.com/rossoctl/cortex/core/redact" +) + +// POST /v1/ingest takes the rows that cortex-gateway records in praxis mode, as NDJSON, one +// line per row (line format v1, docs/session-api.md). Cortex files each row, settles its cost +// and keeps its capture as a listener does. Registered only when WithIngest has a token. + +const ( + // ingestLineVersion is the line format version, the "v" key. + ingestLineVersion = 1 + // ingestLineCap is the largest line read. A longer line is refused as line_cap. + ingestLineCap = 64 << 20 + // requestCaptureTTL is how long a kept request capture waits for its response line. Longer + // than any stream: a 60 s window would drop the response capture of every long stream. + requestCaptureTTL = time.Hour +) + +// The refusal reasons of ingest_refused_total. +const ( + refusedBadJSON = "bad_json" + refusedVersion = "version" + refusedSchema = "schema" + refusedOp = "op" + refusedLineCap = "line_cap" + refusedErased = "erased" +) + +var refusalReasons = []string{refusedBadJSON, refusedVersion, refusedSchema, refusedOp, refusedLineCap, refusedErased} + +// Ingest turns on POST /v1/ingest and GET /v1/ingest/stats. +type Ingest struct { + // Token is the bearer token a producer must send. Empty leaves ingestion off. + Token string + // Rates prices ingested rows. nil reports them unpriced. + Rates pricing.Resolver +} + +// WithIngest turns on the ingestion endpoint (praxis mode). Without it, or with an empty +// token, both routes answer 404 and no erase mark is kept. +func WithIngest(in Ingest) Option { + return func(s *Server) { s.ingestCfg = in } +} + +// IngestStats is GET /v1/ingest/stats. +type IngestStats struct { + Lines uint64 `json:"ingest_lines_total"` + Refused map[string]uint64 `json:"ingest_refused_total"` + CapturesDropped uint64 `json:"ingest_captures_dropped_total"` +} + +// ingester is the state of the ingestion endpoint. +type ingester struct { + cfg Ingest + marks *eraseMarks + + lines, capturesDropped atomic.Uint64 + refusedMu sync.Mutex + refused map[string]uint64 + + // reqCaps holds the request ids of kept request captures, with when each was kept, until + // the response line arrives. A response capture is kept only for an id here. + reqCapsMu sync.Mutex + reqCaps map[string]time.Time + + // streams are the open ingest streams, so Shutdown can end them. + streamsMu sync.Mutex + streams map[*http.ResponseController]struct{} +} + +func newIngester(cfg Ingest) *ingester { + return &ingester{cfg: cfg, marks: newEraseMarks(), refused: map[string]uint64{}, + reqCaps: map[string]time.Time{}, streams: map[*http.ResponseController]struct{}{}} +} + +func (in *ingester) refuse(reason string) { + in.refusedMu.Lock() + in.refused[reason]++ + in.refusedMu.Unlock() +} + +// endStreams sets a read deadline of now on each open stream, so a blocked read returns and +// its handler ends. Restarts stay quick: queued lines are lost, and the producer counts them. +func (in *ingester) endStreams() { + in.streamsMu.Lock() + defer in.streamsMu.Unlock() + for rc := range in.streams { + _ = rc.SetReadDeadline(time.Now()) + } +} + +// ingestLine is one line, format v1. +type ingestLine struct { + V int `json:"v"` + SchemaVersion int `json:"schemaVersion"` + Op string `json:"op"` + RequestID string `json:"requestId"` + StartedAt time.Time `json:"startedAt"` + Session string `json:"session"` + Event model.SessionEvent `json:"event"` + Capture *ingestCapture `json:"capture"` + Cost *ingestCost `json:"cost"` +} + +// ingestCost holds a response row's cost-relevant headers. +type ingestCost struct { + Headers map[string]string `json:"headers"` +} + +// ingestCapture is the wire form of model.RequestCapture, which has no JSON tags. +type ingestCapture struct { + Original []byte `json:"original"` + Forwarded []byte `json:"forwarded"` + Header http.Header `json:"header"` + Query string `json:"query"` + Response *ingestResponse `json:"response"` + Dropped bool `json:"dropped"` +} + +type ingestResponse struct { + Status int `json:"status"` + Header http.Header `json:"header"` + Body []byte `json:"body"` + Decoded bool `json:"decoded"` + Truncated bool `json:"truncated"` + Ended bool `json:"ended"` + Incomplete bool `json:"incomplete"` +} + +// ingestGuard is why r may not reach an ingest route, or "". It is not clearRefusal: those +// texts say "erasing", and its third check is allowClear. +func ingestGuard(r *http.Request) string { + switch { + case !httpx.LoopbackHost(r.Host): + return "ingest is accepted only for a loopback Host (localhost, 127.0.0.1 or [::1])" + case r.Header.Get("Origin") != "": + return "ingest is refused from a browser: the request carries an Origin header" + } + return "" +} + +func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) { + in := s.ingest + if reason := ingestGuard(r); reason != "" { + http.Error(w, reason, http.StatusForbidden) + return + } + if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+in.cfg.Token)) != 1 { + http.Error(w, "ingest needs the gateway's bearer token", http.StatusUnauthorized) + return + } + rc := http.NewResponseController(w) + in.streamsMu.Lock() + in.streams[rc] = struct{}{} + in.streamsMu.Unlock() + defer func() { + in.streamsMu.Lock() + delete(in.streams, rc) + in.streamsMu.Unlock() + }() + + br := bufio.NewReaderSize(r.Body, 64<<10) + for { + b, over, err := readIngestLine(br) + switch { + case over: + in.refuse(refusedLineCap) + case len(bytes.TrimSpace(b)) > 0: + s.ingestOne(b) + } + if errors.Is(err, io.EOF) { + w.WriteHeader(http.StatusNoContent) + return + } + if err != nil { + slog.Debug("sessionapi: ingest stream ended", "error", err) + return + } + } +} + +// readIngestLine reads one line without its '\n'. A line over ingestLineCap is read to its +// end and discarded, and over is set. +func readIngestLine(br *bufio.Reader) (line []byte, over bool, err error) { + for { + frag, e := br.ReadSlice('\n') + if !over { + if len(line)+len(frag) > ingestLineCap+1 { + over, line = true, nil + } else { + line = append(line, frag...) + } + } + if errors.Is(e, bufio.ErrBufferFull) { + continue + } + line = bytes.TrimSuffix(line, []byte("\n")) + if len(line) > ingestLineCap { + over, line = true, nil + } + return line, over, e + } +} + +// ingestOne files one line, or counts why it was refused. +func (s *Server) ingestOne(b []byte) { + in := s.ingest + var l ingestLine + switch err := json.Unmarshal(b, &l); { + case err != nil: + in.refuse(refusedBadJSON) + return + case l.V != ingestLineVersion: + in.refuse(refusedVersion) + return + case l.SchemaVersion != SchemaVersion: + in.refuse(refusedSchema) + return + case l.Op != "append": + in.refuse(refusedOp) + return + } + sid := attribution.FiledSession(l.Session, s.store.ActiveSession) + // #1722 releases the line's tunnel join here. + if in.marks.erasedAfter(sid, l.StartedAt) { + in.refuse(refusedErased) + return + } + + ev := l.Event + // Cortex does not trust a producer's cost record, nor its numbering or time. + delete(ev.Plugins, event.Key) + delete(ev.Plugins, event.PluginName) + ev.Seq, ev.SessionID, ev.At = 0, "", time.Now() + + if ev.Phase == model.SessionResponse { + s.settleIngested(&ev, l.Cost) + } + s.store.AppendCapture(sid, ev, in.capture(l, &ev)) + in.lines.Add(1) +} + +// settleIngested prices a response row from its cost headers, with the publish gate of +// inferenceparser's settleCost. +func (s *Server) settleIngested(ev *model.SessionEvent, c *ingestCost) { + hdr := http.Header{} + if c != nil { + for k, v := range c.Headers { + hdr.Set(k, v) + } + } + inference.SettleServedModel(ev.Inference, hdr) + st := settle.SettleInput(settle.Input{Host: ev.Host, Path: ev.HTTPPath, Inference: ev.Inference, ResponseHeaders: hdr}, s.ingest.cfg.Rates) + if !st.Priced && !st.HasPrompt && st.RejectedReason == "" { + return + } + rec, err := json.Marshal(settle.NewRecord(st, nil)) + if err != nil { + slog.Warn("sessionapi: ingest cost record", "error", err) + return + } + if ev.Plugins == nil { + ev.Plugins = map[string]json.RawMessage{} + } + ev.Plugins[event.Key], ev.Plugins[event.PluginName] = rec, rec +} + +// capture is the capture to keep for l, or nil. Only an inference row keeps one (a body of +// any other request can be a credential), credentials are removed again, and a response +// capture is kept only after its request's. +func (in *ingester) capture(l ingestLine, ev *model.SessionEvent) *model.RequestCapture { + c := l.Capture + switch { + case c == nil: + return nil + case c.Dropped: + in.capturesDropped.Add(1) + return nil + case ev.Inference == nil: + return nil + } + in.reqCapsMu.Lock() + defer in.reqCapsMu.Unlock() + if c.Response == nil { + in.reqCaps[l.RequestID] = time.Now() + return &model.RequestCapture{Original: c.Original, Forwarded: c.Forwarded, + Header: redact.Headers(c.Header), Query: redact.Query(c.Query)} + } + now := time.Now() + for id, at := range in.reqCaps { + if now.Sub(at) > requestCaptureTTL { + delete(in.reqCaps, id) + } + } + if _, ok := in.reqCaps[l.RequestID]; !ok { + return nil + } + delete(in.reqCaps, l.RequestID) + res := c.Response + return &model.RequestCapture{Response: &model.ResponseCapture{Status: res.Status, Header: redact.Headers(res.Header), + Body: res.Body, Decoded: res.Decoded, Truncated: res.Truncated, Ended: res.Ended, Incomplete: res.Incomplete}} +} + +func (s *Server) handleIngestStats(w http.ResponseWriter, r *http.Request) { + if reason := ingestGuard(r); reason != "" { + http.Error(w, reason, http.StatusForbidden) + return + } + in := s.ingest + st := IngestStats{Lines: in.lines.Load(), CapturesDropped: in.capturesDropped.Load(), Refused: map[string]uint64{}} + in.refusedMu.Lock() + for _, reason := range refusalReasons { + st.Refused[reason] = in.refused[reason] + } + in.refusedMu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(st) +} diff --git a/core/sessionapi/ingest_test.go b/core/sessionapi/ingest_test.go new file mode 100644 index 000000000..cb1a8ae77 --- /dev/null +++ b/core/sessionapi/ingest_test.go @@ -0,0 +1,470 @@ +package sessionapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/rossoctl/cortex/core/cost/event" + "github.com/rossoctl/cortex/core/model" + "github.com/rossoctl/cortex/core/session" +) + +const testToken = "tok-123" + +// ingestCaptures is a session.CaptureRecorder that keeps what it is handed, by request id. +type ingestCaptures struct { + mu sync.Mutex + req map[string]*model.RequestCapture + res map[string]*model.ResponseCapture +} + +func (c *ingestCaptures) Record(string, *model.SessionEvent) {} + +func (c *ingestCaptures) RecordCapture(_ string, e *model.SessionEvent, rc *model.RequestCapture) { + if rc == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if rc.Response != nil { + c.res[e.RequestID] = rc.Response + return + } + c.req[e.RequestID] = rc +} + +func (c *ingestCaptures) get(id string) (*model.RequestCapture, *model.ResponseCapture) { + c.mu.Lock() + defer c.mu.Unlock() + return c.req[id], c.res[id] +} + +type ingestRig struct { + srv *Server + store *session.Store + caps *ingestCaptures + base string +} + +func newIngestRig(t *testing.T, opts ...Option) *ingestRig { + t.Helper() + store := session.New(time.Hour, 0, 0) + t.Cleanup(store.Close) + caps := &ingestCaptures{req: map[string]*model.RequestCapture{}, res: map[string]*model.ResponseCapture{}} + store.AddRecorder(caps) + opts = append([]Option{WithClearAllowed(true), WithIngest(Ingest{Token: testToken})}, opts...) + srv := New("", store, opts...) + ts := httptest.NewServer(srv.Server().Handler) + t.Cleanup(ts.Close) + return &ingestRig{srv: srv, store: store, caps: caps, base: ts.URL} +} + +// post sends lines as one NDJSON body and returns the status. +func (r *ingestRig) post(t *testing.T, edit func(*http.Request), lines ...[]byte) int { + t.Helper() + req, err := http.NewRequest(http.MethodPost, r.base+"/v1/ingest", bytes.NewReader(append(bytes.Join(lines, []byte("\n")), '\n'))) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+testToken) + if edit != nil { + edit(req) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + return resp.StatusCode +} + +func (r *ingestRig) stats(t *testing.T) IngestStats { + t.Helper() + resp, err := http.Get(r.base + "/v1/ingest/stats") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var st IngestStats + if err := json.NewDecoder(resp.Body).Decode(&st); err != nil { + t.Fatal(err) + } + return st +} + +func (r *ingestRig) events(sid string) []model.SessionEvent { + v := r.store.View(sid) + if v == nil { + return nil + } + return v.Events +} + +// line is one v1 ingest line; fields override the defaults. +func line(t *testing.T, fields map[string]any) []byte { + t.Helper() + l := map[string]any{ + "v": 1, "schemaVersion": SchemaVersion, "op": "append", + "requestId": "r1", "startedAt": time.Now().UTC().Format(time.RFC3339Nano), + "session": "s1", + "event": map[string]any{ + "at": "2020-01-01T00:00:00Z", "phase": "request", "direction": "outbound", + "requestId": "r1", "host": "api.anthropic.com", "httpPath": "/v1/messages", + "inference": map[string]any{"model": "claude-x"}, + }, + } + for k, v := range fields { + if v == nil { + delete(l, k) + continue + } + l[k] = v + } + b, err := json.Marshal(l) + if err != nil { + t.Fatal(err) + } + return b +} + +func responseEvent(extra map[string]any) map[string]any { + ev := map[string]any{ + "at": "2020-01-01T00:00:00Z", "phase": "response", "direction": "outbound", + "requestId": "r1", "host": "api.anthropic.com", "httpPath": "/v1/messages", + "inference": map[string]any{"model": "claude-x"}, + } + for k, v := range extra { + ev[k] = v + } + return ev +} + +func TestIngest_NotRegisteredWithoutOption(t *testing.T) { + srv := New("", session.New(time.Hour, 0, 0)) + if srv.ingest != nil { + t.Fatal("ingest state exists without WithIngest: no erase marks may be kept in direct mode") + } + if srv2 := New("", session.New(time.Hour, 0, 0), WithIngest(Ingest{})); srv2.ingest != nil { + t.Fatal("an empty token must not turn ingestion on") + } + ts := httptest.NewServer(srv.Server().Handler) + defer ts.Close() + resp, err := http.Post(ts.URL+"/v1/ingest", "application/x-ndjson", strings.NewReader("{}\n")) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("POST /v1/ingest = %d, want 404", resp.StatusCode) + } + resp, err = http.Get(ts.URL + "/v1/ingest/stats") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("GET /v1/ingest/stats = %d, want 404", resp.StatusCode) + } +} + +func TestIngest_Unauthorized(t *testing.T) { + r := newIngestRig(t) + for name, edit := range map[string]func(*http.Request){ + "none": func(q *http.Request) { q.Header.Del("Authorization") }, + "wrong": func(q *http.Request) { q.Header.Set("Authorization", "Bearer nope") }, + } { + if got := r.post(t, edit, line(t, nil)); got != http.StatusUnauthorized { + t.Errorf("%s token: status %d, want 401", name, got) + } + } + if len(r.events("s1")) != 0 { + t.Error("an unauthorized line was stored") + } +} + +func TestIngest_RefusesOriginAndForeignHost(t *testing.T) { + r := newIngestRig(t) + for name, edit := range map[string]func(*http.Request){ + "origin": func(q *http.Request) { q.Header.Set("Origin", "https://evil.example") }, + "host": func(q *http.Request) { q.Host = "evil.example" }, + } { + if got := r.post(t, edit, line(t, nil)); got != http.StatusForbidden { + t.Errorf("%s: status %d, want 403", name, got) + } + } + if len(r.events("s1")) != 0 { + t.Error("a refused line was stored") + } +} + +func TestIngest_RefusalsCountedConnectionStays(t *testing.T) { + r := newIngestRig(t) + big := bytes.Repeat([]byte("x"), ingestLineCap+1) + got := r.post(t, nil, + []byte("{not json"), + line(t, map[string]any{"v": 2}), + line(t, map[string]any{"schemaVersion": 2}), + line(t, map[string]any{"op": "pair"}), + big, + line(t, nil), + ) + if got != http.StatusNoContent { + t.Fatalf("status %d, want 204", got) + } + if n := len(r.events("s1")); n != 1 { + t.Fatalf("stored %d rows, want the 1 good one", n) + } + st := r.stats(t) + if st.Lines != 1 { + t.Errorf("ingest_lines_total = %d, want 1", st.Lines) + } + for _, reason := range []string{"bad_json", "version", "schema", "op", "line_cap"} { + if st.Refused[reason] != 1 { + t.Errorf("ingest_refused_total[%s] = %d, want 1", reason, st.Refused[reason]) + } + } + if st.Refused["erased"] != 0 { + t.Errorf("erased = %d, want 0", st.Refused["erased"]) + } +} + +func TestIngest_StoreAssignsSeqAndAt(t *testing.T) { + r := newIngestRig(t) + ev := map[string]any{"seq": 99, "sessionId": "forged", "at": "2001-01-01T00:00:00Z", "phase": "request", + "requestId": "r1", "inference": map[string]any{"model": "m"}} + r.post(t, nil, line(t, map[string]any{"event": ev})) + evs := r.events("s1") + if len(evs) != 1 { + t.Fatalf("got %d rows", len(evs)) + } + if evs[0].Seq != 1 { + t.Errorf("seq = %d, want 1", evs[0].Seq) + } + if evs[0].SessionID != "s1" { + t.Errorf("sessionId = %q, want s1", evs[0].SessionID) + } + if d := time.Since(evs[0].At); d < 0 || d > time.Second { + t.Errorf("at = %v, want within 1s of now", evs[0].At) + } +} + +func TestIngest_ProducerCostIsReplaced(t *testing.T) { + r := newIngestRig(t) + forged := map[string]any{"cost_usd": 999, "source": "gateway_header", "settled": true} + ev := responseEvent(map[string]any{"plugins": map[string]any{event.Key: forged, event.PluginName: forged}}) + r.post(t, nil, line(t, map[string]any{"event": ev, + "cost": map[string]any{"headers": map[string]string{"X-Litellm-Response-Cost": "0.01", "Content-Type": "application/json"}}})) + evs := r.events("s1") + if len(evs) != 1 { + t.Fatalf("got %d rows", len(evs)) + } + for _, key := range []string{event.Key, event.PluginName} { + var rec event.Event + if err := json.Unmarshal(evs[0].Plugins[key], &rec); err != nil { + t.Fatalf("plugins[%s]: %v (%s)", key, err, evs[0].Plugins[key]) + } + if rec.CostUSD != 0.01 { + t.Errorf("plugins[%s].cost_usd = %v, want the settled 0.01", key, rec.CostUSD) + } + } +} + +func TestIngest_CaptureRedactedAgain(t *testing.T) { + r := newIngestRig(t) + r.post(t, nil, + line(t, map[string]any{"capture": map[string]any{ + "original": []byte(`{"a":1}`), "forwarded": nil, + "header": map[string][]string{"Authorization": {"Bearer sk"}, "Accept": {"*/*"}}, + "query": "beta=true&api_key=sk", + }}), + line(t, map[string]any{"event": responseEvent(nil), "capture": map[string]any{ + "response": map[string]any{"status": 200, "header": map[string][]string{"Set-Cookie": {"c"}, "Content-Type": {"application/json"}}, + "body": []byte("{}"), "ended": true}, + }}), + ) + req, res := r.caps.get("r1") + if req == nil || res == nil { + t.Fatalf("captures: request %v, response %v", req, res) + } + if req.Header.Get("Authorization") != "" || req.Header.Get("Accept") != "*/*" { + t.Errorf("request header = %v", req.Header) + } + if req.Query != "beta=true" { + t.Errorf("query = %q, want beta=true", req.Query) + } + if string(req.Original) != `{"a":1}` || req.Forwarded != nil { + t.Errorf("original %q forwarded %q", req.Original, req.Forwarded) + } + if res.Header.Get("Set-Cookie") != "" || res.Header.Get("Content-Type") == "" { + t.Errorf("response header = %v", res.Header) + } + if res.Status != 200 || string(res.Body) != "{}" || !res.Ended { + t.Errorf("response = %+v", res) + } +} + +func TestIngest_CaptureOnlyForInference(t *testing.T) { + r := newIngestRig(t) + ev := map[string]any{"phase": "request", "requestId": "r1", "host": "oauth.example"} + r.post(t, nil, line(t, map[string]any{"event": ev, "capture": map[string]any{"original": []byte("secret=1")}})) + if len(r.events("s1")) != 1 { + t.Fatal("the row was not stored") + } + if req, _ := r.caps.get("r1"); req != nil { + t.Error("a capture was kept for a row without inference") + } +} + +func TestIngest_DroppedCaptureKeepsRow(t *testing.T) { + r := newIngestRig(t) + r.post(t, nil, line(t, map[string]any{"capture": map[string]any{"dropped": true}})) + if len(r.events("s1")) != 1 { + t.Fatal("the row was not stored") + } + if req, _ := r.caps.get("r1"); req != nil { + t.Error("a dropped capture was stored") + } + if st := r.stats(t); st.CapturesDropped != 1 { + t.Errorf("ingest_captures_dropped_total = %d, want 1", st.CapturesDropped) + } +} + +func TestIngest_NoResponseCaptureWithoutRequestCapture(t *testing.T) { + r := newIngestRig(t) + r.post(t, nil, + line(t, nil), + line(t, map[string]any{"event": responseEvent(nil), "capture": map[string]any{ + "response": map[string]any{"status": 200, "body": []byte("{}")}}}), + ) + if len(r.events("s1")) != 2 { + t.Fatal("the rows were not stored") + } + if _, res := r.caps.get("r1"); res != nil { + t.Error("a response capture was kept with no request capture") + } +} + +func eraseLines(t *testing.T, r *ingestRig, erase func()) { + t.Helper() + before := time.Now().UTC() + r.post(t, nil, line(t, map[string]any{"startedAt": before.Format(time.RFC3339Nano)})) + if len(r.events("s1")) != 1 { + t.Fatal("the first row was not stored") + } + erase() + if r.store.View("s1") != nil { + t.Fatal("the erase did not remove the session") + } + r.post(t, nil, line(t, map[string]any{"startedAt": before.Format(time.RFC3339Nano), "requestId": "r2"})) + if r.store.View("s1") != nil { + t.Fatal("a line queued before the erase brought the session back") + } + if st := r.stats(t); st.Refused["erased"] != 1 { + t.Errorf("ingest_refused_total[erased] = %d, want 1", st.Refused["erased"]) + } + r.post(t, nil, line(t, map[string]any{"startedAt": time.Now().UTC().Format(time.RFC3339Nano), "requestId": "r3"})) + evs := r.events("s1") + if len(evs) != 1 || evs[0].Seq != 1 { + t.Fatalf("a request after the erase: %d rows, want a new session with 1", len(evs)) + } +} + +func del(t *testing.T, url string) { + t.Helper() + req, _ := http.NewRequest(http.MethodDelete, url, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("DELETE %s = %d", url, resp.StatusCode) + } +} + +func TestIngest_EraseDropsOlderLines(t *testing.T) { + r := newIngestRig(t) + eraseLines(t, r, func() { del(t, r.base+"/v1/sessions/s1") }) +} + +func TestIngest_ClearDropsOlderLines(t *testing.T) { + r := newIngestRig(t) + eraseLines(t, r, func() { del(t, r.base+"/v1/sessions") }) +} + +func TestIngest_EraseMarkExpires(t *testing.T) { + for _, path := range []string{"/v1/sessions/s1", "/v1/sessions"} { + t.Run(path, func(t *testing.T) { + r := newIngestRig(t) + before := time.Now().UTC() + r.post(t, nil, line(t, map[string]any{"startedAt": before.Format(time.RFC3339Nano)})) + del(t, r.base+path) + r.srv.ingest.marks.setClock(func() time.Time { return time.Now().Add(11 * time.Minute) }) + r.post(t, nil, line(t, map[string]any{"startedAt": before.Format(time.RFC3339Nano), "requestId": "r2"})) + if len(r.events("s1")) != 1 { + t.Fatal("a line older than an expired erase mark was not kept") + } + }) + } +} + +func TestIngest_EmptySessionIsFiled(t *testing.T) { + r := newIngestRig(t) + r.store.Append("active", model.SessionEvent{}) + r.post(t, nil, line(t, map[string]any{"session": ""})) + if n := len(r.events("active")); n != 2 { + t.Fatalf("active session has %d rows, want 2", n) + } +} + +func TestIngest_ShutdownEndsStream(t *testing.T) { + store := session.New(time.Hour, 0, 0) + defer store.Close() + srv := New("", store, WithIngest(Ingest{Token: testToken})) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + go func() { _ = srv.Server().Serve(ln) }() + + pr, pw := io.Pipe() + defer pw.Close() + req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s/v1/ingest", ln.Addr()), pr) + req.Header.Set("Authorization", "Bearer "+testToken) + go func() { + if resp, err := http.DefaultClient.Do(req); err == nil { + resp.Body.Close() + } + }() + if _, err := pw.Write(append(line(t, nil), '\n')); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for store.View("s1") == nil { + if time.Now().After(deadline) { + t.Fatal("the line never arrived") + } + time.Sleep(5 * time.Millisecond) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + start := time.Now() + if err := srv.Server().Shutdown(ctx); err != nil { + t.Fatalf("Shutdown: %v", err) + } + if d := time.Since(start); d > time.Second { + t.Errorf("Shutdown took %v, want under 1s", d) + } +} diff --git a/core/sessionapi/server.go b/core/sessionapi/server.go index 7cc86222a..e9b0a397f 100644 --- a/core/sessionapi/server.go +++ b/core/sessionapi/server.go @@ -82,6 +82,10 @@ type Server struct { // "today" is cut at one instant and filled from another — which is why a caller pinning // one of them has to pin all three. now func() time.Time + // ingestCfg is WithIngest's; ingest is the endpoint's state, nil unless ingestCfg has a + // token. See ingest.go. + ingestCfg Ingest + ingest *ingester } // CatalogEntry is the wire shape for one plugin in /v1/plugins. Mirrors @@ -229,6 +233,12 @@ func New(addr string, store *session.Store, opts ...Option) *Server { mux.HandleFunc("GET /v1/usage", s.handleUsage) mux.HandleFunc("GET /healthz", s.handleHealthz) mux.HandleFunc("GET /{$}", s.handleIndex) + if s.ingestCfg.Token != "" { + s.ingest = newIngester(s.ingestCfg) + store.AddRecorder(s.ingest.marks) + mux.HandleFunc("POST /v1/ingest", s.handleIngest) + mux.HandleFunc("GET /v1/ingest/stats", s.handleIngestStats) + } var handler http.Handler = mux if s.loopbackOnly { @@ -239,6 +249,9 @@ func New(addr string, store *session.Store, opts ...Option) *Server { Handler: handler, ReadHeaderTimeout: 10 * time.Second, } + if s.ingest != nil { + s.server.RegisterOnShutdown(s.ingest.endStreams) + } return s } diff --git a/docs/session-api.md b/docs/session-api.md index f1b08a817..a54ce7b83 100644 --- a/docs/session-api.md +++ b/docs/session-api.md @@ -30,6 +30,8 @@ through DNS rebinding. The stats server applies the same check. | `GET /v1/pipeline` | `application/json` | Active pipeline composition, per plugin: name, direction, position, capabilities, runtime `config`. `onError` is sent only when it is not the default — in practice `observe`, since an `on_error: off` plugin is not built. A plugin under observe runs and records, but its rejections, body writes and redirects are dropped (header writes still apply), so its `config` says what it would do. agentop renders this as the Pipeline pane (`P`) | | `GET /v1/plugins` | `application/json` | Catalog of every registered plugin, active or not. 404 when the binary built its session API without `WithCatalog`. agentop renders this as the Catalog pane (`C`) | | `GET /v1/usage` | `application/json` | Windowed usage aggregation — the aggregate behind `agentop cost`; parameters and semantics in [`pricing.md`](pricing.md#reading-usage-over-http) | +| `POST /v1/ingest` | NDJSON in, `204` out | Praxis mode only: rows that `cortex-gateway` records (see below). 404 otherwise | +| `GET /v1/ingest/stats` | `application/json` | Praxis mode only: the ingestion counters (see below). 404 otherwise | | `GET /healthz` | text | Liveness probe | ## `GET /v1/sessions` @@ -318,6 +320,72 @@ kept. An agent still using the session brings it back with its next request. agentop's `x` drives it, one request per session for an agent-wide erase — see [`laptop-service.md`](laptop-service.md) for the user-facing story. +## `POST /v1/ingest` — rows from cortex-gateway (praxis mode) + +Registered only when `praxis.enabled` and `listener.bind_loopback_only` are +both set; otherwise this route and `GET /v1/ingest/stats` answer 404 and no +erase mark is kept. The design is in +[`superpowers/specs/2026-11-07-praxis-mode-design.md`](superpowers/specs/2026-11-07-praxis-mode-design.md), Section 1. + +**Auth and guards, in order:** + +1. A non-loopback `Host`, or an `Origin` header: 403. +2. No `Authorization: Bearer `: 401. Cortex reads the token from the + file that `CORTEX_GATEWAY_TOKEN_FILE` names. + +A browser cannot add `Authorization` to a simple cross-site POST, so the +token is the protection and the guards are a second layer. + +**The body** is NDJSON, one line per row, on one long-lived stream. A line +over 64 MiB is discarded and refused. A refused line does not close the +stream. When the body ends, the answer is 204. On shutdown, Cortex ends each +open stream at once; lines still queued in the gateway are lost and counted +there. + +**Line format, version 1:** + +```json +{"v":1, "schemaVersion":1, "op":"append", "requestId":"…", "startedAt":"", + "session":"", "event":{ "...SessionEvent wire JSON..." }, + "capture":{"original":"", "forwarded":null, "header":{"Name":["v"]}, "query":"", + "response":{"status":200, "header":{}, "body":"", "decoded":true, + "truncated":false, "ended":true, "incomplete":false}}, + "cost":{"headers":{"X-Litellm-Response-Cost":"0.01", "Content-Type":"application/json"}}} +``` + +A request row carries `original`, `header` and `query`; a response row +carries `response` only. A capture the producer dropped is +`{"dropped": true}`. `cost` is on response rows only. + +**What Cortex does with a line:** + +- Files it under `session`; an empty `session` goes to the active session, + then to `default`, as a listener files it. +- Assigns `seq`, `sessionId` and `at`. The producer's values are not kept. +- Removes the producer's `plugins.cost` and `plugins.litellm-budget-track`, + and settles a response row again from `cost.headers` and its own rate table. +- Keeps a capture only for an event with `inference`, removes credentials + from its headers and query again, and keeps a response capture only after + the request capture of the same `requestId` (held for up to 1 hour). +- Drops a line whose `startedAt` is before an erase of its session + (`DELETE /v1/sessions/{id}` or `DELETE /v1/sessions`) in the last 10 + minutes, so a queued row cannot bring an erased session back. + +**Refusal reasons:** `bad_json`, `version` (`v` is not 1), `schema` +(`schemaVersion` is not this API's), `op` (not `append`), `line_cap`, +`erased`. + +## `GET /v1/ingest/stats` + +The ingestion counters, since start. The same loopback and `Origin` guards +as `POST /v1/ingest`; no token. + +```json +{"ingest_lines_total": 120, + "ingest_refused_total": {"bad_json":0, "version":0, "schema":0, "op":0, "line_cap":0, "erased":1}, + "ingest_captures_dropped_total": 0} +``` + ## Quick examples The `agentop` TUI handles port-forward + connection automatically — pick a From 5fe0cc09b5a871e4eb8ae71433bb7d0a89d5e68d Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 15:34:33 -0500 Subject: [PATCH 05/26] Feat: Add the cortex-gateway crate and the record module scaffold Add the server crate `cortex-gateway` to the praxis workspace. It starts the Praxis 0.8.0 server with a filter registry. The registry has no Cortex filters yet. Add the dependencies that the later tasks use, and empty modules for `record` and `filters`. `record::emitter` holds `Queued`, the type that the emitter and the row builder share. Make `parse::inference::text` visible to the crate. In the praxis CI job, set the timeout to 45 minutes and the target/ cache cap to 3072 MB. Run the job also when the Go ingest, redact and subagent code changes. Part of #1719. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .github/workflows/praxis.yaml | 18 +- CLAUDE.md | 2 +- praxis/Cargo.lock | 3176 ++++++++++++++++++++- praxis/Cargo.toml | 2 +- praxis/README.md | 43 +- praxis/filters/Cargo.toml | 10 + praxis/filters/src/filters/attribution.rs | 1 + praxis/filters/src/filters/mod.rs | 4 + praxis/filters/src/filters/observe.rs | 1 + praxis/filters/src/lib.rs | 2 + praxis/filters/src/parse/inference/mod.rs | 2 +- praxis/filters/src/record/attribution.rs | 1 + praxis/filters/src/record/client.rs | 1 + praxis/filters/src/record/emitter.rs | 10 + praxis/filters/src/record/line.rs | 1 + praxis/filters/src/record/mod.rs | 8 + praxis/filters/src/record/redact.rs | 1 + praxis/filters/src/record/row.rs | 1 + praxis/server/Cargo.toml | 17 + praxis/server/src/main.rs | 59 + 20 files changed, 3244 insertions(+), 116 deletions(-) create mode 100644 praxis/filters/src/filters/attribution.rs create mode 100644 praxis/filters/src/filters/mod.rs create mode 100644 praxis/filters/src/filters/observe.rs create mode 100644 praxis/filters/src/record/attribution.rs create mode 100644 praxis/filters/src/record/client.rs create mode 100644 praxis/filters/src/record/emitter.rs create mode 100644 praxis/filters/src/record/line.rs create mode 100644 praxis/filters/src/record/mod.rs create mode 100644 praxis/filters/src/record/redact.rs create mode 100644 praxis/filters/src/record/row.rs create mode 100644 praxis/server/Cargo.toml create mode 100644 praxis/server/src/main.rs diff --git a/.github/workflows/praxis.yaml b/.github/workflows/praxis.yaml index 5111281e6..8acb30a4d 100644 --- a/.github/workflows/praxis.yaml +++ b/.github/workflows/praxis.yaml @@ -26,6 +26,11 @@ on: - "core/model/client.go" - "core/cost/settle/settle.go" - "core/listener/forwardproxy/server.go" + - "core/sessionapi/ingest*.go" + - "core/sessionapi/testdata/ingest/**" + - "core/redact/**" + - "core/pipeline/subagent.go" + - "core/listener/forwardproxy/ingest_replay_test.go" push: branches: - main @@ -44,6 +49,11 @@ on: - "core/model/client.go" - "core/cost/settle/settle.go" - "core/listener/forwardproxy/server.go" + - "core/sessionapi/ingest*.go" + - "core/sessionapi/testdata/ingest/**" + - "core/redact/**" + - "core/pipeline/subagent.go" + - "core/listener/forwardproxy/ingest_replay_test.go" permissions: contents: read @@ -52,7 +62,7 @@ jobs: rust: name: Rust (praxis) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -118,7 +128,7 @@ jobs: [ "$CLIPPY" != failure ] || echo "::warning::cargo clippy failed (does not block: no praxis/ changes)" [ "$TEST" != failure ] || echo "::warning::cargo test failed (does not block: no praxis/ changes)" - # Size cap: the cache post-step must not save a target/ over 2 GiB. + # Size cap: the cache post-step must not save a target/ over 3 GiB. - name: Cap the target/ cache size if: always() run: | @@ -126,7 +136,7 @@ jobs: [ -d praxis/target ] || exit 0 size=$(du -sm praxis/target | cut -f1) echo "praxis/target: ${size} MB" - if [ "$size" -gt 2048 ]; then - echo "Over 2048 MB; removing praxis/target so the cache does not save it." + if [ "$size" -gt 3072 ]; then + echo "Over 3072 MB; removing praxis/target so the cache does not save it." rm -rf praxis/target fi diff --git a/CLAUDE.md b/CLAUDE.md index 26c0a7e0d..b294f9cb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,7 +172,7 @@ remains there: it runs `scripts/install.sh`, for the URL v0.7.0 and its docs giv | `cmd/cortex{,-envoy}/` | Sidecar binaries. [`cmd/README.md`](cmd/README.md) says which binary pins which shape | | `cmd/cortex-cpex/` | No Go code: the `authbridge-cpex` Dockerfile and the CPEX FFI pins | | `cmd/agentop/` | The TUI over the session API, and the component the root README leads with. Flags and keys: [`cmd/agentop/README.md`](cmd/agentop/README.md) | -| `praxis/` | Rust workspace for praxis mode: `cortex-praxis-filters`. Outside `go.work`. See #1714 | +| `praxis/` | Rust workspace for praxis mode: `cortex-praxis-filters` and the `cortex-gateway` binary. Outside `go.work`. See #1714 | | `docs/` | Plugin and framework reference ([`docs/README.md`](docs/README.md) is the index). `docs/superpowers/{plans,specs}/` holds dated design records and is **still written to** | | `scripts/` | `install.sh` (laptop installer), `keycloak_sync.py` (scope mappings from `routes.yaml`), `profile-tags/` (build-tag profiles), `hooks/commit-msg`, `dev/` | | `deploy/` | `proxy-init/` (iptables init container), `sparc-service/`, `lineage-attach/` | diff --git a/praxis/Cargo.lock b/praxis/Cargo.lock index d460c0d56..09fdafc88 100644 --- a/praxis/Cargo.lock +++ b/praxis/Cargo.lock @@ -8,227 +8,3173 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "arc-swap" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84a22b5d51209f6de83d37a0b33d320acb8da88823e42a94c075140708f032a5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link 0.2.1", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cortex-gateway" +version = "0.1.0" +dependencies = [ + "cortex-praxis-filters", + "openssl", + "praxis-proxy", + "praxis-proxy-filter", +] + +[[package]] +name = "cortex-praxis-filters" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "flate2", + "http", + "http-body-util", + "hyper", + "metrics", + "praxis-proxy-filter", + "serde", + "serde_json", + "tokio", + "yaml_serde", + "zstd 0.13.4", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" +dependencies = [ + "cmov", +] + +[[package]] +name = "daemonix" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0b747381562a10fd2104e9333ad72fe5158d79de81b64426fc9e229c6d3fb38" +dependencies = [ + "libc", +] + +[[package]] +name = "daggy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70def8d72740e44d9f676d8dab2c933a236663d86dd24319b57a2bed4d694774" +dependencies = [ + "petgraph", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api 0.4.14", + "once_cell", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "evmap" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8" +dependencies = [ + "hashbag", + "left-right", + "smallvec", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fixedbitset" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5905ec755cc8386d7928353e4d356d2be3e89e81e5ca01fe630a1ca4d08dc175" + +[[package]] +name = "flate2" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" +dependencies = [ + "crc32fast", + "libz-ng-sys", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flurry" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" +dependencies = [ + "ahash", + "num_cpus", + "parking_lot 0.12.5", + "seize", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6c6a73cd1aa4f4f00c02b109fa55d3a9d6ecc2fc4f53177b74ed6ee23b0c53" +dependencies = [ + "cc", + "libc", + "log", + "rustversion", + "windows-core 0.100.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbag" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "left-right" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc015ded5d9b3054dbbdb63332cdd6ee42352ccef19e911e25117490e2f48ee" +dependencies = [ + "crossbeam-utils", + "loom", + "slab", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + +[[package]] +name = "libz-ng-sys" +version = "1.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6f7f90c9e84e2716ee493ef7493995eb52772deb5ce12f960c13f6a90350a3" +dependencies = [ + "cmake", + "libc", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "lock_api" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65d91b22da179613dcf4d6ec8349563f52a363f6849e03b00dd0a185ec4a59af" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "loom" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1611ddbf89dc6ae42cfbd51fc207836140130257646eab8d6d2fb16577db2d2" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" +dependencies = [ + "base64 0.22.1", + "evmap", + "indexmap", + "metrics", + "metrics-util", + "quanta", + "thiserror", +] + +[[package]] +name = "metrics-util" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.16.1", + "metrics", + "quanta", + "rand 0.9.5", + "rand_xoshiro", + "rapidhash", + "sketches-ddsketch", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "no_debug" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f23a60c850e1144fc1dd9435152e0cfdc7dd18725350b4243584118013a52a4" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de8111e94a56adca7ebfe6c365b6d42f99deb69b74b6becacd4204e2f193aa2" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c413d6ec9ebaf5e1c829d355cb3e9f783778d700f135be0921d52fce3c60ae" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api 0.4.14", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9cf937ca4e1d0b7578784559063422fc39ae7c5a00f02471f2614d5486fd226" +dependencies = [ + "lock_api 0.5.0", + "parking_lot_core 0.10.0", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "parking_lot_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cfff060068cc18aa7815010fcaf80c68c7fb9e85fc7f5c0f9bf142c21d19826" +dependencies = [ + "libc", + "redox_syscall 0.9.5", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "praxis-proxy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "679704b99eaf94186b434f762e71676eff8194000de879182a7be88b4107609b" +dependencies = [ + "cargo_metadata", + "clap", + "nix", + "praxis-proxy-core", + "praxis-proxy-filter", + "praxis-proxy-protocol", + "praxis-proxy-tls", + "serde", + "tikv-jemallocator", + "tokio", + "tokio-util", + "tracing", + "yaml_serde", +] + +[[package]] +name = "praxis-proxy-core" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fdbdddaea5d23868729aa8a314fa3a9ba22f7da836e16f47336a16f57575a5" +dependencies = [ + "bytes", + "chrono", + "dashmap", + "http", + "metrics", + "percent-encoding", + "praxis-proxy-tls", + "quixotic-plecostomus-core", + "quixotic-plecostomus-http", + "rand 0.10.3", + "regex", + "serde", + "thiserror", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", + "yaml_serde", +] + +[[package]] +name = "praxis-proxy-filter" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9077d3bbe1506e63f1f4599496190f8231d6d84340a551d64caed877b90e157a" +dependencies = [ + "arc-swap", + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "dashmap", + "http", + "metrics", + "parking_lot 0.12.5", + "percent-encoding", + "praxis-proxy-core", + "praxis-proxy-tls", + "rand 0.10.3", + "regex", + "secrecy", + "serde", + "serde_json", + "smallvec", + "thiserror", + "tokio", + "tracing", + "yaml_serde", + "zeroize", +] + +[[package]] +name = "praxis-proxy-protocol" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69767ac4ddc99631de4fb4a1fc70f3635f3d027faf78599d855bccbf980e964b" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "futures", + "h2", + "http", + "metrics", + "metrics-exporter-prometheus", + "praxis-proxy-core", + "praxis-proxy-filter", + "praxis-proxy-tls", + "quixotic-plecostomus-core", + "quixotic-plecostomus-http", + "quixotic-plecostomus-proxy", + "rand 0.10.3", + "rustls", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "yaml_serde", +] + +[[package]] +name = "praxis-proxy-tls" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f714e194d42c841c5464cc5efabbde3461a7f0640c5d8134dd83d3fbb7547e" +dependencies = [ + "quixotic-plecostomus-rustls-openssl", + "rustls", + "serde", + "thiserror", + "tracing", + "zeroize", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quixotic-plecostomus-cache" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a846a0ab5b483914551ecfab7922da86909cda6c3f76648789434912d32acf38" +dependencies = [ + "ahash", + "arrayvec", + "async-trait", + "blake2", + "bstr", + "bytes", + "hex", + "http", + "httparse", + "httpdate", + "indexmap", + "log", + "lru", + "once_cell", + "parking_lot 0.12.5", + "quixotic-plecostomus-core", + "quixotic-plecostomus-error", + "quixotic-plecostomus-header-serde", + "quixotic-plecostomus-http", + "quixotic-plecostomus-lru", + "quixotic-plecostomus-timeout", + "rand 0.10.3", + "regex", + "rmp", + "rmp-serde", + "serde", + "strum", + "tokio", +] + +[[package]] +name = "quixotic-plecostomus-core" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d31d0e7c6cc1e59bf60d56bb18a3852aab815a1cb4def6b4580a33b64d4541f" +dependencies = [ + "ahash", + "async-trait", + "brotli", + "bstr", + "bytes", + "chrono", + "clap", + "daemonix", + "daggy", + "flate2", + "flurry", + "futures", + "h2", + "http", + "httparse", + "httpdate", + "libc", + "log", + "nix", + "once_cell", + "openssl-probe", + "ouroboros", + "parking_lot 0.12.5", + "percent-encoding", + "quixotic-plecostomus-error", + "quixotic-plecostomus-http", + "quixotic-plecostomus-pool", + "quixotic-plecostomus-runtime", + "quixotic-plecostomus-rustls", + "quixotic-plecostomus-timeout", + "rand 0.10.3", + "regex", + "serde", + "sfv", + "socket2", + "strum", + "strum_macros", + "tokio", + "tokio-stream", + "tokio-test", + "tokio-util", + "unicase", + "windows-sys 0.59.0", + "x509-parser", + "yaml_serde", + "zstd 0.13.4", +] + +[[package]] +name = "quixotic-plecostomus-error" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a76f53bd72ce9a7e1ce66ecd1196bdca3ce602f7963e7e5da6d2b7069465b10" + +[[package]] +name = "quixotic-plecostomus-header-serde" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6169b0102aff5e460cbfb342b047c7b534c2dcfbc62520e42a628d1cf3f26e4" +dependencies = [ + "bytes", + "http", + "httparse", + "quixotic-plecostomus-error", + "quixotic-plecostomus-http", + "thread_local", + "zstd 0.14.3", + "zstd-safe 8.2.0", +] + +[[package]] +name = "quixotic-plecostomus-http" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b706dc57349a6a5e56e585568402a8f1556af06eb6ab3f40603d3037afffed" +dependencies = [ + "bytes", + "http", + "quixotic-plecostomus-error", +] + +[[package]] +name = "quixotic-plecostomus-lru" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60550ff682553ac8aa53d4d7e2f9589714a5f21e593386d00213a211d195bc87" +dependencies = [ + "ahash", + "arrayvec", + "async-channel", + "flurry", + "hashbrown 0.17.1", + "log", + "parking_lot 0.13.0", + "rand 0.10.3", + "tokio", +] + +[[package]] +name = "quixotic-plecostomus-pool" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b5196e83cca914c2070903c7ecae61a352393813ee7d8d8fda953a360d8d4e" +dependencies = [ + "crossbeam-queue", + "dashmap", + "futures", + "log", + "lru", + "parking_lot 0.12.5", + "quixotic-plecostomus-timeout", + "tokio", +] + +[[package]] +name = "quixotic-plecostomus-proxy" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debf4670cfffb2a9cbda8b95f3eb13f85b4a6d7611a984a4765b79c05da67acf" +dependencies = [ + "async-trait", + "bytes", + "clap", + "futures", + "h2", + "http", + "log", + "once_cell", + "quixotic-plecostomus-cache", + "quixotic-plecostomus-core", + "quixotic-plecostomus-error", + "quixotic-plecostomus-http", + "rand 0.10.3", + "regex", + "tokio", +] + +[[package]] +name = "quixotic-plecostomus-runtime" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b741fc2c0885703483f49ba522fde516515a3ebf6c434558d2533df0905a7e4" +dependencies = [ + "log", + "once_cell", + "rand 0.10.3", + "serde", + "thread_local", + "tokio", +] + +[[package]] +name = "quixotic-plecostomus-rustls" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73314b19a16e57d2b8538c8d9b1d123ef5080244e38092417d2df6e86f823cc2" +dependencies = [ + "log", + "no_debug", + "quixotic-plecostomus-error", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "tokio-rustls", +] + +[[package]] +name = "quixotic-plecostomus-rustls-openssl" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6a3a34cedce4e2fa3139daae891222fde83957801bf0de8ecc8c09ffaf8796" +dependencies = [ + "foreign-types", + "once_cell", + "openssl", + "openssl-sys", + "rustls", + "zeroize", +] + +[[package]] +name = "quixotic-plecostomus-timeout" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a87584d90963472790ad0a72590974cf966793b047fecf8866d6f9debb351d" +dependencies = [ + "once_cell", + "parking_lot 0.12.5", + "pin-project-lite", + "thread_local", + "tokio", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "num-traits", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "689224d06523904ebcc9b482c6a3f4f7fb396096645c4cd10c0d2ff7371a34d3" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sfv" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa1f336066b758b7c9df34ed049c0e693a426afe2b27ff7d5b14f410ab1a132" +dependencies = [ + "base64 0.22.1", + "indexmap", + "rust_decimal", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "sketches-ddsketch" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45f82fa313d625dd72aaa65ec0c9f8c95407add0510c1bef3c18ea7257ec34ce" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tikv-jemalloc-sys" +version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2825c78386b4ae0314074867860ba9577875de945f05992c38815cbec327f0" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249f09e49ab1609436f34c776e84231bead18d6a955f119f939bdc1d847561bd" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bc5db7778a8e08ee255090861e0e7aac55e784300077e7ed293c7be36168b2" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] -name = "cc" -version = "1.11.1" +name = "tracing-core" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", + "once_cell", + "valuable", ] [[package]] -name = "cfg-if" -version = "1.0.5" +name = "tracing-log" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] [[package]] -name = "cortex-praxis-filters" -version = "0.1.0" +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" dependencies = [ - "flate2", - "http", + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", "serde", "serde_json", - "zstd", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", ] [[package]] -name = "crc32fast" -version = "1.5.4" +name = "try-lock" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ - "cfg-if", + "try-lock", ] [[package]] -name = "find-msvc-tools" -version = "0.1.15" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "flate2" -version = "1.1.11" +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "crc32fast", - "miniz_oxide", - "zlib-rs", + "wit-bindgen", ] [[package]] -name = "getrandom" -version = "0.4.3" +name = "wasm-bindgen" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" dependencies = [ "cfg-if", - "libc", - "r-efi", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "http" -version = "1.5.0" +name = "wasm-bindgen-macro" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" dependencies = [ - "bytes", - "itoa", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "itoa" -version = "1.0.19" +name = "wasm-bindgen-macro-support" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.10", + "wasm-bindgen-shared", +] [[package]] -name = "jobserver" -version = "0.1.35" +name = "wasm-bindgen-shared" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" dependencies = [ - "getrandom", - "libc", + "unicode-ident", ] [[package]] -name = "libc" -version = "0.2.191" +name = "web-sys" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "memchr" -version = "2.8.3" +name = "winapi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] [[package]] -name = "miniz_oxide" -version = "0.9.1" +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "adler2", - "simd-adler32", + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] -name = "pkg-config" -version = "0.3.34" +name = "windows-core" +version = "0.100.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +checksum = "803590c6856f51d15fa2e9fabe4f4703122c8737f72ef2741b4ac09d4c2192f5" +dependencies = [ + "windows-implement 0.100.0", + "windows-interface 0.100.0", + "windows-link 0.100.0", + "windows-result 0.100.0", + "windows-strings 0.100.0", +] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ - "unicode-ident", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "quote" -version = "1.0.47" +name = "windows-implement" +version = "0.100.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "1bc1bc82e5a1b1a4d7255861b07881a0407b552f50a4d8543c0c7276fa96f6e8" dependencies = [ "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "r-efi" -version = "6.0.0" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] -name = "serde" -version = "1.0.229" +name = "windows-interface" +version = "0.100.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "b3f7005c3b757b9b82c2f40c0e71094ff1080adddaf3e8b6ce99b545d3c48796" dependencies = [ - "serde_core", - "serde_derive", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "serde_core" -version = "1.0.229" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-link" +version = "0.100.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b39de7c7fe78858b0144c50c3fc000bab3be17d5e9b85b1053871693c7f7415" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "serde_derive", + "windows-link 0.2.1", ] [[package]] -name = "serde_derive" -version = "1.0.229" +name = "windows-result" +version = "0.100.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "44873a1ef61c85d5e2991db91fe656cfcf0731097e00dff1ac860f1cb3b752a0" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-link 0.100.0", ] [[package]] -name = "serde_json" -version = "1.0.154" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.100.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a70b590c684f1d2854f74999896f7c0558f1bdde62f599088599e20ea1d2f60" +dependencies = [ + "windows-link 0.100.0", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "yaml_serde" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918" dependencies = [ + "indexmap", "itoa", - "memchr", + "libyaml-rs", + "ryu", "serde", - "serde_core", - "zmij", ] [[package]] -name = "shlex" -version = "2.0.1" +name = "yansi" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] -name = "simd-adler32" -version = "0.3.10" +name = "zerocopy" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] [[package]] -name = "syn" -version = "3.0.10" +name = "zerocopy-derive" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.119", ] [[package]] -name = "unicode-ident" -version = "1.0.27" +name = "zeroize" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zlib-rs" @@ -248,7 +3194,16 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb02c3132d251ab1606e9fc913a7da6c6ed08cc263df15edf082e1dc497d157" dependencies = [ - "zstd-safe", + "zstd-safe 7.4.0", +] + +[[package]] +name = "zstd" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" +dependencies = [ + "zstd-safe 8.2.0", ] [[package]] @@ -260,6 +3215,15 @@ dependencies = [ "zstd-sys", ] +[[package]] +name = "zstd-safe" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" +dependencies = [ + "zstd-sys", +] + [[package]] name = "zstd-sys" version = "2.1.2+zstd.1.5.7" diff --git a/praxis/Cargo.toml b/praxis/Cargo.toml index 15950252c..e0a404859 100644 --- a/praxis/Cargo.toml +++ b/praxis/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["filters"] +members = ["filters", "server"] resolver = "3" diff --git a/praxis/README.md b/praxis/README.md index 1e8fed0fd..4decae951 100644 --- a/praxis/README.md +++ b/praxis/README.md @@ -1,8 +1,12 @@ # praxis -This is the Rust workspace for praxis mode (#1714, phase A). Its crate, -`cortex-praxis-filters`, will hold the Cortex filters that run inside Praxis. -It now holds a port of the Go inference parser. It uses no Praxis API yet. +This is the Rust workspace for praxis mode (#1714). It has two crates: + +- `cortex-praxis-filters` (`filters/`): the Cortex filters that run inside + Praxis, a port of the Go inference parser (`parse`), and the row builder and + emitter that send rows to Cortex (`record`). +- `cortex-gateway` (`server/`): the Praxis server with the Cortex filters + registered (#1719). The workspace is outside `go.work`. @@ -20,6 +24,39 @@ cargo test --locked `rust-toolchain.toml` pins the toolchain (Rust 1.96.0, with `rustfmt` and `clippy`). `rustup toolchain install`, run in this directory, installs it, as CI does. +## Run the gateway + +```sh +cargo run --locked -p cortex-gateway -- -c gateway.yaml +``` + +The config path comes from `-c `, then `PRAXIS_CONFIG`, then +`praxis.yaml` in the current directory. The supervisor (#1720) writes +`gateway.yaml`. Until then, write it by hand. + +## Gateway decisions (#1719) + +The plan `docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md` +gives the full text. + +1. The filters stay thin. All logic is in `record` and `parse`, which take plain values. End-to-end tests start the real binary. +2. Cortex reads the token file path from `CORTEX_GATEWAY_TOKEN_FILE`. The gateway reads it from the `token_file` filter config key. Cortex never creates the file. +3. The Go `praxis` config block has only `enabled` for now. +4. Ingest counters are at `GET /v1/ingest/stats` on the session API, in praxis mode only. +5. `X-Cortex-Attribution` is base64url JSON without padding. A missing or bad header gives the default attribution. +6. Fields that the gateway cannot know in v1 stay unset (`identity`, `tls`, `invocations`, and others). +7. `client`, `subagent` and `parentSubagent` are ported to Rust, pinned by a Go golden table. +8. `client_canceled` and `upstream_error` messages are Rust text. Only the message differs from Go. +9. Ingestion keeps the request id of a kept request capture for up to 1 hour, until its response line arrives. +10. Tunnel joins land with the hop (#1722). +11. One replay NDJSON file is the golden for both the Go ingestion and the Rust row builder. +12. The emitter has its own thread and a current-thread Tokio runtime. +13. `praxis-proxy` builds without default features, with vendored OpenSSL. +14. The memory measurement is a script, not a CI gate. +15. The filters use the Praxis 0.8.0 API. No fork, no patch. +16. `CortexObserve` keeps its exchange in filter state as `Arc>`. +17. The ingest stream ends on Cortex shutdown, so restarts stay quick. + ## The parity rule Go is the source of truth. A Go parser change regenerates `expected.json`, diff --git a/praxis/filters/Cargo.toml b/praxis/filters/Cargo.toml index 84d79cb56..b8546b645 100644 --- a/praxis/filters/Cargo.toml +++ b/praxis/filters/Cargo.toml @@ -12,3 +12,13 @@ serde_json = { version = "1", features = ["raw_value"] } http = "1" flate2 = "1" zstd = "0.13" +praxis-proxy-filter = { version = "=0.8.0", default-features = false } +async-trait = "0.1" +bytes = "1" +tokio = { version = "1", features = ["rt", "net", "sync", "time", "io-util", "macros"] } +hyper = { version = "1", features = ["client", "http1"] } +http-body-util = "0.1" +base64 = "0.22" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +metrics = "0.24" +serde_yaml = { package = "yaml_serde", version = "0.10.7" } diff --git a/praxis/filters/src/filters/attribution.rs b/praxis/filters/src/filters/attribution.rs new file mode 100644 index 000000000..9f5cfbf8f --- /dev/null +++ b/praxis/filters/src/filters/attribution.rs @@ -0,0 +1 @@ +//! The `cortex_attribution` filter. diff --git a/praxis/filters/src/filters/mod.rs b/praxis/filters/src/filters/mod.rs new file mode 100644 index 000000000..5117c8739 --- /dev/null +++ b/praxis/filters/src/filters/mod.rs @@ -0,0 +1,4 @@ +//! Praxis filters that adapt the Praxis hooks to `record` and `parse`. + +pub mod attribution; +pub mod observe; diff --git a/praxis/filters/src/filters/observe.rs b/praxis/filters/src/filters/observe.rs new file mode 100644 index 000000000..b6683e2e6 --- /dev/null +++ b/praxis/filters/src/filters/observe.rs @@ -0,0 +1 @@ +//! The `cortex_observe` filter. diff --git a/praxis/filters/src/lib.rs b/praxis/filters/src/lib.rs index ea868482d..1a06de34e 100644 --- a/praxis/filters/src/lib.rs +++ b/praxis/filters/src/lib.rs @@ -1 +1,3 @@ +pub mod filters; pub mod parse; +pub mod record; diff --git a/praxis/filters/src/parse/inference/mod.rs b/praxis/filters/src/parse/inference/mod.rs index ce27ce133..d55f91c71 100644 --- a/praxis/filters/src/parse/inference/mod.rs +++ b/praxis/filters/src/parse/inference/mod.rs @@ -11,7 +11,7 @@ mod openai; mod responses; mod served_model; mod subagent; -mod text; +pub(crate) mod text; mod usage; pub use observer::InferenceObserver; diff --git a/praxis/filters/src/record/attribution.rs b/praxis/filters/src/record/attribution.rs new file mode 100644 index 000000000..119fc977d --- /dev/null +++ b/praxis/filters/src/record/attribution.rs @@ -0,0 +1 @@ +//! Attribution: the `X-Cortex-Attribution` header, the token check and the hop header lists. diff --git a/praxis/filters/src/record/client.rs b/praxis/filters/src/record/client.rs new file mode 100644 index 000000000..959b922b8 --- /dev/null +++ b/praxis/filters/src/record/client.rs @@ -0,0 +1 @@ +//! Port of `model.ParseUserAgent` and `subagentHeader`. diff --git a/praxis/filters/src/record/emitter.rs b/praxis/filters/src/record/emitter.rs new file mode 100644 index 000000000..6252858c1 --- /dev/null +++ b/praxis/filters/src/record/emitter.rs @@ -0,0 +1,10 @@ +//! The emitter: one thread per process that streams NDJSON lines to Cortex. + +/// One line, serialized twice: without and with its capture. +/// +/// Each ends in `\n`. `row` holds `"capture":{"dropped":true}` when the line +/// has a capture, and no `capture` key otherwise. +pub struct Queued { + pub row: Vec, + pub with_capture: Option>, +} diff --git a/praxis/filters/src/record/line.rs b/praxis/filters/src/record/line.rs new file mode 100644 index 000000000..aea448cf8 --- /dev/null +++ b/praxis/filters/src/record/line.rs @@ -0,0 +1 @@ +//! Wire types of an ingest line: `Line`, `Event`, `Capture`, `Cost`. diff --git a/praxis/filters/src/record/mod.rs b/praxis/filters/src/record/mod.rs new file mode 100644 index 000000000..22a8a4211 --- /dev/null +++ b/praxis/filters/src/record/mod.rs @@ -0,0 +1,8 @@ +//! Record modules: build the rows and lines that the gateway sends to Cortex. + +pub mod attribution; +pub mod client; +pub mod emitter; +pub mod line; +pub mod redact; +pub mod row; diff --git a/praxis/filters/src/record/redact.rs b/praxis/filters/src/record/redact.rs new file mode 100644 index 000000000..24bce25be --- /dev/null +++ b/praxis/filters/src/record/redact.rs @@ -0,0 +1 @@ +//! Port of the credential removal in `core/redact/headers.go`. diff --git a/praxis/filters/src/record/row.rs b/praxis/filters/src/record/row.rs new file mode 100644 index 000000000..0ee92f6a6 --- /dev/null +++ b/praxis/filters/src/record/row.rs @@ -0,0 +1 @@ +//! The row builder: `Exchange`. diff --git a/praxis/server/Cargo.toml b/praxis/server/Cargo.toml new file mode 100644 index 000000000..d6e2041b2 --- /dev/null +++ b/praxis/server/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cortex-gateway" +version = "0.1.0" +edition = "2024" +rust-version = "1.96" +license = "Apache-2.0" +publish = false + +[[bin]] +name = "cortex-gateway" +path = "src/main.rs" + +[dependencies] +praxis-proxy = { version = "=0.8.0", default-features = false, features = ["pingora", "admin-api", "forward-proxy"] } +praxis-proxy-filter = { version = "=0.8.0", default-features = false } +cortex-praxis-filters = { path = "../filters" } +openssl = { version = "0.10", features = ["vendored"] } diff --git a/praxis/server/src/main.rs b/praxis/server/src/main.rs new file mode 100644 index 000000000..16acbfc62 --- /dev/null +++ b/praxis/server/src/main.rs @@ -0,0 +1,59 @@ +//! `cortex-gateway`: the Praxis server with the Cortex filters registered. +//! +//! The shape follows Praxis's own `crates/server/src/main.rs` at v0.8.0. + +praxis_filter::register_filters! {} + +fn main() { + // Before anything that can build a TLS config. + praxis::install_crypto_provider(); + + let explicit = config_arg(std::env::args().skip(1)).or_else(|| { + std::env::var("PRAXIS_CONFIG") + .ok() + .filter(|v| !v.is_empty()) + }); + let config_path = praxis::resolve_config_path(explicit.as_deref()); + let config = praxis::load_config(config_path.as_deref().and_then(|p| p.to_str())) + .unwrap_or_else(|e| praxis::fatal(&e)); + let tracing_guard = praxis::init_tracing(&config).unwrap_or_else(|e| praxis::fatal(&e)); + let log_level = Some(tracing_guard.log_level_state()); + let _tracing_guard = tracing_guard; + praxis::run_server_with_registry(config, custom_registry(), config_path, log_level) +} + +/// The value of `-c ` or `--config `. +fn config_arg(mut args: impl Iterator) -> Option { + while let Some(arg) = args.next() { + if arg == "-c" || arg == "--config" { + return args.next(); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::config_arg; + + fn args(list: &[&str]) -> impl Iterator { + list.iter() + .map(|s| (*s).to_owned()) + .collect::>() + .into_iter() + } + + #[test] + fn config_arg_reads_short_and_long_flags() { + assert_eq!( + config_arg(args(&["-c", "a.yaml"])).as_deref(), + Some("a.yaml") + ); + assert_eq!( + config_arg(args(&["--config", "b.yaml"])).as_deref(), + Some("b.yaml") + ); + assert_eq!(config_arg(args(&[])), None); + assert_eq!(config_arg(args(&["-c"])), None); + } +} From 431f6bb9b3fc6b0b80599af25fbc1b64e3b73a7e Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 15:40:21 -0500 Subject: [PATCH 06/26] Feat: Turn on ingestion in cortex when praxis mode is on When praxis.enabled and listener.bind_loopback_only are both set, cortex reads the gateway token from the file that CORTEX_GATEWAY_TOKEN_FILE names. Then it gives the token and the pricing registry to the session API, which serves POST /v1/ingest. If the variable is not set, the file cannot be read, or the token is empty, cortex stops at startup. In direct mode, cortex does not read the variable or the file. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/cortex/ingest.go | 35 +++++++++++++++ cmd/cortex/ingest_test.go | 89 +++++++++++++++++++++++++++++++++++++++ cmd/cortex/main.go | 35 +++++++++------ 3 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 cmd/cortex/ingest.go create mode 100644 cmd/cortex/ingest_test.go diff --git a/cmd/cortex/ingest.go b/cmd/cortex/ingest.go new file mode 100644 index 000000000..e66efc6fc --- /dev/null +++ b/cmd/cortex/ingest.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/rossoctl/cortex/core/config" + "github.com/rossoctl/cortex/core/cost/pricing" + "github.com/rossoctl/cortex/core/sessionapi" +) + +// gatewayTokenEnv names the file that holds the bearer token cortex-gateway sends on +// POST /v1/ingest. The supervisor creates the file; Cortex only reads it. +const gatewayTokenEnv = "CORTEX_GATEWAY_TOKEN_FILE" + +// ingestOptions turns on the ingestion endpoint in praxis mode. In direct mode it returns +// nil and reads neither the environment nor a file. +func ingestOptions(cfg *config.Config, rates pricing.Resolver, getenv func(string) string, readFile func(string) ([]byte, error)) ([]sessionapi.Option, error) { + if !cfg.PraxisEnabled() || !cfg.Listener.BindLoopbackOnly { + return nil, nil + } + path := getenv(gatewayTokenEnv) + if path == "" { + return nil, fmt.Errorf("praxis.enabled needs %s to name the gateway token file", gatewayTokenEnv) + } + b, err := readFile(path) + if err != nil { + return nil, fmt.Errorf("read the gateway token file (%s): %w", gatewayTokenEnv, err) + } + token := strings.TrimSpace(string(b)) + if token == "" { + return nil, fmt.Errorf("the gateway token file %s (%s) is empty", path, gatewayTokenEnv) + } + return []sessionapi.Option{sessionapi.WithIngest(sessionapi.Ingest{Token: token, Rates: rates})}, nil +} diff --git a/cmd/cortex/ingest_test.go b/cmd/cortex/ingest_test.go new file mode 100644 index 000000000..b0383d46d --- /dev/null +++ b/cmd/cortex/ingest_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/rossoctl/cortex/core/config" + "github.com/rossoctl/cortex/core/session" + "github.com/rossoctl/cortex/core/sessionapi" +) + +func praxisCfg(enabled, loopback bool) *config.Config { + cfg := &config.Config{} + cfg.Listener.BindLoopbackOnly = loopback + if enabled { + cfg.Praxis = &config.PraxisConfig{Enabled: true} + } + return cfg +} + +func TestIngestOptions_DirectModeNone(t *testing.T) { + getenv := func(string) string { t.Fatal("getenv called in direct mode"); return "" } + readFile := func(string) ([]byte, error) { t.Fatal("readFile called in direct mode"); return nil, nil } + for _, cfg := range []*config.Config{praxisCfg(false, true), praxisCfg(true, false)} { + opts, err := ingestOptions(cfg, nil, getenv, readFile) + if err != nil || opts != nil { + t.Fatalf("got %v, %v; want nil, nil", opts, err) + } + } +} + +func TestIngestOptions_NeedsTokenFile(t *testing.T) { + _, err := ingestOptions(praxisCfg(true, true), nil, func(string) string { return "" }, + func(string) ([]byte, error) { return nil, nil }) + if err == nil || !strings.Contains(err.Error(), "CORTEX_GATEWAY_TOKEN_FILE") { + t.Fatalf("err = %v, want one that names CORTEX_GATEWAY_TOKEN_FILE", err) + } + _, err = ingestOptions(praxisCfg(true, true), nil, func(string) string { return "/x" }, + func(string) ([]byte, error) { return nil, errors.New("no such file") }) + if err == nil { + t.Fatal("an unreadable token file must be an error") + } +} + +func TestIngestOptions_EmptyToken(t *testing.T) { + _, err := ingestOptions(praxisCfg(true, true), nil, func(string) string { return "/x" }, + func(string) ([]byte, error) { return []byte(" \n"), nil }) + if err == nil { + t.Fatal("an empty token must be an error") + } +} + +func TestIngestOptions_ReadsToken(t *testing.T) { + opts, err := ingestOptions(praxisCfg(true, true), nil, + func(k string) string { + if k != "CORTEX_GATEWAY_TOKEN_FILE" { + t.Fatalf("getenv(%q)", k) + } + return "/tok" + }, + func(p string) ([]byte, error) { + if p != "/tok" { + t.Fatalf("readFile(%q)", p) + } + return []byte("abc\n"), nil + }) + if err != nil || len(opts) != 1 { + t.Fatalf("got %d options, %v; want 1, nil", len(opts), err) + } + srv := sessionapi.New(":0", session.New(0, 0, 0), opts...) + ts := httptest.NewServer(srv.Server().Handler) + defer ts.Close() + // The trailing newline is trimmed: "abc" is the token, "abc\n" from the file is not. + for token, want := range map[string]int{"abc": http.StatusNoContent, "abcd": http.StatusUnauthorized} { + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/ingest", strings.NewReader("")) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != want { + t.Errorf("Bearer for %q: status %d, want %d", token, resp.StatusCode, want) + } + } +} diff --git a/cmd/cortex/main.go b/cmd/cortex/main.go index 66b51f012..4160ef016 100644 --- a/cmd/cortex/main.go +++ b/cmd/cortex/main.go @@ -1191,23 +1191,30 @@ func main() { var sessionAPISrv *sessionapi.Server if cfg.Listener.SessionAPIAddr != "" && sessions != nil { + // POST /v1/ingest, in praxis mode only: nil options in direct mode. + ingestOpts, err := ingestOptions(cfg, pricingRegistry, os.Getenv, os.ReadFile) + if err != nil { + fatalf("praxis ingestion: %v", err) + } sessionAPISrv = sessionapi.New( cfg.Listener.SessionAPIAddr, sessions, - sessionapi.WithPipelines(inboundH, outboundH), - sessionapi.WithCatalog(sessionapi.PluginsCatalog), - sessionapi.WithUsage(usageAgg), - // nil when the ledger is off, which handleUsage reads as "serve the ring's - // maximum window and say which window that was". - sessionapi.WithCostLedger(costLedger), - // nil unless the archive runs; pages and ?archived=true then read history from disk. - sessionapi.WithArchive(sessArchive), - // DELETE /v1/sessions and DELETE /v1/sessions/{id} only where the API is this - // machine's alone: a laptop install binds loopback-only, and its one user owns the - // history. See sessionapi.clearRefusal. - sessionapi.WithClearAllowed(cfg.Listener.BindLoopbackOnly), - // Reads hold raw prompts: the same browser defenses as the erase, on every route. - sessionapi.WithLoopbackOnly(cfg.Listener.BindLoopbackOnly), + append([]sessionapi.Option{ + sessionapi.WithPipelines(inboundH, outboundH), + sessionapi.WithCatalog(sessionapi.PluginsCatalog), + sessionapi.WithUsage(usageAgg), + // nil when the ledger is off, which handleUsage reads as "serve the ring's + // maximum window and say which window that was". + sessionapi.WithCostLedger(costLedger), + // nil unless the archive runs; pages and ?archived=true then read history from disk. + sessionapi.WithArchive(sessArchive), + // DELETE /v1/sessions and DELETE /v1/sessions/{id} only where the API is this + // machine's alone: a laptop install binds loopback-only, and its one user owns the + // history. See sessionapi.clearRefusal. + sessionapi.WithClearAllowed(cfg.Listener.BindLoopbackOnly), + // Reads hold raw prompts: the same browser defenses as the erase, on every route. + sessionapi.WithLoopbackOnly(cfg.Listener.BindLoopbackOnly), + }, ingestOpts...)..., ) sessionLn := listeners.take("session-api") go func() { From a3e4e967cf1cfc7f476cfc6880e7aab38afc5097 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 15:43:08 -0500 Subject: [PATCH 07/26] Feat: Port core/redact header and query credential removal to Rust Add credential_name, headers and query to praxis/filters/src/record/redact.rs. They use the same word list and the same split on "-" and "_" as core/redact/headers.go. The query function drops a query whole when Go's url.ParseQuery refuses it (a ";" or a bad "%" escape). It keeps the other pairs in their order and in their original escaping. The tests copy each Go case table. One more test pins the ParseQuery refusals. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- praxis/filters/src/record/redact.rs | 213 ++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/praxis/filters/src/record/redact.rs b/praxis/filters/src/record/redact.rs index 24bce25be..083a70549 100644 --- a/praxis/filters/src/record/redact.rs +++ b/praxis/filters/src/record/redact.rs @@ -1 +1,214 @@ //! Port of the credential removal in `core/redact/headers.go`. + +use http::HeaderMap; + +/// The words of a name that make a header or a query parameter a credential. +const CREDENTIAL_WORDS: [&str; 11] = [ + "key", + "apikey", + "token", + "auth", + "authorization", + "cookie", + "secret", + "signature", + "password", + "credential", + "credentials", +]; + +/// Reports whether `name`, split at `-` and `_` with case ignored, has one of the credential words. +pub fn credential_name(name: &str) -> bool { + name.to_lowercase() + .split(['-', '_']) + .any(|w| CREDENTIAL_WORDS.contains(&w)) +} + +/// A copy of `h` without credential headers, for storing. +pub fn headers(h: &HeaderMap) -> HeaderMap { + let mut out = HeaderMap::with_capacity(h.len()); + for (k, v) in h { + if !credential_name(k.as_str()) { + out.append(k, v.clone()); + } + } + out +} + +/// `raw` without credential parameters, for storing. The rest keeps its order and escaping, +/// since a replay must send the same bytes. A query that Go's `url.ParseQuery` refuses (a `;` +/// or a bad `%` escape) is dropped whole: what it holds cannot be told apart. +pub fn query(raw: &str) -> String { + let parses = raw.split('&').all(|p| { + let (name, value) = p.split_once('=').unwrap_or((p, "")); + !p.contains(';') && unescape(name).is_some() && unescape(value).is_some() + }); + if !parses { + return String::new(); + } + raw.split('&') + .filter(|p| { + let name = p.split_once('=').map_or(*p, |(n, _)| n); + !unescape(name).is_some_and(|n| credential_name(&n)) + }) + .collect::>() + .join("&") +} + +/// Go's `url.QueryUnescape`: `+` is a space, `%` takes two hex digits, `None` on a bad escape. +fn unescape(s: &str) -> Option { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + match b[i] { + b'%' => { + let hex = b.get(i + 1..i + 3)?; + if !hex.iter().all(u8::is_ascii_hexdigit) { + return None; + } + out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?); + i += 3; + } + b'+' => { + out.push(b' '); + i += 1; + } + c => { + out.push(c); + i += 1; + } + } + } + Some(String::from_utf8_lossy(&out).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::HeaderValue; + + /// The keys of Go's `SecretHeaders`. + const SECRET_HEADERS: [&str; 7] = [ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "api-key", + "x-goog-api-key", + ]; + + fn one(name: &'static str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(name, HeaderValue::from_static("s")); + h + } + + #[test] + fn headers_drops_credentials_and_copies() { + let mut input = HeaderMap::new(); + input.insert("Authorization", HeaderValue::from_static("Bearer x")); + input.insert("X-Api-Key", HeaderValue::from_static("k")); + input.insert("cookie", HeaderValue::from_static("c")); + input.append("Anthropic-Beta", HeaderValue::from_static("a")); + input.append("Anthropic-Beta", HeaderValue::from_static("b")); + let mut out = headers(&input); + assert_eq!( + out.keys_len(), + 1, + "headers = {out:?}, want only Anthropic-Beta" + ); + assert_eq!(out.get_all("Anthropic-Beta").iter().count(), 2); + if let Some(v) = out.get_mut("Anthropic-Beta") { + *v = HeaderValue::from_static("changed"); + } + assert_eq!( + input.get("Anthropic-Beta").unwrap(), + "a", + "headers shares its values with its input" + ); + } + + // Some APIs take the key in the URL (Gemini's ?key=). A captured query keeps everything else. + #[test] + fn query_drops_credential_parameters() { + for (input, want) in [ + ("beta=true", "beta=true"), + ("key=AIza123&alt=sse", "alt=sse"), + ("api_key=k&apikey=k&access_token=t", ""), + ("token=t&x=1", "x=1"), + ("z=1&a=%2F&b", "z=1&a=%2F&b"), + ("z=1&key=k&a=2", "z=1&a=2"), + ("%zz", ""), + ("", ""), + ] { + assert_eq!(query(input), want, "query({input:?})"); + } + } + + // Not a Go case table: the refusals of Go's url.ParseQuery that `query` reproduces. + #[test] + fn query_drops_what_go_refuses() { + for (input, want) in [ + ("a=1;b=2", ""), + ("a=%2", ""), + ("a=%+1", ""), + ("x=1&a=%zz", ""), + ("x%5Fkey=1&a+key=2&x=%41", "a+key=2&x=%41"), + ("&&x=1", "&&x=1"), + ] { + assert_eq!(query(input), want, "query({input:?})"); + } + } + + #[test] + fn headers_drops_cloud_and_proxy_credentials() { + for h in [ + "X-Amz-Security-Token", + "X-Auth-Token", + "X-Authorization", + "X-Session-Token", + "X-Secret-Token", + "X-Csrf-Token", + "X-Platform-Secret", + "X-Authbridge-Secret", + ] { + assert!(headers(&one(h)).is_empty(), "{h} survived"); + } + } + + // A credential is judged by the words of its name, split at "-" and "_", so a header or + // parameter with a credential word needs no entry of its own. + #[test] + fn headers_and_query_drop_names_with_a_credential_word() { + let names = [ + "Ocp-Apim-Subscription-Key", + "Private-Token", + "X-Vault-Token", + "X-Goog-Iam-Authorization-Token", + ]; + for h in names.into_iter().chain(SECRET_HEADERS) { + assert!(headers(&one(h)).is_empty(), "{h} survived"); + } + let input = "X-Amz-Credential=c&X-Amz-Signature=s&X-Amz-Security-Token=t&subscription-key=k&X-Amz-Expires=60"; + assert_eq!(query(input), "X-Amz-Expires=60", "query({input:?})"); + assert_eq!( + query("max_tokens=5&pageToken=p"), + "max_tokens=5&pageToken=p", + "neither name has a credential word" + ); + } + + // The names docs/laptop-service.md gives as examples of the word rule. + #[test] + fn credential_name_documented_examples() { + for (name, want) in [ + ("Idempotency-Key", true), + ("Sec-WebSocket-Key", true), + ("accessToken", false), + ] { + assert_eq!(credential_name(name), want, "credential_name({name:?})"); + } + } +} From 6e57c3baec0e66603f5a64c0ee7bb1b575f7007c Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 15:44:05 -0500 Subject: [PATCH 08/26] Feat: Add the bounded NDJSON emitter for cortex-gateway The emitter sends lines to POST /v1/ingest from its own thread, with a current-thread Tokio runtime. submit never blocks. A full queue drops captures first, then whole lines, and counts each drop. The sender connects before it takes a line, so a failed connect drops no line. It writes HTTP/1.1 by hand with a chunked body, because hyper 1 needs hyper-util for a Tokio stream, and the crate does not have that dependency. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- praxis/filters/src/record/emitter.rs | 379 +++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) diff --git a/praxis/filters/src/record/emitter.rs b/praxis/filters/src/record/emitter.rs index 6252858c1..e4760dea5 100644 --- a/praxis/filters/src/record/emitter.rs +++ b/praxis/filters/src/record/emitter.rs @@ -8,3 +8,382 @@ pub struct Queued { pub row: Vec, pub with_capture: Option>, } + +use std::collections::VecDeque; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::Notify; + +pub const MAX_QUEUE_LINES: usize = 4096; +pub const MAX_QUEUE_BYTES: usize = 256 << 20; +pub const MAX_LINE: usize = 64 << 20; + +const BACKOFF_MIN: Duration = Duration::from_millis(100); +const BACKOFF_MAX: Duration = Duration::from_secs(5); + +/// The emitter counters, and the queue size at the time of the call. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct EmitterStats { + pub rows_emitted: u64, + pub rows_dropped: u64, + pub captures_dropped_line_cap: u64, + pub captures_dropped_queue_bytes: u64, + pub queued_lines: usize, + pub queued_bytes: usize, +} + +#[derive(Default)] +struct Queue { + lines: VecDeque>, + bytes: usize, +} + +/// Streams lines to `POST /v1/ingest` from its own thread. `submit` never +/// blocks: when the queue is full, it drops captures first, then lines. +pub struct Emitter { + addr: SocketAddr, + token: String, + queue: Mutex, + wake: Notify, + rows_emitted: AtomicU64, + rows_dropped: AtomicU64, + captures_dropped_line_cap: AtomicU64, + captures_dropped_queue_bytes: AtomicU64, +} + +fn bump(c: &AtomicU64, name: &'static str) { + c.fetch_add(1, Ordering::Relaxed); + metrics::counter!(name).increment(1); +} + +fn bump_capture(c: &AtomicU64, reason: &'static str) { + c.fetch_add(1, Ordering::Relaxed); + metrics::counter!("cortex_captures_dropped_total", "reason" => reason).increment(1); +} + +impl Emitter { + /// Starts the sender thread. It runs for the life of the process. + pub fn start(ingest_addr: SocketAddr, token: String) -> Arc { + let e = Arc::new(Emitter { + addr: ingest_addr, + token, + queue: Mutex::default(), + wake: Notify::new(), + rows_emitted: AtomicU64::new(0), + rows_dropped: AtomicU64::new(0), + captures_dropped_line_cap: AtomicU64::new(0), + captures_dropped_queue_bytes: AtomicU64::new(0), + }); + let sender = e.clone(); + std::thread::Builder::new() + .name("cortex-emitter".into()) + .spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("emitter runtime") + .block_on(sender.run()); + }) + .expect("emitter thread"); + e + } + + /// Queues one line. It never blocks and never fails; a line that does + /// not fit loses its capture first, then is dropped and counted. + pub fn submit(&self, q: Queued) { + let Queued { row, with_capture } = q; + let (mut line, mut fallback) = match with_capture { + Some(c) if c.len() > MAX_LINE => { + bump_capture(&self.captures_dropped_line_cap, "line_cap"); + (row, None) + } + Some(c) => (c, Some(row)), + None => (row, None), + }; + { + let mut q = self.queue.lock().unwrap_or_else(|p| p.into_inner()); + if q.lines.len() >= MAX_QUEUE_LINES { + drop(q); + bump(&self.rows_dropped, "cortex_rows_dropped_total"); + return; + } + if q.bytes + line.len() > MAX_QUEUE_BYTES + && let Some(r) = fallback.take() + { + line = r; + bump_capture(&self.captures_dropped_queue_bytes, "queue_bytes"); + } + if q.bytes + line.len() > MAX_QUEUE_BYTES { + drop(q); + bump(&self.rows_dropped, "cortex_rows_dropped_total"); + return; + } + q.bytes += line.len(); + q.lines.push_back(line); + } + self.wake.notify_one(); + } + + pub fn stats(&self) -> EmitterStats { + let (queued_lines, queued_bytes) = { + let q = self.queue.lock().unwrap_or_else(|p| p.into_inner()); + (q.lines.len(), q.bytes) + }; + EmitterStats { + rows_emitted: self.rows_emitted.load(Ordering::Relaxed), + rows_dropped: self.rows_dropped.load(Ordering::Relaxed), + captures_dropped_line_cap: self.captures_dropped_line_cap.load(Ordering::Relaxed), + captures_dropped_queue_bytes: self.captures_dropped_queue_bytes.load(Ordering::Relaxed), + queued_lines, + queued_bytes, + } + } + + fn pop(&self) -> Option> { + let mut q = self.queue.lock().unwrap_or_else(|p| p.into_inner()); + let line = q.lines.pop_front()?; + q.bytes -= line.len(); + Some(line) + } + + /// Waits for the next line. Cancel-safe: the pop and the return are in + /// one poll. `notify_one` keeps a permit, so no wake-up is lost. + async fn next_line(&self) -> Vec { + loop { + if let Some(line) = self.pop() { + return line; + } + self.wake.notified().await; + } + } + + async fn run(self: Arc) { + let mut backoff = BACKOFF_MIN; + loop { + if self.stream(&mut backoff).await.is_err() { + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(BACKOFF_MAX); + } + } + } + + /// One `POST /v1/ingest` with a chunked body, one chunk per line. It + /// connects before it takes a line, so a failed connect drops nothing. + // shortcut: HTTP/1.1 by hand, because hyper 1 needs hyper-util's TokioIo, + // which is not a dependency; move to hyper when a crate brings it in. + async fn stream(&self, backoff: &mut Duration) -> std::io::Result<()> { + let mut conn = TcpStream::connect(self.addr).await?; + conn.set_nodelay(true)?; + let head = format!( + "POST /v1/ingest HTTP/1.1\r\nHost: {}\r\nAuthorization: Bearer {}\r\n\ + Content-Type: application/x-ndjson\r\nTransfer-Encoding: chunked\r\n\r\n", + self.addr, self.token + ); + conn.write_all(head.as_bytes()).await?; + let (mut rd, mut wr) = conn.split(); + let mut buf = [0u8; 512]; + loop { + // Cortex answers only when the stream ends, so any read (a + // response, EOF or an error) ends this stream. + let line = tokio::select! { + line = self.next_line() => line, + _ = rd.read(&mut buf) => { + return Err(std::io::Error::other("ingest ended the stream")); + } + }; + let sent = async { + wr.write_all(format!("{:x}\r\n", line.len()).as_bytes()) + .await?; + wr.write_all(&line).await?; + wr.write_all(b"\r\n").await + } + .await; + if let Err(err) = sent { + bump(&self.rows_dropped, "cortex_rows_dropped_total"); + return Err(err); + } + bump(&self.rows_emitted, "cortex_rows_emitted_total"); + *backoff = BACKOFF_MIN; + } + } +} + +/// The emitter of this process. The first call starts it; a later call with +/// other arguments gets the same emitter, and a warning is logged. +pub fn global(ingest_addr: SocketAddr, token: String) -> Arc { + static GLOBAL: OnceLock> = OnceLock::new(); + let e = GLOBAL.get_or_init(|| Emitter::start(ingest_addr, token.clone())); + if e.addr != ingest_addr || e.token != token { + eprintln!( + "cortex-gateway: the emitter already streams to {}; ignoring a second ingest config for {ingest_addr}", + e.addr + ); + } + e.clone() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader, Read}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + fn line(s: &str) -> Queued { + Queued { + row: format!("{s}\n").into_bytes(), + with_capture: None, + } + } + + /// An address with nothing listening on it. + fn closed_addr() -> SocketAddr { + TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + } + + /// Reads one ingest request: sends its Authorization header, then each + /// chunk. Returns after `max` chunks, which closes the connection. + fn read_stream(conn: std::net::TcpStream, tx: &mpsc::Sender, max: usize) { + let mut r = BufReader::new(conn); + let mut l = String::new(); + loop { + l.clear(); + if r.read_line(&mut l).unwrap_or(0) == 0 { + return; + } + if l == "\r\n" { + break; + } + if let Some(v) = l.strip_prefix("Authorization: ") { + tx.send(format!("auth {}", v.trim_end())).unwrap(); + } + } + for _ in 0..max { + l.clear(); + if r.read_line(&mut l).unwrap_or(0) == 0 { + return; + } + let n = usize::from_str_radix(l.trim_end(), 16).unwrap(); + let mut data = vec![0; n + 2]; + if r.read_exact(&mut data).is_err() { + return; + } + data.truncate(n); + let mut s = String::from_utf8(data).unwrap(); + if s.len() > 64 { + s = format!("{} bytes", s.len()); + } + tx.send(s).unwrap(); + } + } + + /// A mock ingest server. Each accepted connection is read for at most + /// `per_conn[i]` chunks (the last value repeats). + fn serve(per_conn: Vec) -> (SocketAddr, mpsc::Receiver) { + let ln = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = ln.local_addr().unwrap(); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for (i, conn) in ln.incoming().enumerate() { + let max = *per_conn.get(i).or(per_conn.last()).unwrap(); + read_stream(conn.unwrap(), &tx, max); + } + }); + (addr, rx) + } + + fn recv(rx: &mpsc::Receiver) -> String { + rx.recv_timeout(Duration::from_secs(10)).unwrap() + } + + #[test] + fn lines_arrive_in_order_with_token() { + let (addr, rx) = serve(vec![usize::MAX]); + let e = Emitter::start(addr, "t".into()); + for s in ["a", "b", "c"] { + e.submit(line(s)); + } + assert_eq!(recv(&rx), "auth Bearer t"); + for s in ["a\n", "b\n", "c\n"] { + assert_eq!(recv(&rx), s); + } + assert_eq!(e.stats().rows_emitted, 3); + } + + #[test] + fn capture_over_line_cap_is_dropped_row_kept() { + let (addr, rx) = serve(vec![usize::MAX]); + let e = Emitter::start(addr, "t".into()); + e.submit(Queued { + row: b"row\n".to_vec(), + with_capture: Some(vec![b'x'; MAX_LINE + 1]), + }); + assert_eq!(recv(&rx), "auth Bearer t"); + assert_eq!(recv(&rx), "row\n"); + assert_eq!(e.stats().captures_dropped_line_cap, 1); + } + + #[test] + fn queue_drops_captures_before_rows() { + let e = Emitter::start(closed_addr(), "t".into()); + for _ in 0..30 { + e.submit(Queued { + row: b"row\n".to_vec(), + with_capture: Some(vec![b'x'; 10 << 20]), + }); + } + let s = e.stats(); + assert!(s.queued_bytes <= MAX_QUEUE_BYTES, "{}", s.queued_bytes); + assert!(s.captures_dropped_queue_bytes > 0); + assert_eq!(s.rows_dropped, 0); + assert_eq!(s.queued_lines, 30); + } + + #[test] + fn line_limit_drops_whole_lines() { + let e = Emitter::start(closed_addr(), "t".into()); + for _ in 0..=MAX_QUEUE_LINES { + e.submit(line("x")); + } + let s = e.stats(); + assert_eq!(s.rows_dropped, 1); + assert_eq!(s.queued_lines, MAX_QUEUE_LINES); + } + + #[test] + fn submit_never_blocks_when_ingest_is_down() { + let e = Emitter::start(closed_addr(), "t".into()); + let t = Instant::now(); + for _ in 0..1000 { + e.submit(line("x")); + } + assert!(t.elapsed() < Duration::from_secs(1), "{:?}", t.elapsed()); + } + + #[test] + fn reconnects_after_ingest_restarts() { + let (addr, rx) = serve(vec![1, usize::MAX]); + let e = Emitter::start(addr, "t".into()); + e.submit(line("1")); + assert_eq!(recv(&rx), "auth Bearer t"); + assert_eq!(recv(&rx), "1\n"); + // The server closed the stream. Line 2 can be lost; line 3 must arrive. + std::thread::sleep(Duration::from_millis(100)); + e.submit(line("2")); + std::thread::sleep(Duration::from_millis(300)); + e.submit(line("3")); + let t = Instant::now(); + while recv(&rx) != "3\n" { + assert!(t.elapsed() < Duration::from_secs(10)); + } + } +} From 2e9faa4559775bae85fb5548913e0fdf68664eb5 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 15:44:51 -0500 Subject: [PATCH 09/26] Feat: Port the user-agent and subagent rules to Rust Add parse_user_agent and subagent_header to praxis/filters record::client. They are ports of model.ParseUserAgent and pipeline.subagentHeader. A new Go test, TestParseUserAgent_Golden, writes core/pipeline/testdata/user-agent.golden.json when -update is set, and compares the file when it is not set. The Rust test user_agent_golden_matches_go reads the same file. Thus Go and Rust must give the same result for each case. The table holds the user-agent strings of client_test.go, the User-Agent of each inferenceparser fixture, and some edge cases. The subagent part holds the header values of subagent_test.go. Inputs that are not valid UTF-8 are not in the table, because JSON cannot hold them and the Rust functions take a str. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- core/pipeline/client_golden_test.go | 189 ++++++ core/pipeline/testdata/user-agent.golden.json | 642 ++++++++++++++++++ praxis/filters/src/record/client.rs | 128 ++++ 3 files changed, 959 insertions(+) create mode 100644 core/pipeline/client_golden_test.go create mode 100644 core/pipeline/testdata/user-agent.golden.json diff --git a/core/pipeline/client_golden_test.go b/core/pipeline/client_golden_test.go new file mode 100644 index 000000000..625192270 --- /dev/null +++ b/core/pipeline/client_golden_test.go @@ -0,0 +1,189 @@ +package pipeline + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "unicode/utf8" +) + +// userAgentGolden is the table that the Rust port (praxis/filters/src/record/client.rs) reads. +const userAgentGolden = "testdata/user-agent.golden.json" + +type userAgentCase struct { + UA string `json:"ua"` + Client *EventClient `json:"client"` +} + +type subagentCase struct { + Value string `json:"value"` + ID string `json:"id"` +} + +// goldenUserAgents are the user-agent literals of client_test.go, plus edge cases. +var goldenUserAgents = []string{ + "", + "claude-cli/2.1.14 (external, cli)", + "claude-cli/2.1.14", + "SomeNewAgent/9.9", + "curl/8.4.0", + " ", + "claude-cli/2.1.14\t(external, cli)", + "claude-cli/2.1.14\tx", + "ai-sdk/openai-compatible/3.0.36 ai-sdk/provider-utils/5.0.29 runtime/node.js/24 bob-shell/2.0.5", + "ai/7.0.16 ai-sdk/provider-utils/5.0.29 runtime/node.js/24 bob-shell/2.0.5", + "bob-shell/2.0.5", + "claude-cli/2.1.270 bob-shell/2.0.5", + "bob-shell/2.0.5 claude-cli/2.1.270", + "ai-sdk/openai-compatible/3.0.36 claude-cli/2.1.270 bob-shell/2.0.5", + "ai-sdk/openai-compatible/3.0.36 ai-sdk/provider-utils/5.0.29 runtime/node.js/v24.15.0 IBM Bob/2.2.1", + "ai/7.0.16 ai-sdk/provider-utils/5.0.29 runtime/node.js/v24.15.0 IBM Bob/2.2.1", + "IBM Bob/2.2.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) IBMBob/1.126.0+bob2.2.1 Chrome/148.0.7778", + "Code/1.126.0+bob2.2.1 Darwin/24.6.0", + "codex_exec/0.160.1 (Mac OS 26.3.0; arm64) unknown (codex_exec; 0.160.1)", + "codex_exec/0.160.1 (Mac OS 26.3.0; arm64) unknown", + "codex-mcp-client/0.160.1", + "codex-tui/0.160.1 (Mac OS 26.3.0; arm64) xterm-256color (codex-tui; 0.160.1)", + "codex_cli_rs/0.160.1 (Mac OS 26.3.0; arm64) xterm-256color", + "codex-tui/0.162.1 (Mac OS 26.3.0; arm64) xterm-256color (codex-tui; 0.162.1)", + "codex_cli_rs/0.162.1 (Mac OS 26.3.0; arm64) xterm-256color", + "codex_vscode/0.160.1", + "codex_desktop/0.160.1", + "OTel-OTLP-Exporter-Rust/0.31.0", + "pi/1.4.0 (darwin; node/v24.3.0; arm64)", + "pi", + "pi (darwin 25.6.0; arm64)", + "pip/24.0 (python 3.12)", + "pino/9.0", + "pi-hole/6.0", + "claude-cli/2.1.233 (external, claude-vscode, agent-sdk/0.3.233)", + "claude-code/2.1.233 (claude-vscode, agent-sdk/0.3.233)", + "Claude-User (claude-code/2.1.233; +https://support.anthropic.com/)", + "opencode/latest/2.0.21/cli", + "opencode/beta/2.1.0-beta.3/tui", + "opencode/2.0.21", + "bob-shell/2.0.5/extra", + "some-tool/1.2 runtime/node.js/24 libcurl/8.4.0", + strings.Repeat("A", 10_000), + "claude-cli/" + strings.Repeat("9", 10_000), + "claude-cli/2.1.14\x1b[2J\x1b[31mPWNED", + "claude-cli/2.1.14\u009b2J", + "newagent/1\u009b31m", + "newagent/1\x7f", + "newagent/1\nfake-row", + "newagent/1‮gnitcepsus", + "newagent/1⁦x⁩", + "newagent/1​", + "newagent/1‍2", + "claude-cli/2.1.14\x1b[31m (external, cli)", + strings.Repeat("\x1b", maxClientLen+32), + "x" + strings.Repeat("é", 70), + strings.Repeat("✓", 60), + strings.Repeat("é", maxClientLen/2+8), + "wetter-agent/1.0 (Zürich, ✓)", + "claude-cli/2.1.284 (external, cli)", + "Claude-User (claude-code/2.1.284; +https://support.anthropic.com/)", + "ai-sdk/5.0.1 openai-compatible/3.0.36 bob-shell/2.0.5", + "Go-http-client/1.1", + "python-httpx/0.27.0", + "axios/1.15.2", + "node", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + // Edge cases for the port. + strings.Repeat("b", 600), + "claude-cli/2.1.14 (ext\x00ernal)", + " claude-cli/2.1.14\u0085", + "CLAUDE-CLI/2.1.14", + // Go's strings.ToLower maps U+0130 to a plain "i"; a full Unicode lowercase does not. + "pİ/1.0", + "ai/1 İBM-x/2 Bob/3", +} + +// goldenSubagentValues are the header values of subagent_test.go. +var goldenSubagentValues = []string{ + "a1a2e36cb5946f31c", + "", + " ", + " a1a2e36cb5946f31c ", + "\ta1a2e36cb5946f31c\t", + " \t a1a2e36cb5946f31c \t ", + "a1a2\x1b[2Je36cb", + "a1a2\ne36cb", + "a1a2\x7fe36cb", + "a1a2\u009b2Je36cb", + "a1a2‮e36cb", + "a1a2‍e36cb", + strings.Repeat("a", maxClientLen+1), + "a1a2e36cb5946f31c\n", + "\na1a2e36cb5946f31c", + "a1a2e36cb5946f31c\r", + "a1a2e36cb5946f31c\r\n", + "\va1a2e36cb5946f31c", + "a1a2e36cb5946f31c\f", + "a1a2e36cb5946f31c\u0085", + "\u0085a1a2e36cb5946f31c", + strings.Repeat("a", maxClientLen), + "a21728bb34d27cb12", + "a0974b0b8591dc76b", +} + +// TestParseUserAgent_Golden writes or checks the table that pins the Rust port to Go. +// Inputs that are not valid UTF-8 are left out: JSON cannot carry them, and the Rust +// function takes a str. +func TestParseUserAgent_Golden(t *testing.T) { + uas := slices.Clone(goldenUserAgents) + files, err := filepath.Glob("../plugins/inferenceparser/testdata/*/*/request.headers") + if err != nil || len(files) == 0 { + t.Fatalf("no request.headers fixtures: %v", err) + } + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for line := range strings.SplitSeq(string(raw), "\n") { + if name, v, ok := strings.Cut(line, ":"); ok && strings.EqualFold(name, "User-Agent") { + if v = strings.TrimSpace(v); !slices.Contains(uas, v) { + uas = append(uas, v) + } + } + } + } + + var golden struct { + UserAgent []userAgentCase `json:"userAgent"` + Subagent []subagentCase `json:"subagent"` + } + for _, ua := range uas { + if utf8.ValidString(ua) { + golden.UserAgent = append(golden.UserAgent, userAgentCase{ua, ParseUserAgent(ua)}) + } + } + for _, v := range goldenSubagentValues { + golden.Subagent = append(golden.Subagent, subagentCase{v, subagentHeader(v)}) + } + got, err := json.MarshalIndent(golden, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + + if *update { + if err := os.WriteFile(userAgentGolden, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(userAgentGolden) + if err != nil { + t.Fatalf("%v (run with -update to write it)", err) + } + if !bytes.Equal(got, want) { + t.Errorf("%s is out of date (run with -update to rewrite it, then run the Rust test)", userAgentGolden) + } +} diff --git a/core/pipeline/testdata/user-agent.golden.json b/core/pipeline/testdata/user-agent.golden.json new file mode 100644 index 000000000..f7bf6cdb6 --- /dev/null +++ b/core/pipeline/testdata/user-agent.golden.json @@ -0,0 +1,642 @@ +{ + "userAgent": [ + { + "ua": "", + "client": null + }, + { + "ua": "claude-cli/2.1.14 (external, cli)", + "client": { + "name": "claude-code", + "version": "2.1.14", + "raw": "claude-cli/2.1.14 (external, cli)" + } + }, + { + "ua": "claude-cli/2.1.14", + "client": { + "name": "claude-code", + "version": "2.1.14", + "raw": "claude-cli/2.1.14" + } + }, + { + "ua": "SomeNewAgent/9.9", + "client": { + "raw": "SomeNewAgent/9.9" + } + }, + { + "ua": "curl/8.4.0", + "client": { + "raw": "curl/8.4.0" + } + }, + { + "ua": " ", + "client": null + }, + { + "ua": "claude-cli/2.1.14\t(external, cli)", + "client": { + "name": "claude-code", + "version": "2.1.14", + "raw": "claude-cli/2.1.14 (external, cli)" + } + }, + { + "ua": "claude-cli/2.1.14\tx", + "client": { + "name": "claude-code", + "version": "2.1.14", + "raw": "claude-cli/2.1.14 x" + } + }, + { + "ua": "ai-sdk/openai-compatible/3.0.36 ai-sdk/provider-utils/5.0.29 runtime/node.js/24 bob-shell/2.0.5", + "client": { + "name": "bob", + "version": "2.0.5", + "raw": "ai-sdk/openai-compatible/3.0.36 ai-sdk/provider-utils/5.0.29 runtime/node.js/24 bob-shell/2.0.5" + } + }, + { + "ua": "ai/7.0.16 ai-sdk/provider-utils/5.0.29 runtime/node.js/24 bob-shell/2.0.5", + "client": { + "name": "bob", + "version": "2.0.5", + "raw": "ai/7.0.16 ai-sdk/provider-utils/5.0.29 runtime/node.js/24 bob-shell/2.0.5" + } + }, + { + "ua": "bob-shell/2.0.5", + "client": { + "name": "bob", + "version": "2.0.5", + "raw": "bob-shell/2.0.5" + } + }, + { + "ua": "claude-cli/2.1.270 bob-shell/2.0.5", + "client": { + "name": "claude-code", + "version": "2.1.270", + "raw": "claude-cli/2.1.270 bob-shell/2.0.5" + } + }, + { + "ua": "bob-shell/2.0.5 claude-cli/2.1.270", + "client": { + "name": "bob", + "version": "2.0.5", + "raw": "bob-shell/2.0.5 claude-cli/2.1.270" + } + }, + { + "ua": "ai-sdk/openai-compatible/3.0.36 claude-cli/2.1.270 bob-shell/2.0.5", + "client": { + "name": "bob", + "version": "2.0.5", + "raw": "ai-sdk/openai-compatible/3.0.36 claude-cli/2.1.270 bob-shell/2.0.5" + } + }, + { + "ua": "ai-sdk/openai-compatible/3.0.36 ai-sdk/provider-utils/5.0.29 runtime/node.js/v24.15.0 IBM Bob/2.2.1", + "client": { + "name": "bob", + "version": "2.2.1", + "raw": "ai-sdk/openai-compatible/3.0.36 ai-sdk/provider-utils/5.0.29 runtime/node.js/v24.15.0 IBM Bob/2.2.1" + } + }, + { + "ua": "ai/7.0.16 ai-sdk/provider-utils/5.0.29 runtime/node.js/v24.15.0 IBM Bob/2.2.1", + "client": { + "name": "bob", + "version": "2.2.1", + "raw": "ai/7.0.16 ai-sdk/provider-utils/5.0.29 runtime/node.js/v24.15.0 IBM Bob/2.2.1" + } + }, + { + "ua": "IBM Bob/2.2.1", + "client": { + "name": "bob", + "version": "2.2.1", + "raw": "IBM Bob/2.2.1" + } + }, + { + "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) IBMBob/1.126.0+bob2.2.1 Chrome/148.0.7778", + "client": { + "raw": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) IBMBob/1.126.0+bob2.2.1 Chrome/148.0.7778" + } + }, + { + "ua": "Code/1.126.0+bob2.2.1 Darwin/24.6.0", + "client": { + "raw": "Code/1.126.0+bob2.2.1 Darwin/24.6.0" + } + }, + { + "ua": "codex_exec/0.160.1 (Mac OS 26.3.0; arm64) unknown (codex_exec; 0.160.1)", + "client": { + "name": "codex", + "version": "0.160.1", + "raw": "codex_exec/0.160.1 (Mac OS 26.3.0; arm64) unknown (codex_exec; 0.160.1)" + } + }, + { + "ua": "codex_exec/0.160.1 (Mac OS 26.3.0; arm64) unknown", + "client": { + "name": "codex", + "version": "0.160.1", + "raw": "codex_exec/0.160.1 (Mac OS 26.3.0; arm64) unknown" + } + }, + { + "ua": "codex-mcp-client/0.160.1", + "client": { + "name": "codex", + "version": "0.160.1", + "raw": "codex-mcp-client/0.160.1" + } + }, + { + "ua": "codex-tui/0.160.1 (Mac OS 26.3.0; arm64) xterm-256color (codex-tui; 0.160.1)", + "client": { + "name": "codex", + "version": "0.160.1", + "raw": "codex-tui/0.160.1 (Mac OS 26.3.0; arm64) xterm-256color (codex-tui; 0.160.1)" + } + }, + { + "ua": "codex_cli_rs/0.160.1 (Mac OS 26.3.0; arm64) xterm-256color", + "client": { + "name": "codex", + "version": "0.160.1", + "raw": "codex_cli_rs/0.160.1 (Mac OS 26.3.0; arm64) xterm-256color" + } + }, + { + "ua": "codex-tui/0.162.1 (Mac OS 26.3.0; arm64) xterm-256color (codex-tui; 0.162.1)", + "client": { + "name": "codex", + "version": "0.162.1", + "raw": "codex-tui/0.162.1 (Mac OS 26.3.0; arm64) xterm-256color (codex-tui; 0.162.1)" + } + }, + { + "ua": "codex_cli_rs/0.162.1 (Mac OS 26.3.0; arm64) xterm-256color", + "client": { + "name": "codex", + "version": "0.162.1", + "raw": "codex_cli_rs/0.162.1 (Mac OS 26.3.0; arm64) xterm-256color" + } + }, + { + "ua": "codex_vscode/0.160.1", + "client": { + "raw": "codex_vscode/0.160.1" + } + }, + { + "ua": "codex_desktop/0.160.1", + "client": { + "raw": "codex_desktop/0.160.1" + } + }, + { + "ua": "OTel-OTLP-Exporter-Rust/0.31.0", + "client": { + "raw": "OTel-OTLP-Exporter-Rust/0.31.0" + } + }, + { + "ua": "pi/1.4.0 (darwin; node/v24.3.0; arm64)", + "client": { + "name": "pi", + "version": "1.4.0", + "raw": "pi/1.4.0 (darwin; node/v24.3.0; arm64)" + } + }, + { + "ua": "pi", + "client": { + "name": "pi", + "raw": "pi" + } + }, + { + "ua": "pi (darwin 25.6.0; arm64)", + "client": { + "name": "pi", + "raw": "pi (darwin 25.6.0; arm64)" + } + }, + { + "ua": "pip/24.0 (python 3.12)", + "client": { + "raw": "pip/24.0 (python 3.12)" + } + }, + { + "ua": "pino/9.0", + "client": { + "raw": "pino/9.0" + } + }, + { + "ua": "pi-hole/6.0", + "client": { + "raw": "pi-hole/6.0" + } + }, + { + "ua": "claude-cli/2.1.233 (external, claude-vscode, agent-sdk/0.3.233)", + "client": { + "name": "claude-code", + "version": "2.1.233", + "raw": "claude-cli/2.1.233 (external, claude-vscode, agent-sdk/0.3.233)" + } + }, + { + "ua": "claude-code/2.1.233 (claude-vscode, agent-sdk/0.3.233)", + "client": { + "name": "claude-code", + "version": "2.1.233", + "raw": "claude-code/2.1.233 (claude-vscode, agent-sdk/0.3.233)" + } + }, + { + "ua": "Claude-User (claude-code/2.1.233; +https://support.anthropic.com/)", + "client": { + "raw": "Claude-User (claude-code/2.1.233; +https://support.anthropic.com/)" + } + }, + { + "ua": "opencode/latest/2.0.21/cli", + "client": { + "name": "opencode", + "version": "2.0.21", + "raw": "opencode/latest/2.0.21/cli" + } + }, + { + "ua": "opencode/beta/2.1.0-beta.3/tui", + "client": { + "name": "opencode", + "version": "2.1.0-beta.3", + "raw": "opencode/beta/2.1.0-beta.3/tui" + } + }, + { + "ua": "opencode/2.0.21", + "client": { + "name": "opencode", + "version": "2.0.21", + "raw": "opencode/2.0.21" + } + }, + { + "ua": "bob-shell/2.0.5/extra", + "client": { + "name": "bob", + "version": "2.0.5/extra", + "raw": "bob-shell/2.0.5/extra" + } + }, + { + "ua": "some-tool/1.2 runtime/node.js/24 libcurl/8.4.0", + "client": { + "raw": "some-tool/1.2 runtime/node.js/24 libcurl/8.4.0" + } + }, + { + "ua": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "client": { + "raw": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + }, + { + "ua": "claude-cli/9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999", + "client": { + "name": "claude-code", + "version": "999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999", + "raw": "claude-cli/999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999" + } + }, + { + "ua": "claude-cli/2.1.14\u001b[2J\u001b[31mPWNED", + "client": { + "name": "claude-code", + "version": "2.1.14�[2J�[31mPWNED", + "raw": "claude-cli/2.1.14�[2J�[31mPWNED" + } + }, + { + "ua": "claude-cli/2.1.14›2J", + "client": { + "name": "claude-code", + "version": "2.1.14�2J", + "raw": "claude-cli/2.1.14�2J" + } + }, + { + "ua": "newagent/1›31m", + "client": { + "raw": "newagent/1�31m" + } + }, + { + "ua": "newagent/1", + "client": { + "raw": "newagent/1�" + } + }, + { + "ua": "newagent/1\nfake-row", + "client": { + "raw": "newagent/1�fake-row" + } + }, + { + "ua": "newagent/1‮gnitcepsus", + "client": { + "raw": "newagent/1�gnitcepsus" + } + }, + { + "ua": "newagent/1⁦x⁩", + "client": { + "raw": "newagent/1�x�" + } + }, + { + "ua": "newagent/1​", + "client": { + "raw": "newagent/1�" + } + }, + { + "ua": "newagent/1‍2", + "client": { + "raw": "newagent/1�2" + } + }, + { + "ua": "claude-cli/2.1.14\u001b[31m (external, cli)", + "client": { + "name": "claude-code", + "version": "2.1.14�[31m", + "raw": "claude-cli/2.1.14�[31m (external, cli)" + } + }, + { + "ua": "\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b\u001b", + "client": { + "raw": "������������������������������������������" + } + }, + { + "ua": "xéééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "client": { + "raw": "xééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé" + } + }, + { + "ua": "✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓", + "client": { + "raw": "✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓" + } + }, + { + "ua": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "client": { + "raw": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé" + } + }, + { + "ua": "wetter-agent/1.0 (Zürich, ✓)", + "client": { + "raw": "wetter-agent/1.0 (Zürich, ✓)" + } + }, + { + "ua": "claude-cli/2.1.284 (external, cli)", + "client": { + "name": "claude-code", + "version": "2.1.284", + "raw": "claude-cli/2.1.284 (external, cli)" + } + }, + { + "ua": "Claude-User (claude-code/2.1.284; +https://support.anthropic.com/)", + "client": { + "raw": "Claude-User (claude-code/2.1.284; +https://support.anthropic.com/)" + } + }, + { + "ua": "ai-sdk/5.0.1 openai-compatible/3.0.36 bob-shell/2.0.5", + "client": { + "name": "bob", + "version": "2.0.5", + "raw": "ai-sdk/5.0.1 openai-compatible/3.0.36 bob-shell/2.0.5" + } + }, + { + "ua": "Go-http-client/1.1", + "client": { + "raw": "Go-http-client/1.1" + } + }, + { + "ua": "python-httpx/0.27.0", + "client": { + "raw": "python-httpx/0.27.0" + } + }, + { + "ua": "axios/1.15.2", + "client": { + "raw": "axios/1.15.2" + } + }, + { + "ua": "node", + "client": { + "raw": "node" + } + }, + { + "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + "client": { + "raw": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" + } + }, + { + "ua": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "client": { + "raw": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }, + { + "ua": "claude-cli/2.1.14 (ext\u0000ernal)", + "client": { + "name": "claude-code", + "version": "2.1.14", + "raw": "claude-cli/2.1.14 (ext�ernal)" + } + }, + { + "ua": " claude-cli/2.1.14…", + "client": { + "name": "claude-code", + "version": "2.1.14", + "raw": "claude-cli/2.1.14" + } + }, + { + "ua": "CLAUDE-CLI/2.1.14", + "client": { + "name": "claude-code", + "version": "2.1.14", + "raw": "CLAUDE-CLI/2.1.14" + } + }, + { + "ua": "pİ/1.0", + "client": { + "name": "pi", + "version": "1.0", + "raw": "pİ/1.0" + } + }, + { + "ua": "ai/1 İBM-x/2 Bob/3", + "client": { + "name": "bob", + "version": "3", + "raw": "ai/1 İBM-x/2 Bob/3" + } + }, + { + "ua": "claude-cli/2.1.295 (external, cli)", + "client": { + "name": "claude-code", + "version": "2.1.295", + "raw": "claude-cli/2.1.295 (external, cli)" + } + }, + { + "ua": "example-agent/1.0", + "client": { + "raw": "example-agent/1.0" + } + }, + { + "ua": "codex_cli_rs/0.1.0", + "client": { + "name": "codex", + "version": "0.1.0", + "raw": "codex_cli_rs/0.1.0" + } + } + ], + "subagent": [ + { + "value": "a1a2e36cb5946f31c", + "id": "a1a2e36cb5946f31c" + }, + { + "value": "", + "id": "" + }, + { + "value": " ", + "id": "" + }, + { + "value": " a1a2e36cb5946f31c ", + "id": "a1a2e36cb5946f31c" + }, + { + "value": "\ta1a2e36cb5946f31c\t", + "id": "a1a2e36cb5946f31c" + }, + { + "value": " \t a1a2e36cb5946f31c \t ", + "id": "a1a2e36cb5946f31c" + }, + { + "value": "a1a2\u001b[2Je36cb", + "id": "" + }, + { + "value": "a1a2\ne36cb", + "id": "" + }, + { + "value": "a1a2e36cb", + "id": "" + }, + { + "value": "a1a2›2Je36cb", + "id": "" + }, + { + "value": "a1a2‮e36cb", + "id": "" + }, + { + "value": "a1a2‍e36cb", + "id": "" + }, + { + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "id": "" + }, + { + "value": "a1a2e36cb5946f31c\n", + "id": "" + }, + { + "value": "\na1a2e36cb5946f31c", + "id": "" + }, + { + "value": "a1a2e36cb5946f31c\r", + "id": "" + }, + { + "value": "a1a2e36cb5946f31c\r\n", + "id": "" + }, + { + "value": "\u000ba1a2e36cb5946f31c", + "id": "" + }, + { + "value": "a1a2e36cb5946f31c\f", + "id": "" + }, + { + "value": "a1a2e36cb5946f31c…", + "id": "" + }, + { + "value": "…a1a2e36cb5946f31c", + "id": "" + }, + { + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "value": "a21728bb34d27cb12", + "id": "a21728bb34d27cb12" + }, + { + "value": "a0974b0b8591dc76b", + "id": "a0974b0b8591dc76b" + } + ] +} diff --git a/praxis/filters/src/record/client.rs b/praxis/filters/src/record/client.rs index 959b922b8..9969b42cc 100644 --- a/praxis/filters/src/record/client.rs +++ b/praxis/filters/src/record/client.rs @@ -1 +1,129 @@ //! Port of `model.ParseUserAgent` and `subagentHeader`. + +use serde::Serialize; + +use crate::parse::inference::text::{go_to_lower, is_control_rune, sanitize_control_runes}; + +/// The JSON of `model.EventClient`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Client { + #[serde(skip_serializing_if = "String::is_empty")] + pub name: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub version: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub raw: String, +} + +/// The header that names the Claude Code subagent of a request. +pub const CLAUDE_CODE_AGENT_ID_HEADER: &str = "X-Claude-Code-Agent-Id"; +/// The header that names the parent of that subagent. +pub const CLAUDE_CODE_PARENT_AGENT_ID_HEADER: &str = "X-Claude-Code-Parent-Agent-Id"; + +/// `model.maxClientLen`: the byte cap on a User-Agent and on a subagent id. +const MAX_CLIENT_LEN: usize = 128; + +/// `model.knownClients`: a product token, lowercased, to its canonical agent name. +fn known_client(product: &str) -> Option<&'static str> { + Some(match go_to_lower(product).as_str() { + "claude-cli" | "claude-code" => "claude-code", + "bob-shell" | "bob" => "bob", + "opencode" => "opencode", + "codex_exec" | "codex-mcp-client" | "codex-tui" | "codex_cli_rs" => "codex", + "pi" => "pi", + _ => return None, + }) +} + +/// `model.TruncateLabel`: at most `n` bytes, cut on a char boundary. +fn truncate_label(s: &str, n: usize) -> &str { + if s.len() <= n { + return s; + } + let mut cut = n; + while !s.is_char_boundary(cut) { + cut -= 1; + } + &s[..cut] +} + +/// `model.productVersion`: OpenCode puts a release channel before its version. +fn product_version<'a>(name: &str, rest: &'a str) -> &'a str { + if name != "opencode" { + return rest; + } + rest.split('/').nth(1).unwrap_or(rest) +} + +/// Port of `model.ParseUserAgent`. `None` means no User-Agent was sent. +pub fn parse_user_agent(ua: &str) -> Option { + let ua = ua.trim(); + if ua.is_empty() { + return None; + } + let ua = ua.replace('\t', " "); + let sanitized = sanitize_control_runes(&ua); + let ua = truncate_label(&sanitized, MAX_CLIENT_LEN); + let mut c = Client { + raw: ua.to_string(), + ..Client::default() + }; + let token = ua.split(' ').next().unwrap_or(ua); + let (product, version) = token.split_once('/').unwrap_or((token, "")); + if let Some(name) = known_client(product) { + c.name = name.to_string(); + c.version = product_version(name, version).to_string(); + return Some(c); + } + // model.trailingKnownClient: the later tokens, last to first. + let fields: Vec<&str> = ua.split_whitespace().collect(); + for field in fields.iter().skip(1).rev() { + let (product, ver) = field.split_once('/').unwrap_or((field, "")); + if let Some(name) = known_client(product) { + c.name = name.to_string(); + c.version = product_version(name, ver).to_string(); + break; + } + } + Some(c) +} + +/// Port of `pipeline.subagentHeader`: trim SP and HTAB, then refuse an empty id, an id +/// over the cap, or one with a control rune. +pub fn subagent_header(v: Option<&str>) -> String { + let id = v.unwrap_or("").trim_matches([' ', '\t']); + if id.is_empty() || id.len() > MAX_CLIENT_LEN || id.chars().any(is_control_rune) { + return String::new(); + } + id.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_agent_golden_matches_go() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../core/pipeline/testdata/user-agent.golden.json" + ); + let golden: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + let uas = golden["userAgent"].as_array().unwrap(); + assert!(!uas.is_empty()); + for case in uas { + let ua = case["ua"].as_str().unwrap(); + let got = serde_json::to_value(parse_user_agent(ua)).unwrap(); + assert_eq!(got, case["client"], "user agent {ua:?}"); + } + let subs = golden["subagent"].as_array().unwrap(); + assert!(!subs.is_empty()); + for case in subs { + let v = case["value"].as_str().unwrap(); + assert_eq!(subagent_header(Some(v)), case["id"], "header value {v:?}"); + } + assert_eq!(subagent_header(None), ""); + } +} From ab5c2ef1ec3b80b2776296ed929354163c290504 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 15:58:09 -0500 Subject: [PATCH 10/26] Feat: Add the row builder for praxis-mode rows Add the record modules that make the ingest lines of cortex-gateway: - line: the Line, Event, Capture and Cost wire types, with the JSON of line format v1 and Go's omit rules. A drift test compares the Event keys with session-event.golden.json. - attribution: decode of X-Cortex-Attribution, the constant-time token check, and the two hop header lists. - row: Exchange, which makes the request row and the response row from plain values. It parses, counts and captures the response as direct mode does. A gzip body gives a decoded capture, and bytesDown counts the decoded bytes. It also makes the cap rows and the deferred rows. The mislabeled-SSE rule moves here. An upstream status of 400 or more with no other failure gives backend_error, as DeriveError does in Go. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- praxis/filters/src/parse/inference/mod.rs | 1 + .../filters/src/parse/inference/observer.rs | 2 +- praxis/filters/src/record/attribution.rs | 135 +++ praxis/filters/src/record/line.rs | 354 ++++++ praxis/filters/src/record/row.rs | 1030 ++++++++++++++++- 5 files changed, 1520 insertions(+), 2 deletions(-) diff --git a/praxis/filters/src/parse/inference/mod.rs b/praxis/filters/src/parse/inference/mod.rs index d55f91c71..f42f8bd84 100644 --- a/praxis/filters/src/parse/inference/mod.rs +++ b/praxis/filters/src/parse/inference/mod.rs @@ -15,6 +15,7 @@ pub(crate) mod text; mod usage; pub use observer::InferenceObserver; +pub(crate) use observer::is_event_stream; pub use served_model::settle_served_model; use std::collections::HashMap; diff --git a/praxis/filters/src/parse/inference/observer.rs b/praxis/filters/src/parse/inference/observer.rs index cbe69a4a7..325ffb28d 100644 --- a/praxis/filters/src/parse/inference/observer.rs +++ b/praxis/filters/src/parse/inference/observer.rs @@ -24,7 +24,7 @@ fn endpoint_path(path: &str) -> &str { } /// Port of settle.isEventStream: the media type of the first Content-Type is text/event-stream. -fn is_event_stream(h: &HeaderMap) -> bool { +pub(crate) fn is_event_stream(h: &HeaderMap) -> bool { let ct = h .get(http::header::CONTENT_TYPE) .map(|v| go_lossy_utf8(v.as_bytes()).text.into_owned()) diff --git a/praxis/filters/src/record/attribution.rs b/praxis/filters/src/record/attribution.rs index 119fc977d..b745fddfa 100644 --- a/praxis/filters/src/record/attribution.rs +++ b/praxis/filters/src/record/attribution.rs @@ -1 +1,136 @@ //! Attribution: the `X-Cortex-Attribution` header, the token check and the hop header lists. + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde::{Deserialize, Serialize}; + +/// What the forward proxy knows about the caller, sent in `X-Cortex-Attribution` +/// as base64url JSON without padding. Every key is optional. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Attribution { + pub agent: String, + pub proc: String, + pub client_proc: String, + pub session: String, + pub source: String, + pub session_header: String, + pub chain_session: String, + /// The bucket a row is filed under. Empty: ingestion picks it, as a listener does. + pub record_session: String, +} + +pub const ATTRIBUTION_HEADER: &str = "X-Cortex-Attribution"; +pub const TOKEN_HEADER: &str = "X-Cortex-Token"; + +/// The hop headers removed before the upstream. `X-Cortex-Dest` and `X-Cortex-Token` +/// stay: the egress listener needs them and removes them itself. +pub const UPSTREAM_REMOVE: [&str; 10] = [ + "X-Cortex-Agent", + "X-Cortex-Proc", + "X-Cortex-Client-Proc", + "X-Cortex-Session", + "X-Cortex-Source", + "X-Cortex-Session-Header", + "X-Cortex-Chain-Session", + ATTRIBUTION_HEADER, + "X-Cortex-Request-Id", + "X-Cortex-Probe", +]; + +/// The hop headers a request capture never keeps: all of them. +pub const CAPTURE_REMOVE: [&str; 12] = [ + UPSTREAM_REMOVE[0], + UPSTREAM_REMOVE[1], + UPSTREAM_REMOVE[2], + UPSTREAM_REMOVE[3], + UPSTREAM_REMOVE[4], + UPSTREAM_REMOVE[5], + UPSTREAM_REMOVE[6], + UPSTREAM_REMOVE[7], + UPSTREAM_REMOVE[8], + UPSTREAM_REMOVE[9], + "X-Cortex-Dest", + TOKEN_HEADER, +]; + +/// Decodes the header. A missing header, bad base64url or bad JSON gives the default. +pub fn decode(header: Option<&str>) -> Attribution { + header + .and_then(|h| URL_SAFE_NO_PAD.decode(h.trim()).ok()) + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() +} + +/// Compares the given token with `token` in constant time. An empty `token` matches nothing. +pub fn token_matches(given: Option<&[u8]>, token: &[u8]) -> bool { + let Some(given) = given else { + return false; + }; + if token.is_empty() || given.len() != token.len() { + return false; + } + given + .iter() + .zip(token) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attribution_decode_bad_input_is_default() { + assert_eq!(decode(None), Attribution::default()); + assert_eq!(decode(Some("%%%")), Attribution::default()); + assert_eq!( + decode(Some(&URL_SAFE_NO_PAD.encode("not json"))), + Attribution::default() + ); + assert_eq!( + decode(Some(&URL_SAFE_NO_PAD.encode(r#"{"session":7}"#))), + Attribution::default() + ); + // Padding is not base64url without padding. + let padded = base64::engine::general_purpose::URL_SAFE.encode(r#"{"agent":"a1"}"#); + assert!(padded.ends_with('=')); + assert_eq!(decode(Some(&padded)), Attribution::default()); + + let good = URL_SAFE_NO_PAD.encode( + r#"{"agent":"a","proc":"/bin/claude","clientProc":"/bin/zsh","session":"s", + "source":"header","sessionHeader":"x-session-id","chainSession":"c", + "recordSession":"r","unknown":1}"#, + ); + let a = decode(Some(&good)); + assert_eq!(a.agent, "a"); + assert_eq!(a.client_proc, "/bin/zsh"); + assert_eq!(a.session_header, "x-session-id"); + assert_eq!(a.chain_session, "c"); + assert_eq!(a.record_session, "r"); + // A partial header keeps the other keys empty. + let part = decode(Some(&URL_SAFE_NO_PAD.encode(r#"{"session":"s"}"#))); + assert_eq!(part.session, "s"); + assert_eq!(part.proc, ""); + } + + #[test] + fn token_matches_rules() { + assert!(token_matches(Some(b"tok"), b"tok")); + assert!(!token_matches(Some(b"tok"), b"tik")); + assert!(!token_matches(Some(b"to"), b"tok")); + assert!(!token_matches(None, b"tok")); + assert!(!token_matches(Some(b""), b"")); + assert!(!token_matches(Some(b"x"), b"")); + } + + #[test] + fn hop_lists() { + assert!(!UPSTREAM_REMOVE.contains(&"X-Cortex-Dest")); + assert!(!UPSTREAM_REMOVE.contains(&TOKEN_HEADER)); + for h in UPSTREAM_REMOVE { + assert!(CAPTURE_REMOVE.contains(&h)); + } + } +} diff --git a/praxis/filters/src/record/line.rs b/praxis/filters/src/record/line.rs index aea448cf8..a59640e8b 100644 --- a/praxis/filters/src/record/line.rs +++ b/praxis/filters/src/record/line.rs @@ -1 +1,355 @@ //! Wire types of an ingest line: `Line`, `Event`, `Capture`, `Cost`. +//! +//! The JSON is line format v1 (`ingestLine` in core/sessionapi/ingest.go). `Event` +//! is the session event wire JSON (`sessionEventWire` in core/model/session.go) +//! with Go's omit rules. + +use std::collections::BTreeMap; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use serde::{Serialize, Serializer}; + +use super::client::Client; +use super::emitter::Queued; +use crate::parse::model::InferenceExtension; + +/// The line format version, `ingestLineVersion` in Go. +pub const LINE_VERSION: u8 = 1; +/// `sessionapi.SchemaVersion`. +pub const SCHEMA_VERSION: u8 = 1; + +/// The session event fields that praxis mode does not set, with the reason for each. +pub const NOT_SET_IN_PRAXIS_MODE: &[&str] = &[ + // The store sets these three when it appends the row. + "sessionId", + "seq", + "at", + // Ingestion sets the cost record; no other plugin runs. + "plugins", + // No workload identity on a laptop. + "identity", + // The TLS bridge state is in Cortex; #1722 decides if the hop carries it. + "tls", + // No plugins run in the gateway. + "invocations", + // No router in the gateway. + "requestedHost", + // Cortex strips upgrades before the hop. + "upgradeStripped", + // Cortex's own tunnel rows. + "tunnel", + "tunnelReason", + "dialAddr", + // The a2a and mcp parsers move at step 6. + "a2a", + "mcp", +]; + +fn is_zero(v: &i64) -> bool { + *v == 0 +} + +fn b64(b: &[u8], s: S) -> Result { + s.serialize_str(&STANDARD.encode(b)) +} + +/// Header names in Go's canonical form, each with its values. +pub type WireHeader = BTreeMap>; + +/// `EventError` in core/model/session.go. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct EventError { + pub kind: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub code: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub message: String, +} + +/// One session event, as `sessionEventWire` writes it. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Event { + pub direction: &'static str, + pub phase: &'static str, + #[serde(skip_serializing_if = "String::is_empty")] + pub request_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub inference: Option, + #[serde(skip_serializing_if = "is_zero")] + pub status_code: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "String::is_empty")] + pub host: String, + #[serde(skip_serializing_if = "is_zero")] + pub duration_ms: i64, + #[serde(skip_serializing_if = "is_zero")] + pub ttfb_ms: i64, + #[serde(skip_serializing_if = "is_zero")] + pub bytes_up: i64, + #[serde(skip_serializing_if = "is_zero")] + pub bytes_down: i64, + #[serde(skip_serializing_if = "String::is_empty")] + pub http_method: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub http_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub client: Option, + #[serde(skip_serializing_if = "String::is_empty")] + pub subagent: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub parent_subagent: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub proc: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub client_proc: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub session_header: String, +} + +/// The response half of a capture (`ingestResponse` in Go). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct ResponseCapture { + pub status: u16, + pub header: WireHeader, + #[serde(serialize_with = "b64")] + pub body: Vec, + pub decoded: bool, + pub truncated: bool, + pub ended: bool, + pub incomplete: bool, +} + +/// A line's capture (`ingestCapture` in Go). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(untagged)] +pub enum Capture { + Request { + #[serde(serialize_with = "b64")] + original: Vec, + header: WireHeader, + query: String, + }, + Response { + response: ResponseCapture, + }, + /// The capture did not fit; the row went without it. + Dropped { + dropped: bool, + }, +} + +/// A response row's cost-relevant headers. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct Cost { + pub headers: BTreeMap, +} + +/// One ingest line. +#[derive(Debug, Clone)] +pub struct Line { + pub request_id: String, + /// RFC 3339 with nanoseconds, UTC. + pub started_at: String, + pub session: String, + pub event: Event, + pub capture: Option, + pub cost: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Wire<'a> { + v: u8, + schema_version: u8, + op: &'static str, + request_id: &'a str, + started_at: &'a str, + session: &'a str, + event: &'a Event, + #[serde(skip_serializing_if = "Option::is_none")] + capture: Option<&'a Capture>, + #[serde(skip_serializing_if = "Option::is_none")] + cost: Option<&'a Cost>, +} + +const DROPPED: Capture = Capture::Dropped { dropped: true }; + +impl Line { + fn wire<'a>(&'a self, capture: Option<&'a Capture>) -> Wire<'a> { + Wire { + v: LINE_VERSION, + schema_version: SCHEMA_VERSION, + op: "append", + request_id: &self.request_id, + started_at: &self.started_at, + session: &self.session, + event: &self.event, + capture, + cost: self.cost.as_ref(), + } + } + + /// The two serializations the emitter chooses from, each ending in `\n`. + pub fn queued(&self) -> Queued { + let line = |c: Option<&Capture>| { + // shortcut: serializing these plain types cannot fail. + let mut b = serde_json::to_vec(&self.wire(c)).expect("line serializes"); + b.push(b'\n'); + b + }; + match &self.capture { + None => Queued { + row: line(None), + with_capture: None, + }, + Some(c) => Queued { + row: line(Some(&DROPPED)), + with_capture: Some(line(Some(c))), + }, + } + } +} + +impl Serialize for Line { + fn serialize(&self, s: S) -> Result { + self.wire(self.capture.as_ref()).serialize(s) + } +} + +/// Go's `http.CanonicalHeaderKey` for a valid header name. +pub fn canonical_header_key(name: &str) -> String { + let mut up = true; + name.chars() + .map(|c| { + let out = if up { + c.to_ascii_uppercase() + } else { + c.to_ascii_lowercase() + }; + up = c == '-'; + out + }) + .collect() +} + +/// `h` as Go's `http.Header` writes it in JSON. +pub fn wire_header(h: &http::HeaderMap) -> WireHeader { + let mut out = WireHeader::new(); + for (k, v) in h { + out.entry(canonical_header_key(k.as_str())) + .or_default() + .push(String::from_utf8_lossy(v.as_bytes()).into_owned()); + } + out +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use serde_json::Value; + use std::collections::BTreeSet; + + /// An event with every field set. + pub(crate) fn full_event() -> Event { + Event { + direction: "outbound", + phase: "response", + request_id: "r".into(), + inference: Some(InferenceExtension::default()), + status_code: 200, + error: Some(EventError { + kind: "k".into(), + ..Default::default() + }), + host: "h".into(), + duration_ms: 1, + ttfb_ms: 1, + bytes_up: 1, + bytes_down: 1, + http_method: "POST".into(), + http_path: "/".into(), + client: Some(Client { + name: "n".into(), + ..Default::default() + }), + subagent: "s".into(), + parent_subagent: "p".into(), + proc: "p".into(), + client_proc: "c".into(), + session_header: "x".into(), + } + } + + fn keys(v: &Value) -> BTreeSet { + v.as_object().unwrap().keys().cloned().collect() + } + + #[test] + fn every_session_event_field_is_set_or_listed() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../core/pipeline/testdata/session-event.golden.json" + ); + let golden: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + let golden = keys(&golden); + let rust = keys(&serde_json::to_value(full_event()).unwrap()); + let listed: BTreeSet = NOT_SET_IN_PRAXIS_MODE + .iter() + .map(|s| s.to_string()) + .collect(); + let known = &rust | &listed; + let missing: Vec<_> = golden.difference(&known).collect(); + assert!( + missing.is_empty(), + "fields neither set nor listed: {missing:?}" + ); + let extra: Vec<_> = rust.difference(&golden).collect(); + assert!(extra.is_empty(), "fields not in the Go event: {extra:?}"); + let both: Vec<_> = rust.intersection(&listed).collect(); + assert!(both.is_empty(), "fields set and listed: {both:?}"); + } + + #[test] + fn line_json_keys_and_dropped_row() { + let l = Line { + request_id: "r1".into(), + started_at: "2026-11-09T10:00:00.000000001Z".into(), + session: "s".into(), + event: Event { + direction: "outbound", + phase: "request", + ..Default::default() + }, + capture: Some(Capture::Request { + original: b"hi".to_vec(), + header: WireHeader::new(), + query: String::new(), + }), + cost: None, + }; + let q = l.queued(); + let full: Value = serde_json::from_slice(q.with_capture.as_ref().unwrap()).unwrap(); + assert_eq!(full["v"], 1); + assert_eq!(full["schemaVersion"], 1); + assert_eq!(full["op"], "append"); + assert_eq!(full["requestId"], "r1"); + assert_eq!(full["capture"]["original"], "aGk="); + assert!(full.get("cost").is_none()); + let row: Value = serde_json::from_slice(&q.row).unwrap(); + assert_eq!(row["capture"], serde_json::json!({"dropped": true})); + assert!(q.row.ends_with(b"\n")); + } + + #[test] + fn canonical_keys() { + assert_eq!( + canonical_header_key("x-litellm-model-name"), + "X-Litellm-Model-Name" + ); + assert_eq!(canonical_header_key("content-type"), "Content-Type"); + assert_eq!(canonical_header_key("te"), "Te"); + } +} diff --git a/praxis/filters/src/record/row.rs b/praxis/filters/src/record/row.rs index 0ee92f6a6..69de55449 100644 --- a/praxis/filters/src/record/row.rs +++ b/praxis/filters/src/record/row.rs @@ -1 +1,1029 @@ -//! The row builder: `Exchange`. +//! The row builder: `Exchange`, one per request. It turns plain values from the +//! Praxis hooks into the request row and the response row, as direct mode +//! records them (core/listener/internal/sessionevent, forwardproxy). + +use std::collections::BTreeMap; +use std::time::{Duration, Instant, SystemTime}; + +use chrono::{DateTime, SecondsFormat, Utc}; +use http::HeaderMap; +use http::header::{CONTENT_ENCODING, CONTENT_LENGTH, HOST, USER_AGENT}; + +use super::attribution::{Attribution, CAPTURE_REMOVE}; +use super::client::{ + CLAUDE_CODE_AGENT_ID_HEADER, CLAUDE_CODE_PARENT_AGENT_ID_HEADER, Client, parse_user_agent, + subagent_header, +}; +use super::line::{Capture, Cost, Event, EventError, Line, ResponseCapture, wire_header}; +use super::redact; +use crate::parse::MAX_BODY_SIZE; +use crate::parse::decode::{Coding, DecodeError, Decoder}; +use crate::parse::inference::{InferenceObserver, is_event_stream}; +use crate::parse::sse::FrameReader; + +/// `maxRequestBodySize` in core/listener/forwardproxy. +pub const MAX_REQUEST_BODY: usize = 32 << 20; +/// `model.MaxResponseCapture`. +pub const MAX_RESPONSE_CAPTURE: usize = 10 << 20; + +/// The response header that the egress listener sets on a failed dial. +pub const EGRESS_ERROR_HEADER: &str = "X-Cortex-Egress-Error"; + +/// The response headers that a cost record reads. +const COST_HEADERS: [&str; 6] = [ + "X-Litellm-Response-Cost", + "X-Litellm-Response-Cost-Original", + "X-Litellm-Response-Cost-Discount-Amount", + "X-Litellm-Response-Cost-Margin-Amount", + "X-Litellm-Model-Name", + "Content-Type", +]; + +/// Decision 8 of the plan: Rust text where Go puts its error text. +const CLIENT_GONE: &str = "client closed the connection"; +const UPSTREAM_GONE: &str = "upstream closed the connection"; + +/// Why a response stopped before its end. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Abort { + /// A buffered response over `MAX_BODY_SIZE` decoded bytes. + BufferedOverCap, + /// One SSE frame over `MAX_BODY_SIZE`. + FrameOverCap, +} + +/// Port of `isKnownMislabeledSSE` with its 2xx gate (forwardproxy/server.go). +pub fn is_known_mislabeled_sse(status: u16, host: &str, path: &str) -> bool { + status / 100 == 2 && host == "chatgpt.com" && path == "/backend-api/codex/responses" +} + +/// The error kind for an `X-Cortex-Egress-Error` value. +fn egress_kind(v: &str) -> &'static str { + match v.trim() { + "upstream_dns" => "upstream_dns", + "upstream_tls" => "upstream_tls", + "upstream_timeout" => "upstream_timeout", + "upstream_refused" => "upstream_refused", + _ => "upstream_error", + } +} + +/// The coding to decode for parsing. Only one coding is decoded; any other is read as it is. +fn coding(h: &HeaderMap) -> Coding { + let mut values = h.get_all(CONTENT_ENCODING).iter(); + let (Some(v), None) = (values.next(), values.next()) else { + return Coding::Identity; + }; + match v.to_str().map(str::trim) { + Ok(c) if c.eq_ignore_ascii_case("gzip") => Coding::Gzip, + Ok(c) if c.eq_ignore_ascii_case("zstd") => Coding::Zstd, + _ => Coding::Identity, + } +} + +/// Port of `upstreamErrorKind` (core/pipeline/snapshot_context.go): the provider's +/// `error.type`, else `error.code`, when it is a JSON string or number. +fn upstream_error_kind(body: &[u8]) -> String { + let body = &body[..body.len().min(64 * 1024)]; + let Ok(v) = serde_json::from_slice::(body) else { + return String::new(); + }; + let pick = |k: &str| match v.get("error").and_then(|e| e.get(k)) { + Some(serde_json::Value::String(s)) => s.clone(), + Some(serde_json::Value::Number(n)) => n.to_string(), + _ => String::new(), + }; + let mut t = pick("type"); + if t.is_empty() { + t = pick("code"); + } + let mut cut = t.len().min(64); + while !t.is_char_boundary(cut) { + cut -= 1; + } + t.truncate(cut); + t +} + +/// Appends `b` to the capture body, up to `MAX_RESPONSE_CAPTURE`. +fn keep(capture: &mut Option, b: &[u8]) { + let Some(c) = capture else { + return; + }; + let room = MAX_RESPONSE_CAPTURE - c.body.len(); + if b.len() > room { + c.truncated = true; + } + c.body.extend_from_slice(&b[..b.len().min(room)]); +} + +/// The response side of an exchange, from its head on. +struct Response { + status: u16, + headers: HeaderMap, + sse: bool, + /// The body is gzip alone: the capture and `bytesDown` take the decoded bytes. + gzip: bool, + decoder: Decoder, + frames: FrameReader, + /// The decoded body of a buffered response. + buffered: Vec, + wire_bytes: usize, + decoded_bytes: usize, + capture: Option, + egress_error: Option<&'static str>, + /// The body does not decode: parsing stops, the bytes still go through. + corrupt: bool, +} + +impl Response { + /// Gives a decoded chunk (or the decoder's end, for `None`) to the parser. + fn feed( + &mut self, + obs: &mut Option, + chunk: Option<&[u8]>, + ) -> Result<(), Abort> { + if self.corrupt { + return Ok(()); + } + let Response { + headers, + sse, + gzip, + decoder, + frames, + buffered, + decoded_bytes, + capture, + .. + } = self; + let mut frame_over = false; + let mut sink = |b: &[u8]| -> Result<(), DecodeError> { + *decoded_bytes += b.len(); + if *gzip { + keep(capture, b); + } + if !*sse { + buffered.extend_from_slice(b); + return Ok(()); + } + let (done, over) = match frames.push(b) { + Ok(f) => (f, false), + Err(e) => (e.completed, true), + }; + if let Some(o) = obs.as_mut() { + for f in done { + o.on_response_frame(headers, &f, false); + } + } + if over { + frame_over = true; + return Err(DecodeError::CapExceeded); + } + Ok(()) + }; + let r = match chunk { + Some(c) => decoder.push_with(c, &mut sink), + None => decoder.finish_with(&mut sink), + }; + match r { + Ok(()) => Ok(()), + Err(_) if frame_over => Err(Abort::FrameOverCap), + Err(DecodeError::CapExceeded) => Err(Abort::BufferedOverCap), + Err(DecodeError::Corrupt) => { + self.corrupt = true; + Ok(()) + } + } + } +} + +/// One request and its response, from the request head to the last row. +pub struct Exchange { + method: String, + host: String, + path: String, + query: String, + headers: HeaderMap, + attribution: Attribution, + request_id: String, + started_at: String, + start: Instant, + client: Option, + subagent: String, + parent_subagent: String, + observer: Option, + request_built: bool, + capture_kept: bool, + ttfb: Duration, + resp: Option, +} + +impl Exchange { + #[allow(clippy::too_many_arguments)] + pub fn new( + method: &str, + host: &str, + path_and_query: &str, + headers: &HeaderMap, + attribution: Attribution, + request_id: String, + started: SystemTime, + start: Instant, + ) -> Exchange { + let (path, query) = path_and_query + .split_once('?') + .unwrap_or((path_and_query, "")); + let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()); + Exchange { + method: method.to_owned(), + host: host.to_owned(), + path: path.to_owned(), + query: query.to_owned(), + client: parse_user_agent(header(USER_AGENT.as_str()).unwrap_or("")), + subagent: subagent_header(header(CLAUDE_CODE_AGENT_ID_HEADER)), + parent_subagent: subagent_header(header(CLAUDE_CODE_PARENT_AGENT_ID_HEADER)), + headers: headers.clone(), + attribution, + request_id, + started_at: DateTime::::from(started).to_rfc3339_opts(SecondsFormat::Nanos, true), + start, + observer: None, + request_built: false, + capture_kept: false, + ttfb: Duration::ZERO, + resp: None, + } + } + + /// The fields that both rows carry. + fn event(&self, phase: &'static str) -> Event { + Event { + direction: "outbound", + phase, + request_id: self.request_id.clone(), + host: self.host.clone(), + http_method: self.method.clone(), + http_path: self.path.clone(), + client: self.client.clone(), + subagent: self.subagent.clone(), + parent_subagent: self.parent_subagent.clone(), + proc: self.attribution.proc.clone(), + ..Event::default() + } + } + + fn line(&self, event: Event, capture: Option, cost: Option) -> Line { + Line { + request_id: self.request_id.clone(), + started_at: self.started_at.clone(), + session: self.attribution.record_session.clone(), + event, + capture, + cost, + } + } + + /// The request row. Parses the raw body (the parser decodes it itself). + pub fn on_request_body(&mut self, body: &[u8]) -> Line { + let target = match self.query.is_empty() { + true => self.path.clone(), + false => format!("{}?{}", self.path, self.query), + }; + self.observer = Some(InferenceObserver::on_request(&target, &self.headers, body)); + self.request_row(body) + } + + fn request_row(&mut self, body: &[u8]) -> Line { + self.request_built = true; + let inference = self.observer.as_ref().and_then(|o| o.extension()).cloned(); + // Only inference is kept: the body of any other request can be a credential. + let capture = inference.is_some().then(|| { + let mut h = self.headers.clone(); + h.remove(HOST); + for n in CAPTURE_REMOVE { + h.remove(n); + } + Capture::Request { + original: body.to_vec(), + header: wire_header(&redact::headers(&h)), + query: redact::query(&self.query), + } + }); + self.capture_kept = capture.is_some(); + let event = Event { + inference, + bytes_up: body.len() as i64, + client_proc: self.attribution.client_proc.clone(), + session_header: self.attribution.session_header.clone(), + ..self.event("request") + }; + self.line(event, capture, None) + } + + /// Reads the response head. Removes `X-Cortex-Egress-Error` from `headers`. + pub fn on_response_head(&mut self, status: u16, headers: &mut HeaderMap) -> Result<(), Abort> { + self.ttfb = self.start.elapsed(); + let egress_error = headers + .remove(EGRESS_ERROR_HEADER) + .map(|v| egress_kind(v.to_str().unwrap_or(""))); + let sse = + is_event_stream(headers) || is_known_mislabeled_sse(status, &self.host, &self.path); + let coding = coding(headers); + let gzip = coding == Coding::Gzip; + let capture = self.capture_kept.then(|| { + let mut h = redact::headers(headers); + if gzip { + h.remove(CONTENT_ENCODING); + h.remove(CONTENT_LENGTH); + } + ResponseCapture { + status, + header: wire_header(&h), + decoded: gzip, + ..ResponseCapture::default() + } + }); + let over_by_length = !sse + && !headers.contains_key(CONTENT_ENCODING) + && headers + .get(CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()?.trim().parse::().ok()) + .is_some_and(|n| n > MAX_BODY_SIZE as u64); + self.resp = Some(Response { + status, + headers: headers.clone(), + sse, + gzip, + // The buffered cap counts decoded bytes; a stream has only the frame cap. + decoder: Decoder::new(coding, if sse { usize::MAX } else { MAX_BODY_SIZE }), + frames: FrameReader::new(MAX_BODY_SIZE), + buffered: Vec::new(), + wire_bytes: 0, + decoded_bytes: 0, + capture, + egress_error, + corrupt: false, + }); + if over_by_length { + return Err(Abort::BufferedOverCap); + } + Ok(()) + } + + /// Counts, captures and parses one response chunk as it came off the wire. + pub fn on_response_chunk(&mut self, chunk: &[u8]) -> Result<(), Abort> { + let Some(r) = self.resp.as_mut() else { + return Ok(()); + }; + r.wire_bytes += chunk.len(); + if !r.gzip { + keep(&mut r.capture, chunk); + } + r.feed(&mut self.observer, Some(chunk)) + } + + /// The response row of a response read to its end. + pub fn on_response_end(&mut self) -> Line { + let Some(r) = self.resp.as_mut() else { + return self.response_row(0, None); + }; + if let Err(a) = r.feed(&mut self.observer, None) { + return self.on_abort(a); + } + if r.sse && !r.corrupt { + let (done, over) = match r.frames.finish() { + Ok(f) => (f, false), + Err(e) => (e.completed, true), + }; + if let Some(o) = self.observer.as_mut() { + for f in done { + o.on_response_frame(&r.headers, &f, false); + } + } + if over { + return self.on_abort(Abort::FrameOverCap); + } + } + if let Some(o) = self.observer.as_mut() { + let last: &[u8] = if r.sse { b"" } else { &r.buffered }; + o.on_response_frame(&r.headers, last, true); + } + if let Some(c) = r.capture.as_mut() { + c.ended = true; + } + let status = r.status; + let error = match r.egress_error { + Some(kind) => Some(EventError { + kind: kind.to_owned(), + ..EventError::default() + }), + // DeriveError: a status the upstream sent >= 400 is backend_error. + None if status >= 400 => Some(EventError { + kind: "backend_error".to_owned(), + code: status.to_string(), + message: if r.sse { + String::new() + } else { + upstream_error_kind(&r.buffered) + }, + }), + None => None, + }; + self.response_row(status as i64, error) + } + + /// The response row of a response cut at a cap. + pub fn on_abort(&mut self, a: Abort) -> Line { + let status = self.resp.as_ref().map_or(0, |r| r.status); + let (status_code, message) = match a { + Abort::BufferedOverCap => ( + 502, + format!("response body over the {MAX_BODY_SIZE}-byte buffer limit"), + ), + Abort::FrameOverCap => ( + status as i64, + format!("single SSE frame over the {MAX_BODY_SIZE}-byte frame cap"), + ), + }; + let error = EventError { + kind: "proxy_error".to_owned(), + code: status.to_string(), + message, + }; + self.response_row(status_code, Some(error)) + } + + /// The rows of an exchange that ended early or was refused before the upstream. + /// The request row comes first when none was built. + pub fn deferred( + &mut self, + status: u16, + downstream_gone: bool, + upstream_gone: bool, + ) -> Vec { + let mut out = Vec::with_capacity(2); + if !self.request_built { + out.push(self.request_row(&[])); + } + // A failure after the upstream's head carries its status as the code. + let code = self + .resp + .as_ref() + .map_or(String::new(), |r| r.status.to_string()); + let (status_code, error) = if status == 413 { + let message = match self + .headers + .get(CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()?.trim().parse::().ok()) + { + Some(n) => format!( + "request body of {n} bytes is over the {MAX_REQUEST_BODY}-byte buffer limit" + ), + None => format!("request body over the {MAX_REQUEST_BODY}-byte buffer limit"), + }; + (413, ("proxy_error", String::new(), message)) + } else if downstream_gone || status == 499 { + (499, ("client_canceled", code, CLIENT_GONE.to_owned())) + } else if upstream_gone { + ( + status as i64, + ("upstream_error", code, UPSTREAM_GONE.to_owned()), + ) + } else { + ( + status as i64, + ("gateway_refused", String::new(), String::new()), + ) + }; + let (kind, code, message) = error; + let error = EventError { + kind: kind.to_owned(), + code, + message, + }; + out.push(self.response_row(status_code, Some(error))); + out + } + + fn response_row(&mut self, status_code: i64, error: Option) -> Line { + let mut event = Event { + inference: self.observer.as_ref().and_then(|o| o.extension()).cloned(), + status_code, + error, + duration_ms: self.start.elapsed().as_millis() as i64, + ttfb_ms: self.ttfb.as_millis() as i64, + ..self.event("response") + }; + let (mut capture, mut cost) = (None, None); + if let Some(r) = self.resp.as_mut() { + event.bytes_down = if r.gzip { + r.decoded_bytes + } else { + r.wire_bytes + } as i64; + capture = r.capture.take().map(|mut c| { + c.incomplete = !c.ended; + Capture::Response { response: c } + }); + let headers: BTreeMap = COST_HEADERS + .iter() + .filter_map(|&n| { + let v = r.headers.get(n)?; + Some(( + n.to_owned(), + String::from_utf8_lossy(v.as_bytes()).into_owned(), + )) + }) + .collect(); + cost = Some(Cost { headers }); + } + self.line(event, capture, cost) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::record::emitter::MAX_LINE; + use http::{HeaderName, HeaderValue}; + use serde_json::{Value, json}; + use std::io::Write; + + const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../core/plugins/inferenceparser/testdata/claude-code/main-stream-thinking-tool-use" + ); + + fn header_map<'a>(lines: impl Iterator) -> HeaderMap { + let mut h = HeaderMap::new(); + for line in lines.take_while(|l| !l.is_empty()) { + let (name, value) = line.split_once(':').unwrap(); + h.append( + HeaderName::from_bytes(name.trim().as_bytes()).unwrap(), + HeaderValue::from_str(value.trim()).unwrap(), + ); + } + h + } + + struct Fixture { + target: String, + req_headers: HeaderMap, + req_body: Vec, + resp_headers: HeaderMap, + resp_body: Vec, + } + + fn fixture() -> Fixture { + let head = std::fs::read_to_string(format!("{FIXTURE}/request.headers")).unwrap(); + let mut lines = head.lines(); + let target = lines.next().unwrap().split(' ').nth(1).unwrap().to_owned(); + let resp = std::fs::read_to_string(format!("{FIXTURE}/response.headers")).unwrap(); + Fixture { + target, + req_headers: header_map(lines), + req_body: std::fs::read(format!("{FIXTURE}/request.json")).unwrap(), + resp_headers: header_map(resp.lines()), + resp_body: std::fs::read(format!("{FIXTURE}/response.sse")).unwrap(), + } + } + + fn attribution() -> Attribution { + Attribution { + proc: "/bin/claude".into(), + client_proc: "/bin/zsh".into(), + session_header: "x-claude-code-session-id".into(), + record_session: "s1".into(), + ..Attribution::default() + } + } + + fn exchange(method: &str, host: &str, target: &str, h: &HeaderMap) -> Exchange { + Exchange::new( + method, + host, + target, + h, + attribution(), + "req-1".into(), + SystemTime::now(), + Instant::now(), + ) + } + + fn json_of(l: &Line) -> Value { + serde_json::to_value(l).unwrap() + } + + fn gzip(b: &[u8]) -> Vec { + let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(b).unwrap(); + e.finish().unwrap() + } + + /// Runs the fixture with the response body `wire` and the extra response headers. + fn run(wire: &[u8], extra: &[(&'static str, &str)], chunk: usize) -> (Line, Line, HeaderMap) { + let f = fixture(); + let mut ex = exchange("POST", "api.example.com", &f.target, &f.req_headers); + let req = ex.on_request_body(&f.req_body); + let mut h = f.resp_headers.clone(); + for (k, v) in extra { + h.insert(*k, v.parse().unwrap()); + } + ex.on_response_head(200, &mut h).unwrap(); + for c in wire.chunks(chunk) { + ex.on_response_chunk(c).unwrap(); + } + (req, ex.on_response_end(), h) + } + + fn b64(v: &Value) -> Vec { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(v.as_str().unwrap()) + .unwrap() + } + + #[test] + fn request_row_has_inference_and_capture() { + let f = fixture(); + let mut ex = exchange("POST", "api.example.com", &f.target, &f.req_headers); + let l = json_of(&ex.on_request_body(&f.req_body)); + assert_eq!(l["session"], "s1"); + assert_eq!(l["requestId"], "req-1"); + let e = &l["event"]; + assert_eq!(e["phase"], "request"); + assert_eq!(e["direction"], "outbound"); + assert_eq!(e["httpPath"], "/v1/messages"); + assert_eq!(e["httpMethod"], "POST"); + assert_eq!(e["bytesUp"], f.req_body.len()); + assert_eq!(e["client"]["name"], "claude-code"); + assert_eq!(e["proc"], "/bin/claude"); + assert_eq!(e["clientProc"], "/bin/zsh"); + assert_eq!(e["sessionHeader"], "x-claude-code-session-id"); + assert!(e["inference"]["model"].is_string()); + assert_eq!(b64(&l["capture"]["original"]), f.req_body); + assert_eq!(l["capture"]["query"], "beta=true"); + assert!(l.get("cost").is_none()); + let started = chrono::DateTime::parse_from_rfc3339(l["startedAt"].as_str().unwrap()); + assert!(started.is_ok()); + } + + #[test] + fn capture_headers_have_no_hop_or_credential_headers() { + let f = fixture(); + let mut h = f.req_headers.clone(); + for (k, v) in [ + ("authorization", "Bearer secret"), + ("x-cortex-token", "t"), + ("x-cortex-agent", "a"), + ("x-cortex-dest", "d"), + ("x-cortex-custom", "keep"), + ] { + h.insert(k, v.parse().unwrap()); + } + let mut ex = exchange("POST", "api.example.com", &f.target, &h); + let l = json_of(&ex.on_request_body(&f.req_body)); + let hdr = l["capture"]["header"].as_object().unwrap(); + for gone in [ + "Authorization", + "X-Cortex-Token", + "X-Cortex-Agent", + "X-Cortex-Dest", + "Host", + ] { + assert!(!hdr.contains_key(gone), "{gone} kept"); + } + assert_eq!(hdr["X-Cortex-Custom"], json!(["keep"])); + assert!(hdr.contains_key("User-Agent")); + } + + #[test] + fn non_inference_request_has_no_capture() { + let mut ex = exchange( + "POST", + "auth.example.com", + "/oauth/token", + &HeaderMap::new(), + ); + let l = json_of(&ex.on_request_body(b"grant_type=refresh_token&refresh_token=x")); + assert!(l.get("capture").is_none()); + assert!(l["event"].get("inference").is_none()); + let mut h = HeaderMap::new(); + h.insert("content-type", "application/json".parse().unwrap()); + ex.on_response_head(200, &mut h).unwrap(); + ex.on_response_chunk(br#"{"access_token":"x"}"#).unwrap(); + let r = json_of(&ex.on_response_end()); + assert!(r.get("capture").is_none()); + assert_eq!(r["event"]["bytesDown"], 20); + } + + #[test] + fn gzip_response_capture_is_decoded() { + let f = fixture(); + let (_, plain, _) = run(&f.resp_body, &[], 4096); + let plain = json_of(&plain); + let wire = gzip(&f.resp_body); + for chunk in [wire.len(), 4096] { + let (_, r, _) = run( + &wire, + &[("content-encoding", "gzip"), ("content-length", "99")], + chunk, + ); + let r = json_of(&r); + let c = &r["capture"]["response"]; + assert_eq!(c["decoded"], true); + assert_eq!(c["ended"], true); + assert_eq!(c["incomplete"], false); + assert_eq!(b64(&c["body"]), f.resp_body); + let hdr = c["header"].as_object().unwrap(); + assert!(!hdr.contains_key("Content-Encoding")); + assert!(!hdr.contains_key("Content-Length")); + assert_eq!(r["event"]["bytesDown"], f.resp_body.len()); + assert_eq!(r["event"]["inference"], plain["event"]["inference"]); + assert!(r["event"]["inference"]["outputTokens"].as_i64().unwrap() > 0); + } + } + + #[test] + fn identity_response_capture_is_raw() { + let f = fixture(); + let (_, r, _) = run(&f.resp_body, &[], 4096); + let r = json_of(&r); + let c = &r["capture"]["response"]; + assert_eq!(c["decoded"], false); + assert_eq!(c["status"], 200); + assert_eq!(b64(&c["body"]), f.resp_body); + assert_eq!(r["event"]["bytesDown"], f.resp_body.len()); + assert_eq!(r["event"]["statusCode"], 200); + assert!(r["event"].get("error").is_none()); + assert_eq!( + r["cost"]["headers"]["X-Litellm-Model-Name"], + "gateway/claude-opus-5-5" + ); + assert_eq!( + r["event"]["inference"]["servedModel"], + "gateway/claude-opus-5-5" + ); + } + + #[test] + fn br_response_capture_is_raw() { + let f = fixture(); + let (_, r, _) = run(&f.resp_body, &[("content-encoding", "br")], 4096); + let r = json_of(&r); + let c = &r["capture"]["response"]; + assert_eq!(c["decoded"], false); + assert_eq!(c["header"]["Content-Encoding"], json!(["br"])); + assert_eq!(b64(&c["body"]), f.resp_body); + assert_eq!(r["event"]["bytesDown"], f.resp_body.len()); + } + + fn egress(v: &str) -> (Value, HeaderMap) { + let f = fixture(); + let mut ex = exchange("POST", "api.example.com", &f.target, &f.req_headers); + ex.on_request_body(&f.req_body); + let mut h = HeaderMap::new(); + h.insert("x-cortex-egress-error", v.parse().unwrap()); + h.insert("x-cortex-upstream-error", "origin".parse().unwrap()); + ex.on_response_head(502, &mut h).unwrap(); + ex.on_response_chunk(b"bad gateway").unwrap(); + (json_of(&ex.on_response_end()), h) + } + + #[test] + fn egress_error_header_becomes_kind_and_is_removed() { + for kind in [ + "upstream_dns", + "upstream_tls", + "upstream_timeout", + "upstream_refused", + ] { + let (r, h) = egress(kind); + assert!(!h.contains_key(EGRESS_ERROR_HEADER)); + assert!(h.contains_key("x-cortex-upstream-error")); + assert_eq!(r["event"]["statusCode"], 502); + assert_eq!(r["event"]["error"]["kind"], kind); + let hdr = r["capture"]["response"]["header"].as_object().unwrap(); + assert!(!hdr.contains_key("X-Cortex-Egress-Error")); + } + } + + #[test] + fn unknown_egress_error_is_upstream_error() { + let (r, _) = egress("something_else"); + assert_eq!(r["event"]["error"]["kind"], "upstream_error"); + } + + #[test] + fn error_status_is_backend_error_with_provider_type() { + let f = fixture(); + let mut ex = exchange("POST", "api.example.com", &f.target, &f.req_headers); + ex.on_request_body(&f.req_body); + let mut h = HeaderMap::new(); + h.insert("content-type", "application/json".parse().unwrap()); + ex.on_response_head(429, &mut h).unwrap(); + ex.on_response_chunk( + br#"{"type":"error","error":{"type":"rate_limit_error","message":"q"}}"#, + ) + .unwrap(); + let r = json_of(&ex.on_response_end()); + assert_eq!( + r["event"]["error"], + json!({"kind": "backend_error", "code": "429", "message": "rate_limit_error"}) + ); + } + + fn json_exchange() -> Exchange { + let f = fixture(); + let mut ex = exchange("POST", "api.example.com", &f.target, &f.req_headers); + ex.on_request_body(&f.req_body); + ex + } + + #[test] + fn buffered_over_cap_by_content_length_aborts_at_head() { + let mut ex = json_exchange(); + let mut h = HeaderMap::new(); + h.insert("content-type", "application/json".parse().unwrap()); + h.insert( + "content-length", + (MAX_BODY_SIZE + 1).to_string().parse().unwrap(), + ); + assert_eq!( + ex.on_response_head(200, &mut h), + Err(Abort::BufferedOverCap) + ); + let r = json_of(&ex.on_abort(Abort::BufferedOverCap)); + assert_eq!(r["event"]["statusCode"], 502); + assert_eq!( + r["event"]["error"], + json!({"kind": "proxy_error", "code": "200", + "message": "response body over the 10485760-byte buffer limit"}) + ); + assert_eq!(r["capture"]["response"]["incomplete"], true); + + // A coded body's decoded size is not known from its length. + let mut ex = json_exchange(); + h.insert("content-encoding", "gzip".parse().unwrap()); + assert_eq!(ex.on_response_head(200, &mut h), Ok(())); + // A stream has no total cap. + let mut ex = json_exchange(); + h.remove("content-encoding"); + h.insert("content-type", "text/event-stream".parse().unwrap()); + assert_eq!(ex.on_response_head(200, &mut h), Ok(())); + } + + #[test] + fn chunked_over_cap_row_is_502_proxy_error() { + let mut ex = json_exchange(); + let mut h = HeaderMap::new(); + h.insert("content-type", "application/json".parse().unwrap()); + ex.on_response_head(200, &mut h).unwrap(); + let chunk = vec![b' '; 1 << 20]; + let mut got = Ok(()); + for _ in 0..11 { + got = ex.on_response_chunk(&chunk); + if got.is_err() { + break; + } + } + assert_eq!(got, Err(Abort::BufferedOverCap)); + let r = json_of(&ex.on_abort(Abort::BufferedOverCap)); + assert_eq!(r["event"]["statusCode"], 502); + assert_eq!(r["event"]["error"]["kind"], "proxy_error"); + assert_eq!(r["event"]["error"]["code"], "200"); + } + + #[test] + fn frame_over_cap_row_keeps_upstream_status() { + let mut ex = json_exchange(); + let mut h = HeaderMap::new(); + h.insert("content-type", "text/event-stream".parse().unwrap()); + ex.on_response_head(200, &mut h).unwrap(); + let mut frame = b"data: ".to_vec(); + frame.resize(MAX_BODY_SIZE + 16, b'a'); + assert_eq!(ex.on_response_chunk(&frame), Err(Abort::FrameOverCap)); + let r = json_of(&ex.on_abort(Abort::FrameOverCap)); + assert_eq!(r["event"]["statusCode"], 200); + assert_eq!( + r["event"]["error"], + json!({"kind": "proxy_error", "code": "200", + "message": "single SSE frame over the 10485760-byte frame cap"}) + ); + } + + #[test] + fn deferred_413_gives_request_and_response_rows() { + let f = fixture(); + let mut h = f.req_headers.clone(); + h.insert("content-length", "34603008".parse().unwrap()); + let mut ex = exchange("POST", "api.example.com", &f.target, &h); + let rows: Vec = ex.deferred(413, false, false).iter().map(json_of).collect(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["event"]["phase"], "request"); + assert!(rows[0].get("capture").is_none()); + assert_eq!(rows[1]["event"]["phase"], "response"); + assert_eq!(rows[1]["event"]["statusCode"], 413); + assert_eq!( + rows[1]["event"]["error"], + json!({"kind": "proxy_error", + "message": "request body of 34603008 bytes is over the 33554432-byte buffer limit"}) + ); + let mut ex = exchange("POST", "api.example.com", &f.target, &f.req_headers); + let rows = ex.deferred(413, false, false); + assert_eq!( + json_of(&rows[1])["event"]["error"]["message"], + "request body over the 33554432-byte buffer limit" + ); + } + + fn streaming_exchange() -> Exchange { + let f = fixture(); + let mut ex = json_exchange(); + let mut h = f.resp_headers.clone(); + ex.on_response_head(200, &mut h).unwrap(); + ex.on_response_chunk(&f.resp_body[..100]).unwrap(); + ex + } + + #[test] + fn deferred_499_is_client_canceled() { + for (status, gone) in [(200, true), (499, false)] { + let rows = streaming_exchange().deferred(status, gone, false); + assert_eq!(rows.len(), 1); + let r = json_of(&rows[0]); + assert_eq!(r["event"]["statusCode"], 499); + assert_eq!(r["event"]["error"]["kind"], "client_canceled"); + assert_eq!(r["event"]["error"]["message"], CLIENT_GONE); + assert_eq!(r["capture"]["response"]["incomplete"], true); + assert_eq!(r["event"]["bytesDown"], 100); + } + } + + #[test] + fn deferred_upstream_gone_is_upstream_error() { + let rows = streaming_exchange().deferred(200, false, true); + let r = json_of(&rows[0]); + assert_eq!(r["event"]["statusCode"], 200); + assert_eq!( + r["event"]["error"], + json!({"kind": "upstream_error", "code": "200", "message": UPSTREAM_GONE}) + ); + } + + #[test] + fn deferred_other_is_gateway_refused() { + let f = fixture(); + let mut ex = exchange("POST", "api.example.com", &f.target, &f.req_headers); + let rows: Vec = ex.deferred(400, false, false).iter().map(json_of).collect(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1]["event"]["statusCode"], 400); + assert_eq!( + rows[1]["event"]["error"], + json!({"kind": "gateway_refused"}) + ); + } + + #[test] + fn mislabeled_sse_streams_only_on_2xx() { + let (host, path) = ("chatgpt.com", "/backend-api/codex/responses"); + assert!(is_known_mislabeled_sse(200, host, path)); + assert!(is_known_mislabeled_sse(204, host, path)); + for s in [101, 301, 401, 405, 429, 500] { + assert!(!is_known_mislabeled_sse(s, host, path), "{s}"); + } + assert!(!is_known_mislabeled_sse(200, "api.openai.com", path)); + assert!(!is_known_mislabeled_sse( + 200, + host, + "/backend-api/codex/other" + )); + + for (status, sse) in [(200, true), (405, false)] { + let mut ex = exchange("POST", host, path, &HeaderMap::new()); + ex.on_request_body(br#"{"model":"gpt-5","input":"hi","stream":true}"#); + let mut h = HeaderMap::new(); + h.insert("content-type", "application/json".parse().unwrap()); + ex.on_response_head(status, &mut h).unwrap(); + assert_eq!(ex.resp.as_ref().unwrap().sse, sse, "{status}"); + } + } + + #[test] + fn line_with_capture_over_cap_has_dropped_row() { + let text = "a".repeat(30 << 20); + let body = serde_json::to_vec(&json!({ + "model": "claude-opus-5", + "messages": [{"role": "user", "content": text}], + })) + .unwrap(); + let mut ex = exchange("POST", "api.example.com", "/v1/messages", &HeaderMap::new()); + let q = ex.on_request_body(&body).queued(); + assert!(q.with_capture.as_ref().unwrap().len() > MAX_LINE); + let row: Value = serde_json::from_slice(&q.row).unwrap(); + assert_eq!(row["capture"], json!({"dropped": true})); + assert_eq!(row["event"]["inference"]["model"], "claude-opus-5"); + } +} From 1a7cc741e1c7b173c50f26f746d0566c79512f87 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 16:01:29 -0500 Subject: [PATCH 11/26] test: Check the response capture truncation past 10 MiB Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- praxis/filters/src/record/row.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/praxis/filters/src/record/row.rs b/praxis/filters/src/record/row.rs index 69de55449..93e49a9c0 100644 --- a/praxis/filters/src/record/row.rs +++ b/praxis/filters/src/record/row.rs @@ -894,6 +894,9 @@ mod tests { assert_eq!(r["event"]["statusCode"], 502); assert_eq!(r["event"]["error"]["kind"], "proxy_error"); assert_eq!(r["event"]["error"]["code"], "200"); + let c = &r["capture"]["response"]; + assert_eq!(c["truncated"], true); + assert_eq!(b64(&c["body"]).len(), MAX_RESPONSE_CAPTURE); } #[test] From 3c5cfec244659ebbdb6cd17b1093aa9bea2d1445 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 16:17:01 -0500 Subject: [PATCH 12/26] Test: Pin praxis-mode rows against a direct-mode replay TestIngestReplay_MatchesLiveRun sends each claude-code fixture through a direct-mode forward proxy with inference-parser and a fixed rate table. It writes the rows and captures as v1 ingest lines to core/sessionapi/testdata/ingest/replay.ndjson (with -update). Then it sends that file to POST /v1/ingest on a second session API. The session events, the session summaries and the usage totals must be equal. The test does not compare times, request ids and timings, because the file comes from an earlier run. row_parity_matches_replay builds the same exchanges with the Rust row builder and compares the event and the capture of each line with the file. The captured request headers are a known difference: Go captures the headers of the agent at entry. The fixture loader of parity.rs moves to tests/common/mod.rs, so that both Rust tests use it. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../forwardproxy/ingest_replay_test.go | 378 ++++++++++++++++++ core/sessionapi/testdata/ingest/replay.ndjson | 18 + praxis/filters/tests/common/mod.rs | 122 ++++++ praxis/filters/tests/parity.rs | 123 +----- praxis/filters/tests/row_parity.rs | 116 ++++++ 5 files changed, 640 insertions(+), 117 deletions(-) create mode 100644 core/listener/forwardproxy/ingest_replay_test.go create mode 100644 core/sessionapi/testdata/ingest/replay.ndjson create mode 100644 praxis/filters/tests/common/mod.rs create mode 100644 praxis/filters/tests/row_parity.rs diff --git a/core/listener/forwardproxy/ingest_replay_test.go b/core/listener/forwardproxy/ingest_replay_test.go new file mode 100644 index 000000000..eb61f44ca --- /dev/null +++ b/core/listener/forwardproxy/ingest_replay_test.go @@ -0,0 +1,378 @@ +package forwardproxy + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "flag" + "io" + "net" + "net/http" + "net/http/httptest" + "net/textproto" + "os" + "path/filepath" + "reflect" + "strconv" + "sync" + "testing" + "time" + + "github.com/rossoctl/cortex/core/cost/pricing" + "github.com/rossoctl/cortex/core/cost/usage" + "github.com/rossoctl/cortex/core/pipeline" + "github.com/rossoctl/cortex/core/plugins/inferenceparser" + "github.com/rossoctl/cortex/core/session" + "github.com/rossoctl/cortex/core/sessionapi" +) + +// The replay test pins praxis mode against direct mode (spec Section 5, [DR 4]). A direct-mode +// forward proxy records every claude-code fixture. Its rows become v1 ingest lines, as +// cortex-gateway writes them, in testdata/ingest/replay.ndjson. Ingesting that file must give +// the same session, summary and usage totals. praxis/filters/tests/row_parity.rs reads the +// same file as the golden of the Rust row builder, so one file pins both sides. + +var updateReplay = flag.Bool("update", false, "rewrite core/sessionapi/testdata/ingest/replay.ndjson from a live run") + +const ( + replayFile = "../../sessionapi/testdata/ingest/replay.ndjson" + replayFixtures = "../../plugins/inferenceparser/testdata/claude-code" + replaySession = "00000000-0000-4000-8000-000000000000" + replayToken = "replay-token" + // replayCost is the gateway cost header that every second case carries. + replayCost = "0.0123" +) + +// replayCase is one fixture: the request the agent sends and the response the origin gives. +type replayCase struct { + name string + method string + host string + target string + reqHeader http.Header + reqBody []byte + status int + respHeader http.Header + respBody []byte +} + +// replayCases loads every claude-code fixture with the parsefixture rules, in name order. +func replayCases(t *testing.T) []replayCase { + t.Helper() + heads, err := filepath.Glob(filepath.Join(replayFixtures, "*", "request.headers")) + if err != nil || len(heads) == 0 { + t.Fatalf("no fixtures under %s: %v", replayFixtures, err) + } + endHead := func(b []byte) []byte { return append(bytes.TrimRight(b, "\n"), "\n\n"...) } + readOpt := func(p string) []byte { b, _ := os.ReadFile(p); return b } + var out []replayCase + for i, head := range heads { + dir := filepath.Dir(head) + raw, _ := os.ReadFile(head) + req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(endHead(raw)))) + if err != nil { + t.Fatalf("%s: %v", head, err) + } + c := replayCase{name: filepath.Base(dir), method: req.Method, host: req.Host, target: req.RequestURI, + reqHeader: req.Header, reqBody: readOpt(filepath.Join(dir, "request.json")), status: http.StatusOK} + if c.name == "error-502-html" { + c.status = http.StatusBadGateway + } + c.respHeader = http.Header{"Content-Type": {"application/json"}} + c.respBody = readOpt(filepath.Join(dir, "response.json")) + for file, ct := range map[string]string{"response.sse": "text/event-stream", "response.html": "text/html"} { + if b, err := os.ReadFile(filepath.Join(dir, file)); err == nil { + c.respHeader, c.respBody = http.Header{"Content-Type": {ct}}, b + } + } + if raw, err := os.ReadFile(filepath.Join(dir, "response.headers")); err == nil { + h, err := textproto.NewReader(bufio.NewReader(bytes.NewReader(endHead(raw)))).ReadMIMEHeader() + if err != nil { + t.Fatalf("%s/response.headers: %v", dir, err) + } + c.respHeader = http.Header(h) + } + if i%2 == 0 { + c.respHeader.Set("X-Litellm-Response-Cost", replayCost) + } + c.respHeader.Set("Content-Length", strconv.Itoa(len(c.respBody))) + out = append(out, c) + } + return out +} + +func replayRates(t *testing.T) *pricing.Registry { + t.Helper() + var r pricing.Rates + for tier, perToken := range map[pricing.Tier]float64{ + pricing.TierInput: 7e-6, pricing.TierCacheWrite: 11e-6, pricing.TierCacheRead: 3e-6, pricing.TierOutput: 23e-6, + } { + r.Base[tier], r.Set[tier] = perToken, true + } + tab, err := pricing.NewTable([]pricing.Entry{{Host: "*", Model: "*", Rates: r, Prov: pricing.ProvConfigured}}) + if err != nil { + t.Fatal(err) + } + return pricing.NewRegistry(tab) +} + +// replayRig is a store with a usage aggregator, served by a session API. +type replayRig struct { + store *session.Store + base string +} + +func newReplayRig(t *testing.T, opts ...sessionapi.Option) *replayRig { + t.Helper() + store := session.New(time.Hour, 0, 0) + t.Cleanup(store.Close) + agg := usage.New() + store.AddRecorder(agg) + srv := sessionapi.New("", store, append([]sessionapi.Option{sessionapi.WithUsage(agg)}, opts...)...) + ts := httptest.NewServer(srv.Server().Handler) + t.Cleanup(ts.Close) + return &replayRig{store: store, base: ts.URL} +} + +func (r *replayRig) getJSON(t *testing.T, path string) any { + t.Helper() + resp, err := http.Get(r.base + path) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s: %d %s", path, resp.StatusCode, b) + } + var v any + if err := json.Unmarshal(b, &v); err != nil { + t.Fatalf("GET %s: %v", path, err) + } + return v +} + +// liveRun sends each case through a direct-mode forward proxy, one at a time, and returns the +// rig that holds its rows and the captures it took. +func liveRun(t *testing.T, cases []replayCase, rates pricing.Resolver) (*replayRig, *captures) { + t.Helper() + var mu sync.Mutex + var cur replayCase + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + mu.Lock() + c := cur + mu.Unlock() + for k, vs := range c.respHeader { + w.Header()[k] = vs + } + w.Header()["Date"] = nil // a fixed head, so the captures are the same in every run + w.WriteHeader(c.status) + w.Write(c.respBody) + })) + t.Cleanup(origin.Close) + + rig := newReplayRig(t) + rec := &captures{} + rig.store.AddRecorder(rec) + parser := inferenceparser.NewInferenceParser() + parser.SetPricingResolver(rates) + p, err := pipeline.New([]pipeline.Plugin{parser}) + if err != nil { + t.Fatal(err) + } + // Every host dials the origin, so rows carry the fixture's host, not a test port. + dial := func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, origin.Listener.Addr().String()) + } + srv := &Server{OutboundPipeline: pipeline.NewHolder(p), Sessions: rig.store, + SessionIDHeaders: []string{session.ClaudeCodeSessionHeader}, + Client: &http.Client{Transport: &http.Transport{DialContext: dial}}} + proxy := httptest.NewServer(srv.Handler()) + t.Cleanup(proxy.Close) + client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(proxy.URL)), DisableCompression: true}} + + for i, c := range cases { + mu.Lock() + cur = c + mu.Unlock() + req, _ := http.NewRequest(c.method, "http://"+c.host+c.target, bytes.NewReader(c.reqBody)) + for k, vs := range c.reqHeader { + req.Header[k] = vs + } + req.Header.Set(session.ClaudeCodeSessionHeader, replaySession) + if req.Header.Get("User-Agent") == "" { + req.Header["User-Agent"] = []string{""} // no Go default: the Rust side sees only the fixture's headers + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + // The response row lands after the last byte reaches the client: wait for it, so the + // rows stay in case order. + deadline := time.Now().Add(5 * time.Second) + for { + v := rig.store.View(replaySession) + if v != nil && len(v.Events) >= 2*(i+1) { + break + } + if time.Now().After(deadline) { + t.Fatalf("%s: no response row", c.name) + } + time.Sleep(5 * time.Millisecond) + } + } + return rig, rec +} + +// The wire form of a capture (ingestCapture in core/sessionapi/ingest.go), with the keys +// cortex-gateway writes: a request capture has no forwarded copy, a response capture only +// its response. +type replayCapture struct { + Original []byte `json:"original,omitempty"` + Forwarded []byte `json:"forwarded,omitempty"` + Header http.Header `json:"header,omitempty"` + Query string `json:"query,omitempty"` + Response *replayResponse `json:"response,omitempty"` +} + +type replayResponse struct { + Status int `json:"status"` + Header http.Header `json:"header"` + Body []byte `json:"body"` + Decoded bool `json:"decoded"` + Truncated bool `json:"truncated"` + Ended bool `json:"ended"` + Incomplete bool `json:"incomplete"` +} + +type replayLine struct { + V int `json:"v"` + SchemaVersion int `json:"schemaVersion"` + Op string `json:"op"` + RequestID string `json:"requestId"` + StartedAt string `json:"startedAt"` + Session string `json:"session"` + Event pipeline.SessionEvent `json:"event"` + Capture *replayCapture `json:"capture,omitempty"` + Cost map[string]any `json:"cost,omitempty"` +} + +// replayLines converts the live run's rows and captures to v1 lines, as a producer sends them. +func replayLines(t *testing.T, rig *replayRig, rec *captures, cases []replayCase) []byte { + t.Helper() + v := rig.store.View(replaySession) + if v == nil || len(v.Events) != 2*len(cases) { + t.Fatalf("live run has %v rows, want %d", v, 2*len(cases)) + } + rec.mu.Lock() + defer rec.mu.Unlock() + var buf bytes.Buffer + for i, ev := range v.Events { + l := replayLine{V: 1, SchemaVersion: sessionapi.SchemaVersion, Op: "append", RequestID: ev.RequestID, + StartedAt: ev.At.UTC().Format(time.RFC3339Nano), Session: replaySession} + if rc := rec.got[ev.RequestID]; ev.Phase == pipeline.SessionRequest && rc != nil { + l.Capture = &replayCapture{Original: rc.Original, Forwarded: rc.Forwarded, Header: rc.Header, Query: rc.Query} + } + if ev.Phase == pipeline.SessionResponse { + if rc := rec.res[ev.RequestID]; rc != nil { + l.Capture = &replayCapture{Response: &replayResponse{Status: rc.Status, Header: rc.Header, Body: rc.Body, + Decoded: rc.Decoded, Truncated: rc.Truncated, Ended: rc.Ended, Incomplete: rc.Incomplete}} + } + hdr := map[string]string{} + for _, k := range []string{"X-Litellm-Response-Cost", "X-Litellm-Model-Name", "Content-Type"} { + if s := cases[i/2].respHeader.Get(k); s != "" { + hdr[k] = s + } + } + l.Cost = map[string]any{"headers": hdr} + } + ev.Seq, ev.SessionID, ev.At = 0, "", ev.At.UTC() + l.Event = ev + b, err := json.Marshal(l) + if err != nil { + t.Fatal(err) + } + buf.Write(append(b, '\n')) + } + return buf.Bytes() +} + +// strip removes the keys from each object in v, at any depth. +func strip(v any, keys ...string) any { + switch v := v.(type) { + case map[string]any: + for _, k := range keys { + delete(v, k) + } + for _, c := range v { + strip(c, keys...) + } + case []any: + for _, c := range v { + strip(c, keys...) + } + } + return v +} + +func sameJSON(t *testing.T, what string, live, replayed any) { + t.Helper() + if !reflect.DeepEqual(live, replayed) { + l, _ := json.MarshalIndent(live, "", " ") + r, _ := json.MarshalIndent(replayed, "", " ") + t.Errorf("%s differs\nlive:\n%s\nreplayed:\n%s", what, l, r) + } +} + +func TestIngestReplay_MatchesLiveRun(t *testing.T) { + cases := replayCases(t) + rates := replayRates(t) + live, rec := liveRun(t, cases, rates) + + lines := replayLines(t, live, rec, cases) + if *updateReplay { + if err := os.MkdirAll(filepath.Dir(replayFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(replayFile, lines, 0o644); err != nil { + t.Fatal(err) + } + } + file, err := os.ReadFile(replayFile) + if err != nil { + t.Fatalf("%v (run with -update to write it)", err) + } + + replay := newReplayRig(t, sessionapi.WithIngest(sessionapi.Ingest{Token: replayToken, Rates: rates})) + req, _ := http.NewRequest(http.MethodPost, replay.base+"/v1/ingest", bytes.NewReader(file)) + req.Header.Set("Authorization", "Bearer "+replayToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("ingest answered %d", resp.StatusCode) + } + stats := replay.getJSON(t, "/v1/ingest/stats").(map[string]any) + if got := stats["ingest_lines_total"]; got != float64(2*len(cases)) { + t.Fatalf("ingested %v lines, want %d: %v", got, 2*len(cases), stats) + } + + // The file comes from an earlier run: its request ids and timings are that run's. + volatile := []string{"at", "requestId", "durationMs", "ttfbMs"} + path := "/v1/sessions/" + replaySession + sameJSON(t, path, strip(live.getJSON(t, path), volatile...), strip(replay.getJSON(t, path), volatile...)) + summaryTimes := []string{"createdAt", "updatedAt", "lastRequestAt", "at"} + sameJSON(t, "/v1/sessions", strip(live.getJSON(t, "/v1/sessions"), summaryTimes...), + strip(replay.getJSON(t, "/v1/sessions"), summaryTimes...)) + totals := func(r *replayRig) any { return r.getJSON(t, "/v1/usage").(map[string]any)["totals"] } + sameJSON(t, "/v1/usage totals", totals(live), totals(replay)) + if t.Failed() { + t.Log("when direct mode changed on purpose, run with -update") + } +} diff --git a/core/sessionapi/testdata/ingest/replay.ndjson b/core/sessionapi/testdata/ingest/replay.ndjson new file mode 100644 index 000000000..bca868b46 --- /dev/null +++ b/core/sessionapi/testdata/ingest/replay.ndjson @@ -0,0 +1,18 @@ +{"v":1,"schemaVersion":1,"op":"append","requestId":"1-1f0f83","startedAt":"2026-11-09T21:09:06.542345Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.542345Z","direction":"outbound","phase":"request","requestId":"1-1f0f83","inference":{"model":"claude-sonnet-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1\nsystem text 2","contentBytes":212},{"role":"user","content":"user text 1","contentBytes":75},{"role":"user","content":"user text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7\nuser text 8\nuser text 9\nuser text 10\nuser text 11\nuser text 12\nuser text 13\nuser text 14\nuser text 15\nuser text 16\nuser text 17\nuser text 18\nuser text 19\nuser text 20\nuser text 21\nuser text 22\nuser text 23\nuser text 24\nuser text 25\nuser text 26\nuser text 27\nuser text 28\nuser text 29\nuser text 30","contentBytes":1169}],"maxTokens":64,"thinkingType":"disabled","anthropicBeta":"claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-sonnet-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":1816,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNSIsIm1heF90b2tlbnMiOjY0LCJzeXN0ZW0iOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6IngtYW50aHJvcGljLWJpbGxpbmctaGVhZGVyOiBjY192ZXJzaW9uPTIuMS4yOTUuMDAwOyBjY19lbnRyeXBvaW50PWNsaTsifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDEiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCAyIn1dLCJtZXNzYWdlcyI6W3sicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMSIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgOCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDkifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxMCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDExIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMTIifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxMyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDE0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMTUifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxNiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDE3In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMTgifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxOSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDIwIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjEifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAyMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDIzIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjQifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAyNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDI2In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjciLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjgiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjkifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAzMCJ9XX1dLCJzdG9wX3NlcXVlbmNlcyI6WyI8L3N0b3A+Il0sInRoaW5raW5nIjp7InR5cGUiOiJkaXNhYmxlZCJ9LCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In19Cg==","header":{"Anthropic-Beta":["claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14"],"Anthropic-Version":["2023-06-01"],"Content-Length":["1816"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"1-1f0f83","startedAt":"2026-11-09T21:09:06.543352Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.543352Z","direction":"outbound","phase":"response","requestId":"1-1f0f83","inference":{"model":"claude-sonnet-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1\nsystem text 2","contentBytes":212},{"role":"user","content":"user text 1","contentBytes":75},{"role":"user","content":"user text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7\nuser text 8\nuser text 9\nuser text 10\nuser text 11\nuser text 12\nuser text 13\nuser text 14\nuser text 15\nuser text 16\nuser text 17\nuser text 18\nuser text 19\nuser text 20\nuser text 21\nuser text 22\nuser text 23\nuser text 24\nuser text 25\nuser text 26\nuser text 27\nuser text 28\nuser text 29\nuser text 30","contentBytes":1169}],"maxTokens":64,"thinkingType":"disabled","anthropicBeta":"claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-sonnet-5_response","path":"/v1/messages"}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true}},"statusCode":502,"error":{"kind":"backend_error","code":"502"},"host":"api.example.com","durationMs":1,"ttfbMs":1,"bytesDown":107,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":502,"header":{"Content-Length":["107"],"Content-Type":["text/html"],"X-Litellm-Response-Cost":["0.0123"]},"body":"PGh0bWw+PGJvZHk+PGgxPjUwMiBCYWQgR2F0ZXdheTwvaDE+ClRoZSBzZXJ2ZXIgcmV0dXJuZWQgYW4gaW52YWxpZCBvciBpbmNvbXBsZXRlIHJlc3BvbnNlLgo8L2JvZHk+PC9odG1sPgo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/html","X-Litellm-Response-Cost":"0.0123"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"2-734a92","startedAt":"2026-11-09T21:09:06.54391Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.54391Z","direction":"outbound","phase":"request","requestId":"2-734a92","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 2\nsystem text 3","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":297},{"role":"system","content":"system text 1","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":1594,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3IiwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XX0seyJyb2xlIjoic3lzdGVtIiwiY29udGVudCI6InN5c3RlbSB0ZXh0IDEifV0sInN5c3RlbSI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoieC1hbnRocm9waWMtYmlsbGluZy1oZWFkZXI6IGNjX3ZlcnNpb249Mi4xLjI5NS4wMDA7IGNjX2VudHJ5cG9pbnQ9Y2xpOyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgMiIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDMiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dLCJ0b29scyI6W3sibmFtZSI6IlNraWxsIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJza2lsbCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImFyZ3MiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsic2tpbGwiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0seyJuYW1lIjoibWNwX19leGFtcGxlX190b29sXzEiLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7Im51bV9yZXN1bHRzIjp7ImRlZmF1bHQiOjEsImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsIm1heGltdW0iOjEwLCJtaW5pbXVtIjoxLCJ0eXBlIjoiaW50ZWdlciJ9LCJxdWVyeSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJxdWVyeSJdfX1dLCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In0sIm1heF90b2tlbnMiOjEyODAwMCwidGhpbmtpbmciOnsidHlwZSI6ImFkYXB0aXZlIn0sIm91dHB1dF9jb25maWciOnsiZWZmb3J0IjoiaGlnaCJ9LCJzdHJlYW0iOnRydWV9Cg==","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24"],"Anthropic-Version":["2023-06-01"],"Content-Length":["1594"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"2-734a92","startedAt":"2026-11-09T21:09:06.544826Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.544826Z","direction":"outbound","phase":"response","requestId":"2-734a92","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 2\nsystem text 3","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":297},{"role":"system","content":"system text 1","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","responseThinking":{"blocks":1,"signatureBytes":12},"agentRole":"main","streamedResponse":true,"finishReason":"tool_use","toolCalls":[{"id":"toolu_fixture_01","name":"Skill","arguments":"{\"skill\": \"value\"}"},{"id":"toolu_fixture_02","name":"Skill","arguments":"{\"skill\": \"value\"}"}],"promptTokens":42987,"completionTokens":239,"totalTokens":43226,"inputTokens":12566,"cacheReadTokens":17449,"cacheWriteTokens":12972,"outputTokens":239,"reasoningTokens":140,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.288498,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"prompt_usd":0.283001,"output_usd":0.005497,"tiers":{"input":0.087962,"cache_write":0.142692,"cache_read":0.052347,"output":0.005497}},"litellm-budget-track":{"cost_usd":0.288498,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"prompt_usd":0.283001,"output_usd":0.005497,"tiers":{"input":0.087962,"cache_write":0.142692,"cache_read":0.052347,"output":0.005497}}},"statusCode":200,"host":"api.example.com","bytesDown":3377,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["3377"],"Content-Type":["text/event-stream"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDEyNTY2LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMTI5NzIsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE3NDQ5LCAiY2FjaGVfY3JlYXRpb24iOiB7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOiAxMjk3MiwgImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOiAwfSwgIm91dHB1dF90b2tlbnMiOiA2LCAic2VydmljZV90aWVyIjogInN0YW5kYXJkIn19fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RhcnQiLCAiaW5kZXgiOiAwLCAiY29udGVudF9ibG9jayI6IHsidHlwZSI6ICJ0aGlua2luZyIsICJ0aGlua2luZyI6ICIiLCAic2lnbmF0dXJlIjogIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAwLCAiZGVsdGEiOiB7InR5cGUiOiAidGhpbmtpbmdfZGVsdGEiLCAidGhpbmtpbmciOiAiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDAsICJkZWx0YSI6IHsidHlwZSI6ICJzaWduYXR1cmVfZGVsdGEiLCAic2lnbmF0dXJlIjogImMybG5ibUYwZFhKbCJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RvcApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdG9wIiwgImluZGV4IjogMH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0YXJ0CmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0YXJ0IiwgImluZGV4IjogMSwgImNvbnRlbnRfYmxvY2siOiB7InR5cGUiOiAidG9vbF91c2UiLCAiaWQiOiAidG9vbHVfZml4dHVyZV8wMSIsICJuYW1lIjogIlNraWxsIiwgImlucHV0Ijoge319fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAie1wic2tpIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJsbFwiOiAifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIlwidmFsdSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiZVwifSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RvcApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdG9wIiwgImluZGV4IjogMX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0YXJ0CmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0YXJ0IiwgImluZGV4IjogMiwgImNvbnRlbnRfYmxvY2siOiB7InR5cGUiOiAidG9vbF91c2UiLCAiaWQiOiAidG9vbHVfZml4dHVyZV8wMiIsICJuYW1lIjogIlNraWxsIiwgImlucHV0Ijoge319fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAyLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAie1wic2sifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMiwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogImlsbFwiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDIsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICI6IFwidiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAyLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiYWx1ZSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAyLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiXCJ9In19CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0b3AiLCAiaW5kZXgiOiAyfQoKZXZlbnQ6IG1lc3NhZ2VfZGVsdGEKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2VfZGVsdGEiLCAiZGVsdGEiOiB7InN0b3BfcmVhc29uIjogInRvb2xfdXNlIiwgInN0b3Bfc2VxdWVuY2UiOiBudWxsLCAic3RvcF9kZXRhaWxzIjogbnVsbCwgImNvbnRhaW5lciI6IG51bGx9LCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDEyNTY2LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMTI5NzIsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE3NDQ5LCAib3V0cHV0X3Rva2VucyI6IDIzOSwgIm91dHB1dF90b2tlbnNfZGV0YWlscyI6IHsidGhpbmtpbmdfdG9rZW5zIjogMTQwfSwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMTI5NzIsICJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjogMH19fQoKZXZlbnQ6IG1lc3NhZ2Vfc3RvcApkYXRhOiB7InR5cGUiOiAibWVzc2FnZV9zdG9wIiwgInVzYWdlIjogeyJpbnB1dF90b2tlbnMiOiAxMjU2NiwgIm91dHB1dF90b2tlbnMiOiAyMzksICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE3NDQ5LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMTI5NzJ9fQo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"3-15274c","startedAt":"2026-11-09T21:09:06.5462Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.5462Z","direction":"outbound","phase":"request","requestId":"3-15274c","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 4\nsystem text 5","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"assistant","content":"assistant text 1","contentBytes":104},{"role":"user","content":"user text 8","contentBytes":13},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","content":"assistant text 2","contentBytes":104},{"role":"user","content":"user text 9","contentBytes":13},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","content":"assistant text 3","contentBytes":224,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":142,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":64000,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\",\"timeout\":1}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":3,"signatureBytes":36},"agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":2663,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3In1dfSx7InJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGhpbmtpbmciLCJ0aGlua2luZyI6IiIsInNpZ25hdHVyZSI6ImMybG5ibUYwZFhKbCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoiYXNzaXN0YW50IHRleHQgMSJ9XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOiJ1c2VyIHRleHQgOCJ9LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAxIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6ImFzc2lzdGFudCB0ZXh0IDIifV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjoidXNlciB0ZXh0IDkifSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMiJ9LHsicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOlt7InR5cGUiOiJ0aGlua2luZyIsInRoaW5raW5nIjoiIiwic2lnbmF0dXJlIjoiYzJsbmJtRjBkWEpsIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAzIn0seyJ0eXBlIjoidG9vbF91c2UiLCJpZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJuYW1lIjoiQmFzaCIsImlucHV0Ijp7ImNvbW1hbmQiOiJ2YWx1ZSIsImRlc2NyaXB0aW9uIjoidmFsdWUiLCJ0aW1lb3V0IjoxfX1dfSx7InJvbGUiOiJ1c2VyIiwiY29udGVudCI6W3sidG9vbF91c2VfaWQiOiJ0b29sdV9maXh0dXJlXzAxIiwidHlwZSI6InRvb2xfcmVzdWx0IiwiY29udGVudCI6InRvb2wgcmVzdWx0IHRleHQgMSIsImlzX2Vycm9yIjpmYWxzZSwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XX0seyJyb2xlIjoic3lzdGVtIiwiY29udGVudCI6InN5c3RlbSB0ZXh0IDMifV0sInN5c3RlbSI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoieC1hbnRocm9waWMtYmlsbGluZy1oZWFkZXI6IGNjX3ZlcnNpb249Mi4xLjI5NS4wMDA7IGNjX2VudHJ5cG9pbnQ9Y2xpOyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgNCIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDUiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dLCJ0b29scyI6W3sibmFtZSI6IkJhc2giLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLmV4YW1wbGUuY29tL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImNvbW1hbmQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJ0aW1lb3V0Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJudW1iZXIifSwiZGVzY3JpcHRpb24iOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJydW5faW5fYmFja2dyb3VuZCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoiYm9vbGVhbiJ9LCJkYW5nZXJvdXNseURpc2FibGVTYW5kYm94Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJib29sZWFuIn19LCJyZXF1aXJlZCI6WyJjb21tYW5kIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LHsibmFtZSI6Im1jcF9fZXhhbXBsZV9fdG9vbF8xIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJudW1fcmVzdWx0cyI6eyJkZWZhdWx0IjoxLCJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJtYXhpbXVtIjoxMCwibWluaW11bSI6MSwidHlwZSI6ImludGVnZXIifSwicXVlcnkiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsicXVlcnkiXX19XSwibWV0YWRhdGEiOnsidXNlcl9pZCI6IntcImRldmljZV9pZFwiOiBcImZpeHR1cmUtZGV2aWNlXCIsIFwiYWNjb3VudF91dWlkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxXCIsIFwic2Vzc2lvbl9pZFwiOiBcIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMFwifSJ9LCJtYXhfdG9rZW5zIjo2NDAwMCwidGhpbmtpbmciOnsidHlwZSI6ImFkYXB0aXZlIn0sIm91dHB1dF9jb25maWciOnsiZWZmb3J0IjoiaGlnaCJ9LCJzdHJlYW0iOmZhbHNlfQo=","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24"],"Anthropic-Version":["2023-06-01"],"Content-Length":["2663"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"3-15274c","startedAt":"2026-11-09T21:09:06.546561Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.546561Z","direction":"outbound","phase":"response","requestId":"3-15274c","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 4\nsystem text 5","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"assistant","content":"assistant text 1","contentBytes":104},{"role":"user","content":"user text 8","contentBytes":13},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","content":"assistant text 2","contentBytes":104},{"role":"user","content":"user text 9","contentBytes":13},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","content":"assistant text 3","contentBytes":224,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":142,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":64000,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\",\"timeout\":1}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":3,"signatureBytes":36},"responseThinking":{"blocks":1,"signatureBytes":12},"agentRole":"main","completion":"assistant text 4","finishReason":"tool_use","toolCalls":[{"id":"toolu_fixture_02","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\",\"timeout\":1}"}],"promptTokens":578214,"completionTokens":1282,"totalTokens":579496,"inputTokens":26,"cacheReadTokens":578188,"outputTokens":1282,"reasoningTokens":435,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages"}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":1.734746,"output_usd":0.029486,"tiers":{"input":0.000182,"cache_read":1.734564,"output":0.029486}},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":1.734746,"output_usd":0.029486,"tiers":{"input":0.000182,"cache_read":1.734564,"output":0.029486}}},"statusCode":200,"host":"api.example.com","bytesDown":668,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["668"],"Content-Type":["application/json"],"X-Litellm-Response-Cost":["0.0123"]},"body":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsImlkIjoibXNnX2ZpeHR1cmUiLCJ0eXBlIjoibWVzc2FnZSIsInJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGhpbmtpbmciLCJ0aGlua2luZyI6IiIsInNpZ25hdHVyZSI6ImMybG5ibUYwZFhKbCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoiYXNzaXN0YW50IHRleHQgNCJ9LHsidHlwZSI6InRvb2xfdXNlIiwiaWQiOiJ0b29sdV9maXh0dXJlXzAyIiwibmFtZSI6IkJhc2giLCJpbnB1dCI6eyJjb21tYW5kIjoidmFsdWUiLCJkZXNjcmlwdGlvbiI6InZhbHVlIiwidGltZW91dCI6MX19XSwiY29udGFpbmVyIjpudWxsLCJzdG9wX3JlYXNvbiI6InRvb2xfdXNlIiwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjI2LCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjo1NzgxODgsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjAsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjoxMjgyLCJvdXRwdXRfdG9rZW5zX2RldGFpbHMiOnsidGhpbmtpbmdfdG9rZW5zIjo0MzV9LCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCJ9fQo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"application/json","X-Litellm-Response-Cost":"0.0123"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"4-414743","startedAt":"2026-11-09T21:09:06.546825Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.546825Z","direction":"outbound","phase":"request","requestId":"4-414743","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 3\nsystem text 4","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"assistant","content":"assistant text 1","contentBytes":104},{"role":"user","content":"user text 8","contentBytes":13},{"role":"assistant","content":"assistant text 2","contentBytes":212,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","content":"assistant text 3","contentBytes":104},{"role":"user","content":"user text 9","contentBytes":75},{"role":"system","content":"system text 2","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":3,"signatureBytes":36},"agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":2632,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3In1dfSx7InJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGhpbmtpbmciLCJ0aGlua2luZyI6IiIsInNpZ25hdHVyZSI6ImMybG5ibUYwZFhKbCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoiYXNzaXN0YW50IHRleHQgMSJ9XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOiJ1c2VyIHRleHQgOCJ9LHsicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOlt7InR5cGUiOiJ0aGlua2luZyIsInRoaW5raW5nIjoiIiwic2lnbmF0dXJlIjoiYzJsbmJtRjBkWEpsIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAyIn0seyJ0eXBlIjoidG9vbF91c2UiLCJpZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJuYW1lIjoiQmFzaCIsImlucHV0Ijp7ImNvbW1hbmQiOiJ2YWx1ZSIsImRlc2NyaXB0aW9uIjoidmFsdWUifX1dfSx7InJvbGUiOiJ1c2VyIiwiY29udGVudCI6W3sidG9vbF91c2VfaWQiOiJ0b29sdV9maXh0dXJlXzAxIiwidHlwZSI6InRvb2xfcmVzdWx0IiwiY29udGVudCI6InRvb2wgcmVzdWx0IHRleHQgMSIsImlzX2Vycm9yIjpmYWxzZX1dfSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMSJ9LHsicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOlt7InR5cGUiOiJ0aGlua2luZyIsInRoaW5raW5nIjoiIiwic2lnbmF0dXJlIjoiYzJsbmJtRjBkWEpsIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAzIn1dfSx7InJvbGUiOiJ1c2VyIiwiY29udGVudCI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDkiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dfSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMiJ9XSwic3lzdGVtIjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ4LWFudGhyb3BpYy1iaWxsaW5nLWhlYWRlcjogY2NfdmVyc2lvbj0yLjEuMjk1LjAwMDsgY2NfZW50cnlwb2ludD1jbGk7In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCAzIiwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgNCIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV0sInRvb2xzIjpbeyJuYW1lIjoiQmFzaCIsImRlc2NyaXB0aW9uIjoiVG9vbCBkZXNjcmlwdGlvbi4iLCJpbnB1dF9zY2hlbWEiOnsiJHNjaGVtYSI6Imh0dHBzOi8vanNvbi1zY2hlbWEuZXhhbXBsZS5jb20vc2NoZW1hIiwidHlwZSI6Im9iamVjdCIsInByb3BlcnRpZXMiOnsiY29tbWFuZCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sInRpbWVvdXQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6Im51bWJlciJ9LCJkZXNjcmlwdGlvbiI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sInJ1bl9pbl9iYWNrZ3JvdW5kIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJib29sZWFuIn0sImRhbmdlcm91c2x5RGlzYWJsZVNhbmRib3giOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbImNvbW1hbmQiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0seyJuYW1lIjoibWNwX19leGFtcGxlX190b29sXzEiLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7Im51bV9yZXN1bHRzIjp7ImRlZmF1bHQiOjEsImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsIm1heGltdW0iOjEwLCJtaW5pbXVtIjoxLCJ0eXBlIjoiaW50ZWdlciJ9LCJxdWVyeSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJxdWVyeSJdfX1dLCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In0sIm1heF90b2tlbnMiOjEyODAwMCwidGhpbmtpbmciOnsidHlwZSI6ImFkYXB0aXZlIn0sIm91dHB1dF9jb25maWciOnsiZWZmb3J0IjoiaGlnaCJ9LCJzdHJlYW0iOnRydWV9Cg==","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24"],"Anthropic-Version":["2023-06-01"],"Content-Length":["2632"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"4-414743","startedAt":"2026-11-09T21:09:06.54709Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.54709Z","direction":"outbound","phase":"response","requestId":"4-414743","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 3\nsystem text 4","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"assistant","content":"assistant text 1","contentBytes":104},{"role":"user","content":"user text 8","contentBytes":13},{"role":"assistant","content":"assistant text 2","contentBytes":212,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","content":"assistant text 3","contentBytes":104},{"role":"user","content":"user text 9","contentBytes":75},{"role":"system","content":"system text 2","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":3,"signatureBytes":36},"responseThinking":{"blocks":1},"agentRole":"main","streamedResponse":true,"promptTokens":289421,"totalTokens":289421,"inputTokens":27,"cacheReadTokens":286566,"cacheWriteTokens":2828,"presentKinds":15,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.890995,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"incomplete":true,"incomplete_reason":"output-uncounted","prompt_usd":0.890995,"tiers":{"input":0.000189,"cache_write":0.031108,"cache_read":0.859698}},"litellm-budget-track":{"cost_usd":0.890995,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"incomplete":true,"incomplete_reason":"output-uncounted","prompt_usd":0.890995,"tiers":{"input":0.000189,"cache_write":0.031108,"cache_read":0.859698}}},"statusCode":200,"host":"api.example.com","bytesDown":643,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["643"],"Content-Type":["text/event-stream"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDI3LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMjgyOCwgImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjogMjg2NTY2LCAiY2FjaGVfY3JlYXRpb24iOiB7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOiAyODI4LCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9LCAib3V0cHV0X3Rva2VucyI6IDgsICJzZXJ2aWNlX3RpZXIiOiAic3RhbmRhcmQifX19CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdGFydCIsICJpbmRleCI6IDAsICJjb250ZW50X2Jsb2NrIjogeyJ0eXBlIjogInRoaW5raW5nIiwgInRoaW5raW5nIjogIiIsICJzaWduYXR1cmUiOiAiIn19Cg==","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"5-47c3d1","startedAt":"2026-11-09T21:09:06.547563Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.547563Z","direction":"outbound","phase":"request","requestId":"5-47c3d1","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 4\nsystem text 5","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":232,"toolCallNames":"Skill, Skill","toolUseIds":"toolu_fixture_01, toolu_fixture_02"},{"role":"user","content":"user text 8\nuser text 9","contentBytes":249,"toolUseIds":"toolu_fixture_01, toolu_fixture_02"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":170,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_03"},{"role":"user","contentBytes":142,"toolUseIds":"toolu_fixture_03"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"AskUserQuestion","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"questions":{"description":"Parameter description.","minItems":1,"maxItems":4,"type":"array","items":{"type":"object","properties":{"question":{"description":"Parameter description.","type":"string"},"header":{"description":"Parameter description.","type":"string"},"options":{"description":"Parameter description.","minItems":2,"maxItems":4,"type":"array","items":{"type":"object","properties":{"label":{"description":"Parameter description.","type":"string"},"description":{"description":"Parameter description.","type":"string"},"preview":{"description":"Parameter description.","type":"string"}},"required":["label","description"],"additionalProperties":false}},"multiSelect":{"description":"Parameter description.","default":false,"type":"boolean"}},"required":["question","header","options","multiSelect"],"additionalProperties":false}},"answers":{"description":"Parameter description.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"annotations":{"description":"Parameter description.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"preview":{"description":"Parameter description.","type":"string"},"notes":{"description":"Parameter description.","type":"string"}},"additionalProperties":false}},"metadata":{"description":"Parameter description.","type":"object","properties":{"source":{"description":"Parameter description.","type":"string"}},"additionalProperties":false}},"required":["questions"],"additionalProperties":false}},{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"},{"toolUseId":"toolu_fixture_03","content":"tool result text 3"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Skill","arguments":"{\"skill\":\"value\"}"},{"id":"toolu_fixture_02","name":"Skill","arguments":"{\"skill\":\"value\"}"},{"id":"toolu_fixture_03","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":2,"signatureBytes":24},"agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":4825,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3In1dfSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMSJ9LHsicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOlt7InR5cGUiOiJ0aGlua2luZyIsInRoaW5raW5nIjoiIiwic2lnbmF0dXJlIjoiYzJsbmJtRjBkWEpsIn0seyJ0eXBlIjoidG9vbF91c2UiLCJpZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJuYW1lIjoiU2tpbGwiLCJpbnB1dCI6eyJza2lsbCI6InZhbHVlIn19LHsidHlwZSI6InRvb2xfdXNlIiwiaWQiOiJ0b29sdV9maXh0dXJlXzAyIiwibmFtZSI6IlNraWxsIiwiaW5wdXQiOnsic2tpbGwiOiJ2YWx1ZSJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidG9vbF9yZXN1bHQiLCJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAxIn0seyJ0eXBlIjoidG9vbF9yZXN1bHQiLCJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDIiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAyIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgOCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDkifV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAyIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfZml4dHVyZV8wMyIsIm5hbWUiOiJCYXNoIiwiaW5wdXQiOnsiY29tbWFuZCI6InZhbHVlIiwiZGVzY3JpcHRpb24iOiJ2YWx1ZSJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDMiLCJ0eXBlIjoidG9vbF9yZXN1bHQiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAzIiwiaXNfZXJyb3IiOmZhbHNlLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dfSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMyJ9XSwic3lzdGVtIjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ4LWFudGhyb3BpYy1iaWxsaW5nLWhlYWRlcjogY2NfdmVyc2lvbj0yLjEuMjk1LjAwMDsgY2NfZW50cnlwb2ludD1jbGk7In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCA0IiwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgNSIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV0sInRvb2xzIjpbeyJuYW1lIjoiQXNrVXNlclF1ZXN0aW9uIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJxdWVzdGlvbnMiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwibWluSXRlbXMiOjEsIm1heEl0ZW1zIjo0LCJ0eXBlIjoiYXJyYXkiLCJpdGVtcyI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJxdWVzdGlvbiI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImhlYWRlciI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sIm9wdGlvbnMiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwibWluSXRlbXMiOjIsIm1heEl0ZW1zIjo0LCJ0eXBlIjoiYXJyYXkiLCJpdGVtcyI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJsYWJlbCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImRlc2NyaXB0aW9uIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifSwicHJldmlldyI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJsYWJlbCIsImRlc2NyaXB0aW9uIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LCJtdWx0aVNlbGVjdCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJkZWZhdWx0IjpmYWxzZSwidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInF1ZXN0aW9uIiwiaGVhZGVyIiwib3B0aW9ucyIsIm11bHRpU2VsZWN0Il0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LCJhbnN3ZXJzIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0eU5hbWVzIjp7InR5cGUiOiJzdHJpbmcifSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOnsidHlwZSI6InN0cmluZyJ9fSwiYW5ub3RhdGlvbnMiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6Im9iamVjdCIsInByb3BlcnR5TmFtZXMiOnsidHlwZSI6InN0cmluZyJ9LCJhZGRpdGlvbmFsUHJvcGVydGllcyI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJwcmV2aWV3Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifSwibm90ZXMiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0sIm1ldGFkYXRhIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7InNvdXJjZSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2V9fSwicmVxdWlyZWQiOlsicXVlc3Rpb25zIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LHsibmFtZSI6IkJhc2giLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLmV4YW1wbGUuY29tL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImNvbW1hbmQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJ0aW1lb3V0Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJudW1iZXIifSwiZGVzY3JpcHRpb24iOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJydW5faW5fYmFja2dyb3VuZCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoiYm9vbGVhbiJ9LCJkYW5nZXJvdXNseURpc2FibGVTYW5kYm94Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJib29sZWFuIn19LCJyZXF1aXJlZCI6WyJjb21tYW5kIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LHsibmFtZSI6IlNraWxsIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJza2lsbCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImFyZ3MiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsic2tpbGwiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0seyJuYW1lIjoibWNwX19leGFtcGxlX190b29sXzEiLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7Im51bV9yZXN1bHRzIjp7ImRlZmF1bHQiOjEsImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsIm1heGltdW0iOjEwLCJtaW5pbXVtIjoxLCJ0eXBlIjoiaW50ZWdlciJ9LCJxdWVyeSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJxdWVyeSJdfX1dLCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In0sIm1heF90b2tlbnMiOjEyODAwMCwidGhpbmtpbmciOnsidHlwZSI6ImFkYXB0aXZlIn0sIm91dHB1dF9jb25maWciOnsiZWZmb3J0IjoiaGlnaCJ9LCJzdHJlYW0iOnRydWV9Cg==","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24"],"Anthropic-Version":["2023-06-01"],"Content-Length":["4825"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"5-47c3d1","startedAt":"2026-11-09T21:09:06.549023Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.549023Z","direction":"outbound","phase":"response","requestId":"5-47c3d1","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 4\nsystem text 5","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":232,"toolCallNames":"Skill, Skill","toolUseIds":"toolu_fixture_01, toolu_fixture_02"},{"role":"user","content":"user text 8\nuser text 9","contentBytes":249,"toolUseIds":"toolu_fixture_01, toolu_fixture_02"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":170,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_03"},{"role":"user","contentBytes":142,"toolUseIds":"toolu_fixture_03"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"AskUserQuestion","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"questions":{"description":"Parameter description.","minItems":1,"maxItems":4,"type":"array","items":{"type":"object","properties":{"question":{"description":"Parameter description.","type":"string"},"header":{"description":"Parameter description.","type":"string"},"options":{"description":"Parameter description.","minItems":2,"maxItems":4,"type":"array","items":{"type":"object","properties":{"label":{"description":"Parameter description.","type":"string"},"description":{"description":"Parameter description.","type":"string"},"preview":{"description":"Parameter description.","type":"string"}},"required":["label","description"],"additionalProperties":false}},"multiSelect":{"description":"Parameter description.","default":false,"type":"boolean"}},"required":["question","header","options","multiSelect"],"additionalProperties":false}},"answers":{"description":"Parameter description.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"annotations":{"description":"Parameter description.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"preview":{"description":"Parameter description.","type":"string"},"notes":{"description":"Parameter description.","type":"string"}},"additionalProperties":false}},"metadata":{"description":"Parameter description.","type":"object","properties":{"source":{"description":"Parameter description.","type":"string"}},"additionalProperties":false}},"required":["questions"],"additionalProperties":false}},{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"servedModel":"gateway/claude-opus-5-5","toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"},{"toolUseId":"toolu_fixture_03","content":"tool result text 3"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Skill","arguments":"{\"skill\":\"value\"}"},{"id":"toolu_fixture_02","name":"Skill","arguments":"{\"skill\":\"value\"}"},{"id":"toolu_fixture_03","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":2,"signatureBytes":24},"responseThinking":{"blocks":1,"signatureBytes":12},"agentRole":"main","streamedResponse":true,"finishReason":"tool_use","toolCalls":[{"id":"toolu_fixture_04","name":"Bash","arguments":"{\"command\": \"value\", \"description\": \"value\"}"}],"promptTokens":50279,"completionTokens":275,"totalTokens":50554,"inputTokens":26,"cacheReadTokens":49941,"cacheWriteTokens":312,"outputTokens":275,"reasoningTokens":58,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.153437,"output_usd":0.006325,"tiers":{"input":0.000182,"cache_write":0.003432,"cache_read":0.149823,"output":0.006325}},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.153437,"output_usd":0.006325,"tiers":{"input":0.000182,"cache_write":0.003432,"cache_read":0.149823,"output":0.006325}}},"statusCode":200,"host":"api.example.com","durationMs":1,"bytesDown":8595,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["8595"],"Content-Type":["text/event-stream; charset=utf-8"],"X-Litellm-Model-Name":["gateway/claude-opus-5-5"],"X-Litellm-Response-Cost":["0.0123"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDI2LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMzEyLCAiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiOiA0OTk0MSwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMzEyLCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9LCAib3V0cHV0X3Rva2VucyI6IDgsICJzZXJ2aWNlX3RpZXIiOiAic3RhbmRhcmQifX19CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdGFydCIsICJpbmRleCI6IDAsICJjb250ZW50X2Jsb2NrIjogeyJ0eXBlIjogInRoaW5raW5nIiwgInRoaW5raW5nIjogIiIsICJzaWduYXR1cmUiOiAiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDAsICJkZWx0YSI6IHsidHlwZSI6ICJ0aGlua2luZ19kZWx0YSIsICJ0aGlua2luZyI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMCwgImRlbHRhIjogeyJ0eXBlIjogInNpZ25hdHVyZV9kZWx0YSIsICJzaWduYXR1cmUiOiAiYzJsbmJtRjBkWEpsIn19CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0b3AiLCAiaW5kZXgiOiAwfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RhcnQiLCAiaW5kZXgiOiAxLCAiY29udGVudF9ibG9jayI6IHsidHlwZSI6ICJ0b29sX3VzZSIsICJpZCI6ICJ0b29sdV9maXh0dXJlXzA0IiwgIm5hbWUiOiAiQmFzaCIsICJpbnB1dCI6IHt9fX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogInsifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIlwiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJjIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJvIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJtIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJtIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJhIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJuIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJkIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJcIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiOiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiICJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiXCIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogInYifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogImEifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogImwifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogInUifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogImUifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIlwiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICIsIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICIgIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJcIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiZCJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiZSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAicyJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiYyJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiciJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiaSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAicCJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAidCJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiaSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAibyJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAibiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiXCIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIjoifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIiAifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIlwiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJ2In19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJhIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJsIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJ1In19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJlIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJcIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAifSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RvcCIsICJpbmRleCI6IDF9CgpldmVudDogbWVzc2FnZV9kZWx0YQpkYXRhOiB7InR5cGUiOiAibWVzc2FnZV9kZWx0YSIsICJkZWx0YSI6IHsic3RvcF9yZWFzb24iOiAidG9vbF91c2UiLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAiY29udGFpbmVyIjogbnVsbH0sICJ1c2FnZSI6IHsiaW5wdXRfdG9rZW5zIjogMjYsICJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOiAzMTIsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDQ5OTQxLCAib3V0cHV0X3Rva2VucyI6IDI3NSwgIm91dHB1dF90b2tlbnNfZGV0YWlscyI6IHsidGhpbmtpbmdfdG9rZW5zIjogNTh9LCAiY2FjaGVfY3JlYXRpb24iOiB7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOiAzMTIsICJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjogMH19fQoKZXZlbnQ6IG1lc3NhZ2Vfc3RvcApkYXRhOiB7InR5cGUiOiAibWVzc2FnZV9zdG9wIiwgInVzYWdlIjogeyJpbnB1dF90b2tlbnMiOiAyNiwgIm91dHB1dF90b2tlbnMiOiAyNzUsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDQ5OTQxLCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMzEyfX0K","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream; charset=utf-8","X-Litellm-Model-Name":"gateway/claude-opus-5-5","X-Litellm-Response-Cost":"0.0123"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"6-719c65","startedAt":"2026-11-09T21:09:06.550421Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.550421Z","direction":"outbound","phase":"request","requestId":"6-719c65","inference":{"model":"claude-sonnet-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1\nsystem text 2","contentBytes":212},{"role":"user","content":"user text 1","contentBytes":75},{"role":"user","content":"user text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6","contentBytes":260}],"maxTokens":64,"thinkingType":"disabled","anthropicBeta":"claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-sonnet-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":907,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNSIsIm1heF90b2tlbnMiOjY0LCJzeXN0ZW0iOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6IngtYW50aHJvcGljLWJpbGxpbmctaGVhZGVyOiBjY192ZXJzaW9uPTIuMS4yOTUuMDAwOyBjY19lbnRyeXBvaW50PWNsaTsifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDEiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCAyIn1dLCJtZXNzYWdlcyI6W3sicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMSIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNCIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA1In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNiJ9XX1dLCJzdG9wX3NlcXVlbmNlcyI6WyI8L3N0b3A+Il0sInRoaW5raW5nIjp7InR5cGUiOiJkaXNhYmxlZCJ9LCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In19Cg==","header":{"Anthropic-Beta":["claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14"],"Anthropic-Version":["2023-06-01"],"Content-Length":["907"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"6-719c65","startedAt":"2026-11-09T21:09:06.550708Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.550708Z","direction":"outbound","phase":"response","requestId":"6-719c65","inference":{"model":"claude-sonnet-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1\nsystem text 2","contentBytes":212},{"role":"user","content":"user text 1","contentBytes":75},{"role":"user","content":"user text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6","contentBytes":260}],"maxTokens":64,"servedModel":"gateway/claude-sonnet-5","thinkingType":"disabled","anthropicBeta":"claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14","agentRole":"main","completion":"assistant text 1","finishReason":"stop_sequence","promptTokens":60457,"completionTokens":9,"totalTokens":60466,"inputTokens":90,"cacheWriteTokens":60367,"outputTokens":9,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-sonnet-5_response","path":"/v1/messages"}]},"plugins":{"cost":{"cost_usd":0.664874,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"prompt_usd":0.664667,"output_usd":0.000207,"tiers":{"input":0.00063,"cache_write":0.664037,"output":0.000207}},"litellm-budget-track":{"cost_usd":0.664874,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"prompt_usd":0.664667,"output_usd":0.000207,"tiers":{"input":0.00063,"cache_write":0.664037,"output":0.000207}}},"statusCode":200,"host":"api.example.com","bytesDown":495,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["495"],"Content-Type":["application/json"],"X-Litellm-Model-Name":["gateway/claude-sonnet-5"]},"body":"eyJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNSIsImlkIjoibXNnX2ZpeHR1cmUiLCJ0eXBlIjoibWVzc2FnZSIsInJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAxIn1dLCJjb250YWluZXIiOm51bGwsInN0b3BfcmVhc29uIjoic3RvcF9zZXF1ZW5jZSIsInN0b3Bfc2VxdWVuY2UiOiI8L3N0b3A+Iiwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjkwLCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjYwMzY3LCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MCwiY2FjaGVfY3JlYXRpb24iOnsiZXBoZW1lcmFsXzVtX2lucHV0X3Rva2VucyI6NjAzNjcsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjo5LCJvdXRwdXRfdG9rZW5zX2RldGFpbHMiOnsidGhpbmtpbmdfdG9rZW5zIjowfSwic2VydmljZV90aWVyIjoic3RhbmRhcmQifX0K","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"application/json","X-Litellm-Model-Name":"gateway/claude-sonnet-5"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"7-1155ed","startedAt":"2026-11-09T21:09:06.550963Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.550963Z","direction":"outbound","phase":"request","requestId":"7-1155ed","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1","contentBytes":173},{"role":"user","content":"user text 1","contentBytes":13}],"maxTokens":3072,"anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":461,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1heF90b2tlbnMiOjMwNzIsInN5c3RlbSI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoieC1hbnRocm9waWMtYmlsbGluZy1oZWFkZXI6IGNjX3ZlcnNpb249Mi4xLjI5NS4wMDA7IGNjX2VudHJ5cG9pbnQ9Y2xpOyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgMSIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV0sIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOiJ1c2VyIHRleHQgMSJ9XSwibWV0YWRhdGEiOnsidXNlcl9pZCI6IntcImRldmljZV9pZFwiOiBcImZpeHR1cmUtZGV2aWNlXCIsIFwiYWNjb3VudF91dWlkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxXCIsIFwic2Vzc2lvbl9pZFwiOiBcIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMFwifSJ9fQo=","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14"],"Anthropic-Version":["2023-06-01"],"Content-Length":["461"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"7-1155ed","startedAt":"2026-11-09T21:09:06.551227Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.551227Z","direction":"outbound","phase":"response","requestId":"7-1155ed","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1","contentBytes":173},{"role":"user","content":"user text 1","contentBytes":13}],"maxTokens":3072,"anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14","agentRole":"main","completion":"assistant text 1","finishReason":"end_turn","promptTokens":6407,"completionTokens":62,"totalTokens":6469,"inputTokens":173,"cacheReadTokens":6234,"outputTokens":62,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages"}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.019913,"output_usd":0.001426,"tiers":{"input":0.001211,"cache_read":0.018702,"output":0.001426}},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.019913,"output_usd":0.001426,"tiers":{"input":0.001211,"cache_read":0.018702,"output":0.001426}}},"statusCode":200,"host":"api.example.com","bytesDown":482,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["482"],"Content-Type":["application/json"],"X-Litellm-Response-Cost":["0.0123"]},"body":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsImlkIjoibXNnX2ZpeHR1cmUiLCJ0eXBlIjoibWVzc2FnZSIsInJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAxIn1dLCJjb250YWluZXIiOm51bGwsInN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsLCJzdG9wX2RldGFpbHMiOm51bGwsInVzYWdlIjp7ImlucHV0X3Rva2VucyI6MTczLCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjo2MjM0LCJjYWNoZV9jcmVhdGlvbiI6eyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjowLCJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjowfSwib3V0cHV0X3Rva2VucyI6NjIsIm91dHB1dF90b2tlbnNfZGV0YWlscyI6eyJ0aGlua2luZ190b2tlbnMiOjB9LCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCJ9fQo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"application/json","X-Litellm-Response-Cost":"0.0123"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"8-f978f3","startedAt":"2026-11-09T21:09:06.55156Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.55156Z","direction":"outbound","phase":"request","requestId":"8-f978f3","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli; cc_is_subagent=true;\nsystem text 4\nsystem text 5","contentBytes":270},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5","contentBytes":186},{"role":"assistant","contentBytes":182,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":170,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_02"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_02"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":184,"toolCallNames":"Skill","toolUseIds":"toolu_fixture_03"},{"role":"user","content":"user text 6\nuser text 7","contentBytes":162,"toolUseIds":"toolu_fixture_03"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}},{"name":"SubagentHandback","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"message":{"description":"Parameter description.","type":"string"}},"required":["message"],"additionalProperties":false}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"},{"toolUseId":"toolu_fixture_03","content":"tool result text 3"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\",\"timeout\":1}"},{"id":"toolu_fixture_02","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"},{"id":"toolu_fixture_03","name":"Skill","arguments":"{\"skill\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01","historyThinking":{"blocks":3,"signatureBytes":36},"agentRole":"subagent","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":3527,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9XX0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfZml4dHVyZV8wMSIsIm5hbWUiOiJCYXNoIiwiaW5wdXQiOnsiY29tbWFuZCI6InZhbHVlIiwiZGVzY3JpcHRpb24iOiJ2YWx1ZSIsInRpbWVvdXQiOjF9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJ0eXBlIjoidG9vbF9yZXN1bHQiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAxIiwiaXNfZXJyb3IiOmZhbHNlfV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAxIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfZml4dHVyZV8wMiIsIm5hbWUiOiJCYXNoIiwiaW5wdXQiOnsiY29tbWFuZCI6InZhbHVlIiwiZGVzY3JpcHRpb24iOiJ2YWx1ZSJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDIiLCJ0eXBlIjoidG9vbF9yZXN1bHQiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAyIiwiaXNfZXJyb3IiOmZhbHNlfV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAyIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfZml4dHVyZV8wMyIsIm5hbWUiOiJTa2lsbCIsImlucHV0Ijp7InNraWxsIjoidmFsdWUifSwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0b29sX3Jlc3VsdCIsInRvb2xfdXNlX2lkIjoidG9vbHVfZml4dHVyZV8wMyIsImNvbnRlbnQiOiJ0b29sIHJlc3VsdCB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA2In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNyJ9XX0seyJyb2xlIjoic3lzdGVtIiwiY29udGVudCI6InN5c3RlbSB0ZXh0IDMifV0sInN5c3RlbSI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoieC1hbnRocm9waWMtYmlsbGluZy1oZWFkZXI6IGNjX3ZlcnNpb249Mi4xLjI5NS4wMDA7IGNjX2VudHJ5cG9pbnQ9Y2xpOyBjY19pc19zdWJhZ2VudD10cnVlOyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgNCIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDUiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dLCJ0b29scyI6W3sibmFtZSI6IkJhc2giLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLmV4YW1wbGUuY29tL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImNvbW1hbmQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJ0aW1lb3V0Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJudW1iZXIifSwiZGVzY3JpcHRpb24iOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJydW5faW5fYmFja2dyb3VuZCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoiYm9vbGVhbiJ9LCJkYW5nZXJvdXNseURpc2FibGVTYW5kYm94Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJib29sZWFuIn19LCJyZXF1aXJlZCI6WyJjb21tYW5kIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LHsibmFtZSI6IlNraWxsIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJza2lsbCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImFyZ3MiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsic2tpbGwiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0seyJuYW1lIjoibWNwX19leGFtcGxlX190b29sXzEiLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7Im51bV9yZXN1bHRzIjp7ImRlZmF1bHQiOjEsImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsIm1heGltdW0iOjEwLCJtaW5pbXVtIjoxLCJ0eXBlIjoiaW50ZWdlciJ9LCJxdWVyeSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJxdWVyeSJdfX0seyJuYW1lIjoiU3ViYWdlbnRIYW5kYmFjayIsImRlc2NyaXB0aW9uIjoiVG9vbCBkZXNjcmlwdGlvbi4iLCJpbnB1dF9zY2hlbWEiOnsiJHNjaGVtYSI6Imh0dHBzOi8vanNvbi1zY2hlbWEuZXhhbXBsZS5jb20vc2NoZW1hIiwidHlwZSI6Im9iamVjdCIsInByb3BlcnRpZXMiOnsibWVzc2FnZSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJtZXNzYWdlIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19XSwibWV0YWRhdGEiOnsidXNlcl9pZCI6IntcImRldmljZV9pZFwiOiBcImZpeHR1cmUtZGV2aWNlXCIsIFwiYWNjb3VudF91dWlkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxXCIsIFwic2Vzc2lvbl9pZFwiOiBcIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMFwifSJ9LCJtYXhfdG9rZW5zIjoxMjgwMDAsInRoaW5raW5nIjp7InR5cGUiOiJhZGFwdGl2ZSJ9LCJvdXRwdXRfY29uZmlnIjp7ImVmZm9ydCI6ImhpZ2gifSwic3RyZWFtIjp0cnVlfQo=","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01"],"Anthropic-Version":["2023-06-01"],"Content-Length":["3527"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"8-f978f3","startedAt":"2026-11-09T21:09:06.551977Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.551977Z","direction":"outbound","phase":"response","requestId":"8-f978f3","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli; cc_is_subagent=true;\nsystem text 4\nsystem text 5","contentBytes":270},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5","contentBytes":186},{"role":"assistant","contentBytes":182,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":170,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_02"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_02"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":184,"toolCallNames":"Skill","toolUseIds":"toolu_fixture_03"},{"role":"user","content":"user text 6\nuser text 7","contentBytes":162,"toolUseIds":"toolu_fixture_03"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}},{"name":"SubagentHandback","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"message":{"description":"Parameter description.","type":"string"}},"required":["message"],"additionalProperties":false}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"},{"toolUseId":"toolu_fixture_03","content":"tool result text 3"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\",\"timeout\":1}"},{"id":"toolu_fixture_02","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"},{"id":"toolu_fixture_03","name":"Skill","arguments":"{\"skill\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01","historyThinking":{"blocks":3,"signatureBytes":36},"responseThinking":{"blocks":1,"signatureBytes":12},"agentRole":"subagent","streamedResponse":true,"toolCalls":[{"id":"toolu_fixture_04","name":"SubagentHandback"}],"promptTokens":227618,"totalTokens":227618,"inputTokens":5678,"cacheReadTokens":221800,"cacheWriteTokens":140,"presentKinds":15,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.706686,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"incomplete":true,"incomplete_reason":"output-uncounted","prompt_usd":0.706686,"tiers":{"input":0.039746,"cache_write":0.00154,"cache_read":0.6654}},"litellm-budget-track":{"cost_usd":0.706686,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"incomplete":true,"incomplete_reason":"output-uncounted","prompt_usd":0.706686,"tiers":{"input":0.039746,"cache_write":0.00154,"cache_read":0.6654}}},"statusCode":200,"host":"api.example.com","bytesDown":1388,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["1388"],"Content-Type":["text/event-stream"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDU2NzgsICJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOiAxNDAsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDIyMTgwMCwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMTQwLCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9LCAib3V0cHV0X3Rva2VucyI6IDYsICJzZXJ2aWNlX3RpZXIiOiAic3RhbmRhcmQifX19CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdGFydCIsICJpbmRleCI6IDAsICJjb250ZW50X2Jsb2NrIjogeyJ0eXBlIjogInRoaW5raW5nIiwgInRoaW5raW5nIjogIiIsICJzaWduYXR1cmUiOiAiIn19CgpldmVudDogcGluZwpkYXRhOiB7InR5cGUiOiAicGluZyJ9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDAsICJkZWx0YSI6IHsidHlwZSI6ICJ0aGlua2luZ19kZWx0YSIsICJ0aGlua2luZyI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMCwgImRlbHRhIjogeyJ0eXBlIjogInNpZ25hdHVyZV9kZWx0YSIsICJzaWduYXR1cmUiOiAiYzJsbmJtRjBkWEpsIn19CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0b3AiLCAiaW5kZXgiOiAwfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RhcnQiLCAiaW5kZXgiOiAxLCAiY29udGVudF9ibG9jayI6IHsidHlwZSI6ICJ0b29sX3VzZSIsICJpZCI6ICJ0b29sdV9maXh0dXJlXzA0IiwgIm5hbWUiOiAiU3ViYWdlbnRIYW5kYmFjayIsICJpbnB1dCI6IHt9fX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIiJ9fQoKZXZlbnQ6IHBpbmcKZGF0YTogeyJ0eXBlIjogInBpbmcifQo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"9-29b9ca","startedAt":"2026-11-09T21:09:06.552359Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.552359Z","direction":"outbound","phase":"request","requestId":"9-29b9ca","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli; cc_is_subagent=true;\nsystem text 4\nsystem text 5","contentBytes":270},{"role":"user","content":"user text 1\nuser text 2\nuser text 3","contentBytes":112},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":109,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":146,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_02"},{"role":"user","content":"user text 4","contentBytes":142,"toolUseIds":"toolu_fixture_02"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"},{"id":"toolu_fixture_02","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01","agentRole":"subagent","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":2379,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAxIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRvb2xfdXNlIiwiaWQiOiJ0b29sdV9maXh0dXJlXzAxIiwibmFtZSI6IkJhc2giLCJpbnB1dCI6eyJjb21tYW5kIjoidmFsdWUiLCJkZXNjcmlwdGlvbiI6InZhbHVlIn19XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InRvb2xfdXNlX2lkIjoidG9vbHVfZml4dHVyZV8wMSIsInR5cGUiOiJ0b29sX3Jlc3VsdCIsImNvbnRlbnQiOiJ0b29sIHJlc3VsdCB0ZXh0IDEiLCJpc19lcnJvciI6ZmFsc2V9XX0seyJyb2xlIjoic3lzdGVtIiwiY29udGVudCI6InN5c3RlbSB0ZXh0IDIifSx7InJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidG9vbF91c2UiLCJpZCI6InRvb2x1X2ZpeHR1cmVfMDIiLCJuYW1lIjoiQmFzaCIsImlucHV0Ijp7ImNvbW1hbmQiOiJ2YWx1ZSIsImRlc2NyaXB0aW9uIjoidmFsdWUifSwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InRvb2xfdXNlX2lkIjoidG9vbHVfZml4dHVyZV8wMiIsInR5cGUiOiJ0b29sX3Jlc3VsdCIsImNvbnRlbnQiOiJ0b29sIHJlc3VsdCB0ZXh0IDIiLCJpc19lcnJvciI6ZmFsc2V9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDQifV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAzIn1dLCJzeXN0ZW0iOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6IngtYW50aHJvcGljLWJpbGxpbmctaGVhZGVyOiBjY192ZXJzaW9uPTIuMS4yOTUuMDAwOyBjY19lbnRyeXBvaW50PWNsaTsgY2NfaXNfc3ViYWdlbnQ9dHJ1ZTsifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDQiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCA1IiwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XSwidG9vbHMiOlt7Im5hbWUiOiJCYXNoIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJjb21tYW5kIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifSwidGltZW91dCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoibnVtYmVyIn0sImRlc2NyaXB0aW9uIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifSwicnVuX2luX2JhY2tncm91bmQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6ImJvb2xlYW4ifSwiZGFuZ2Vyb3VzbHlEaXNhYmxlU2FuZGJveCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoiYm9vbGVhbiJ9fSwicmVxdWlyZWQiOlsiY29tbWFuZCJdLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2V9fSx7Im5hbWUiOiJtY3BfX2V4YW1wbGVfX3Rvb2xfMSIsImRlc2NyaXB0aW9uIjoiVG9vbCBkZXNjcmlwdGlvbi4iLCJpbnB1dF9zY2hlbWEiOnsidHlwZSI6Im9iamVjdCIsInByb3BlcnRpZXMiOnsibnVtX3Jlc3VsdHMiOnsiZGVmYXVsdCI6MSwiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwibWF4aW11bSI6MTAsIm1pbmltdW0iOjEsInR5cGUiOiJpbnRlZ2VyIn0sInF1ZXJ5Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifX0sInJlcXVpcmVkIjpbInF1ZXJ5Il19fV0sIm1ldGFkYXRhIjp7InVzZXJfaWQiOiJ7XCJkZXZpY2VfaWRcIjogXCJmaXh0dXJlLWRldmljZVwiLCBcImFjY291bnRfdXVpZFwiOiBcIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMVwiLCBcInNlc3Npb25faWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDBcIn0ifSwibWF4X3Rva2VucyI6MTI4MDAwLCJ0aGlua2luZyI6eyJ0eXBlIjoiYWRhcHRpdmUifSwib3V0cHV0X2NvbmZpZyI6eyJlZmZvcnQiOiJoaWdoIn0sInN0cmVhbSI6dHJ1ZX0K","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01"],"Anthropic-Version":["2023-06-01"],"Content-Length":["2379"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"9-29b9ca","startedAt":"2026-11-09T21:09:06.55276Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.55276Z","direction":"outbound","phase":"response","requestId":"9-29b9ca","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli; cc_is_subagent=true;\nsystem text 4\nsystem text 5","contentBytes":270},{"role":"user","content":"user text 1\nuser text 2\nuser text 3","contentBytes":112},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":109,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":146,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_02"},{"role":"user","content":"user text 4","contentBytes":142,"toolUseIds":"toolu_fixture_02"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"},{"id":"toolu_fixture_02","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01","agentRole":"subagent","streamedResponse":true,"completion":"word word word ","finishReason":"end_turn","promptTokens":29877,"completionTokens":14,"totalTokens":29891,"inputTokens":11093,"cacheReadTokens":18525,"cacheWriteTokens":259,"outputTokens":14,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.136075,"output_usd":0.000322,"tiers":{"input":0.077651,"cache_write":0.002849,"cache_read":0.055575,"output":0.000322}},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.136075,"output_usd":0.000322,"tiers":{"input":0.077651,"cache_write":0.002849,"cache_read":0.055575,"output":0.000322}}},"statusCode":200,"host":"api.example.com","bytesDown":1671,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["1671"],"Content-Type":["text/event-stream"],"X-Litellm-Response-Cost":["0.0123"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDExMDkzLCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMjU5LCAiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiOiAxODUyNSwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMjU5LCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9LCAib3V0cHV0X3Rva2VucyI6IDIsICJzZXJ2aWNlX3RpZXIiOiAic3RhbmRhcmQifX19CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdGFydCIsICJpbmRleCI6IDAsICJjb250ZW50X2Jsb2NrIjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMCwgImRlbHRhIjogeyJ0eXBlIjogInRleHRfZGVsdGEiLCAidGV4dCI6ICJ3b3JkICJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAwLCAiZGVsdGEiOiB7InR5cGUiOiAidGV4dF9kZWx0YSIsICJ0ZXh0IjogIndvcmQgIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDAsICJkZWx0YSI6IHsidHlwZSI6ICJ0ZXh0X2RlbHRhIiwgInRleHQiOiAid29yZCAifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RvcCIsICJpbmRleCI6IDB9CgpldmVudDogbWVzc2FnZV9kZWx0YQpkYXRhOiB7InR5cGUiOiAibWVzc2FnZV9kZWx0YSIsICJkZWx0YSI6IHsic3RvcF9yZWFzb24iOiAiZW5kX3R1cm4iLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAiY29udGFpbmVyIjogbnVsbH0sICJ1c2FnZSI6IHsiaW5wdXRfdG9rZW5zIjogMTEwOTMsICJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOiAyNTksICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE4NTI1LCAib3V0cHV0X3Rva2VucyI6IDE0LCAib3V0cHV0X3Rva2Vuc19kZXRhaWxzIjogeyJ0aGlua2luZ190b2tlbnMiOiAwfSwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMjU5LCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9fX0KCmV2ZW50OiBtZXNzYWdlX3N0b3AKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RvcCIsICJ1c2FnZSI6IHsiaW5wdXRfdG9rZW5zIjogMTEwOTMsICJvdXRwdXRfdG9rZW5zIjogMTQsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE4NTI1LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMjU5fX0K","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream","X-Litellm-Response-Cost":"0.0123"}}} diff --git a/praxis/filters/tests/common/mod.rs b/praxis/filters/tests/common/mod.rs new file mode 100644 index 000000000..6a6446ac1 --- /dev/null +++ b/praxis/filters/tests/common/mod.rs @@ -0,0 +1,122 @@ +//! The fixture loader and the JSON comparison that parity.rs and row_parity.rs share. +#![allow(dead_code)] // each test crate uses only part of this module + +use std::path::{Path, PathBuf}; + +use http::{HeaderMap, HeaderName, HeaderValue}; +use serde_json::Value; + +pub const TESTDATA: &str = "../../core/plugins/inferenceparser/testdata"; + +/// The response files and the Content-Type each one has (`parsefixture.responseTypes`). +pub const RESPONSE_TYPES: &[(&str, &str)] = &[ + ("response.sse", "text/event-stream"), + ("response.json", "application/json"), + ("response.html", "text/html"), +]; + +pub struct Case { + pub dir: PathBuf, + pub method: String, + pub target: String, + pub req_headers: HeaderMap, + pub req_body: Vec, + pub resp_headers: HeaderMap, + pub resp_body: Vec, + pub sse: bool, +} + +/// Every case directory under `testdata//` that has a request.headers file. +pub fn cases(agent: &str) -> Vec { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join(TESTDATA) + .join(agent); + let mut dirs: Vec = std::fs::read_dir(&root) + .unwrap_or_else(|e| panic!("{}: {e}", root.display())) + .map(|e| e.unwrap().path()) + .filter(|d| d.join("request.headers").is_file()) + .collect(); + dirs.sort(); + assert!(!dirs.is_empty(), "no cases under {}", root.display()); + dirs.into_iter().map(load).collect() +} + +pub fn load(dir: PathBuf) -> Case { + let head = std::fs::read_to_string(dir.join("request.headers")).unwrap(); + let mut lines = head.lines(); + let request_line = lines.next().expect("request line"); + let mut parts = request_line.split(' '); + let method = parts.next().expect("method").to_owned(); + let target = parts.next().expect("request target").to_owned(); + let req_headers = header_map(lines); + + let found: Vec<_> = RESPONSE_TYPES + .iter() + .filter(|(name, _)| dir.join(name).is_file()) + .collect(); + assert!( + found.len() <= 1, + "{}: more than one response file", + dir.display() + ); + let (file, content_type) = found + .first() + .map_or(("response.json", "application/json"), |&&p| p); + let mut resp_headers = HeaderMap::new(); + resp_headers.insert("content-type", HeaderValue::from_static(content_type)); + // An optional response.headers file replaces the default headers. + if let Ok(raw) = std::fs::read_to_string(dir.join("response.headers")) { + resp_headers = header_map(raw.lines()); + } + Case { + req_body: read_body(&dir.join("request.json")), + resp_body: read_body(&dir.join(file)), + sse: content_type == "text/event-stream", + dir, + method, + target, + req_headers, + resp_headers, + } +} + +/// Parses `Name: value` lines, as textproto.ReadMIMEHeader does, up to a blank line. +pub fn header_map<'a>(lines: impl Iterator) -> HeaderMap { + let mut h = HeaderMap::new(); + for line in lines.take_while(|l| !l.is_empty()) { + let (name, value) = line.split_once(':').expect("Name: value"); + h.append( + HeaderName::from_bytes(name.trim().as_bytes()).unwrap(), + HeaderValue::from_str(value.trim_matches([' ', '\t'])).unwrap(), + ); + } + h +} + +/// The file's bytes, or no bytes when it does not exist. +pub fn read_body(path: &Path) -> Vec { + std::fs::read(path).unwrap_or_default() +} + +/// Compares two JSON values. Numbers are equal when their f64 values are +/// equal: Go writes 1 where Rust writes 1.0. +pub fn same(expected: &Value, got: &Value, path: &str) { + match (expected, got) { + (Value::Number(a), Value::Number(b)) if a.as_f64() == b.as_f64() => {} + (Value::Object(a), Value::Object(b)) => { + for k in a.keys().chain(b.keys()) { + match (a.get(k), b.get(k)) { + (Some(x), Some(y)) => same(x, y, &format!("{path}.{k}")), + (x, y) => panic!("{path}.{k}: expected {x:?}, got {y:?}"), + } + } + } + (Value::Array(a), Value::Array(b)) => { + assert_eq!(a.len(), b.len(), "{path}: array lengths differ"); + for (i, (x, y)) in a.iter().zip(b).enumerate() { + same(x, y, &format!("{path}[{i}]")); + } + } + _ => assert_eq!(expected, got, "{path}: values differ"), + } +} diff --git a/praxis/filters/tests/parity.rs b/praxis/filters/tests/parity.rs index ecc4c3147..8e1824f7c 100644 --- a/praxis/filters/tests/parity.rs +++ b/praxis/filters/tests/parity.rs @@ -2,108 +2,20 @@ //! core/plugins/inferenceparser/testdata// must give the //! extension that Go wrote to its expected.json, in every feeding variant. +mod common; + use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::Path; + +use common::{Case, TESTDATA, cases, same}; use cortex_praxis_filters::parse::MAX_BODY_SIZE; use cortex_praxis_filters::parse::decode::{Coding, Decoder}; use cortex_praxis_filters::parse::inference::InferenceObserver; use cortex_praxis_filters::parse::sse::FrameReader; -use http::{HeaderMap, HeaderName, HeaderValue}; +use http::HeaderMap; use serde_json::Value; -const TESTDATA: &str = "../../core/plugins/inferenceparser/testdata"; - -/// The response files and the Content-Type each one has (`parsefixture.responseTypes`). -const RESPONSE_TYPES: &[(&str, &str)] = &[ - ("response.sse", "text/event-stream"), - ("response.json", "application/json"), - ("response.html", "text/html"), -]; - -struct Case { - dir: PathBuf, - target: String, - req_headers: HeaderMap, - req_body: Vec, - resp_headers: HeaderMap, - resp_body: Vec, - sse: bool, -} - -/// Every case directory under `testdata//` that has a request.headers file. -fn cases(agent: &str) -> Vec { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join(TESTDATA) - .join(agent); - let mut dirs: Vec = std::fs::read_dir(&root) - .unwrap_or_else(|e| panic!("{}: {e}", root.display())) - .map(|e| e.unwrap().path()) - .filter(|d| d.join("request.headers").is_file()) - .collect(); - dirs.sort(); - assert!(!dirs.is_empty(), "no cases under {}", root.display()); - dirs.into_iter().map(load).collect() -} - -fn load(dir: PathBuf) -> Case { - let head = std::fs::read_to_string(dir.join("request.headers")).unwrap(); - let mut lines = head.lines(); - let request_line = lines.next().expect("request line"); - let target = request_line - .split(' ') - .nth(1) - .expect("request target") - .to_owned(); - let req_headers = header_map(lines); - - let found: Vec<_> = RESPONSE_TYPES - .iter() - .filter(|(name, _)| dir.join(name).is_file()) - .collect(); - assert!( - found.len() <= 1, - "{}: more than one response file", - dir.display() - ); - let (file, content_type) = found - .first() - .map_or(("response.json", "application/json"), |&&p| p); - let mut resp_headers = HeaderMap::new(); - resp_headers.insert("content-type", HeaderValue::from_static(content_type)); - // An optional response.headers file replaces the default headers. - if let Ok(raw) = std::fs::read_to_string(dir.join("response.headers")) { - resp_headers = header_map(raw.lines()); - } - Case { - req_body: read_body(&dir.join("request.json")), - resp_body: read_body(&dir.join(file)), - sse: content_type == "text/event-stream", - dir, - target, - req_headers, - resp_headers, - } -} - -/// Parses `Name: value` lines, as textproto.ReadMIMEHeader does, up to a blank line. -fn header_map<'a>(lines: impl Iterator) -> HeaderMap { - let mut h = HeaderMap::new(); - for line in lines.take_while(|l| !l.is_empty()) { - let (name, value) = line.split_once(':').expect("Name: value"); - h.append( - HeaderName::from_bytes(name.trim().as_bytes()).unwrap(), - HeaderValue::from_str(value.trim_matches([' ', '\t'])).unwrap(), - ); - } - h -} - -/// The file's bytes, or no bytes when it does not exist. -fn read_body(path: &Path) -> Vec { - std::fs::read(path).unwrap_or_default() -} - fn variants(case: &Case) -> Vec<&'static str> { let mut v = if case.sse { vec!["buffered", "chunks_4096", "chunks_1", "crlf"] @@ -187,29 +99,6 @@ fn gzip(body: &[u8]) -> Vec { e.finish().unwrap() } -/// Compares two JSON values. Numbers are equal when their f64 values are -/// equal: Go writes 1 where Rust writes 1.0. -fn same(expected: &Value, got: &Value, path: &str) { - match (expected, got) { - (Value::Number(a), Value::Number(b)) if a.as_f64() == b.as_f64() => {} - (Value::Object(a), Value::Object(b)) => { - for k in a.keys().chain(b.keys()) { - match (a.get(k), b.get(k)) { - (Some(x), Some(y)) => same(x, y, &format!("{path}.{k}")), - (x, y) => panic!("{path}.{k}: expected {x:?}, got {y:?}"), - } - } - } - (Value::Array(a), Value::Array(b)) => { - assert_eq!(a.len(), b.len(), "{path}: array lengths differ"); - for (i, (x, y)) in a.iter().zip(b).enumerate() { - same(x, y, &format!("{path}[{i}]")); - } - } - _ => assert_eq!(expected, got, "{path}: values differ"), - } -} - /// Checks every case under `testdata//` whose directory name starts with `prefix`. fn check(agent: &str, prefix: &str) { let cases: Vec<_> = cases(agent) diff --git a/praxis/filters/tests/row_parity.rs b/praxis/filters/tests/row_parity.rs new file mode 100644 index 000000000..777b3dc43 --- /dev/null +++ b/praxis/filters/tests/row_parity.rs @@ -0,0 +1,116 @@ +//! Row parity with direct mode: core/sessionapi/testdata/ingest/replay.ndjson holds +//! the rows that a direct-mode forward proxy recorded for each claude-code fixture +//! (TestIngestReplay_MatchesLiveRun in core/listener/forwardproxy writes it). The +//! Rust row builder must give the same event and capture for the same exchange. + +mod common; + +use std::path::Path; +use std::time::{Instant, SystemTime}; + +use common::{cases, same}; +use cortex_praxis_filters::record::attribution::Attribution; +use cortex_praxis_filters::record::line::NOT_SET_IN_PRAXIS_MODE; +use cortex_praxis_filters::record::row::Exchange; +use http::HeaderValue; +use serde_json::Value; + +const REPLAY: &str = "../../core/sessionapi/testdata/ingest/replay.ndjson"; +/// The session header value that the live run sent with every request. +const SESSION: &str = "00000000-0000-4000-8000-000000000000"; + +/// The event keys left out of the comparison: timings and the request id differ +/// per run, ingestion owns `plugins`, and praxis mode does not set the rest. +fn skipped() -> Vec<&'static str> { + let mut k = vec!["durationMs", "ttfbMs", "requestId", "plugins"]; + k.extend_from_slice(NOT_SET_IN_PRAXIS_MODE); + k +} + +fn text(v: &Value) -> String { + v.as_str().unwrap_or_default().to_owned() +} + +/// Compares the event and the capture of the Rust line with the Go line. +fn check(got: &Value, want: &Value, name: &str) { + let (mut got, mut want) = (got.clone(), want.clone()); + for l in [&mut got, &mut want] { + let ev = l["event"].as_object_mut().unwrap(); + for k in skipped() { + ev.remove(k); + } + // Known difference: Go captures the agent's request headers at entry + // (spec Section 1, "Captures", [F 2]), with the client's own headers. + if let Some(c) = l.get_mut("capture").and_then(Value::as_object_mut) { + c.remove("header"); + } + } + same(&want["event"], &got["event"], &format!("{name}.event")); + same( + &want["capture"], + &got["capture"], + &format!("{name}.capture"), + ); +} + +#[test] +fn row_parity_matches_replay() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(REPLAY); + let raw = std::fs::read_to_string(&path).unwrap(); + let lines: Vec = raw + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + let cases = cases("claude-code"); + assert_eq!( + lines.len(), + 2 * cases.len(), + "one request and one response row per case" + ); + + for (case, pair) in cases.iter().zip(lines.chunks(2)) { + let name = case.dir.file_name().unwrap().to_string_lossy().into_owned(); + let (want_req, want_resp) = (&pair[0], &pair[1]); + let ev = &want_req["event"]; + // What the hop carries: the proxy's attribution of the caller. + let attribution = Attribution { + proc: text(&ev["proc"]), + client_proc: text(&ev["clientProc"]), + session_header: text(&ev["sessionHeader"]), + record_session: text(&want_req["session"]), + ..Attribution::default() + }; + let mut headers = case.req_headers.clone(); + headers.insert( + "x-claude-code-session-id", + HeaderValue::from_static(SESSION), + ); + let host = headers["host"].to_str().unwrap().to_owned(); + let mut ex = Exchange::new( + &case.method, + &host, + &case.target, + &headers, + attribution, + text(&want_req["requestId"]), + SystemTime::now(), + Instant::now(), + ); + let got = serde_json::to_value(ex.on_request_body(&case.req_body)).unwrap(); + check(&got, want_req, &format!("{name}/request")); + + // The live run's origin: the fixture's head, its length and the cost header it sent. + let mut h = case.resp_headers.clone(); + h.insert("content-length", case.resp_body.len().into()); + if let Some(c) = want_resp["cost"]["headers"]["X-Litellm-Response-Cost"].as_str() { + h.insert("x-litellm-response-cost", c.parse().unwrap()); + } + let status = if name == "error-502-html" { 502 } else { 200 }; + ex.on_response_head(status, &mut h).unwrap(); + for c in case.resp_body.chunks(4096) { + ex.on_response_chunk(c).unwrap(); + } + let got = serde_json::to_value(ex.on_response_end()).unwrap(); + check(&got, want_resp, &format!("{name}/response")); + } +} From e226b89710bd20fafea32f3663194bb423639522 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 16:22:06 -0500 Subject: [PATCH 13/26] Feat: Add the cortex_attribution and cortex_observe filters and the cortex-gateway binary cortex_attribution refuses a request without the Cortex token (403), reads X-Cortex-Attribution and removes the hop headers before the upstream. cortex_observe builds an Exchange for each request with a valid token and submits the request and response rows to the emitter. The binary registers the two filters. Both filters run in the request-head phase. Praxis reads a StreamBuffer body before on_request, so the token check and the Exchange must exist before the body read. The end-to-end tests start the real binary between a mock origin and a mock ingest endpoint. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- praxis/Cargo.lock | 4 + praxis/README.md | 15 + praxis/filters/src/filters/attribution.rs | 92 ++ praxis/filters/src/filters/observe.rs | 271 ++++++ praxis/server/Cargo.toml | 7 + praxis/server/src/main.rs | 8 +- praxis/server/tests/gateway.rs | 819 ++++++++++++++++++ .../server/tests/testdata/gateway.yaml.tmpl | 33 + 8 files changed, 1248 insertions(+), 1 deletion(-) create mode 100644 praxis/server/tests/gateway.rs create mode 100644 praxis/server/tests/testdata/gateway.yaml.tmpl diff --git a/praxis/Cargo.lock b/praxis/Cargo.lock index 09fdafc88..bcf19baf7 100644 --- a/praxis/Cargo.lock +++ b/praxis/Cargo.lock @@ -475,10 +475,14 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" name = "cortex-gateway" version = "0.1.0" dependencies = [ + "base64 0.22.1", "cortex-praxis-filters", + "flate2", "openssl", "praxis-proxy", "praxis-proxy-filter", + "serde_json", + "yaml_serde", ] [[package]] diff --git a/praxis/README.md b/praxis/README.md index 4decae951..436ddb261 100644 --- a/praxis/README.md +++ b/praxis/README.md @@ -34,6 +34,21 @@ The config path comes from `-c `, then `PRAXIS_CONFIG`, then `praxis.yaml` in the current directory. The supervisor (#1720) writes `gateway.yaml`. Until then, write it by hand. +The binary registers two filters. Put them first in the chain, in this order, +and add `access_log` after them: + +- `cortex_attribution` (`token_file`): refuses a request without the token in + `X-Cortex-Token` with 403, and removes the hop headers before the upstream. +- `cortex_observe` (`token_file`, `ingest` as `host:port`): records the request + and response rows and streams them to `POST /v1/ingest`. + +Without `access_log` in the chain, Praxis does not call the hook that records +an aborted or refused request, so that request has no response row. +`server/tests/testdata/gateway.yaml.tmpl` is a full example. + +`server/tests/gateway.rs` starts the real binary between a mock origin and a +mock ingest endpoint. Run it with `cargo test --locked -p cortex-gateway`. + ## Gateway decisions (#1719) The plan `docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md` diff --git a/praxis/filters/src/filters/attribution.rs b/praxis/filters/src/filters/attribution.rs index 9f5cfbf8f..3be719320 100644 --- a/praxis/filters/src/filters/attribution.rs +++ b/praxis/filters/src/filters/attribution.rs @@ -1 +1,93 @@ //! The `cortex_attribution` filter. +//! +//! It refuses a request without the Cortex token, reads `X-Cortex-Attribution` +//! into [`TokenValid`], and removes the hop headers before the upstream. It runs +//! in the request-head phase, so a refused request has no body read. + +use async_trait::async_trait; +use http::HeaderName; +use praxis_filter::{FilterAction, FilterError, HttpFilter, HttpFilterContext, Rejection}; +use serde::Deserialize; + +use crate::record::attribution::{ + ATTRIBUTION_HEADER, Attribution, TOKEN_HEADER, UPSTREAM_REMOVE, decode, token_matches, +}; + +/// Set on a request with a valid token, unless it is a probe. `cortex_observe` +/// records only requests that carry it. +pub(crate) struct TokenValid(pub Attribution); + +pub const PROBE_HEADER: &str = "X-Cortex-Probe"; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Config { + token_file: String, +} + +/// Reads the token from `path`. Surrounding white space is not part of it. +pub(crate) fn read_token(path: &str) -> Result { + let token = std::fs::read_to_string(path) + .map_err(|e| format!("cannot read the token file {path}: {e}"))?; + let token = token.trim().to_owned(); + if token.is_empty() { + return Err(format!("the token file {path} is empty").into()); + } + Ok(token) +} + +pub struct CortexAttribution { + token: String, +} + +impl CortexAttribution { + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: Config = praxis_filter::parse_filter_config("cortex_attribution", config)?; + Ok(Box::new(CortexAttribution { + token: read_token(&cfg.token_file)?, + })) + } +} + +#[async_trait] +impl HttpFilter for CortexAttribution { + fn name(&self) -> &'static str { + "cortex_attribution" + } + + fn runs_request_head(&self) -> bool { + true + } + + async fn on_request_head( + &self, + ctx: &mut HttpFilterContext<'_>, + ) -> Result { + let headers = &ctx.request.headers; + let given = headers.get(TOKEN_HEADER).map(|v| v.as_bytes()); + if !token_matches(given, self.token.as_bytes()) { + return Ok(FilterAction::Reject(Rejection::status(403))); + } + if !headers.contains_key(PROBE_HEADER) { + let attribution = decode( + headers + .get(ATTRIBUTION_HEADER) + .and_then(|v| v.to_str().ok()), + ); + ctx.extensions.insert(TokenValid(attribution)); + } + ctx.request_headers_to_remove.extend( + UPSTREAM_REMOVE + .iter() + .filter_map(|n| HeaderName::from_bytes(n.as_bytes()).ok()), + ); + Ok(FilterAction::Continue) + } + + async fn on_request( + &self, + _ctx: &mut HttpFilterContext<'_>, + ) -> Result { + Ok(FilterAction::Continue) + } +} diff --git a/praxis/filters/src/filters/observe.rs b/praxis/filters/src/filters/observe.rs index b6683e2e6..6e37727cb 100644 --- a/praxis/filters/src/filters/observe.rs +++ b/praxis/filters/src/filters/observe.rs @@ -1 +1,272 @@ //! The `cortex_observe` filter. +//! +//! It adapts the Praxis hooks to [`Exchange`] and submits each row to the +//! emitter. It records only requests that `cortex_attribution` let through +//! with [`TokenValid`]. + +use std::net::{SocketAddr, ToSocketAddrs}; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use async_trait::async_trait; +use bytes::Bytes; +use http::Method; +use http::header::HOST; +use praxis_filter::{ + BodyAccess, BodyMode, FilterAction, FilterError, HttpFilter, HttpFilterContext, Rejection, +}; +use serde::Deserialize; + +use super::attribution::{TokenValid, read_token}; +use crate::record::emitter::{self, Emitter}; +use crate::record::line::Line; +use crate::record::row::{EGRESS_ERROR_HEADER, Exchange}; + +/// The request id that the forward proxy sets on the hop. +pub const REQUEST_ID_HEADER: &str = "X-Cortex-Request-Id"; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Config { + token_file: String, + /// `host:port` of the Cortex session API. + ingest: String, +} + +/// One exchange, whether the upstream answered, and whether its response row +/// was submitted. In a `Mutex`, because `emit_deferred_record` gets only +/// `&HttpFilterContext`. +struct Observed { + exchange: Exchange, + answered: bool, + done: bool, +} + +type State = Arc>; + +pub struct CortexObserve { + emitter: Arc, +} + +impl CortexObserve { + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: Config = praxis_filter::parse_filter_config("cortex_observe", config)?; + let token = read_token(&cfg.token_file)?; + let addr: SocketAddr = cfg + .ingest + .to_socket_addrs() + .map_err(|e| format!("cortex_observe: bad ingest address {}: {e}", cfg.ingest))? + .next() + .ok_or_else(|| format!("cortex_observe: no address for {}", cfg.ingest))?; + Ok(Box::new(CortexObserve { + emitter: emitter::global(addr, token), + })) + } + + fn submit(&self, line: &Line) { + self.emitter.submit(line.queued()); + } + + /// Submits the response row once. + fn finish(&self, o: &mut Observed, row: impl FnOnce(&mut Exchange) -> Line) { + if !o.done { + o.done = true; + self.submit(&row(&mut o.exchange)); + } + } +} + +fn state(ctx: &HttpFilterContext<'_>) -> Option { + ctx.get_filter_state::().cloned() +} + +fn lock(s: &State) -> std::sync::MutexGuard<'_, Observed> { + s.lock().unwrap_or_else(|p| p.into_inner()) +} + +/// A response that has no body, so no body hook ends it. +fn bodyless(method: &Method, status: u16, headers: &http::HeaderMap) -> bool { + *method == Method::HEAD + || status == 204 + || status == 304 + || (100..200).contains(&status) + || headers + .get(http::header::CONTENT_LENGTH) + .is_some_and(|v| v.as_bytes() == b"0") +} + +#[async_trait] +impl HttpFilter for CortexObserve { + fn name(&self) -> &'static str { + "cortex_observe" + } + + // The exchange must exist before the request body pre-read, which runs + // before `on_request`. + fn runs_request_head(&self) -> bool { + true + } + + async fn on_request_head( + &self, + ctx: &mut HttpFilterContext<'_>, + ) -> Result { + let Some(TokenValid(attribution)) = ctx.extensions.get::() else { + return Ok(FilterAction::Continue); + }; + let attribution = attribution.clone(); + let req = ctx.request; + let request_id = req + .headers + .get(REQUEST_ID_HEADER) + .and_then(|v| v.to_str().ok()) + .filter(|v| !v.is_empty()) + .map(str::to_owned) + .or_else(|| ctx.request_id().map(str::to_owned)) + .unwrap_or_else(|| ctx.id_generator.generate(ctx.time_source)); + let host = req + .uri + .host() + .or_else(|| req.headers.get(HOST).and_then(|v| v.to_str().ok())) + .unwrap_or(""); + let path = req.uri.path_and_query().map_or("/", |p| p.as_str()); + let exchange = Exchange::new( + req.method.as_str(), + host, + path, + &req.headers, + attribution, + request_id, + SystemTime::now(), + ctx.request_start, + ); + let s: State = Arc::new(Mutex::new(Observed { + exchange, + answered: false, + done: false, + })); + ctx.insert_filter_state(s); + Ok(FilterAction::Continue) + } + + async fn on_request( + &self, + _ctx: &mut HttpFilterContext<'_>, + ) -> Result { + Ok(FilterAction::Continue) + } + + fn request_body_access(&self) -> BodyAccess { + BodyAccess::ReadOnly + } + + fn request_body_mode(&self) -> BodyMode { + BodyMode::StreamBuffer { max_bytes: None } + } + + fn response_body_access(&self) -> BodyAccess { + BodyAccess::ReadOnly + } + + fn response_body_mode(&self) -> BodyMode { + BodyMode::Stream + } + + async fn on_request_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + // StreamBuffer: at the end of the stream, `body` is the whole body. + if end_of_stream && let Some(s) = state(ctx) { + let line = lock(&s) + .exchange + .on_request_body(body.as_deref().unwrap_or_default()); + self.submit(&line); + } + Ok(FilterAction::Continue) + } + + async fn on_response( + &self, + ctx: &mut HttpFilterContext<'_>, + ) -> Result { + let Some(s) = state(ctx) else { + return Ok(FilterAction::Continue); + }; + let method = ctx.request.method.clone(); + let Some(resp) = ctx.response_header.as_deref_mut() else { + return Ok(FilterAction::Continue); + }; + let status = resp.status.as_u16(); + let had_egress_error = resp.headers.contains_key(EGRESS_ERROR_HEADER); + let empty = bodyless(&method, status, &resp.headers); + let mut o = lock(&s); + o.answered = true; + let head = o.exchange.on_response_head(status, &mut resp.headers); + if had_egress_error { + ctx.response_headers_modified = true; + } + if let Err(a) = head { + self.finish(&mut o, |e| e.on_abort(a)); + return Ok(FilterAction::Reject(Rejection::status(502))); + } + if empty { + self.finish(&mut o, Exchange::on_response_end); + } + Ok(FilterAction::Continue) + } + + fn on_response_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + let Some(s) = state(ctx) else { + return Ok(FilterAction::Continue); + }; + let mut o = lock(&s); + if o.done { + return Ok(FilterAction::Continue); + } + if let Some(chunk) = body.as_deref() + && let Err(a) = o.exchange.on_response_chunk(chunk) + { + self.finish(&mut o, |e| e.on_abort(a)); + return Err("cortex_observe: response over a cap".into()); + } + if end_of_stream { + self.finish(&mut o, Exchange::on_response_end); + } + Ok(FilterAction::Continue) + } + + fn emit_deferred_record(&self, ctx: &HttpFilterContext<'_>, status: u16) -> bool { + // The pipeline does not set `current_filter_id` for this hook, so + // `get_filter_state` cannot find the state. Find it by its type. + let Some(s) = ctx + .filter_state + .values() + .find_map(|b| b.downcast_ref::()) + else { + return false; + }; + let mut o = lock(s); + if o.done { + return false; + } + o.done = true; + // Praxis 0.8.0 gives a client that went away as status 499, so + // `downstream_gone` stays false. A proxied response sets no + // `stream_termination`: an upstream that sent its head and then did + // not finish the body is the upstream's failure. + let upstream_gone = + ctx.upstream_reached && (o.answered || ctx.stream_termination().is_some()); + for line in o.exchange.deferred(status, false, upstream_gone) { + self.submit(&line); + } + true + } +} diff --git a/praxis/server/Cargo.toml b/praxis/server/Cargo.toml index d6e2041b2..75cb0a71c 100644 --- a/praxis/server/Cargo.toml +++ b/praxis/server/Cargo.toml @@ -15,3 +15,10 @@ praxis-proxy = { version = "=0.8.0", default-features = false, features = ["ping praxis-proxy-filter = { version = "=0.8.0", default-features = false } cortex-praxis-filters = { path = "../filters" } openssl = { version = "0.10", features = ["vendored"] } +# The `register_filters!` expansion names `serde_yaml::Value`. +serde_yaml = { package = "yaml_serde", version = "0.10.7" } + +[dev-dependencies] +base64 = "0.22" +flate2 = "1" +serde_json = "1" diff --git a/praxis/server/src/main.rs b/praxis/server/src/main.rs index 16acbfc62..fb80d640f 100644 --- a/praxis/server/src/main.rs +++ b/praxis/server/src/main.rs @@ -2,7 +2,13 @@ //! //! The shape follows Praxis's own `crates/server/src/main.rs` at v0.8.0. -praxis_filter::register_filters! {} +use cortex_praxis_filters::filters::attribution::CortexAttribution; +use cortex_praxis_filters::filters::observe::CortexObserve; + +praxis_filter::register_filters! { + http "cortex_attribution" => CortexAttribution::from_config, + http "cortex_observe" => CortexObserve::from_config, +} fn main() { // Before anything that can build a TLS config. diff --git a/praxis/server/tests/gateway.rs b/praxis/server/tests/gateway.rs new file mode 100644 index 000000000..48c863d61 --- /dev/null +++ b/praxis/server/tests/gateway.rs @@ -0,0 +1,819 @@ +//! End-to-end tests: the real `cortex-gateway` binary between a test client, +//! a mock origin and a mock ingest endpoint. + +use std::io::{Read, Write}; +use std::net::{Shutdown, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::{Duration, Instant}; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde_json::{Value, json}; + +const TOKEN: &str = "test-token"; +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../core/plugins/inferenceparser/testdata/claude-code/main-stream-thinking-tool-use" +); +const TEMPLATE: &str = include_str!("testdata/gateway.yaml.tmpl"); +const ACCESS_LOG: &str = " - filter: access_log"; +const WAIT: Duration = Duration::from_secs(10); +const QUIET: Duration = Duration::from_secs(1); + +// --------------------------------------------------------------------------- +// HTTP/1.1 reading, enough for these tests +// --------------------------------------------------------------------------- + +/// Reads up to the end of the head. Returns the head and the bytes after it. +fn read_head(s: &mut impl Read) -> Option<(String, Vec)> { + let mut buf = Vec::new(); + let mut b = [0u8; 4096]; + loop { + if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + let rest = buf.split_off(i + 4); + return Some((String::from_utf8_lossy(&buf).into_owned(), rest)); + } + let n = s.read(&mut b).ok()?; + if n == 0 { + return None; + } + buf.extend_from_slice(&b[..n]); + } +} + +fn header<'a>(head: &'a str, name: &str) -> Option<&'a str> { + head.lines().skip(1).find_map(|l| { + let (n, v) = l.split_once(':')?; + n.trim().eq_ignore_ascii_case(name).then(|| v.trim()) + }) +} + +/// A body reader over buffered bytes and a stream, chunked or not. +struct Body<'a, S: Read> { + s: &'a mut S, + buf: Vec, + chunked: bool, + length: Option, + out: Vec, + done: bool, +} + +impl<'a, S: Read> Body<'a, S> { + fn new(s: &'a mut S, head: &str, rest: Vec) -> Self { + let chunked = header(head, "transfer-encoding").is_some_and(|v| v.contains("chunked")); + let length = header(head, "content-length").and_then(|v| v.parse().ok()); + Body { + s, + buf: rest, + chunked, + length, + out: Vec::new(), + done: false, + } + } + + fn fill(&mut self) -> bool { + let mut b = [0u8; 65536]; + match self.s.read(&mut b) { + Ok(0) | Err(_) => false, + Ok(n) => { + self.buf.extend_from_slice(&b[..n]); + true + } + } + } + + /// Moves what can be decoded from `buf` to `out`. + fn decode(&mut self) { + if !self.chunked { + self.out.append(&mut self.buf); + if self.length.is_some_and(|l| self.out.len() >= l) { + self.done = true; + } + return; + } + loop { + let Some(i) = self.buf.windows(2).position(|w| w == b"\r\n") else { + return; + }; + let line = String::from_utf8_lossy(&self.buf[..i]).into_owned(); + let size = usize::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16) + .expect("chunk size"); + if size == 0 { + self.done = true; + return; + } + if self.buf.len() < i + 2 + size + 2 { + return; + } + self.out.extend_from_slice(&self.buf[i + 2..i + 2 + size]); + self.buf.drain(..i + 2 + size + 2); + } + } + + /// Reads until `out` holds `n` bytes, the body ends or the stream ends. + fn read_at_least(&mut self, n: usize) { + self.decode(); + while !self.done && self.out.len() < n && self.fill() { + self.decode(); + } + } + + fn read_to_end(mut self) -> Vec { + self.read_at_least(usize::MAX); + self.out + } +} + +fn status(head: &str) -> u16 { + head.split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .expect("status") +} + +// --------------------------------------------------------------------------- +// The mock origin +// --------------------------------------------------------------------------- + +/// A request the origin received. +#[derive(Clone, Debug)] +struct Seen { + head: String, + body: Vec, +} + +type Handler = Arc; + +struct Origin { + port: u16, + seen: mpsc::Receiver, +} + +fn origin(handler: impl Fn(&Seen, &mut TcpStream) + Send + Sync + 'static) -> Origin { + let l = TcpListener::bind("127.0.0.1:0").expect("bind origin"); + let port = l.local_addr().expect("addr").port(); + let (tx, seen) = mpsc::channel(); + let handler: Handler = Arc::new(handler); + std::thread::spawn(move || { + for s in l.incoming().flatten() { + let (tx, handler) = (tx.clone(), handler.clone()); + std::thread::spawn(move || serve(s, &tx, &handler)); + } + }); + Origin { port, seen } +} + +fn serve(mut s: TcpStream, tx: &mpsc::Sender, handler: &Handler) { + let mut carry = Vec::new(); + loop { + let mut r = std::io::Cursor::new(std::mem::take(&mut carry)).chain(&mut s); + let Some((head, rest)) = read_head(&mut r) else { + return; + }; + drop(r); + let mut body = Body::new(&mut s, &head, rest); + if body.length.is_none() && !body.chunked { + body.done = true; + } + body.read_at_least(usize::MAX); + let seen = Seen { + head, + body: body.out, + }; + carry = body.buf; + let _ = tx.send(seen.clone()); + handler(&seen, &mut s); + } +} + +fn respond(s: &mut TcpStream, status_line: &str, headers: &[(&str, &str)], body: &[u8]) { + let mut out = format!( + "HTTP/1.1 {status_line}\r\nContent-Length: {}\r\n", + body.len() + ); + for (n, v) in headers { + out.push_str(&format!("{n}: {v}\r\n")); + } + out.push_str("\r\n"); + let _ = s.write_all(out.as_bytes()); + let _ = s.write_all(body); +} + +fn write_chunk(s: &mut TcpStream, b: &[u8]) -> std::io::Result<()> { + s.write_all(format!("{:x}\r\n", b.len()).as_bytes())?; + s.write_all(b)?; + s.write_all(b"\r\n") +} + +// --------------------------------------------------------------------------- +// The mock ingest endpoint +// --------------------------------------------------------------------------- + +struct Ingest { + port: u16, + lines: mpsc::Receiver, +} + +fn ingest() -> Ingest { + let l = TcpListener::bind("127.0.0.1:0").expect("bind ingest"); + let port = l.local_addr().expect("addr").port(); + let (tx, lines) = mpsc::channel(); + std::thread::spawn(move || { + for mut s in l.incoming().flatten() { + let tx = tx.clone(); + std::thread::spawn(move || { + let Some((head, rest)) = read_head(&mut s) else { + return; + }; + assert_eq!( + header(&head, "authorization"), + Some(format!("Bearer {TOKEN}").as_str()) + ); + let mut body = Body::new(&mut s, &head, rest); + let mut sent = 0; + loop { + let before = body.out.len(); + body.read_at_least(before + 1); + while let Some(i) = body.out[sent..].iter().position(|&b| b == b'\n') { + let line = &body.out[sent..sent + i]; + let _ = tx.send(serde_json::from_slice(line).expect("line is JSON")); + sent += i + 1; + } + if body.done || body.out.len() == before { + return; + } + } + }); + } + }); + Ingest { port, lines } +} + +impl Ingest { + /// Waits for `n` lines. + fn take(&self, n: usize) -> Vec { + let deadline = Instant::now() + WAIT; + (0..n) + .map(|i| { + let left = deadline.saturating_duration_since(Instant::now()); + self.lines + .recv_timeout(left) + .unwrap_or_else(|_| panic!("line {i} of {n} did not arrive")) + }) + .collect() + } + + /// Every line that arrives within `QUIET`. + fn quiet(&self) -> Vec { + let deadline = Instant::now() + QUIET; + let mut out = Vec::new(); + while let Ok(v) = self + .lines + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + { + out.push(v); + } + out + } +} + +// --------------------------------------------------------------------------- +// The gateway process +// --------------------------------------------------------------------------- + +struct Gateway { + child: Child, + port: u16, + dir: PathBuf, +} + +impl Drop for Gateway { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + if std::thread::panicking() { + let log = std::fs::read_to_string(self.dir.join("gateway.log")).unwrap_or_default(); + eprintln!("cortex-gateway log:\n{log}"); + } + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .and_then(|l| l.local_addr()) + .expect("free port") + .port() +} + +fn gateway(origin: &Origin, ingest: &Ingest, access_log: bool) -> Gateway { + static N: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "cortex-gateway-test-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + let token_file = dir.join("token"); + std::fs::write(&token_file, format!("{TOKEN}\n")).expect("token file"); + let port = free_port(); + let yaml = TEMPLATE + .replace("{{LISTEN}}", &port.to_string()) + .replace("{{TOKEN_FILE}}", token_file.to_str().expect("path")) + .replace("{{INGEST}}", &ingest.port.to_string()) + .replace("{{ORIGIN}}", &origin.port.to_string()) + .replace("{{ACCESS_LOG}}", if access_log { ACCESS_LOG } else { "" }); + let config = dir.join("gateway.yaml"); + std::fs::write(&config, yaml).expect("config"); + let log = std::fs::File::create(dir.join("gateway.log")).expect("log"); + let mut child = Command::new(env!("CARGO_BIN_EXE_cortex-gateway")) + .arg("-c") + .arg(&config) + .stdout(Stdio::null()) + .stderr(log) + .spawn() + .expect("start cortex-gateway"); + let deadline = Instant::now() + WAIT; + while TcpStream::connect(("127.0.0.1", port)).is_err() { + if let Ok(Some(st)) = child.try_wait() { + let log = std::fs::read_to_string(dir.join("gateway.log")).unwrap_or_default(); + panic!("cortex-gateway exited with {st}:\n{log}"); + } + assert!(Instant::now() < deadline, "cortex-gateway is not ready"); + std::thread::sleep(Duration::from_millis(20)); + } + Gateway { child, port, dir } +} + +impl Gateway { + fn connect(&self) -> TcpStream { + let s = TcpStream::connect(("127.0.0.1", self.port)).expect("connect"); + s.set_read_timeout(Some(WAIT)).expect("timeout"); + s + } +} + +// --------------------------------------------------------------------------- +// Requests +// --------------------------------------------------------------------------- + +fn head(method: &str, path: &str, headers: &[(&str, &str)], length: usize) -> Vec { + let mut out = format!( + "{method} {path} HTTP/1.1\r\nHost: api.example.com\r\nConnection: close\r\nContent-Length: {length}\r\n" + ); + for (n, v) in headers { + out.push_str(&format!("{n}: {v}\r\n")); + } + out.push_str("\r\n"); + out.into_bytes() +} + +/// Sends one request and reads the whole response: (head, body). +fn send( + g: &Gateway, + method: &str, + path: &str, + headers: &[(&str, &str)], + body: &[u8], +) -> (String, Vec) { + let mut s = g.connect(); + s.write_all(&head(method, path, headers, body.len())) + .expect("write head"); + s.write_all(body).expect("write body"); + let (h, rest) = read_head(&mut s).expect("response head"); + let body = Body::new(&mut s, &h, rest).read_to_end(); + (h, body) +} + +fn attribution(v: Value) -> String { + URL_SAFE_NO_PAD.encode(v.to_string()) +} + +fn fixture(name: &str) -> Vec { + std::fs::read(format!("{FIXTURE}/{name}")).expect("fixture") +} + +/// The fixture's request headers, without `Host`, plus the token. +fn fixture_headers() -> Vec<(String, String)> { + let raw = String::from_utf8(fixture("request.headers")).expect("utf-8"); + let mut out: Vec<(String, String)> = raw + .lines() + .skip(1) + .filter_map(|l| l.split_once(':')) + .map(|(n, v)| (n.trim().to_owned(), v.trim().to_owned())) + .filter(|(n, _)| !n.eq_ignore_ascii_case("host")) + .collect(); + out.push(("X-Cortex-Token".into(), TOKEN.into())); + out +} + +fn as_refs(h: &[(String, String)]) -> Vec<(&str, &str)> { + h.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect() +} + +fn fixture_response_headers() -> Vec<(String, String)> { + String::from_utf8(fixture("response.headers")) + .expect("utf-8") + .lines() + .filter_map(|l| l.split_once(':')) + .map(|(n, v)| (n.trim().to_owned(), v.trim().to_owned())) + .collect() +} + +fn response_row(lines: &[Value]) -> &Value { + lines + .iter() + .find(|l| l["event"]["phase"] == "response") + .expect("a response row") +} + +fn ok_origin() -> Origin { + origin(|_, s| respond(s, "200 OK", &[("Content-Type", "text/plain")], b"ok")) +} + +/// Sends half of a declared body and closes the connection. +fn abort_mid_body(g: &Gateway) { + let mut s = g.connect(); + let mut h = fixture_headers(); + h.push(("Content-Type".into(), "application/json".into())); + s.write_all(&head("POST", "/v1/messages", &as_refs(&h), 1000)) + .expect("head"); + s.write_all(&[b' '; 500]).expect("half body"); + s.flush().expect("flush"); + std::thread::sleep(Duration::from_millis(200)); + let _ = s.shutdown(Shutdown::Both); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[test] +fn tokenless_request_gets_403_and_no_line() { + let (o, i) = (ok_origin(), ingest()); + let g = gateway(&o, &i, true); + let (h, _) = send(&g, "GET", "/v1/models", &[], b""); + assert_eq!(status(&h), 403); + let (h, _) = send(&g, "GET", "/v1/models", &[("X-Cortex-Token", "wrong")], b""); + assert_eq!(status(&h), 403); + assert!( + o.seen.recv_timeout(QUIET).is_err(), + "the origin saw a request" + ); + assert_eq!(i.quiet(), Vec::::new()); +} + +#[test] +fn probe_request_records_nothing() { + let (o, i) = (ok_origin(), ingest()); + let g = gateway(&o, &i, true); + let (h, body) = send( + &g, + "GET", + "/health", + &[("X-Cortex-Token", TOKEN), ("X-Cortex-Probe", "1")], + b"", + ); + assert_eq!(status(&h), 200); + assert_eq!(body, b"ok"); + let seen = o.seen.recv_timeout(WAIT).expect("the origin saw the probe"); + assert!(header(&seen.head, "x-cortex-probe").is_none()); + assert_eq!(i.quiet(), Vec::::new()); +} + +#[test] +fn hop_headers_removed_custom_kept() { + let (o, i) = (ok_origin(), ingest()); + let g = gateway(&o, &i, true); + let hop = [ + ("X-Cortex-Agent", "a"), + ("X-Cortex-Proc", "p"), + ("X-Cortex-Client-Proc", "cp"), + ("X-Cortex-Session", "s"), + ("X-Cortex-Source", "src"), + ("X-Cortex-Session-Header", "sh"), + ("X-Cortex-Chain-Session", "cs"), + ("X-Cortex-Attribution", "e30"), + ("X-Cortex-Request-Id", "rid-1"), + ("X-Cortex-Dest", "api.example.com:443"), + ("X-Cortex-Token", TOKEN), + ("X-Cortex-Custom", "kept"), + ]; + let (h, _) = send(&g, "GET", "/v1/models", &hop, b""); + assert_eq!(status(&h), 200); + let seen = o + .seen + .recv_timeout(WAIT) + .expect("the origin saw the request"); + let mut cortex: Vec = seen + .head + .lines() + .skip(1) + .filter_map(|l| l.split_once(':')) + .map(|(n, _)| n.trim().to_ascii_lowercase()) + .filter(|n| n.starts_with("x-cortex-")) + .collect(); + cortex.sort(); + assert_eq!( + cortex, + ["x-cortex-custom", "x-cortex-dest", "x-cortex-token"] + ); + assert_eq!(header(&seen.head, "x-cortex-custom"), Some("kept")); +} + +#[test] +fn inference_request_gives_request_and_response_lines() { + let sse = fixture("response.sse"); + let o = origin(move |_, s| { + let mut h = fixture_response_headers(); + h.push(("X-Litellm-Response-Cost".into(), "0.01".into())); + respond(s, "200 OK", &as_refs(&h), &sse); + }); + let i = ingest(); + let g = gateway(&o, &i, true); + let mut h = fixture_headers(); + h.push(( + "X-Cortex-Attribution".into(), + attribution(json!({"recordSession": "s1", "proc": "claude"})), + )); + let (rh, body) = send( + &g, + "POST", + "/v1/messages?beta=true", + &as_refs(&h), + &fixture("request.json"), + ); + assert_eq!(status(&rh), 200); + assert_eq!(body, fixture("response.sse")); + let seen = o + .seen + .recv_timeout(WAIT) + .expect("the origin saw the request"); + assert_eq!(seen.body, fixture("request.json")); + + let lines = i.take(2); + assert_eq!(lines[0]["event"]["phase"], "request"); + assert_eq!(lines[1]["event"]["phase"], "response"); + assert_eq!(lines[0]["requestId"], lines[1]["requestId"]); + assert_ne!(lines[0]["requestId"], ""); + for l in &lines { + assert_eq!(l["session"], "s1"); + assert_eq!(l["event"]["proc"], "claude"); + } + assert_eq!(lines[0]["event"]["inference"]["model"], "claude-opus-5-5"); + assert!(lines[0]["capture"]["original"].is_string()); + let resp = &lines[1]; + assert_eq!(resp["event"]["statusCode"], 200); + assert_eq!(resp["event"]["inference"]["completionTokens"], 275); + assert_eq!(resp["cost"]["headers"]["X-Litellm-Response-Cost"], "0.01"); + assert_eq!(resp["capture"]["response"]["ended"], true); +} + +#[test] +fn gzip_sse_streams_as_frames_and_wire_bytes_unchanged() { + use flate2::Compression; + use flate2::write::GzEncoder; + + let sse = fixture("response.sse"); + let first_frame = sse.windows(2).position(|w| w == b"\n\n").expect("a frame") + 2; + let mut enc = GzEncoder::new(Vec::new(), Compression::default()); + enc.write_all(&sse[..first_frame]).expect("gzip"); + enc.flush().expect("sync flush"); + let part1 = std::mem::take(enc.get_mut()); + enc.write_all(&sse[first_frame..]).expect("gzip"); + let part2 = enc.finish().expect("gzip end"); + let wire: Vec = [part1.clone(), part2.clone()].concat(); + let part1_len = part1.len(); + + let (go_tx, go_rx) = mpsc::channel::<()>(); + let go_rx = Mutex::new(go_rx); + let o = origin(move |_, s| { + let _ = s.write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Encoding: gzip\r\nTransfer-Encoding: chunked\r\n\r\n", + ); + let _ = write_chunk(s, &part1); + let _ = s.flush(); + // The last frame goes only after the client got the first one. + let _ = go_rx.lock().expect("lock").recv_timeout(WAIT); + let _ = write_chunk(s, &part2); + let _ = s.write_all(b"0\r\n\r\n"); + }); + let i = ingest(); + let g = gateway(&o, &i, true); + let mut s = g.connect(); + let h = fixture_headers(); + let req = fixture("request.json"); + s.write_all(&head( + "POST", + "/v1/messages?beta=true", + &as_refs(&h), + req.len(), + )) + .expect("head"); + s.write_all(&req).expect("body"); + let (rh, rest) = read_head(&mut s).expect("response head"); + assert_eq!(status(&rh), 200); + assert_eq!(header(&rh, "content-encoding"), Some("gzip")); + let mut body = Body::new(&mut s, &rh, rest); + body.read_at_least(part1_len); + assert!( + body.out.len() >= part1_len, + "the first frame did not arrive before the last one was sent" + ); + go_tx.send(()).expect("go"); + assert_eq!(body.read_to_end(), wire); + + let lines = i.take(2); + let resp = response_row(&lines); + assert_eq!(resp["event"]["inference"]["completionTokens"], 275); + assert_eq!(resp["capture"]["response"]["decoded"], true); + assert_eq!(resp["event"]["bytesDown"], sse.len()); +} + +#[test] +fn mid_body_abort_is_client_canceled() { + let (o, i) = (ok_origin(), ingest()); + let g = gateway(&o, &i, true); + abort_mid_body(&g); + let lines = i.take(2); + assert_eq!(lines[0]["event"]["phase"], "request"); + let resp = response_row(&lines); + assert_eq!(resp["event"]["statusCode"], 499); + assert_eq!(resp["event"]["error"]["kind"], "client_canceled"); +} + +#[test] +fn body_over_limit_is_413_proxy_error() { + const LEN: usize = 33 << 20; + let (o, i) = (ok_origin(), ingest()); + let g = gateway(&o, &i, true); + let mut s = g.connect(); + let h = fixture_headers(); + s.write_all(&head("POST", "/v1/messages", &as_refs(&h), LEN)) + .expect("head"); + let mut w = s.try_clone().expect("clone"); + std::thread::spawn(move || { + let chunk = vec![b' '; 1 << 20]; + for _ in 0..33 { + if w.write_all(&chunk).is_err() { + return; + } + } + }); + let (rh, _) = read_head(&mut s).expect("response head"); + assert_eq!(status(&rh), 413); + let lines = i.take(2); + assert_eq!(lines[0]["event"]["phase"], "request"); + let resp = response_row(&lines); + assert_eq!(resp["event"]["statusCode"], 413); + assert_eq!(resp["event"]["error"]["kind"], "proxy_error"); + assert_eq!( + resp["event"]["error"]["message"], + "request body of 34603008 bytes is over the 33554432-byte buffer limit" + ); +} + +#[test] +fn buffered_over_cap_with_content_length_is_502() { + const LEN: usize = 12_000_000; + let o = origin(|_, s| { + let _ = s.write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {LEN}\r\n\r\n" + ) + .as_bytes(), + ); + let chunk = vec![b' '; 1 << 20]; + let mut left = LEN; + while left > 0 { + let n = left.min(chunk.len()); + if s.write_all(&chunk[..n]).is_err() { + return; + } + left -= n; + } + }); + let i = ingest(); + let g = gateway(&o, &i, true); + let (rh, _) = send( + &g, + "POST", + "/v1/messages", + &as_refs(&fixture_headers()), + &fixture("request.json"), + ); + assert_eq!(status(&rh), 502); + let lines = i.take(2); + let resp = response_row(&lines); + assert_eq!(resp["event"]["statusCode"], 502); + assert_eq!(resp["event"]["error"]["kind"], "proxy_error"); + assert_eq!(resp["event"]["error"]["code"], "200"); + assert_eq!( + resp["event"]["error"]["message"], + "response body over the 10485760-byte buffer limit" + ); +} + +#[test] +fn frame_over_cap_aborts_stream() { + let o = origin(|_, s| { + let _ = s.write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n", + ); + if write_chunk(s, b"data: ").is_err() { + return; + } + let chunk = vec![b'a'; 1 << 20]; + for _ in 0..12 { + if write_chunk(s, &chunk).is_err() { + return; + } + } + let _ = write_chunk(s, b"\n\n"); + let _ = s.write_all(b"0\r\n\r\n"); + }); + let i = ingest(); + let g = gateway(&o, &i, true); + let mut s = g.connect(); + let h = fixture_headers(); + let req = fixture("request.json"); + s.write_all(&head("POST", "/v1/messages", &as_refs(&h), req.len())) + .expect("head"); + s.write_all(&req).expect("body"); + let (rh, rest) = read_head(&mut s).expect("response head"); + assert_eq!(status(&rh), 200); + let body = Body::new(&mut s, &rh, rest).read_to_end(); + assert!(body.len() < 12 << 20, "the stream was not cut"); + let lines = i.take(2); + let resp = response_row(&lines); + assert_eq!(resp["event"]["statusCode"], 200); + assert_eq!(resp["event"]["error"]["kind"], "proxy_error"); + assert_eq!( + resp["event"]["error"]["message"], + "single SSE frame over the 10485760-byte frame cap" + ); + assert_eq!(i.quiet(), Vec::::new(), "a second response row"); +} + +#[test] +fn egress_error_header_becomes_kind() { + let o = origin(|_, s| { + respond( + s, + "502 Bad Gateway", + &[ + ("X-Cortex-Egress-Error", "upstream_dns"), + ("X-Cortex-Upstream-Error", "no such host"), + ], + b"", + ) + }); + let i = ingest(); + let g = gateway(&o, &i, true); + let (rh, _) = send(&g, "GET", "/v1/models", &[("X-Cortex-Token", TOKEN)], b""); + assert_eq!(status(&rh), 502); + assert!(header(&rh, "x-cortex-egress-error").is_none()); + assert_eq!(header(&rh, "x-cortex-upstream-error"), Some("no such host")); + let lines = i.take(2); + let resp = response_row(&lines); + assert_eq!(resp["event"]["statusCode"], 502); + assert_eq!(resp["event"]["error"]["kind"], "upstream_dns"); + assert_eq!(i.quiet(), Vec::::new(), "a second response row"); +} + +#[test] +fn without_access_log_no_deferred_row() { + let (o, i) = (ok_origin(), ingest()); + let g = gateway(&o, &i, false); + abort_mid_body(&g); + let lines = i.quiet(); + assert!( + lines.iter().all(|l| l["event"]["phase"] != "response"), + "a response row without access_log: {lines:?}" + ); +} + +#[test] +fn upstream_close_mid_body_is_upstream_error() { + let o = origin(|_, s| { + let _ = s.write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 1000\r\n\r\n{\"a\":", + ); + let _ = s.flush(); + let _ = s.shutdown(Shutdown::Both); + }); + let i = ingest(); + let g = gateway(&o, &i, true); + let mut s = g.connect(); + s.write_all(&head("GET", "/v1/models", &[("X-Cortex-Token", TOKEN)], 0)) + .expect("head"); + let mut sink = Vec::new(); + let _ = s.read_to_end(&mut sink); + let lines = i.take(2); + let resp = response_row(&lines); + assert_eq!(resp["event"]["error"]["kind"], "upstream_error"); +} diff --git a/praxis/server/tests/testdata/gateway.yaml.tmpl b/praxis/server/tests/testdata/gateway.yaml.tmpl new file mode 100644 index 000000000..334b2a860 --- /dev/null +++ b/praxis/server/tests/testdata/gateway.yaml.tmpl @@ -0,0 +1,33 @@ +# The gateway config of the end-to-end tests (tests/gateway.rs). The test +# replaces each {{NAME}}. +insecure_options: + allow_unbounded_body: true + allow_private_upstreams: true + allow_private_endpoints: true + +body_limits: + max_request_bytes: 33554432 + max_response_bytes: null + +listeners: + - name: gateway + address: "127.0.0.1:{{LISTEN}}" + filter_chains: [main] + +filter_chains: + - name: main + filters: + - filter: cortex_attribution + token_file: "{{TOKEN_FILE}}" + - filter: cortex_observe + token_file: "{{TOKEN_FILE}}" + ingest: "127.0.0.1:{{INGEST}}" +{{ACCESS_LOG}} + - filter: router + routes: + - path_prefix: "/" + cluster: origin + - filter: load_balancer + clusters: + - name: origin + endpoints: ["127.0.0.1:{{ORIGIN}}"] From 0217b379d1c82fd4f3d90b030f11c9c84e5e92b6 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 16:27:46 -0500 Subject: [PATCH 14/26] Test: Make the gateway tests stable and pin the request id source The gateway port came from a bind to port 0 that the test then released. A concurrent test's mock could bind the same port before the gateway did. Pingora then retries its bind and does not exit, so the readiness check connected to the mock. Pick the gateway port in 20000-29999, outside the ephemeral range that port 0 binds use. The ingest mock now sends an Authorization or JSON problem to the test through its channel, not as a panic in its own thread. hop_headers_removed_custom_kept now checks that both lines carry the requestId from X-Cortex-Request-Id. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- praxis/server/tests/gateway.rs | 41 +++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/praxis/server/tests/gateway.rs b/praxis/server/tests/gateway.rs index 48c863d61..aee70165b 100644 --- a/praxis/server/tests/gateway.rs +++ b/praxis/server/tests/gateway.rs @@ -215,7 +215,8 @@ fn write_chunk(s: &mut TcpStream, b: &[u8]) -> std::io::Result<()> { struct Ingest { port: u16, - lines: mpsc::Receiver, + /// A line, or what the mock found wrong, so that the test sees it. + lines: mpsc::Receiver>, } fn ingest() -> Ingest { @@ -229,10 +230,11 @@ fn ingest() -> Ingest { let Some((head, rest)) = read_head(&mut s) else { return; }; - assert_eq!( - header(&head, "authorization"), - Some(format!("Bearer {TOKEN}").as_str()) - ); + let auth = header(&head, "authorization"); + if auth != Some(format!("Bearer {TOKEN}").as_str()) { + let _ = tx.send(Err(format!("ingest Authorization is {auth:?}"))); + return; + } let mut body = Body::new(&mut s, &head, rest); let mut sent = 0; loop { @@ -240,7 +242,10 @@ fn ingest() -> Ingest { body.read_at_least(before + 1); while let Some(i) = body.out[sent..].iter().position(|&b| b == b'\n') { let line = &body.out[sent..sent + i]; - let _ = tx.send(serde_json::from_slice(line).expect("line is JSON")); + let _ = tx.send( + serde_json::from_slice(line) + .map_err(|e| format!("ingest line is not JSON: {e}")), + ); sent += i + 1; } if body.done || body.out.len() == before { @@ -263,6 +268,7 @@ impl Ingest { self.lines .recv_timeout(left) .unwrap_or_else(|_| panic!("line {i} of {n} did not arrive")) + .unwrap_or_else(|e| panic!("{e}")) }) .collect() } @@ -275,7 +281,7 @@ impl Ingest { .lines .recv_timeout(deadline.saturating_duration_since(Instant::now())) { - out.push(v); + out.push(v.unwrap_or_else(|e| panic!("{e}"))); } out } @@ -303,11 +309,18 @@ impl Drop for Gateway { } } +/// A free port in 20000-29999. The mocks bind port 0, which the OS takes from +/// its ephemeral range (32768 and up on Linux, 49152 and up on macOS), so a +/// concurrent test's mock cannot take the gateway's port after the check. fn free_port() -> u16 { - TcpListener::bind("127.0.0.1:0") - .and_then(|l| l.local_addr()) - .expect("free port") - .port() + static N: AtomicUsize = AtomicUsize::new(0); + loop { + let n = std::process::id() as usize * 37 + N.fetch_add(1, Ordering::Relaxed); + let port = 20000 + (n % 10000) as u16; + if TcpListener::bind(("127.0.0.1", port)).is_ok() { + return port; + } + } } fn gateway(origin: &Origin, ingest: &Ingest, access_log: bool) -> Gateway { @@ -523,6 +536,12 @@ fn hop_headers_removed_custom_kept() { ["x-cortex-custom", "x-cortex-dest", "x-cortex-token"] ); assert_eq!(header(&seen.head, "x-cortex-custom"), Some("kept")); + for l in i.take(2) { + assert_eq!( + l["requestId"], "rid-1", + "the id comes from X-Cortex-Request-Id" + ); + } } #[test] From 5961d038c5c5466e2206802a1962978dc0ccf774 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 16:36:55 -0500 Subject: [PATCH 15/26] Test: Wait for a 403 from the gateway, not for a TCP connect During startup, the gateway port accepts a connect and then refuses connections again for a short time. The tests then failed with ConnectionRefused, BrokenPipe or no response head. The readiness check now sends a tokenless request and waits for the gateway's 403. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- praxis/server/tests/gateway.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/praxis/server/tests/gateway.rs b/praxis/server/tests/gateway.rs index aee70165b..8f091ba7d 100644 --- a/praxis/server/tests/gateway.rs +++ b/praxis/server/tests/gateway.rs @@ -351,7 +351,9 @@ fn gateway(origin: &Origin, ingest: &Ingest, access_log: bool) -> Gateway { .spawn() .expect("start cortex-gateway"); let deadline = Instant::now() + WAIT; - while TcpStream::connect(("127.0.0.1", port)).is_err() { + // A connect alone is not proof: during startup the port accepts and then + // refuses again. Only the gateway answers a tokenless request with 403. + while !answers_403(port) { if let Ok(Some(st)) = child.try_wait() { let log = std::fs::read_to_string(dir.join("gateway.log")).unwrap_or_default(); panic!("cortex-gateway exited with {st}:\n{log}"); @@ -362,6 +364,15 @@ fn gateway(origin: &Origin, ingest: &Ingest, access_log: bool) -> Gateway { Gateway { child, port, dir } } +fn answers_403(port: u16) -> bool { + let Ok(mut s) = TcpStream::connect(("127.0.0.1", port)) else { + return false; + }; + let _ = s.set_read_timeout(Some(QUIET)); + s.write_all(&head("GET", "/", &[], 0)).is_ok() + && read_head(&mut s).is_some_and(|(h, _)| h.split_whitespace().nth(1) == Some("403")) +} + impl Gateway { fn connect(&self) -> TcpStream { let s = TcpStream::connect(("127.0.0.1", self.port)).expect("connect"); From 43b93baa3ae1e5426f98647c22348b147c4a2f08 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 16:45:01 -0500 Subject: [PATCH 16/26] Fix: Record a client that closes during a streamed response as client_canceled After the response head, Praxis 0.8.0 gives emit_deferred_record the written status (200) and upstream_reached for a client that went away. The adapter then gave the row upstream_error. Direct mode gives 499 client_canceled, and the hop's Cortex-cut override needs that kind. A stream cut after the head is now client_canceled. Praxis 0.8.0 gives no error source there, so an upstream that drops mid-body reads the same. The new test client_close_mid_sse_is_client_canceled covers the client case, and the upstream test now pins the known limit. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- praxis/README.md | 1 + praxis/filters/src/filters/observe.rs | 17 ++++++---- praxis/server/tests/gateway.rs | 46 +++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/praxis/README.md b/praxis/README.md index 436ddb261..082b3fde4 100644 --- a/praxis/README.md +++ b/praxis/README.md @@ -71,6 +71,7 @@ gives the full text. 15. The filters use the Praxis 0.8.0 API. No fork, no patch. 16. `CortexObserve` keeps its exchange in filter state as `Arc>`. 17. The ingest stream ends on Cortex shutdown, so restarts stay quick. +18. A stream cut after the response head is `client_canceled`, also when the upstream cut it. Praxis 0.8.0 gives no error source after the head. ## The parity rule diff --git a/praxis/filters/src/filters/observe.rs b/praxis/filters/src/filters/observe.rs index 6e37727cb..aabfc6942 100644 --- a/praxis/filters/src/filters/observe.rs +++ b/praxis/filters/src/filters/observe.rs @@ -258,13 +258,16 @@ impl HttpFilter for CortexObserve { return false; } o.done = true; - // Praxis 0.8.0 gives a client that went away as status 499, so - // `downstream_gone` stays false. A proxied response sets no - // `stream_termination`: an upstream that sent its head and then did - // not finish the body is the upstream's failure. - let upstream_gone = - ctx.upstream_reached && (o.answered || ctx.stream_termination().is_some()); - for line in o.exchange.deferred(status, false, upstream_gone) { + // Before the response head, Praxis 0.8.0 gives a client that went + // away as status 499. After the head, it gives the written status and + // `upstream_reached` for both a client and an upstream that went away. + // shortcut: a stream cut after the head is always `client_canceled` + // (the frequent case: a user stop or a Cortex idle cut), so an + // upstream that drops mid-body reads the same; upgrade when Praxis + // gives the error source to `emit_deferred_record`. + let upstream_gone = ctx.upstream_reached && ctx.stream_termination().is_some(); + let downstream_gone = o.answered; + for line in o.exchange.deferred(status, downstream_gone, upstream_gone) { self.submit(&line); } true diff --git a/praxis/server/tests/gateway.rs b/praxis/server/tests/gateway.rs index 8f091ba7d..1d9d5a78d 100644 --- a/praxis/server/tests/gateway.rs +++ b/praxis/server/tests/gateway.rs @@ -827,8 +827,10 @@ fn without_access_log_no_deferred_row() { ); } +/// Praxis 0.8.0 does not tell this case from a client that closes after the +/// response head, so it reads as `client_canceled` (see `emit_deferred_record`). #[test] -fn upstream_close_mid_body_is_upstream_error() { +fn upstream_close_mid_body_reads_as_client_canceled() { let o = origin(|_, s| { let _ = s.write_all( b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 1000\r\n\r\n{\"a\":", @@ -845,5 +847,45 @@ fn upstream_close_mid_body_is_upstream_error() { let _ = s.read_to_end(&mut sink); let lines = i.take(2); let resp = response_row(&lines); - assert_eq!(resp["event"]["error"]["kind"], "upstream_error"); + assert_eq!(resp["event"]["statusCode"], 499); + assert_eq!(resp["event"]["error"]["kind"], "client_canceled"); +} + +#[test] +fn client_close_mid_sse_is_client_canceled() { + let (closed_tx, closed_rx) = mpsc::channel::<()>(); + let closed_rx = Mutex::new(closed_rx); + let o = origin(move |_, s| { + let _ = s.write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n", + ); + let _ = write_chunk(s, b"event: ping\ndata: {\"type\":\"ping\"}\n\n"); + let _ = s.flush(); + let _ = closed_rx.lock().expect("lock").recv_timeout(WAIT); + // Keep the stream open, as a model still writing its answer does. + for _ in 0..40 { + if write_chunk(s, b"event: ping\ndata: {\"type\":\"ping\"}\n\n").is_err() { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + }); + let i = ingest(); + let g = gateway(&o, &i, true); + let mut s = g.connect(); + let h = fixture_headers(); + let req = fixture("request.json"); + s.write_all(&head("POST", "/v1/messages", &as_refs(&h), req.len())) + .expect("head"); + s.write_all(&req).expect("body"); + let (rh, rest) = read_head(&mut s).expect("response head"); + assert_eq!(status(&rh), 200); + Body::new(&mut s, &rh, rest).read_at_least(1); + let _ = s.shutdown(Shutdown::Both); + drop(s); + closed_tx.send(()).expect("closed"); + let lines = i.take(2); + let resp = response_row(&lines); + assert_eq!(resp["event"]["statusCode"], 499, "{resp}"); + assert_eq!(resp["event"]["error"]["kind"], "client_canceled"); } From 67a4a31192120952d18c70b3db2aa44c8bcf4e36 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 17:03:08 -0500 Subject: [PATCH 17/26] Chore: Add a script that measures praxis mode against direct mode scripts/dev/praxis-measure.sh starts a scratch Cortex under a temp HOME on its own ports. It never uses the shared proxy on :47600. It sends N requests (default 200) through cortex-gateway in praxis mode, and then the same traffic through the forward proxy of a direct-mode Cortex. The request bodies are 7 MiB and 12 MiB in turn, with two X-Claude-Code-Agent-Id values. A Python stdlib mock origin answers with the largest claude-code fixture response. For each process, the script prints the CPU time, the peak RSS and the Cortex heap in use from /debug/pprof/heap. It also prints the ingest counters of Cortex and the emitter counters of the gateway. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- scripts/dev/praxis-measure.sh | 316 ++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100755 scripts/dev/praxis-measure.sh diff --git a/scripts/dev/praxis-measure.sh b/scripts/dev/praxis-measure.sh new file mode 100755 index 000000000..9fd66906d --- /dev/null +++ b/scripts/dev/praxis-measure.sh @@ -0,0 +1,316 @@ +#!/bin/bash +# praxis-measure.sh measures praxis mode against direct mode, for each process. +# +# Praxis mode: client -> cortex-gateway -> mock origin, and the gateway streams its +# rows to a scratch Cortex (praxis.enabled) on POST /v1/ingest. +# Direct mode: client -> scratch Cortex forward proxy -> mock origin. +# +# Both runs send the same traffic: N requests, 7 MiB and 12 MiB request bodies in +# turn, two X-Claude-Code-Agent-Id values in turn, one session. The origin answers +# each one with the largest claude-code fixture response. +# +# The script prints, for each process: the CPU time (ps -o time), the peak RSS +# (sampled every 0.2 s) and, for Cortex, the heap in use from /debug/pprof/heap. +# cortex-gateway serves no pprof. +# +# Every process runs under a temp HOME on its own ports. The script never touches +# the shared proxy on :47600 or ~/.cortex. +# +# Usage: scripts/dev/praxis-measure.sh [-n N] [-c CONCURRENCY] +# -n N the number of requests in each run (default 200) +# -c C the number of requests in flight at one time (default 4) +# Set CORTEX_BIN or GATEWAY_BIN to use a binary you built. Otherwise the script +# builds cortex (profile full) and cortex-gateway (release). +set -euo pipefail + +n=200 +conc=4 +while [ $# -gt 0 ]; do + case "$1" in + -n) n="$2"; shift 2 ;; + -c) conc="$2"; shift 2 ;; + *) echo "praxis-measure: unknown argument $1" >&2; exit 2 ;; + esac +done + +root=$(cd "$(dirname "$0")/../.." && pwd) +tmp=$(mktemp -d "${TMPDIR:-/tmp}/praxis-measure.XXXXXX") +pids="" +cleanup() { + for p in $pids; do kill "$p" 2>/dev/null || true; done + wait 2>/dev/null || true + if [ -n "${KEEP:-}" ]; then echo "logs kept in $tmp" >&2; else rm -rf "$tmp"; fi +} +trap cleanup EXIT + +if [ -z "${CORTEX_BIN:-}" ]; then + tags=$(go -C "$root/scripts/profile-tags" run . full) + echo "building cortex (profile full)" >&2 + (cd "$root/cmd/cortex" && GOWORK=off go build -tags "$tags" -o "$tmp/cortex" .) + CORTEX_BIN="$tmp/cortex" +fi +if [ -z "${GATEWAY_BIN:-}" ]; then + echo "building cortex-gateway (release)" >&2 + (cd "$root/praxis" && cargo build --release --locked -p cortex-gateway >&2) + GATEWAY_BIN="$root/praxis/target/release/cortex-gateway" +fi + +fixtures="$root/core/plugins/inferenceparser/testdata/claude-code" +# shellcheck disable=SC2012 # fixture names have no spaces +response=$(ls -S "$fixtures"/*/response.sse "$fixtures"/*/response.json | head -1) +fixture=$(dirname "$response") +echo "fixture: $(basename "$fixture") ($(wc -c <"$response" | tr -d ' ') response bytes)" >&2 + +free_port() { + python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])' +} + +# wait_http URL CODE: waits up to 30 s for URL to answer CODE. +wait_http() { + for _ in $(seq 300); do + if [ "$(curl -s --noproxy '*' -o /dev/null -w '%{http_code}' "$1" || true)" = "$2" ]; then + return 0 + fi + sleep 0.1 + done + echo "praxis-measure: $1 did not answer $2" >&2 + return 1 +} + +# sample_rss PID FILE: writes the peak RSS in KiB of PID to FILE until PID ends. +sample_rss() { + peak=0 + while kill -0 "$1" 2>/dev/null; do + r=$(ps -o rss= -p "$1" 2>/dev/null | tr -d ' ' || true) + if [ -n "$r" ] && [ "$r" -gt "$peak" ]; then + peak=$r + echo "$peak" >"$2" + fi + sleep 0.2 + done +} + +cat >"$tmp/origin.py" <<'EOF' +import http.server, sys +port, body_path, ctype = int(sys.argv[1]), sys.argv[2], sys.argv[3] +body = open(body_path, "rb").read() + +class H(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + def log_message(self, *a): pass + def do_POST(self): + if self.headers.get("Transfer-Encoding", "").lower() == "chunked": + while True: + size = int(self.rfile.readline().split(b";")[0], 16) + self.rfile.read(size + 2) + if size == 0: + break + else: + left = int(self.headers.get("Content-Length", "0")) + while left > 0: + left -= len(self.rfile.read(min(left, 1 << 20))) + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + +http.server.ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever() +EOF + +cat >"$tmp/client.py" <<'EOF' +import concurrent.futures, http.client, json, sys +mode, target, origin, n, conc, fixture, token = sys.argv[1:8] +n, conc = int(n), int(conc) +headers = {} +for line in open(fixture + "/request.headers").read().splitlines()[1:]: + if ":" in line: + k, v = line.split(":", 1) + if k.strip().lower() not in ("host", "content-length"): + headers[k.strip()] = v.strip() +base = json.load(open(fixture + "/request.json")) + +def body(size): + # A valid inference request: the fixture plus one user message that pads it to size. + req = dict(base) + req["messages"] = list(base.get("messages", [])) + [{"role": "user", "content": ""}] + pad = size - len(json.dumps(req).encode()) + req["messages"][-1]["content"] = "x" * max(pad, 0) + return json.dumps(req).encode() + +bodies = [body(7 << 20), body(12 << 20)] +path = "/v1/messages?beta=true" + +def one(i): + h = dict(headers) + h["X-Claude-Code-Agent-Id"] = "agent-%d" % (i % 2) + host, port = target.split(":") + c = http.client.HTTPConnection(host, int(port), timeout=120) + if mode == "praxis": + h["X-Cortex-Token"] = token + url = path + else: + url = "http://" + origin + path + c.request("POST", url, body=bodies[i % 2], headers=h) + r = c.getresponse() + r.read() + c.close() + return r.status + +with concurrent.futures.ThreadPoolExecutor(conc) as ex: + codes = list(ex.map(one, range(n))) +bad = [c for c in codes if c != 200] +if bad: + sys.exit("%d of %d requests did not answer 200: %s" % (len(bad), n, sorted(set(bad)))) +EOF + +origin_port=$(free_port) +ctype="text/event-stream; charset=utf-8" +case "$response" in *.json) ctype="application/json" ;; esac +if [ -f "$fixture/response.headers" ]; then + ct=$(grep -i '^content-type:' "$fixture/response.headers" | head -1 | cut -d: -f2- | sed 's/^ *//' || true) + [ -n "$ct" ] && ctype=$ct +fi +python3 "$tmp/origin.py" "$origin_port" "$response" "$ctype" & +pids="$pids $!" + +token="measure-$$" +echo "$token" >"$tmp/token" + +results="" + +# run MODE: one scratch Cortex (and in praxis mode one gateway), N requests, numbers. +run() { + mode=$1 + dir="$tmp/$mode" + mkdir -p "$dir/home" + proxy=$(free_port) + api=$(free_port) + health=$(free_port) + trans=$(free_port) + stats=$(free_port) + praxis_block="" + [ "$mode" = praxis ] && praxis_block="praxis: + enabled: true" + cat >"$dir/config.yaml" <"$dir/cortex.log" 2>&1 & + cortex_pid=$! + pids="$pids $cortex_pid" + wait_http "http://127.0.0.1:$health/healthz" 200 + sample_rss "$cortex_pid" "$dir/cortex.rss" & + + target="127.0.0.1:$proxy" + gateway_pid="" + if [ "$mode" = praxis ]; then + gw=$(free_port) + gw_metrics=$(free_port) + cat >"$dir/gateway.yaml" <"$dir/gateway.log" 2>&1 & + gateway_pid=$! + pids="$pids $gateway_pid" + # Only the gateway answers a request without the token with 403. + wait_http "http://127.0.0.1:$gw/" 403 + sample_rss "$gateway_pid" "$dir/gateway.rss" & + target="127.0.0.1:$gw" + fi + + echo "$mode: $n requests, $conc in flight" >&2 + start=$(date +%s) + python3 "$tmp/client.py" "$mode" "$target" "127.0.0.1:$origin_port" "$n" "$conc" "$fixture" "$token" + secs=$(($(date +%s) - start)) + + if [ "$mode" = praxis ]; then + # Each request gives a request row and a response row. Wait until all of them + # arrive, or until no line arrives for 10 s (the emitter dropped the rest). + last=-1 + still=0 + while [ "$still" -lt 100 ]; do + lines=$(curl -s --noproxy '*' "http://127.0.0.1:$api/v1/ingest/stats" | + python3 -c 'import json,sys; print(json.load(sys.stdin).get("ingest_lines_total", 0))' || echo 0) + [ "$lines" -ge $((2 * n)) ] && break + if [ "$lines" = "$last" ]; then still=$((still + 1)); else still=0; last=$lines; fi + sleep 0.1 + done + echo "praxis: $(($(date +%s) - start)) s to ingest; stats: $(curl -s --noproxy '*' "http://127.0.0.1:$api/v1/ingest/stats")" >&2 + echo "praxis: cortex-gateway emitter counters:" >&2 + curl -s --noproxy '*' "http://127.0.0.1:$gw_metrics/metrics" | grep '^cortex_' >&2 || true + fi + sleep 0.5 # one more RSS sample after the last request + + heap=$(curl -s --noproxy '*' "http://127.0.0.1:$stats/debug/pprof/heap?debug=1" | + awk '/^# HeapInuse = / {printf "%.1f", $4 / 1048576}') + row "$mode" cortex "$cortex_pid" "$dir/cortex.rss" "${heap:-n/a}" "$secs" + if [ -n "$gateway_pid" ]; then + row "$mode" cortex-gateway "$gateway_pid" "$dir/gateway.rss" n/a "$secs" + kill "$gateway_pid" + fi + kill "$cortex_pid" + wait "$cortex_pid" 2>/dev/null || true +} + +# row MODE PROCESS PID RSSFILE HEAP SECS: adds one table row. +row() { + cpu=$(ps -o time= -p "$3" | tr -d ' ') + rss=$(awk '{printf "%.1f", $1 / 1024}' "$4" 2>/dev/null || echo n/a) + results="$results$(printf '| %s | %s | %s | %s | %s | %s |' "$1" "$2" "$cpu" "$rss" "$5" "$6") +" +} + +run praxis +run direct + +echo +echo "$n requests, $conc in flight, 7 and 12 MiB request bodies in turn, fixture $(basename "$fixture"):" +echo +echo "| Mode | Process | CPU time | Peak RSS (MiB) | Heap in use (MiB) | Wall (s) |" +echo "|---|---|---|---|---|---|" +printf '%s' "$results" From 6f4b6eafaf1a81e00651c886496b3368037de48a Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 17:31:46 -0500 Subject: [PATCH 18/26] Fix: Drop queued captures before the emitter drops a row The emitter dropped captures before rows only for the line that arrived. When the queue was full of lines with captures, a new row was dropped and the queued captures stayed. A lost response row is lost spend in the ledger and a gap in the timeline. Each queued line now keeps its row without the capture. When a line does not fit, the emitter first drops its own capture. Then it changes queued lines to their rows, newest first, until the line fits. Only then does it drop the line. The byte cap counts the kept rows too, because they are in memory. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- praxis/filters/src/record/emitter.rs | 126 +++++++++++++++++++++------ 1 file changed, 99 insertions(+), 27 deletions(-) diff --git a/praxis/filters/src/record/emitter.rs b/praxis/filters/src/record/emitter.rs index e4760dea5..171cc5bc3 100644 --- a/praxis/filters/src/record/emitter.rs +++ b/praxis/filters/src/record/emitter.rs @@ -37,12 +37,45 @@ pub struct EmitterStats { pub queued_bytes: usize, } +/// A queued line. `row` is the line without its capture, kept so that a later +/// row can take the capture's place in the queue. +struct Entry { + line: Vec, + row: Option>, +} + +impl Entry { + fn size(&self) -> usize { + self.line.len() + self.row.as_ref().map_or(0, Vec::len) + } +} + +/// `bytes` counts each entry's line and its kept row: both are in memory. #[derive(Default)] struct Queue { - lines: VecDeque>, + lines: VecDeque, bytes: usize, } +impl Queue { + /// Replaces queued captures with their rows, newest first, until `need` + /// more bytes fit. Returns how many captures it dropped. + fn drop_captures(&mut self, need: usize) -> u64 { + let mut dropped = 0; + for e in self.lines.iter_mut().rev() { + if self.bytes + need <= MAX_QUEUE_BYTES { + break; + } + if let Some(row) = e.row.take() { + self.bytes -= e.line.len(); + e.line = row; + dropped += 1; + } + } + dropped + } +} + /// Streams lines to `POST /v1/ingest` from its own thread. `submit` never /// blocks: when the queue is full, it drops captures first, then lines. pub struct Emitter { @@ -93,38 +126,55 @@ impl Emitter { e } - /// Queues one line. It never blocks and never fails; a line that does - /// not fit loses its capture first, then is dropped and counted. + /// Queues one line. It never blocks and never fails. A line that does not + /// fit loses its capture first. Then queued lines lose their captures, + /// newest first. Only then is the line dropped and counted. pub fn submit(&self, q: Queued) { let Queued { row, with_capture } = q; - let (mut line, mut fallback) = match with_capture { + let mut entry = match with_capture { Some(c) if c.len() > MAX_LINE => { bump_capture(&self.captures_dropped_line_cap, "line_cap"); - (row, None) + Entry { + line: row, + row: None, + } } - Some(c) => (c, Some(row)), - None => (row, None), + Some(c) => Entry { + line: c, + row: Some(row), + }, + None => Entry { + line: row, + row: None, + }, }; - { + let mut captures_dropped = 0; + let queued = { let mut q = self.queue.lock().unwrap_or_else(|p| p.into_inner()); if q.lines.len() >= MAX_QUEUE_LINES { - drop(q); - bump(&self.rows_dropped, "cortex_rows_dropped_total"); - return; - } - if q.bytes + line.len() > MAX_QUEUE_BYTES - && let Some(r) = fallback.take() - { - line = r; - bump_capture(&self.captures_dropped_queue_bytes, "queue_bytes"); - } - if q.bytes + line.len() > MAX_QUEUE_BYTES { - drop(q); - bump(&self.rows_dropped, "cortex_rows_dropped_total"); - return; + false + } else { + if q.bytes + entry.size() > MAX_QUEUE_BYTES + && let Some(r) = entry.row.take() + { + entry.line = r; + captures_dropped += 1; + } + captures_dropped += q.drop_captures(entry.size()); + let fits = q.bytes + entry.size() <= MAX_QUEUE_BYTES; + if fits { + q.bytes += entry.size(); + q.lines.push_back(entry); + } + fits } - q.bytes += line.len(); - q.lines.push_back(line); + }; + for _ in 0..captures_dropped { + bump_capture(&self.captures_dropped_queue_bytes, "queue_bytes"); + } + if !queued { + bump(&self.rows_dropped, "cortex_rows_dropped_total"); + return; } self.wake.notify_one(); } @@ -146,9 +196,9 @@ impl Emitter { fn pop(&self) -> Option> { let mut q = self.queue.lock().unwrap_or_else(|p| p.into_inner()); - let line = q.lines.pop_front()?; - q.bytes -= line.len(); - Some(line) + let e = q.lines.pop_front()?; + q.bytes -= e.size(); + Some(e.line) } /// Waits for the next line. Cancel-safe: the pop and the return are in @@ -348,6 +398,28 @@ mod tests { assert_eq!(s.queued_lines, 30); } + #[test] + fn queued_captures_make_room_for_a_row() { + let e = Emitter::start(closed_addr(), "t".into()); + for _ in 0..25 { + e.submit(Queued { + row: b"row\n".to_vec(), + with_capture: Some(vec![b'x'; 10 << 20]), + }); + } + assert_eq!(e.stats().captures_dropped_queue_bytes, 0); + // A 15 MiB row with no capture: the newest queued capture gives it room. + e.submit(Queued { + row: vec![b'r'; 15 << 20], + with_capture: None, + }); + let s = e.stats(); + assert_eq!(s.rows_dropped, 0); + assert_eq!(s.captures_dropped_queue_bytes, 1); + assert_eq!(s.queued_lines, 26); + assert!(s.queued_bytes <= MAX_QUEUE_BYTES, "{}", s.queued_bytes); + } + #[test] fn line_limit_drops_whole_lines() { let e = Emitter::start(closed_addr(), "t".into()); From 60a4ca3809f67947293af1b9d9b42ebeaa4f12ea Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 17:31:47 -0500 Subject: [PATCH 19/26] Fix: Remove X-Cortex-Token and X-Cortex-Dest before the upstream The gateway kept both headers for the egress listener of #1721. That listener is not here yet, so a gateway that routes to an origin sent its token to that origin. Anyone with the token can write rows into POST /v1/ingest. One list, HOP_HEADERS, now holds all twelve hop headers. The upstream and the request capture get none of them. #1721 keeps the two headers again when its listener lands. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- praxis/filters/src/filters/attribution.rs | 4 +-- praxis/filters/src/record/attribution.rs | 31 ++++------------------- praxis/filters/src/record/row.rs | 4 +-- praxis/server/tests/gateway.rs | 6 ++--- 4 files changed, 11 insertions(+), 34 deletions(-) diff --git a/praxis/filters/src/filters/attribution.rs b/praxis/filters/src/filters/attribution.rs index 3be719320..1ac6c4c0d 100644 --- a/praxis/filters/src/filters/attribution.rs +++ b/praxis/filters/src/filters/attribution.rs @@ -10,7 +10,7 @@ use praxis_filter::{FilterAction, FilterError, HttpFilter, HttpFilterContext, Re use serde::Deserialize; use crate::record::attribution::{ - ATTRIBUTION_HEADER, Attribution, TOKEN_HEADER, UPSTREAM_REMOVE, decode, token_matches, + ATTRIBUTION_HEADER, Attribution, HOP_HEADERS, TOKEN_HEADER, decode, token_matches, }; /// Set on a request with a valid token, unless it is a probe. `cortex_observe` @@ -77,7 +77,7 @@ impl HttpFilter for CortexAttribution { ctx.extensions.insert(TokenValid(attribution)); } ctx.request_headers_to_remove.extend( - UPSTREAM_REMOVE + HOP_HEADERS .iter() .filter_map(|n| HeaderName::from_bytes(n.as_bytes()).ok()), ); diff --git a/praxis/filters/src/record/attribution.rs b/praxis/filters/src/record/attribution.rs index b745fddfa..4c3fe67ed 100644 --- a/praxis/filters/src/record/attribution.rs +++ b/praxis/filters/src/record/attribution.rs @@ -23,9 +23,11 @@ pub struct Attribution { pub const ATTRIBUTION_HEADER: &str = "X-Cortex-Attribution"; pub const TOKEN_HEADER: &str = "X-Cortex-Token"; -/// The hop headers removed before the upstream. `X-Cortex-Dest` and `X-Cortex-Token` -/// stay: the egress listener needs them and removes them itself. -pub const UPSTREAM_REMOVE: [&str; 10] = [ +/// The hop headers. The upstream and the request capture get none of them. +/// `X-Cortex-Token` is the gateway's secret, so it must not reach an origin. +/// #1722 keeps `X-Cortex-Dest` and the token for its egress listener, which +/// removes them itself. +pub const HOP_HEADERS: [&str; 12] = [ "X-Cortex-Agent", "X-Cortex-Proc", "X-Cortex-Client-Proc", @@ -36,20 +38,6 @@ pub const UPSTREAM_REMOVE: [&str; 10] = [ ATTRIBUTION_HEADER, "X-Cortex-Request-Id", "X-Cortex-Probe", -]; - -/// The hop headers a request capture never keeps: all of them. -pub const CAPTURE_REMOVE: [&str; 12] = [ - UPSTREAM_REMOVE[0], - UPSTREAM_REMOVE[1], - UPSTREAM_REMOVE[2], - UPSTREAM_REMOVE[3], - UPSTREAM_REMOVE[4], - UPSTREAM_REMOVE[5], - UPSTREAM_REMOVE[6], - UPSTREAM_REMOVE[7], - UPSTREAM_REMOVE[8], - UPSTREAM_REMOVE[9], "X-Cortex-Dest", TOKEN_HEADER, ]; @@ -124,13 +112,4 @@ mod tests { assert!(!token_matches(Some(b""), b"")); assert!(!token_matches(Some(b"x"), b"")); } - - #[test] - fn hop_lists() { - assert!(!UPSTREAM_REMOVE.contains(&"X-Cortex-Dest")); - assert!(!UPSTREAM_REMOVE.contains(&TOKEN_HEADER)); - for h in UPSTREAM_REMOVE { - assert!(CAPTURE_REMOVE.contains(&h)); - } - } } diff --git a/praxis/filters/src/record/row.rs b/praxis/filters/src/record/row.rs index 93e49a9c0..4a9ea474e 100644 --- a/praxis/filters/src/record/row.rs +++ b/praxis/filters/src/record/row.rs @@ -9,7 +9,7 @@ use chrono::{DateTime, SecondsFormat, Utc}; use http::HeaderMap; use http::header::{CONTENT_ENCODING, CONTENT_LENGTH, HOST, USER_AGENT}; -use super::attribution::{Attribution, CAPTURE_REMOVE}; +use super::attribution::{Attribution, HOP_HEADERS}; use super::client::{ CLAUDE_CODE_AGENT_ID_HEADER, CLAUDE_CODE_PARENT_AGENT_ID_HEADER, Client, parse_user_agent, subagent_header, @@ -301,7 +301,7 @@ impl Exchange { let capture = inference.is_some().then(|| { let mut h = self.headers.clone(); h.remove(HOST); - for n in CAPTURE_REMOVE { + for n in HOP_HEADERS { h.remove(n); } Capture::Request { diff --git a/praxis/server/tests/gateway.rs b/praxis/server/tests/gateway.rs index 1d9d5a78d..a4f3d1d34 100644 --- a/praxis/server/tests/gateway.rs +++ b/praxis/server/tests/gateway.rs @@ -542,10 +542,8 @@ fn hop_headers_removed_custom_kept() { .filter(|n| n.starts_with("x-cortex-")) .collect(); cortex.sort(); - assert_eq!( - cortex, - ["x-cortex-custom", "x-cortex-dest", "x-cortex-token"] - ); + // The token is the gateway's secret: no hop header reaches the origin. + assert_eq!(cortex, ["x-cortex-custom"]); assert_eq!(header(&seen.head, "x-cortex-custom"), Some("kept")); for l in i.take(2) { assert_eq!( From 83244cccf9a5e50816907b42eb9ccd174d47aff1 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 17:31:48 -0500 Subject: [PATCH 20/26] Chore: Remove the unused hyper and http-body-util crates The emitter writes HTTP/1.1 itself, so no file uses these crates. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- praxis/Cargo.lock | 59 --------------------------------------- praxis/filters/Cargo.toml | 2 -- 2 files changed, 61 deletions(-) diff --git a/praxis/Cargo.lock b/praxis/Cargo.lock index c37e7e19e..31ca47e12 100644 --- a/praxis/Cargo.lock +++ b/praxis/Cargo.lock @@ -495,8 +495,6 @@ dependencies = [ "chrono", "flate2", "http", - "http-body-util", - "hyper", "metrics", "praxis-proxy-filter", "serde", @@ -993,29 +991,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - [[package]] name = "httparse" version = "1.10.1" @@ -1037,25 +1012,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "hyper" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" -dependencies = [ - "atomic-waker", - "bytes", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2749,12 +2705,6 @@ dependencies = [ "tracing-serde", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typenum" version = "1.20.1" @@ -2803,15 +2753,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "want" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/praxis/filters/Cargo.toml b/praxis/filters/Cargo.toml index 40794561c..6cb51667b 100644 --- a/praxis/filters/Cargo.toml +++ b/praxis/filters/Cargo.toml @@ -16,8 +16,6 @@ praxis-proxy-filter = { version = "=0.8.0", default-features = false } async-trait = "0.1" bytes = "1" tokio = { version = "1", features = ["rt", "net", "sync", "time", "io-util", "macros"] } -hyper = { version = "1", features = ["client", "http1"] } -http-body-util = "0.1" base64 = "0.22" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } metrics = "0.24" From 5080682a1dda789167c10f9fec40e59d1f136ec0 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 17:40:57 -0500 Subject: [PATCH 21/26] Fix: Refuse praxis mode without sessions, and reuse the loopback guard - Validate refuses praxis.enabled with session.enabled: false. Cortex then has no session store, so it registers no ingest route. The gateway's rows were lost, and no log line told the user. - The ingest routes use httpx.LoopbackOnly, not a copy of its two checks. The routes stay loopback-only also when WithLoopbackOnly is off. - The emitter tests use port 0 as the closed address. A freed ephemeral port can go to a parallel test's serve(), and the drop counts then change. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- core/config/praxis_test.go | 11 +++++++++++ core/config/validate.go | 4 ++++ core/sessionapi/ingest.go | 23 ++--------------------- core/sessionapi/server.go | 5 +++-- praxis/filters/src/record/emitter.rs | 9 ++++----- 5 files changed, 24 insertions(+), 28 deletions(-) diff --git a/core/config/praxis_test.go b/core/config/praxis_test.go index 96e8c5a4e..961af9ffd 100644 --- a/core/config/praxis_test.go +++ b/core/config/praxis_test.go @@ -45,6 +45,17 @@ func TestPraxis_RefusedWithMTLS(t *testing.T) { } } +func TestPraxis_RefusedWithSessionsOff(t *testing.T) { + c := praxisConfig() + c.Listener.BindLoopbackOnly = true + off := false + c.Session.Enabled = &off + err := Validate(c) + if err == nil || !strings.Contains(err.Error(), "session.enabled") { + t.Fatalf("Validate = %v, want an error naming session.enabled", err) + } +} + // praxisConfig is a proxy-sidecar config that passes the listener checks, so // only the praxis rules can refuse it. func praxisConfig() *Config { diff --git a/core/config/validate.go b/core/config/validate.go index d5184cdab..ad33cdc19 100644 --- a/core/config/validate.go +++ b/core/config/validate.go @@ -89,6 +89,10 @@ func validatePraxis(cfg *Config) error { if !cfg.Listener.BindLoopbackOnly { return fmt.Errorf("praxis.enabled: true requires listener.bind_loopback_only: true") } + // The rows reach Cortex only through POST /v1/ingest, which needs the session store. + if !cfg.Session.SessionEnabled() { + return fmt.Errorf("praxis.enabled: true cannot be used with session.enabled: false: Cortex ingests the gateway's rows into the session store") + } return nil } diff --git a/core/sessionapi/ingest.go b/core/sessionapi/ingest.go index d35f01f95..309759c62 100644 --- a/core/sessionapi/ingest.go +++ b/core/sessionapi/ingest.go @@ -17,7 +17,6 @@ import ( "github.com/rossoctl/cortex/core/cost/event" "github.com/rossoctl/cortex/core/cost/pricing" "github.com/rossoctl/cortex/core/cost/settle" - "github.com/rossoctl/cortex/core/listener/httpx" "github.com/rossoctl/cortex/core/model" "github.com/rossoctl/cortex/core/parse/inference" "github.com/rossoctl/cortex/core/redact" @@ -148,24 +147,10 @@ type ingestResponse struct { Incomplete bool `json:"incomplete"` } -// ingestGuard is why r may not reach an ingest route, or "". It is not clearRefusal: those -// texts say "erasing", and its third check is allowClear. -func ingestGuard(r *http.Request) string { - switch { - case !httpx.LoopbackHost(r.Host): - return "ingest is accepted only for a loopback Host (localhost, 127.0.0.1 or [::1])" - case r.Header.Get("Origin") != "": - return "ingest is refused from a browser: the request carries an Origin header" - } - return "" -} - +// handleIngest is registered behind httpx.LoopbackOnly (see New), also when the rest of the +// API is not. func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) { in := s.ingest - if reason := ingestGuard(r); reason != "" { - http.Error(w, reason, http.StatusForbidden) - return - } if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+in.cfg.Token)) != 1 { http.Error(w, "ingest needs the gateway's bearer token", http.StatusUnauthorized) return @@ -323,10 +308,6 @@ func (in *ingester) capture(l ingestLine, ev *model.SessionEvent) *model.Request } func (s *Server) handleIngestStats(w http.ResponseWriter, r *http.Request) { - if reason := ingestGuard(r); reason != "" { - http.Error(w, reason, http.StatusForbidden) - return - } in := s.ingest st := IngestStats{Lines: in.lines.Load(), CapturesDropped: in.capturesDropped.Load(), Refused: map[string]uint64{}} in.refusedMu.Lock() diff --git a/core/sessionapi/server.go b/core/sessionapi/server.go index e9b0a397f..902d3e43d 100644 --- a/core/sessionapi/server.go +++ b/core/sessionapi/server.go @@ -236,8 +236,9 @@ func New(addr string, store *session.Store, opts ...Option) *Server { if s.ingestCfg.Token != "" { s.ingest = newIngester(s.ingestCfg) store.AddRecorder(s.ingest.marks) - mux.HandleFunc("POST /v1/ingest", s.handleIngest) - mux.HandleFunc("GET /v1/ingest/stats", s.handleIngestStats) + // Loopback-only even when WithLoopbackOnly is off: only the local gateway may write rows. + mux.Handle("POST /v1/ingest", httpx.LoopbackOnly(http.HandlerFunc(s.handleIngest))) + mux.Handle("GET /v1/ingest/stats", httpx.LoopbackOnly(http.HandlerFunc(s.handleIngestStats))) } var handler http.Handler = mux diff --git a/praxis/filters/src/record/emitter.rs b/praxis/filters/src/record/emitter.rs index 171cc5bc3..c8e239fbe 100644 --- a/praxis/filters/src/record/emitter.rs +++ b/praxis/filters/src/record/emitter.rs @@ -292,12 +292,11 @@ mod tests { } } - /// An address with nothing listening on it. + /// An address that refuses every connect. Port 0 cannot be listened on, + /// so a parallel test's `serve()` can never take it (a freed ephemeral + /// port can be reused). fn closed_addr() -> SocketAddr { - TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() + SocketAddr::from(([127, 0, 0, 1], 0)) } /// Reads one ingest request: sends its Authorization header, then each From 3e37e8d070e8614d02208119b039560255c487ab Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 18:05:21 -0500 Subject: [PATCH 22/26] Fix: Stop dropping rows when request bodies are large With 7-12 MiB request bodies, cortex-gateway dropped 55-80% of its rows. Cortex ingested about 147 MiB/s on one goroutine, mostly in encoding/json. Each request put about 32 MiB of NDJSON on the wire, because the body went over three times. The emitter queue then filled. - A response row now leaves out the request-only inference fields (messages, tools, toolResults, historyToolCalls, historyThinking). Cortex keeps them from the request row and puts them back, so the stored response row is the same as in direct mode. An erase drops the held fields. - Cortex decodes up to 8 lines of a stream at the same time (at most one per CPU) and files them in stream order. - praxis-measure.sh reads the heap after a GC. The replay parity test now writes response lines without these fields. It fails when Cortex does not put them back. Measured with praxis-measure.sh: 100 requests, 1 in flight: 0 of 200 rows dropped (before: 110), heap after GC 954 MiB (direct: 955 MiB). 200 requests, 4 in flight: 0 of 400 rows dropped (before: 328). Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../forwardproxy/ingest_replay_test.go | 7 + core/sessionapi/erase_marks.go | 15 +- core/sessionapi/ingest.go | 155 +++++++++++++++--- core/sessionapi/ingest_test.go | 109 ++++++++++++ core/sessionapi/testdata/ingest/replay.ndjson | 36 ++-- docs/session-api.md | 10 ++ praxis/filters/src/record/row.rs | 33 +++- scripts/dev/praxis-measure.sh | 5 +- 8 files changed, 328 insertions(+), 42 deletions(-) diff --git a/core/listener/forwardproxy/ingest_replay_test.go b/core/listener/forwardproxy/ingest_replay_test.go index eb61f44ca..c6da4dee6 100644 --- a/core/listener/forwardproxy/ingest_replay_test.go +++ b/core/listener/forwardproxy/ingest_replay_test.go @@ -290,6 +290,13 @@ func replayLines(t *testing.T, rig *replayRig, rec *captures, cases []replayCase } l.Cost = map[string]any{"headers": hdr} } + if ev.Phase == pipeline.SessionResponse && ev.Inference != nil { + // Line format v1: a response row leaves out the request-only fields, and Cortex + // puts them back from the request row. The live session still has them. + inf := *ev.Inference + inf.Messages, inf.Tools, inf.ToolResults, inf.HistoryToolCalls, inf.HistoryThinking = nil, nil, nil, nil, nil + ev.Inference = &inf + } ev.Seq, ev.SessionID, ev.At = 0, "", ev.At.UTC() l.Event = ev b, err := json.Marshal(l) diff --git a/core/sessionapi/erase_marks.go b/core/sessionapi/erase_marks.go index eec337699..769ce46ff 100644 --- a/core/sessionapi/erase_marks.go +++ b/core/sessionapi/erase_marks.go @@ -19,6 +19,9 @@ type eraseMarks struct { now func() time.Time ids map[string]time.Time lastClear time.Time + // forget, when set, is told each erase after mu is released: a session id, or "" for + // a clear. It must not call the store. + forget func(sessionID string) } func newEraseMarks() *eraseMarks { @@ -28,6 +31,13 @@ func newEraseMarks() *eraseMarks { func (m *eraseMarks) Record(string, *model.SessionEvent) {} func (m *eraseMarks) Deleted(sessionID string) { + m.mark(sessionID) + if m.forget != nil { + m.forget(sessionID) + } +} + +func (m *eraseMarks) mark(sessionID string) { m.mu.Lock() defer m.mu.Unlock() now := m.now() @@ -41,9 +51,12 @@ func (m *eraseMarks) Deleted(sessionID string) { func (m *eraseMarks) Cleared() { m.mu.Lock() - defer m.mu.Unlock() m.lastClear = m.now() clear(m.ids) // the clear's mark is later than each of them + m.mu.Unlock() + if m.forget != nil { + m.forget("") + } } // erasedAfter reports whether sid was erased, by a delete or a clear, after startedAt and diff --git a/core/sessionapi/ingest.go b/core/sessionapi/ingest.go index 309759c62..abdcf4782 100644 --- a/core/sessionapi/ingest.go +++ b/core/sessionapi/ingest.go @@ -9,6 +9,7 @@ import ( "io" "log/slog" "net/http" + "runtime" "sync" "sync/atomic" "time" @@ -34,6 +35,9 @@ const ( // requestCaptureTTL is how long a kept request capture waits for its response line. Longer // than any stream: a 60 s window would drop the response capture of every long stream. requestCaptureTTL = time.Hour + // maxIngestDecoders caps how many lines of one stream decode at the same time. Each one + // can hold a 64 MiB line and its decoded form. + maxIngestDecoders = 8 ) // The refusal reasons of ingest_refused_total. @@ -82,6 +86,9 @@ type ingester struct { // the response line arrives. A response capture is kept only for an id here. reqCapsMu sync.Mutex reqCaps map[string]time.Time + // reqInf holds, under reqCapsMu, the request-only inference fields of each request row, + // by request id, until its response line puts them back. + reqInf map[string]heldRequest // streams are the open ingest streams, so Shutdown can end them. streamsMu sync.Mutex @@ -89,8 +96,84 @@ type ingester struct { } func newIngester(cfg Ingest) *ingester { - return &ingester{cfg: cfg, marks: newEraseMarks(), refused: map[string]uint64{}, - reqCaps: map[string]time.Time{}, streams: map[*http.ResponseController]struct{}{}} + in := &ingester{cfg: cfg, marks: newEraseMarks(), refused: map[string]uint64{}, + reqCaps: map[string]time.Time{}, reqInf: map[string]heldRequest{}, + streams: map[*http.ResponseController]struct{}{}} + in.marks.forget = in.forgetRequests + return in +} + +// heldRequest is what a response line needs from its request line. +type heldRequest struct { + sid string + at time.Time + inf model.InferenceExtension +} + +// holdRequest keeps the request-only fields of a request row until its response line. +// Line format v1: a response row leaves them out, because they are most of its bytes and +// the request row carries them already. In direct mode both rows hold them. +func (in *ingester) holdRequest(id, sid string, inf *model.InferenceExtension) { + in.reqCapsMu.Lock() + defer in.reqCapsMu.Unlock() + in.sweepLocked(time.Now()) + in.reqInf[id] = heldRequest{sid: sid, at: time.Now(), inf: model.InferenceExtension{ + Messages: inf.Messages, Tools: inf.Tools, ToolResults: inf.ToolResults, + HistoryToolCalls: inf.HistoryToolCalls, HistoryThinking: inf.HistoryThinking}} +} + +// restoreRequest puts the request-only fields back on a response row. A field the row +// carries is kept. With no request row (the producer dropped it), the row stays as it is. +func (in *ingester) restoreRequest(id string, inf *model.InferenceExtension) { + in.reqCapsMu.Lock() + h, ok := in.reqInf[id] + delete(in.reqInf, id) + in.reqCapsMu.Unlock() + if !ok { + return + } + if inf.Messages == nil { + inf.Messages = h.inf.Messages + } + if inf.Tools == nil { + inf.Tools = h.inf.Tools + } + if inf.ToolResults == nil { + inf.ToolResults = h.inf.ToolResults + } + if inf.HistoryToolCalls == nil { + inf.HistoryToolCalls = h.inf.HistoryToolCalls + } + if inf.HistoryThinking == nil { + inf.HistoryThinking = h.inf.HistoryThinking + } +} + +// sweepLocked drops the held requests and request captures older than requestCaptureTTL: +// their response line is not coming. +func (in *ingester) sweepLocked(now time.Time) { + for id, at := range in.reqCaps { + if now.Sub(at) > requestCaptureTTL { + delete(in.reqCaps, id) + } + } + for id, h := range in.reqInf { + if now.Sub(h.at) > requestCaptureTTL { + delete(in.reqInf, id) + } + } +} + +// forgetRequests drops the held requests of an erased session, or of every session when +// sid is "", so an erase does not leave its prompts in memory. +func (in *ingester) forgetRequests(sid string) { + in.reqCapsMu.Lock() + defer in.reqCapsMu.Unlock() + for id, h := range in.reqInf { + if sid == "" || h.sid == sid { + delete(in.reqInf, id) + } + } } func (in *ingester) refuse(reason string) { @@ -165,6 +248,24 @@ func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) { in.streamsMu.Unlock() }() + // JSON decoding is most of a line's cost, so lines decode in parallel. They are filed in + // order: a response line needs its request line filed first. The channel's size bounds + // the lines held in memory. + decoded := make(chan chan decodedLine, min(runtime.GOMAXPROCS(0), maxIngestDecoders)) + done := make(chan struct{}) + go func() { + defer close(done) + for ch := range decoded { + if d := <-ch; d.refused != "" { + in.refuse(d.refused) + } else { + s.fileIngest(d.line) + } + } + }() + finish := sync.OnceFunc(func() { close(decoded); <-done }) + defer finish() + br := bufio.NewReaderSize(r.Body, 64<<10) for { b, over, err := readIngestLine(br) @@ -172,9 +273,12 @@ func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) { case over: in.refuse(refusedLineCap) case len(bytes.TrimSpace(b)) > 0: - s.ingestOne(b) + ch := make(chan decodedLine, 1) + decoded <- ch + go func() { ch <- decodeIngestLine(b) }() } if errors.Is(err, io.EOF) { + finish() // the producer sees 204 only after every line is filed w.WriteHeader(http.StatusNoContent) return } @@ -208,24 +312,32 @@ func readIngestLine(br *bufio.Reader) (line []byte, over bool, err error) { } } -// ingestOne files one line, or counts why it was refused. -func (s *Server) ingestOne(b []byte) { - in := s.ingest +// decodedLine is a decoded line, or why it was refused. +type decodedLine struct { + line ingestLine + refused string +} + +// decodeIngestLine decodes one line and checks its version, schema and op. It touches no +// shared state, so lines decode in parallel. +func decodeIngestLine(b []byte) decodedLine { var l ingestLine switch err := json.Unmarshal(b, &l); { case err != nil: - in.refuse(refusedBadJSON) - return + return decodedLine{refused: refusedBadJSON} case l.V != ingestLineVersion: - in.refuse(refusedVersion) - return + return decodedLine{refused: refusedVersion} case l.SchemaVersion != SchemaVersion: - in.refuse(refusedSchema) - return + return decodedLine{refused: refusedSchema} case l.Op != "append": - in.refuse(refusedOp) - return + return decodedLine{refused: refusedOp} } + return decodedLine{line: l} +} + +// fileIngest files one decoded line. Lines are filed one at a time, in stream order. +func (s *Server) fileIngest(l ingestLine) { + in := s.ingest sid := attribution.FiledSession(l.Session, s.store.ActiveSession) // #1722 releases the line's tunnel join here. if in.marks.erasedAfter(sid, l.StartedAt) { @@ -239,6 +351,14 @@ func (s *Server) ingestOne(b []byte) { delete(ev.Plugins, event.PluginName) ev.Seq, ev.SessionID, ev.At = 0, "", time.Now() + if ev.Inference != nil { + switch ev.Phase { + case model.SessionRequest: + in.holdRequest(l.RequestID, sid, ev.Inference) + case model.SessionResponse: + in.restoreRequest(l.RequestID, ev.Inference) + } + } if ev.Phase == model.SessionResponse { s.settleIngested(&ev, l.Cost) } @@ -292,12 +412,7 @@ func (in *ingester) capture(l ingestLine, ev *model.SessionEvent) *model.Request return &model.RequestCapture{Original: c.Original, Forwarded: c.Forwarded, Header: redact.Headers(c.Header), Query: redact.Query(c.Query)} } - now := time.Now() - for id, at := range in.reqCaps { - if now.Sub(at) > requestCaptureTTL { - delete(in.reqCaps, id) - } - } + in.sweepLocked(time.Now()) if _, ok := in.reqCaps[l.RequestID]; !ok { return nil } diff --git a/core/sessionapi/ingest_test.go b/core/sessionapi/ingest_test.go index cb1a8ae77..c3ba82701 100644 --- a/core/sessionapi/ingest_test.go +++ b/core/sessionapi/ingest_test.go @@ -468,3 +468,112 @@ func TestIngest_ShutdownEndsStream(t *testing.T) { t.Errorf("Shutdown took %v, want under 1s", d) } } + +func TestIngest_ResponseGetsRequestFields(t *testing.T) { + r := newIngestRig(t) + reqInf := map[string]any{"model": "claude-x", + "messages": []map[string]any{{"role": "user", "content": "hi"}}, + "tools": []map[string]any{{"name": "Read"}}, + "toolResults": []map[string]any{{"toolUseId": "t1", "content": "ok"}}, + "historyToolCalls": []map[string]any{{"id": "t1", "name": "Read"}}, + "historyThinking": map[string]any{"blocks": 1}} + reqEv := map[string]any{"phase": "request", "requestId": "r1", "inference": reqInf} + r.post(t, nil, + line(t, map[string]any{"event": reqEv}), + // A response row of format v1: the request-only fields are left out. + line(t, map[string]any{"event": responseEvent(map[string]any{"inference": map[string]any{"model": "claude-x", "outputTokens": 5}})}), + // A response row that carries its own messages keeps them. + line(t, map[string]any{"requestId": "r2", "event": map[string]any{"phase": "request", "requestId": "r2", "inference": reqInf}}), + line(t, map[string]any{"requestId": "r2", "event": responseEvent(map[string]any{"requestId": "r2", "inference": map[string]any{"model": "claude-x", + "messages": []map[string]any{{"role": "user", "content": "own"}}}})}), + // A response with no request row stays as it is. + line(t, map[string]any{"requestId": "r3", "event": responseEvent(map[string]any{"requestId": "r3"})}), + ) + evs := r.events("s1") + if len(evs) != 5 { + t.Fatalf("got %d rows, want 5", len(evs)) + } + req, res := evs[0].Inference, evs[1].Inference + if res.OutputTokens != 5 { + t.Errorf("the response's own field was lost: outputTokens = %d", res.OutputTokens) + } + for name, eq := range map[string]bool{ + "messages": len(res.Messages) == 1 && res.Messages[0].Content == req.Messages[0].Content, + "tools": len(res.Tools) == 1 && res.Tools[0].Name == "Read", + "toolResults": len(res.ToolResults) == 1, + "historyToolCalls": len(res.HistoryToolCalls) == 1, + "historyThinking": res.HistoryThinking != nil && res.HistoryThinking.Blocks == 1, + } { + if !eq { + t.Errorf("response %s was not restored from the request row", name) + } + } + if m := evs[3].Inference.Messages; len(m) != 1 || m[0].Content != "own" { + t.Errorf("a response's own messages were replaced: %+v", m) + } + if m := evs[4].Inference.Messages; m != nil { + t.Errorf("a response with no request row got messages: %+v", m) + } + r.srv.ingest.reqCapsMu.Lock() + n := len(r.srv.ingest.reqInf) + r.srv.ingest.reqCapsMu.Unlock() + if n != 0 { + t.Errorf("%d held requests remain after their responses", n) + } +} + +func TestIngest_EraseForgetsHeldRequests(t *testing.T) { + held := func(r *ingestRig) int { + r.srv.ingest.reqCapsMu.Lock() + defer r.srv.ingest.reqCapsMu.Unlock() + return len(r.srv.ingest.reqInf) + } + for name, erase := range map[string]func(r *ingestRig){ + "delete": func(r *ingestRig) { del(t, r.base+"/v1/sessions/s1") }, + "clear": func(r *ingestRig) { del(t, r.base+"/v1/sessions") }, + } { + t.Run(name, func(t *testing.T) { + r := newIngestRig(t) + r.post(t, nil, line(t, nil), line(t, map[string]any{"requestId": "r2", "session": "s2"})) + if held(r) != 2 { + t.Fatalf("held %d requests, want 2", held(r)) + } + erase(r) + want := 1 // s2 stays + if name == "clear" { + want = 0 + } + if got := held(r); got != want { + t.Errorf("after the erase, %d held requests, want %d", got, want) + } + }) + } +} + +func TestIngest_ParallelDecodeKeepsOrder(t *testing.T) { + r := newIngestRig(t) + var lines [][]byte + for i := range 200 { + id := fmt.Sprintf("r%03d", i) + // Uneven sizes, so a later line can finish decoding first. + pad := strings.Repeat("x", (i%7)*20000) + lines = append(lines, line(t, map[string]any{"requestId": id, "event": map[string]any{"phase": "request", + "requestId": id, "inference": map[string]any{"model": "m", "messages": []map[string]any{{"role": "user", "content": pad}}}}})) + } + lines = append(lines, []byte("{not json")) + if got := r.post(t, nil, lines...); got != http.StatusNoContent { + t.Fatalf("status %d", got) + } + evs := r.events("s1") + if len(evs) != 200 { + t.Fatalf("got %d rows, want 200", len(evs)) + } + for i, ev := range evs { + if want := fmt.Sprintf("r%03d", i); ev.RequestID != want { + t.Fatalf("row %d is %s, want %s: lines were filed out of order", i, ev.RequestID, want) + } + } + if st := r.stats(t); st.Lines != 200 || st.Refused["bad_json"] != 1 { + t.Errorf("stats = %+v", st) + } +} diff --git a/core/sessionapi/testdata/ingest/replay.ndjson b/core/sessionapi/testdata/ingest/replay.ndjson index bca868b46..2c5c08f6e 100644 --- a/core/sessionapi/testdata/ingest/replay.ndjson +++ b/core/sessionapi/testdata/ingest/replay.ndjson @@ -1,18 +1,18 @@ -{"v":1,"schemaVersion":1,"op":"append","requestId":"1-1f0f83","startedAt":"2026-11-09T21:09:06.542345Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.542345Z","direction":"outbound","phase":"request","requestId":"1-1f0f83","inference":{"model":"claude-sonnet-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1\nsystem text 2","contentBytes":212},{"role":"user","content":"user text 1","contentBytes":75},{"role":"user","content":"user text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7\nuser text 8\nuser text 9\nuser text 10\nuser text 11\nuser text 12\nuser text 13\nuser text 14\nuser text 15\nuser text 16\nuser text 17\nuser text 18\nuser text 19\nuser text 20\nuser text 21\nuser text 22\nuser text 23\nuser text 24\nuser text 25\nuser text 26\nuser text 27\nuser text 28\nuser text 29\nuser text 30","contentBytes":1169}],"maxTokens":64,"thinkingType":"disabled","anthropicBeta":"claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-sonnet-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":1816,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNSIsIm1heF90b2tlbnMiOjY0LCJzeXN0ZW0iOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6IngtYW50aHJvcGljLWJpbGxpbmctaGVhZGVyOiBjY192ZXJzaW9uPTIuMS4yOTUuMDAwOyBjY19lbnRyeXBvaW50PWNsaTsifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDEiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCAyIn1dLCJtZXNzYWdlcyI6W3sicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMSIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgOCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDkifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxMCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDExIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMTIifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxMyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDE0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMTUifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxNiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDE3In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMTgifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxOSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDIwIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjEifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAyMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDIzIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjQifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAyNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDI2In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjciLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjgiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjkifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAzMCJ9XX1dLCJzdG9wX3NlcXVlbmNlcyI6WyI8L3N0b3A+Il0sInRoaW5raW5nIjp7InR5cGUiOiJkaXNhYmxlZCJ9LCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In19Cg==","header":{"Anthropic-Beta":["claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14"],"Anthropic-Version":["2023-06-01"],"Content-Length":["1816"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"1-1f0f83","startedAt":"2026-11-09T21:09:06.543352Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.543352Z","direction":"outbound","phase":"response","requestId":"1-1f0f83","inference":{"model":"claude-sonnet-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1\nsystem text 2","contentBytes":212},{"role":"user","content":"user text 1","contentBytes":75},{"role":"user","content":"user text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7\nuser text 8\nuser text 9\nuser text 10\nuser text 11\nuser text 12\nuser text 13\nuser text 14\nuser text 15\nuser text 16\nuser text 17\nuser text 18\nuser text 19\nuser text 20\nuser text 21\nuser text 22\nuser text 23\nuser text 24\nuser text 25\nuser text 26\nuser text 27\nuser text 28\nuser text 29\nuser text 30","contentBytes":1169}],"maxTokens":64,"thinkingType":"disabled","anthropicBeta":"claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-sonnet-5_response","path":"/v1/messages"}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true}},"statusCode":502,"error":{"kind":"backend_error","code":"502"},"host":"api.example.com","durationMs":1,"ttfbMs":1,"bytesDown":107,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":502,"header":{"Content-Length":["107"],"Content-Type":["text/html"],"X-Litellm-Response-Cost":["0.0123"]},"body":"PGh0bWw+PGJvZHk+PGgxPjUwMiBCYWQgR2F0ZXdheTwvaDE+ClRoZSBzZXJ2ZXIgcmV0dXJuZWQgYW4gaW52YWxpZCBvciBpbmNvbXBsZXRlIHJlc3BvbnNlLgo8L2JvZHk+PC9odG1sPgo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/html","X-Litellm-Response-Cost":"0.0123"}}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"2-734a92","startedAt":"2026-11-09T21:09:06.54391Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.54391Z","direction":"outbound","phase":"request","requestId":"2-734a92","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 2\nsystem text 3","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":297},{"role":"system","content":"system text 1","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":1594,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3IiwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XX0seyJyb2xlIjoic3lzdGVtIiwiY29udGVudCI6InN5c3RlbSB0ZXh0IDEifV0sInN5c3RlbSI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoieC1hbnRocm9waWMtYmlsbGluZy1oZWFkZXI6IGNjX3ZlcnNpb249Mi4xLjI5NS4wMDA7IGNjX2VudHJ5cG9pbnQ9Y2xpOyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgMiIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDMiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dLCJ0b29scyI6W3sibmFtZSI6IlNraWxsIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJza2lsbCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImFyZ3MiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsic2tpbGwiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0seyJuYW1lIjoibWNwX19leGFtcGxlX190b29sXzEiLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7Im51bV9yZXN1bHRzIjp7ImRlZmF1bHQiOjEsImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsIm1heGltdW0iOjEwLCJtaW5pbXVtIjoxLCJ0eXBlIjoiaW50ZWdlciJ9LCJxdWVyeSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJxdWVyeSJdfX1dLCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In0sIm1heF90b2tlbnMiOjEyODAwMCwidGhpbmtpbmciOnsidHlwZSI6ImFkYXB0aXZlIn0sIm91dHB1dF9jb25maWciOnsiZWZmb3J0IjoiaGlnaCJ9LCJzdHJlYW0iOnRydWV9Cg==","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24"],"Anthropic-Version":["2023-06-01"],"Content-Length":["1594"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"2-734a92","startedAt":"2026-11-09T21:09:06.544826Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.544826Z","direction":"outbound","phase":"response","requestId":"2-734a92","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 2\nsystem text 3","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":297},{"role":"system","content":"system text 1","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","responseThinking":{"blocks":1,"signatureBytes":12},"agentRole":"main","streamedResponse":true,"finishReason":"tool_use","toolCalls":[{"id":"toolu_fixture_01","name":"Skill","arguments":"{\"skill\": \"value\"}"},{"id":"toolu_fixture_02","name":"Skill","arguments":"{\"skill\": \"value\"}"}],"promptTokens":42987,"completionTokens":239,"totalTokens":43226,"inputTokens":12566,"cacheReadTokens":17449,"cacheWriteTokens":12972,"outputTokens":239,"reasoningTokens":140,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.288498,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"prompt_usd":0.283001,"output_usd":0.005497,"tiers":{"input":0.087962,"cache_write":0.142692,"cache_read":0.052347,"output":0.005497}},"litellm-budget-track":{"cost_usd":0.288498,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"prompt_usd":0.283001,"output_usd":0.005497,"tiers":{"input":0.087962,"cache_write":0.142692,"cache_read":0.052347,"output":0.005497}}},"statusCode":200,"host":"api.example.com","bytesDown":3377,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["3377"],"Content-Type":["text/event-stream"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDEyNTY2LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMTI5NzIsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE3NDQ5LCAiY2FjaGVfY3JlYXRpb24iOiB7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOiAxMjk3MiwgImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOiAwfSwgIm91dHB1dF90b2tlbnMiOiA2LCAic2VydmljZV90aWVyIjogInN0YW5kYXJkIn19fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RhcnQiLCAiaW5kZXgiOiAwLCAiY29udGVudF9ibG9jayI6IHsidHlwZSI6ICJ0aGlua2luZyIsICJ0aGlua2luZyI6ICIiLCAic2lnbmF0dXJlIjogIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAwLCAiZGVsdGEiOiB7InR5cGUiOiAidGhpbmtpbmdfZGVsdGEiLCAidGhpbmtpbmciOiAiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDAsICJkZWx0YSI6IHsidHlwZSI6ICJzaWduYXR1cmVfZGVsdGEiLCAic2lnbmF0dXJlIjogImMybG5ibUYwZFhKbCJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RvcApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdG9wIiwgImluZGV4IjogMH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0YXJ0CmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0YXJ0IiwgImluZGV4IjogMSwgImNvbnRlbnRfYmxvY2siOiB7InR5cGUiOiAidG9vbF91c2UiLCAiaWQiOiAidG9vbHVfZml4dHVyZV8wMSIsICJuYW1lIjogIlNraWxsIiwgImlucHV0Ijoge319fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAie1wic2tpIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJsbFwiOiAifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIlwidmFsdSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiZVwifSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RvcApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdG9wIiwgImluZGV4IjogMX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0YXJ0CmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0YXJ0IiwgImluZGV4IjogMiwgImNvbnRlbnRfYmxvY2siOiB7InR5cGUiOiAidG9vbF91c2UiLCAiaWQiOiAidG9vbHVfZml4dHVyZV8wMiIsICJuYW1lIjogIlNraWxsIiwgImlucHV0Ijoge319fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAyLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAie1wic2sifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMiwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogImlsbFwiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDIsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICI6IFwidiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAyLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiYWx1ZSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAyLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiXCJ9In19CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0b3AiLCAiaW5kZXgiOiAyfQoKZXZlbnQ6IG1lc3NhZ2VfZGVsdGEKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2VfZGVsdGEiLCAiZGVsdGEiOiB7InN0b3BfcmVhc29uIjogInRvb2xfdXNlIiwgInN0b3Bfc2VxdWVuY2UiOiBudWxsLCAic3RvcF9kZXRhaWxzIjogbnVsbCwgImNvbnRhaW5lciI6IG51bGx9LCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDEyNTY2LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMTI5NzIsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE3NDQ5LCAib3V0cHV0X3Rva2VucyI6IDIzOSwgIm91dHB1dF90b2tlbnNfZGV0YWlscyI6IHsidGhpbmtpbmdfdG9rZW5zIjogMTQwfSwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMTI5NzIsICJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjogMH19fQoKZXZlbnQ6IG1lc3NhZ2Vfc3RvcApkYXRhOiB7InR5cGUiOiAibWVzc2FnZV9zdG9wIiwgInVzYWdlIjogeyJpbnB1dF90b2tlbnMiOiAxMjU2NiwgIm91dHB1dF90b2tlbnMiOiAyMzksICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE3NDQ5LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMTI5NzJ9fQo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream"}}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"3-15274c","startedAt":"2026-11-09T21:09:06.5462Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.5462Z","direction":"outbound","phase":"request","requestId":"3-15274c","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 4\nsystem text 5","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"assistant","content":"assistant text 1","contentBytes":104},{"role":"user","content":"user text 8","contentBytes":13},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","content":"assistant text 2","contentBytes":104},{"role":"user","content":"user text 9","contentBytes":13},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","content":"assistant text 3","contentBytes":224,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":142,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":64000,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\",\"timeout\":1}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":3,"signatureBytes":36},"agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":2663,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3In1dfSx7InJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGhpbmtpbmciLCJ0aGlua2luZyI6IiIsInNpZ25hdHVyZSI6ImMybG5ibUYwZFhKbCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoiYXNzaXN0YW50IHRleHQgMSJ9XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOiJ1c2VyIHRleHQgOCJ9LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAxIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6ImFzc2lzdGFudCB0ZXh0IDIifV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjoidXNlciB0ZXh0IDkifSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMiJ9LHsicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOlt7InR5cGUiOiJ0aGlua2luZyIsInRoaW5raW5nIjoiIiwic2lnbmF0dXJlIjoiYzJsbmJtRjBkWEpsIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAzIn0seyJ0eXBlIjoidG9vbF91c2UiLCJpZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJuYW1lIjoiQmFzaCIsImlucHV0Ijp7ImNvbW1hbmQiOiJ2YWx1ZSIsImRlc2NyaXB0aW9uIjoidmFsdWUiLCJ0aW1lb3V0IjoxfX1dfSx7InJvbGUiOiJ1c2VyIiwiY29udGVudCI6W3sidG9vbF91c2VfaWQiOiJ0b29sdV9maXh0dXJlXzAxIiwidHlwZSI6InRvb2xfcmVzdWx0IiwiY29udGVudCI6InRvb2wgcmVzdWx0IHRleHQgMSIsImlzX2Vycm9yIjpmYWxzZSwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XX0seyJyb2xlIjoic3lzdGVtIiwiY29udGVudCI6InN5c3RlbSB0ZXh0IDMifV0sInN5c3RlbSI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoieC1hbnRocm9waWMtYmlsbGluZy1oZWFkZXI6IGNjX3ZlcnNpb249Mi4xLjI5NS4wMDA7IGNjX2VudHJ5cG9pbnQ9Y2xpOyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgNCIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDUiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dLCJ0b29scyI6W3sibmFtZSI6IkJhc2giLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLmV4YW1wbGUuY29tL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImNvbW1hbmQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJ0aW1lb3V0Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJudW1iZXIifSwiZGVzY3JpcHRpb24iOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJydW5faW5fYmFja2dyb3VuZCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoiYm9vbGVhbiJ9LCJkYW5nZXJvdXNseURpc2FibGVTYW5kYm94Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJib29sZWFuIn19LCJyZXF1aXJlZCI6WyJjb21tYW5kIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LHsibmFtZSI6Im1jcF9fZXhhbXBsZV9fdG9vbF8xIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJudW1fcmVzdWx0cyI6eyJkZWZhdWx0IjoxLCJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJtYXhpbXVtIjoxMCwibWluaW11bSI6MSwidHlwZSI6ImludGVnZXIifSwicXVlcnkiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsicXVlcnkiXX19XSwibWV0YWRhdGEiOnsidXNlcl9pZCI6IntcImRldmljZV9pZFwiOiBcImZpeHR1cmUtZGV2aWNlXCIsIFwiYWNjb3VudF91dWlkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxXCIsIFwic2Vzc2lvbl9pZFwiOiBcIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMFwifSJ9LCJtYXhfdG9rZW5zIjo2NDAwMCwidGhpbmtpbmciOnsidHlwZSI6ImFkYXB0aXZlIn0sIm91dHB1dF9jb25maWciOnsiZWZmb3J0IjoiaGlnaCJ9LCJzdHJlYW0iOmZhbHNlfQo=","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24"],"Anthropic-Version":["2023-06-01"],"Content-Length":["2663"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"3-15274c","startedAt":"2026-11-09T21:09:06.546561Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.546561Z","direction":"outbound","phase":"response","requestId":"3-15274c","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 4\nsystem text 5","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"assistant","content":"assistant text 1","contentBytes":104},{"role":"user","content":"user text 8","contentBytes":13},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","content":"assistant text 2","contentBytes":104},{"role":"user","content":"user text 9","contentBytes":13},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","content":"assistant text 3","contentBytes":224,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":142,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":64000,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\",\"timeout\":1}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":3,"signatureBytes":36},"responseThinking":{"blocks":1,"signatureBytes":12},"agentRole":"main","completion":"assistant text 4","finishReason":"tool_use","toolCalls":[{"id":"toolu_fixture_02","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\",\"timeout\":1}"}],"promptTokens":578214,"completionTokens":1282,"totalTokens":579496,"inputTokens":26,"cacheReadTokens":578188,"outputTokens":1282,"reasoningTokens":435,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages"}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":1.734746,"output_usd":0.029486,"tiers":{"input":0.000182,"cache_read":1.734564,"output":0.029486}},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":1.734746,"output_usd":0.029486,"tiers":{"input":0.000182,"cache_read":1.734564,"output":0.029486}}},"statusCode":200,"host":"api.example.com","bytesDown":668,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["668"],"Content-Type":["application/json"],"X-Litellm-Response-Cost":["0.0123"]},"body":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsImlkIjoibXNnX2ZpeHR1cmUiLCJ0eXBlIjoibWVzc2FnZSIsInJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGhpbmtpbmciLCJ0aGlua2luZyI6IiIsInNpZ25hdHVyZSI6ImMybG5ibUYwZFhKbCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoiYXNzaXN0YW50IHRleHQgNCJ9LHsidHlwZSI6InRvb2xfdXNlIiwiaWQiOiJ0b29sdV9maXh0dXJlXzAyIiwibmFtZSI6IkJhc2giLCJpbnB1dCI6eyJjb21tYW5kIjoidmFsdWUiLCJkZXNjcmlwdGlvbiI6InZhbHVlIiwidGltZW91dCI6MX19XSwiY29udGFpbmVyIjpudWxsLCJzdG9wX3JlYXNvbiI6InRvb2xfdXNlIiwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjI2LCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjo1NzgxODgsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjAsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjoxMjgyLCJvdXRwdXRfdG9rZW5zX2RldGFpbHMiOnsidGhpbmtpbmdfdG9rZW5zIjo0MzV9LCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCJ9fQo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"application/json","X-Litellm-Response-Cost":"0.0123"}}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"4-414743","startedAt":"2026-11-09T21:09:06.546825Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.546825Z","direction":"outbound","phase":"request","requestId":"4-414743","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 3\nsystem text 4","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"assistant","content":"assistant text 1","contentBytes":104},{"role":"user","content":"user text 8","contentBytes":13},{"role":"assistant","content":"assistant text 2","contentBytes":212,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","content":"assistant text 3","contentBytes":104},{"role":"user","content":"user text 9","contentBytes":75},{"role":"system","content":"system text 2","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":3,"signatureBytes":36},"agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":2632,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3In1dfSx7InJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGhpbmtpbmciLCJ0aGlua2luZyI6IiIsInNpZ25hdHVyZSI6ImMybG5ibUYwZFhKbCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoiYXNzaXN0YW50IHRleHQgMSJ9XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOiJ1c2VyIHRleHQgOCJ9LHsicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOlt7InR5cGUiOiJ0aGlua2luZyIsInRoaW5raW5nIjoiIiwic2lnbmF0dXJlIjoiYzJsbmJtRjBkWEpsIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAyIn0seyJ0eXBlIjoidG9vbF91c2UiLCJpZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJuYW1lIjoiQmFzaCIsImlucHV0Ijp7ImNvbW1hbmQiOiJ2YWx1ZSIsImRlc2NyaXB0aW9uIjoidmFsdWUifX1dfSx7InJvbGUiOiJ1c2VyIiwiY29udGVudCI6W3sidG9vbF91c2VfaWQiOiJ0b29sdV9maXh0dXJlXzAxIiwidHlwZSI6InRvb2xfcmVzdWx0IiwiY29udGVudCI6InRvb2wgcmVzdWx0IHRleHQgMSIsImlzX2Vycm9yIjpmYWxzZX1dfSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMSJ9LHsicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOlt7InR5cGUiOiJ0aGlua2luZyIsInRoaW5raW5nIjoiIiwic2lnbmF0dXJlIjoiYzJsbmJtRjBkWEpsIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAzIn1dfSx7InJvbGUiOiJ1c2VyIiwiY29udGVudCI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDkiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dfSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMiJ9XSwic3lzdGVtIjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ4LWFudGhyb3BpYy1iaWxsaW5nLWhlYWRlcjogY2NfdmVyc2lvbj0yLjEuMjk1LjAwMDsgY2NfZW50cnlwb2ludD1jbGk7In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCAzIiwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgNCIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV0sInRvb2xzIjpbeyJuYW1lIjoiQmFzaCIsImRlc2NyaXB0aW9uIjoiVG9vbCBkZXNjcmlwdGlvbi4iLCJpbnB1dF9zY2hlbWEiOnsiJHNjaGVtYSI6Imh0dHBzOi8vanNvbi1zY2hlbWEuZXhhbXBsZS5jb20vc2NoZW1hIiwidHlwZSI6Im9iamVjdCIsInByb3BlcnRpZXMiOnsiY29tbWFuZCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sInRpbWVvdXQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6Im51bWJlciJ9LCJkZXNjcmlwdGlvbiI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sInJ1bl9pbl9iYWNrZ3JvdW5kIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJib29sZWFuIn0sImRhbmdlcm91c2x5RGlzYWJsZVNhbmRib3giOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbImNvbW1hbmQiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0seyJuYW1lIjoibWNwX19leGFtcGxlX190b29sXzEiLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7Im51bV9yZXN1bHRzIjp7ImRlZmF1bHQiOjEsImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsIm1heGltdW0iOjEwLCJtaW5pbXVtIjoxLCJ0eXBlIjoiaW50ZWdlciJ9LCJxdWVyeSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJxdWVyeSJdfX1dLCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In0sIm1heF90b2tlbnMiOjEyODAwMCwidGhpbmtpbmciOnsidHlwZSI6ImFkYXB0aXZlIn0sIm91dHB1dF9jb25maWciOnsiZWZmb3J0IjoiaGlnaCJ9LCJzdHJlYW0iOnRydWV9Cg==","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24"],"Anthropic-Version":["2023-06-01"],"Content-Length":["2632"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"4-414743","startedAt":"2026-11-09T21:09:06.54709Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.54709Z","direction":"outbound","phase":"response","requestId":"4-414743","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 3\nsystem text 4","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"assistant","content":"assistant text 1","contentBytes":104},{"role":"user","content":"user text 8","contentBytes":13},{"role":"assistant","content":"assistant text 2","contentBytes":212,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","content":"assistant text 3","contentBytes":104},{"role":"user","content":"user text 9","contentBytes":75},{"role":"system","content":"system text 2","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":3,"signatureBytes":36},"responseThinking":{"blocks":1},"agentRole":"main","streamedResponse":true,"promptTokens":289421,"totalTokens":289421,"inputTokens":27,"cacheReadTokens":286566,"cacheWriteTokens":2828,"presentKinds":15,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.890995,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"incomplete":true,"incomplete_reason":"output-uncounted","prompt_usd":0.890995,"tiers":{"input":0.000189,"cache_write":0.031108,"cache_read":0.859698}},"litellm-budget-track":{"cost_usd":0.890995,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"incomplete":true,"incomplete_reason":"output-uncounted","prompt_usd":0.890995,"tiers":{"input":0.000189,"cache_write":0.031108,"cache_read":0.859698}}},"statusCode":200,"host":"api.example.com","bytesDown":643,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["643"],"Content-Type":["text/event-stream"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDI3LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMjgyOCwgImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjogMjg2NTY2LCAiY2FjaGVfY3JlYXRpb24iOiB7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOiAyODI4LCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9LCAib3V0cHV0X3Rva2VucyI6IDgsICJzZXJ2aWNlX3RpZXIiOiAic3RhbmRhcmQifX19CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdGFydCIsICJpbmRleCI6IDAsICJjb250ZW50X2Jsb2NrIjogeyJ0eXBlIjogInRoaW5raW5nIiwgInRoaW5raW5nIjogIiIsICJzaWduYXR1cmUiOiAiIn19Cg==","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream"}}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"5-47c3d1","startedAt":"2026-11-09T21:09:06.547563Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.547563Z","direction":"outbound","phase":"request","requestId":"5-47c3d1","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 4\nsystem text 5","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":232,"toolCallNames":"Skill, Skill","toolUseIds":"toolu_fixture_01, toolu_fixture_02"},{"role":"user","content":"user text 8\nuser text 9","contentBytes":249,"toolUseIds":"toolu_fixture_01, toolu_fixture_02"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":170,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_03"},{"role":"user","contentBytes":142,"toolUseIds":"toolu_fixture_03"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"AskUserQuestion","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"questions":{"description":"Parameter description.","minItems":1,"maxItems":4,"type":"array","items":{"type":"object","properties":{"question":{"description":"Parameter description.","type":"string"},"header":{"description":"Parameter description.","type":"string"},"options":{"description":"Parameter description.","minItems":2,"maxItems":4,"type":"array","items":{"type":"object","properties":{"label":{"description":"Parameter description.","type":"string"},"description":{"description":"Parameter description.","type":"string"},"preview":{"description":"Parameter description.","type":"string"}},"required":["label","description"],"additionalProperties":false}},"multiSelect":{"description":"Parameter description.","default":false,"type":"boolean"}},"required":["question","header","options","multiSelect"],"additionalProperties":false}},"answers":{"description":"Parameter description.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"annotations":{"description":"Parameter description.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"preview":{"description":"Parameter description.","type":"string"},"notes":{"description":"Parameter description.","type":"string"}},"additionalProperties":false}},"metadata":{"description":"Parameter description.","type":"object","properties":{"source":{"description":"Parameter description.","type":"string"}},"additionalProperties":false}},"required":["questions"],"additionalProperties":false}},{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"},{"toolUseId":"toolu_fixture_03","content":"tool result text 3"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Skill","arguments":"{\"skill\":\"value\"}"},{"id":"toolu_fixture_02","name":"Skill","arguments":"{\"skill\":\"value\"}"},{"id":"toolu_fixture_03","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":2,"signatureBytes":24},"agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":4825,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3In1dfSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMSJ9LHsicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOlt7InR5cGUiOiJ0aGlua2luZyIsInRoaW5raW5nIjoiIiwic2lnbmF0dXJlIjoiYzJsbmJtRjBkWEpsIn0seyJ0eXBlIjoidG9vbF91c2UiLCJpZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJuYW1lIjoiU2tpbGwiLCJpbnB1dCI6eyJza2lsbCI6InZhbHVlIn19LHsidHlwZSI6InRvb2xfdXNlIiwiaWQiOiJ0b29sdV9maXh0dXJlXzAyIiwibmFtZSI6IlNraWxsIiwiaW5wdXQiOnsic2tpbGwiOiJ2YWx1ZSJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidG9vbF9yZXN1bHQiLCJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAxIn0seyJ0eXBlIjoidG9vbF9yZXN1bHQiLCJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDIiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAyIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgOCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDkifV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAyIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfZml4dHVyZV8wMyIsIm5hbWUiOiJCYXNoIiwiaW5wdXQiOnsiY29tbWFuZCI6InZhbHVlIiwiZGVzY3JpcHRpb24iOiJ2YWx1ZSJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDMiLCJ0eXBlIjoidG9vbF9yZXN1bHQiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAzIiwiaXNfZXJyb3IiOmZhbHNlLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dfSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMyJ9XSwic3lzdGVtIjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ4LWFudGhyb3BpYy1iaWxsaW5nLWhlYWRlcjogY2NfdmVyc2lvbj0yLjEuMjk1LjAwMDsgY2NfZW50cnlwb2ludD1jbGk7In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCA0IiwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgNSIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV0sInRvb2xzIjpbeyJuYW1lIjoiQXNrVXNlclF1ZXN0aW9uIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJxdWVzdGlvbnMiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwibWluSXRlbXMiOjEsIm1heEl0ZW1zIjo0LCJ0eXBlIjoiYXJyYXkiLCJpdGVtcyI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJxdWVzdGlvbiI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImhlYWRlciI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sIm9wdGlvbnMiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwibWluSXRlbXMiOjIsIm1heEl0ZW1zIjo0LCJ0eXBlIjoiYXJyYXkiLCJpdGVtcyI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJsYWJlbCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImRlc2NyaXB0aW9uIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifSwicHJldmlldyI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJsYWJlbCIsImRlc2NyaXB0aW9uIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LCJtdWx0aVNlbGVjdCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJkZWZhdWx0IjpmYWxzZSwidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInF1ZXN0aW9uIiwiaGVhZGVyIiwib3B0aW9ucyIsIm11bHRpU2VsZWN0Il0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LCJhbnN3ZXJzIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0eU5hbWVzIjp7InR5cGUiOiJzdHJpbmcifSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOnsidHlwZSI6InN0cmluZyJ9fSwiYW5ub3RhdGlvbnMiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6Im9iamVjdCIsInByb3BlcnR5TmFtZXMiOnsidHlwZSI6InN0cmluZyJ9LCJhZGRpdGlvbmFsUHJvcGVydGllcyI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJwcmV2aWV3Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifSwibm90ZXMiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0sIm1ldGFkYXRhIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7InNvdXJjZSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2V9fSwicmVxdWlyZWQiOlsicXVlc3Rpb25zIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LHsibmFtZSI6IkJhc2giLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLmV4YW1wbGUuY29tL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImNvbW1hbmQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJ0aW1lb3V0Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJudW1iZXIifSwiZGVzY3JpcHRpb24iOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJydW5faW5fYmFja2dyb3VuZCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoiYm9vbGVhbiJ9LCJkYW5nZXJvdXNseURpc2FibGVTYW5kYm94Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJib29sZWFuIn19LCJyZXF1aXJlZCI6WyJjb21tYW5kIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LHsibmFtZSI6IlNraWxsIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJza2lsbCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImFyZ3MiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsic2tpbGwiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0seyJuYW1lIjoibWNwX19leGFtcGxlX190b29sXzEiLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7Im51bV9yZXN1bHRzIjp7ImRlZmF1bHQiOjEsImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsIm1heGltdW0iOjEwLCJtaW5pbXVtIjoxLCJ0eXBlIjoiaW50ZWdlciJ9LCJxdWVyeSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJxdWVyeSJdfX1dLCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In0sIm1heF90b2tlbnMiOjEyODAwMCwidGhpbmtpbmciOnsidHlwZSI6ImFkYXB0aXZlIn0sIm91dHB1dF9jb25maWciOnsiZWZmb3J0IjoiaGlnaCJ9LCJzdHJlYW0iOnRydWV9Cg==","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24"],"Anthropic-Version":["2023-06-01"],"Content-Length":["4825"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"5-47c3d1","startedAt":"2026-11-09T21:09:06.549023Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.549023Z","direction":"outbound","phase":"response","requestId":"5-47c3d1","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 4\nsystem text 5","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":232,"toolCallNames":"Skill, Skill","toolUseIds":"toolu_fixture_01, toolu_fixture_02"},{"role":"user","content":"user text 8\nuser text 9","contentBytes":249,"toolUseIds":"toolu_fixture_01, toolu_fixture_02"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":170,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_03"},{"role":"user","contentBytes":142,"toolUseIds":"toolu_fixture_03"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"AskUserQuestion","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"questions":{"description":"Parameter description.","minItems":1,"maxItems":4,"type":"array","items":{"type":"object","properties":{"question":{"description":"Parameter description.","type":"string"},"header":{"description":"Parameter description.","type":"string"},"options":{"description":"Parameter description.","minItems":2,"maxItems":4,"type":"array","items":{"type":"object","properties":{"label":{"description":"Parameter description.","type":"string"},"description":{"description":"Parameter description.","type":"string"},"preview":{"description":"Parameter description.","type":"string"}},"required":["label","description"],"additionalProperties":false}},"multiSelect":{"description":"Parameter description.","default":false,"type":"boolean"}},"required":["question","header","options","multiSelect"],"additionalProperties":false}},"answers":{"description":"Parameter description.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"annotations":{"description":"Parameter description.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"preview":{"description":"Parameter description.","type":"string"},"notes":{"description":"Parameter description.","type":"string"}},"additionalProperties":false}},"metadata":{"description":"Parameter description.","type":"object","properties":{"source":{"description":"Parameter description.","type":"string"}},"additionalProperties":false}},"required":["questions"],"additionalProperties":false}},{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"servedModel":"gateway/claude-opus-5-5","toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"},{"toolUseId":"toolu_fixture_03","content":"tool result text 3"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Skill","arguments":"{\"skill\":\"value\"}"},{"id":"toolu_fixture_02","name":"Skill","arguments":"{\"skill\":\"value\"}"},{"id":"toolu_fixture_03","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":2,"signatureBytes":24},"responseThinking":{"blocks":1,"signatureBytes":12},"agentRole":"main","streamedResponse":true,"finishReason":"tool_use","toolCalls":[{"id":"toolu_fixture_04","name":"Bash","arguments":"{\"command\": \"value\", \"description\": \"value\"}"}],"promptTokens":50279,"completionTokens":275,"totalTokens":50554,"inputTokens":26,"cacheReadTokens":49941,"cacheWriteTokens":312,"outputTokens":275,"reasoningTokens":58,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.153437,"output_usd":0.006325,"tiers":{"input":0.000182,"cache_write":0.003432,"cache_read":0.149823,"output":0.006325}},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.153437,"output_usd":0.006325,"tiers":{"input":0.000182,"cache_write":0.003432,"cache_read":0.149823,"output":0.006325}}},"statusCode":200,"host":"api.example.com","durationMs":1,"bytesDown":8595,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["8595"],"Content-Type":["text/event-stream; charset=utf-8"],"X-Litellm-Model-Name":["gateway/claude-opus-5-5"],"X-Litellm-Response-Cost":["0.0123"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDI2LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMzEyLCAiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiOiA0OTk0MSwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMzEyLCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9LCAib3V0cHV0X3Rva2VucyI6IDgsICJzZXJ2aWNlX3RpZXIiOiAic3RhbmRhcmQifX19CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdGFydCIsICJpbmRleCI6IDAsICJjb250ZW50X2Jsb2NrIjogeyJ0eXBlIjogInRoaW5raW5nIiwgInRoaW5raW5nIjogIiIsICJzaWduYXR1cmUiOiAiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDAsICJkZWx0YSI6IHsidHlwZSI6ICJ0aGlua2luZ19kZWx0YSIsICJ0aGlua2luZyI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMCwgImRlbHRhIjogeyJ0eXBlIjogInNpZ25hdHVyZV9kZWx0YSIsICJzaWduYXR1cmUiOiAiYzJsbmJtRjBkWEpsIn19CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0b3AiLCAiaW5kZXgiOiAwfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RhcnQiLCAiaW5kZXgiOiAxLCAiY29udGVudF9ibG9jayI6IHsidHlwZSI6ICJ0b29sX3VzZSIsICJpZCI6ICJ0b29sdV9maXh0dXJlXzA0IiwgIm5hbWUiOiAiQmFzaCIsICJpbnB1dCI6IHt9fX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogInsifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIlwiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJjIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJvIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJtIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJtIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJhIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJuIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJkIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJcIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiOiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiICJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiXCIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogInYifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogImEifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogImwifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogInUifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogImUifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIlwiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICIsIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICIgIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJcIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiZCJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiZSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAicyJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiYyJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiciJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiaSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAicCJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAidCJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiaSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAibyJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAibiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiXCIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIjoifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIiAifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIlwiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJ2In19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJhIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJsIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJ1In19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJlIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJcIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAifSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RvcCIsICJpbmRleCI6IDF9CgpldmVudDogbWVzc2FnZV9kZWx0YQpkYXRhOiB7InR5cGUiOiAibWVzc2FnZV9kZWx0YSIsICJkZWx0YSI6IHsic3RvcF9yZWFzb24iOiAidG9vbF91c2UiLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAiY29udGFpbmVyIjogbnVsbH0sICJ1c2FnZSI6IHsiaW5wdXRfdG9rZW5zIjogMjYsICJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOiAzMTIsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDQ5OTQxLCAib3V0cHV0X3Rva2VucyI6IDI3NSwgIm91dHB1dF90b2tlbnNfZGV0YWlscyI6IHsidGhpbmtpbmdfdG9rZW5zIjogNTh9LCAiY2FjaGVfY3JlYXRpb24iOiB7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOiAzMTIsICJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjogMH19fQoKZXZlbnQ6IG1lc3NhZ2Vfc3RvcApkYXRhOiB7InR5cGUiOiAibWVzc2FnZV9zdG9wIiwgInVzYWdlIjogeyJpbnB1dF90b2tlbnMiOiAyNiwgIm91dHB1dF90b2tlbnMiOiAyNzUsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDQ5OTQxLCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMzEyfX0K","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream; charset=utf-8","X-Litellm-Model-Name":"gateway/claude-opus-5-5","X-Litellm-Response-Cost":"0.0123"}}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"6-719c65","startedAt":"2026-11-09T21:09:06.550421Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.550421Z","direction":"outbound","phase":"request","requestId":"6-719c65","inference":{"model":"claude-sonnet-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1\nsystem text 2","contentBytes":212},{"role":"user","content":"user text 1","contentBytes":75},{"role":"user","content":"user text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6","contentBytes":260}],"maxTokens":64,"thinkingType":"disabled","anthropicBeta":"claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-sonnet-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":907,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNSIsIm1heF90b2tlbnMiOjY0LCJzeXN0ZW0iOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6IngtYW50aHJvcGljLWJpbGxpbmctaGVhZGVyOiBjY192ZXJzaW9uPTIuMS4yOTUuMDAwOyBjY19lbnRyeXBvaW50PWNsaTsifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDEiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCAyIn1dLCJtZXNzYWdlcyI6W3sicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMSIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNCIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA1In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNiJ9XX1dLCJzdG9wX3NlcXVlbmNlcyI6WyI8L3N0b3A+Il0sInRoaW5raW5nIjp7InR5cGUiOiJkaXNhYmxlZCJ9LCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In19Cg==","header":{"Anthropic-Beta":["claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14"],"Anthropic-Version":["2023-06-01"],"Content-Length":["907"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"6-719c65","startedAt":"2026-11-09T21:09:06.550708Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.550708Z","direction":"outbound","phase":"response","requestId":"6-719c65","inference":{"model":"claude-sonnet-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1\nsystem text 2","contentBytes":212},{"role":"user","content":"user text 1","contentBytes":75},{"role":"user","content":"user text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6","contentBytes":260}],"maxTokens":64,"servedModel":"gateway/claude-sonnet-5","thinkingType":"disabled","anthropicBeta":"claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14","agentRole":"main","completion":"assistant text 1","finishReason":"stop_sequence","promptTokens":60457,"completionTokens":9,"totalTokens":60466,"inputTokens":90,"cacheWriteTokens":60367,"outputTokens":9,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-sonnet-5_response","path":"/v1/messages"}]},"plugins":{"cost":{"cost_usd":0.664874,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"prompt_usd":0.664667,"output_usd":0.000207,"tiers":{"input":0.00063,"cache_write":0.664037,"output":0.000207}},"litellm-budget-track":{"cost_usd":0.664874,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"prompt_usd":0.664667,"output_usd":0.000207,"tiers":{"input":0.00063,"cache_write":0.664037,"output":0.000207}}},"statusCode":200,"host":"api.example.com","bytesDown":495,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["495"],"Content-Type":["application/json"],"X-Litellm-Model-Name":["gateway/claude-sonnet-5"]},"body":"eyJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNSIsImlkIjoibXNnX2ZpeHR1cmUiLCJ0eXBlIjoibWVzc2FnZSIsInJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAxIn1dLCJjb250YWluZXIiOm51bGwsInN0b3BfcmVhc29uIjoic3RvcF9zZXF1ZW5jZSIsInN0b3Bfc2VxdWVuY2UiOiI8L3N0b3A+Iiwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjkwLCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjYwMzY3LCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MCwiY2FjaGVfY3JlYXRpb24iOnsiZXBoZW1lcmFsXzVtX2lucHV0X3Rva2VucyI6NjAzNjcsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjo5LCJvdXRwdXRfdG9rZW5zX2RldGFpbHMiOnsidGhpbmtpbmdfdG9rZW5zIjowfSwic2VydmljZV90aWVyIjoic3RhbmRhcmQifX0K","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"application/json","X-Litellm-Model-Name":"gateway/claude-sonnet-5"}}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"7-1155ed","startedAt":"2026-11-09T21:09:06.550963Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.550963Z","direction":"outbound","phase":"request","requestId":"7-1155ed","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1","contentBytes":173},{"role":"user","content":"user text 1","contentBytes":13}],"maxTokens":3072,"anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":461,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1heF90b2tlbnMiOjMwNzIsInN5c3RlbSI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoieC1hbnRocm9waWMtYmlsbGluZy1oZWFkZXI6IGNjX3ZlcnNpb249Mi4xLjI5NS4wMDA7IGNjX2VudHJ5cG9pbnQ9Y2xpOyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgMSIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV0sIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOiJ1c2VyIHRleHQgMSJ9XSwibWV0YWRhdGEiOnsidXNlcl9pZCI6IntcImRldmljZV9pZFwiOiBcImZpeHR1cmUtZGV2aWNlXCIsIFwiYWNjb3VudF91dWlkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxXCIsIFwic2Vzc2lvbl9pZFwiOiBcIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMFwifSJ9fQo=","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14"],"Anthropic-Version":["2023-06-01"],"Content-Length":["461"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"7-1155ed","startedAt":"2026-11-09T21:09:06.551227Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.551227Z","direction":"outbound","phase":"response","requestId":"7-1155ed","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1","contentBytes":173},{"role":"user","content":"user text 1","contentBytes":13}],"maxTokens":3072,"anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14","agentRole":"main","completion":"assistant text 1","finishReason":"end_turn","promptTokens":6407,"completionTokens":62,"totalTokens":6469,"inputTokens":173,"cacheReadTokens":6234,"outputTokens":62,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages"}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.019913,"output_usd":0.001426,"tiers":{"input":0.001211,"cache_read":0.018702,"output":0.001426}},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.019913,"output_usd":0.001426,"tiers":{"input":0.001211,"cache_read":0.018702,"output":0.001426}}},"statusCode":200,"host":"api.example.com","bytesDown":482,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["482"],"Content-Type":["application/json"],"X-Litellm-Response-Cost":["0.0123"]},"body":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsImlkIjoibXNnX2ZpeHR1cmUiLCJ0eXBlIjoibWVzc2FnZSIsInJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAxIn1dLCJjb250YWluZXIiOm51bGwsInN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsLCJzdG9wX2RldGFpbHMiOm51bGwsInVzYWdlIjp7ImlucHV0X3Rva2VucyI6MTczLCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjo2MjM0LCJjYWNoZV9jcmVhdGlvbiI6eyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjowLCJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjowfSwib3V0cHV0X3Rva2VucyI6NjIsIm91dHB1dF90b2tlbnNfZGV0YWlscyI6eyJ0aGlua2luZ190b2tlbnMiOjB9LCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCJ9fQo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"application/json","X-Litellm-Response-Cost":"0.0123"}}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"8-f978f3","startedAt":"2026-11-09T21:09:06.55156Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.55156Z","direction":"outbound","phase":"request","requestId":"8-f978f3","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli; cc_is_subagent=true;\nsystem text 4\nsystem text 5","contentBytes":270},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5","contentBytes":186},{"role":"assistant","contentBytes":182,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":170,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_02"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_02"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":184,"toolCallNames":"Skill","toolUseIds":"toolu_fixture_03"},{"role":"user","content":"user text 6\nuser text 7","contentBytes":162,"toolUseIds":"toolu_fixture_03"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}},{"name":"SubagentHandback","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"message":{"description":"Parameter description.","type":"string"}},"required":["message"],"additionalProperties":false}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"},{"toolUseId":"toolu_fixture_03","content":"tool result text 3"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\",\"timeout\":1}"},{"id":"toolu_fixture_02","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"},{"id":"toolu_fixture_03","name":"Skill","arguments":"{\"skill\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01","historyThinking":{"blocks":3,"signatureBytes":36},"agentRole":"subagent","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":3527,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9XX0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfZml4dHVyZV8wMSIsIm5hbWUiOiJCYXNoIiwiaW5wdXQiOnsiY29tbWFuZCI6InZhbHVlIiwiZGVzY3JpcHRpb24iOiJ2YWx1ZSIsInRpbWVvdXQiOjF9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJ0eXBlIjoidG9vbF9yZXN1bHQiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAxIiwiaXNfZXJyb3IiOmZhbHNlfV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAxIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfZml4dHVyZV8wMiIsIm5hbWUiOiJCYXNoIiwiaW5wdXQiOnsiY29tbWFuZCI6InZhbHVlIiwiZGVzY3JpcHRpb24iOiJ2YWx1ZSJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDIiLCJ0eXBlIjoidG9vbF9yZXN1bHQiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAyIiwiaXNfZXJyb3IiOmZhbHNlfV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAyIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfZml4dHVyZV8wMyIsIm5hbWUiOiJTa2lsbCIsImlucHV0Ijp7InNraWxsIjoidmFsdWUifSwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0b29sX3Jlc3VsdCIsInRvb2xfdXNlX2lkIjoidG9vbHVfZml4dHVyZV8wMyIsImNvbnRlbnQiOiJ0b29sIHJlc3VsdCB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA2In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNyJ9XX0seyJyb2xlIjoic3lzdGVtIiwiY29udGVudCI6InN5c3RlbSB0ZXh0IDMifV0sInN5c3RlbSI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoieC1hbnRocm9waWMtYmlsbGluZy1oZWFkZXI6IGNjX3ZlcnNpb249Mi4xLjI5NS4wMDA7IGNjX2VudHJ5cG9pbnQ9Y2xpOyBjY19pc19zdWJhZ2VudD10cnVlOyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgNCIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDUiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dLCJ0b29scyI6W3sibmFtZSI6IkJhc2giLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLmV4YW1wbGUuY29tL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImNvbW1hbmQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJ0aW1lb3V0Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJudW1iZXIifSwiZGVzY3JpcHRpb24iOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJydW5faW5fYmFja2dyb3VuZCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoiYm9vbGVhbiJ9LCJkYW5nZXJvdXNseURpc2FibGVTYW5kYm94Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJib29sZWFuIn19LCJyZXF1aXJlZCI6WyJjb21tYW5kIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LHsibmFtZSI6IlNraWxsIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJza2lsbCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImFyZ3MiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsic2tpbGwiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0seyJuYW1lIjoibWNwX19leGFtcGxlX190b29sXzEiLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7Im51bV9yZXN1bHRzIjp7ImRlZmF1bHQiOjEsImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsIm1heGltdW0iOjEwLCJtaW5pbXVtIjoxLCJ0eXBlIjoiaW50ZWdlciJ9LCJxdWVyeSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJxdWVyeSJdfX0seyJuYW1lIjoiU3ViYWdlbnRIYW5kYmFjayIsImRlc2NyaXB0aW9uIjoiVG9vbCBkZXNjcmlwdGlvbi4iLCJpbnB1dF9zY2hlbWEiOnsiJHNjaGVtYSI6Imh0dHBzOi8vanNvbi1zY2hlbWEuZXhhbXBsZS5jb20vc2NoZW1hIiwidHlwZSI6Im9iamVjdCIsInByb3BlcnRpZXMiOnsibWVzc2FnZSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJtZXNzYWdlIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19XSwibWV0YWRhdGEiOnsidXNlcl9pZCI6IntcImRldmljZV9pZFwiOiBcImZpeHR1cmUtZGV2aWNlXCIsIFwiYWNjb3VudF91dWlkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxXCIsIFwic2Vzc2lvbl9pZFwiOiBcIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMFwifSJ9LCJtYXhfdG9rZW5zIjoxMjgwMDAsInRoaW5raW5nIjp7InR5cGUiOiJhZGFwdGl2ZSJ9LCJvdXRwdXRfY29uZmlnIjp7ImVmZm9ydCI6ImhpZ2gifSwic3RyZWFtIjp0cnVlfQo=","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01"],"Anthropic-Version":["2023-06-01"],"Content-Length":["3527"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"8-f978f3","startedAt":"2026-11-09T21:09:06.551977Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.551977Z","direction":"outbound","phase":"response","requestId":"8-f978f3","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli; cc_is_subagent=true;\nsystem text 4\nsystem text 5","contentBytes":270},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5","contentBytes":186},{"role":"assistant","contentBytes":182,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":170,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_02"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_02"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":184,"toolCallNames":"Skill","toolUseIds":"toolu_fixture_03"},{"role":"user","content":"user text 6\nuser text 7","contentBytes":162,"toolUseIds":"toolu_fixture_03"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}},{"name":"SubagentHandback","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"message":{"description":"Parameter description.","type":"string"}},"required":["message"],"additionalProperties":false}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"},{"toolUseId":"toolu_fixture_03","content":"tool result text 3"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\",\"timeout\":1}"},{"id":"toolu_fixture_02","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"},{"id":"toolu_fixture_03","name":"Skill","arguments":"{\"skill\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01","historyThinking":{"blocks":3,"signatureBytes":36},"responseThinking":{"blocks":1,"signatureBytes":12},"agentRole":"subagent","streamedResponse":true,"toolCalls":[{"id":"toolu_fixture_04","name":"SubagentHandback"}],"promptTokens":227618,"totalTokens":227618,"inputTokens":5678,"cacheReadTokens":221800,"cacheWriteTokens":140,"presentKinds":15,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.706686,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"incomplete":true,"incomplete_reason":"output-uncounted","prompt_usd":0.706686,"tiers":{"input":0.039746,"cache_write":0.00154,"cache_read":0.6654}},"litellm-budget-track":{"cost_usd":0.706686,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"incomplete":true,"incomplete_reason":"output-uncounted","prompt_usd":0.706686,"tiers":{"input":0.039746,"cache_write":0.00154,"cache_read":0.6654}}},"statusCode":200,"host":"api.example.com","bytesDown":1388,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["1388"],"Content-Type":["text/event-stream"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDU2NzgsICJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOiAxNDAsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDIyMTgwMCwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMTQwLCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9LCAib3V0cHV0X3Rva2VucyI6IDYsICJzZXJ2aWNlX3RpZXIiOiAic3RhbmRhcmQifX19CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdGFydCIsICJpbmRleCI6IDAsICJjb250ZW50X2Jsb2NrIjogeyJ0eXBlIjogInRoaW5raW5nIiwgInRoaW5raW5nIjogIiIsICJzaWduYXR1cmUiOiAiIn19CgpldmVudDogcGluZwpkYXRhOiB7InR5cGUiOiAicGluZyJ9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDAsICJkZWx0YSI6IHsidHlwZSI6ICJ0aGlua2luZ19kZWx0YSIsICJ0aGlua2luZyI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMCwgImRlbHRhIjogeyJ0eXBlIjogInNpZ25hdHVyZV9kZWx0YSIsICJzaWduYXR1cmUiOiAiYzJsbmJtRjBkWEpsIn19CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0b3AiLCAiaW5kZXgiOiAwfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RhcnQiLCAiaW5kZXgiOiAxLCAiY29udGVudF9ibG9jayI6IHsidHlwZSI6ICJ0b29sX3VzZSIsICJpZCI6ICJ0b29sdV9maXh0dXJlXzA0IiwgIm5hbWUiOiAiU3ViYWdlbnRIYW5kYmFjayIsICJpbnB1dCI6IHt9fX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIiJ9fQoKZXZlbnQ6IHBpbmcKZGF0YTogeyJ0eXBlIjogInBpbmcifQo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream"}}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"9-29b9ca","startedAt":"2026-11-09T21:09:06.552359Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.552359Z","direction":"outbound","phase":"request","requestId":"9-29b9ca","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli; cc_is_subagent=true;\nsystem text 4\nsystem text 5","contentBytes":270},{"role":"user","content":"user text 1\nuser text 2\nuser text 3","contentBytes":112},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":109,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":146,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_02"},{"role":"user","content":"user text 4","contentBytes":142,"toolUseIds":"toolu_fixture_02"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"},{"id":"toolu_fixture_02","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01","agentRole":"subagent","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":2379,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAxIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRvb2xfdXNlIiwiaWQiOiJ0b29sdV9maXh0dXJlXzAxIiwibmFtZSI6IkJhc2giLCJpbnB1dCI6eyJjb21tYW5kIjoidmFsdWUiLCJkZXNjcmlwdGlvbiI6InZhbHVlIn19XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InRvb2xfdXNlX2lkIjoidG9vbHVfZml4dHVyZV8wMSIsInR5cGUiOiJ0b29sX3Jlc3VsdCIsImNvbnRlbnQiOiJ0b29sIHJlc3VsdCB0ZXh0IDEiLCJpc19lcnJvciI6ZmFsc2V9XX0seyJyb2xlIjoic3lzdGVtIiwiY29udGVudCI6InN5c3RlbSB0ZXh0IDIifSx7InJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidG9vbF91c2UiLCJpZCI6InRvb2x1X2ZpeHR1cmVfMDIiLCJuYW1lIjoiQmFzaCIsImlucHV0Ijp7ImNvbW1hbmQiOiJ2YWx1ZSIsImRlc2NyaXB0aW9uIjoidmFsdWUifSwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InRvb2xfdXNlX2lkIjoidG9vbHVfZml4dHVyZV8wMiIsInR5cGUiOiJ0b29sX3Jlc3VsdCIsImNvbnRlbnQiOiJ0b29sIHJlc3VsdCB0ZXh0IDIiLCJpc19lcnJvciI6ZmFsc2V9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDQifV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAzIn1dLCJzeXN0ZW0iOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6IngtYW50aHJvcGljLWJpbGxpbmctaGVhZGVyOiBjY192ZXJzaW9uPTIuMS4yOTUuMDAwOyBjY19lbnRyeXBvaW50PWNsaTsgY2NfaXNfc3ViYWdlbnQ9dHJ1ZTsifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDQiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCA1IiwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XSwidG9vbHMiOlt7Im5hbWUiOiJCYXNoIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJjb21tYW5kIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifSwidGltZW91dCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoibnVtYmVyIn0sImRlc2NyaXB0aW9uIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifSwicnVuX2luX2JhY2tncm91bmQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6ImJvb2xlYW4ifSwiZGFuZ2Vyb3VzbHlEaXNhYmxlU2FuZGJveCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoiYm9vbGVhbiJ9fSwicmVxdWlyZWQiOlsiY29tbWFuZCJdLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2V9fSx7Im5hbWUiOiJtY3BfX2V4YW1wbGVfX3Rvb2xfMSIsImRlc2NyaXB0aW9uIjoiVG9vbCBkZXNjcmlwdGlvbi4iLCJpbnB1dF9zY2hlbWEiOnsidHlwZSI6Im9iamVjdCIsInByb3BlcnRpZXMiOnsibnVtX3Jlc3VsdHMiOnsiZGVmYXVsdCI6MSwiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwibWF4aW11bSI6MTAsIm1pbmltdW0iOjEsInR5cGUiOiJpbnRlZ2VyIn0sInF1ZXJ5Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifX0sInJlcXVpcmVkIjpbInF1ZXJ5Il19fV0sIm1ldGFkYXRhIjp7InVzZXJfaWQiOiJ7XCJkZXZpY2VfaWRcIjogXCJmaXh0dXJlLWRldmljZVwiLCBcImFjY291bnRfdXVpZFwiOiBcIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMVwiLCBcInNlc3Npb25faWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDBcIn0ifSwibWF4X3Rva2VucyI6MTI4MDAwLCJ0aGlua2luZyI6eyJ0eXBlIjoiYWRhcHRpdmUifSwib3V0cHV0X2NvbmZpZyI6eyJlZmZvcnQiOiJoaWdoIn0sInN0cmVhbSI6dHJ1ZX0K","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01"],"Anthropic-Version":["2023-06-01"],"Content-Length":["2379"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} -{"v":1,"schemaVersion":1,"op":"append","requestId":"9-29b9ca","startedAt":"2026-11-09T21:09:06.55276Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T21:09:06.55276Z","direction":"outbound","phase":"response","requestId":"9-29b9ca","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli; cc_is_subagent=true;\nsystem text 4\nsystem text 5","contentBytes":270},{"role":"user","content":"user text 1\nuser text 2\nuser text 3","contentBytes":112},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":109,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":146,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_02"},{"role":"user","content":"user text 4","contentBytes":142,"toolUseIds":"toolu_fixture_02"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"},{"id":"toolu_fixture_02","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01","agentRole":"subagent","streamedResponse":true,"completion":"word word word ","finishReason":"end_turn","promptTokens":29877,"completionTokens":14,"totalTokens":29891,"inputTokens":11093,"cacheReadTokens":18525,"cacheWriteTokens":259,"outputTokens":14,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.136075,"output_usd":0.000322,"tiers":{"input":0.077651,"cache_write":0.002849,"cache_read":0.055575,"output":0.000322}},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.136075,"output_usd":0.000322,"tiers":{"input":0.077651,"cache_write":0.002849,"cache_read":0.055575,"output":0.000322}}},"statusCode":200,"host":"api.example.com","bytesDown":1671,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["1671"],"Content-Type":["text/event-stream"],"X-Litellm-Response-Cost":["0.0123"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDExMDkzLCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMjU5LCAiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiOiAxODUyNSwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMjU5LCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9LCAib3V0cHV0X3Rva2VucyI6IDIsICJzZXJ2aWNlX3RpZXIiOiAic3RhbmRhcmQifX19CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdGFydCIsICJpbmRleCI6IDAsICJjb250ZW50X2Jsb2NrIjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMCwgImRlbHRhIjogeyJ0eXBlIjogInRleHRfZGVsdGEiLCAidGV4dCI6ICJ3b3JkICJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAwLCAiZGVsdGEiOiB7InR5cGUiOiAidGV4dF9kZWx0YSIsICJ0ZXh0IjogIndvcmQgIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDAsICJkZWx0YSI6IHsidHlwZSI6ICJ0ZXh0X2RlbHRhIiwgInRleHQiOiAid29yZCAifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RvcCIsICJpbmRleCI6IDB9CgpldmVudDogbWVzc2FnZV9kZWx0YQpkYXRhOiB7InR5cGUiOiAibWVzc2FnZV9kZWx0YSIsICJkZWx0YSI6IHsic3RvcF9yZWFzb24iOiAiZW5kX3R1cm4iLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAiY29udGFpbmVyIjogbnVsbH0sICJ1c2FnZSI6IHsiaW5wdXRfdG9rZW5zIjogMTEwOTMsICJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOiAyNTksICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE4NTI1LCAib3V0cHV0X3Rva2VucyI6IDE0LCAib3V0cHV0X3Rva2Vuc19kZXRhaWxzIjogeyJ0aGlua2luZ190b2tlbnMiOiAwfSwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMjU5LCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9fX0KCmV2ZW50OiBtZXNzYWdlX3N0b3AKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RvcCIsICJ1c2FnZSI6IHsiaW5wdXRfdG9rZW5zIjogMTEwOTMsICJvdXRwdXRfdG9rZW5zIjogMTQsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE4NTI1LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMjU5fX0K","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream","X-Litellm-Response-Cost":"0.0123"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"1-fde521","startedAt":"2026-11-09T22:54:43.18601Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.18601Z","direction":"outbound","phase":"request","requestId":"1-fde521","inference":{"model":"claude-sonnet-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1\nsystem text 2","contentBytes":212},{"role":"user","content":"user text 1","contentBytes":75},{"role":"user","content":"user text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7\nuser text 8\nuser text 9\nuser text 10\nuser text 11\nuser text 12\nuser text 13\nuser text 14\nuser text 15\nuser text 16\nuser text 17\nuser text 18\nuser text 19\nuser text 20\nuser text 21\nuser text 22\nuser text 23\nuser text 24\nuser text 25\nuser text 26\nuser text 27\nuser text 28\nuser text 29\nuser text 30","contentBytes":1169}],"maxTokens":64,"thinkingType":"disabled","anthropicBeta":"claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-sonnet-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":1816,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNSIsIm1heF90b2tlbnMiOjY0LCJzeXN0ZW0iOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6IngtYW50aHJvcGljLWJpbGxpbmctaGVhZGVyOiBjY192ZXJzaW9uPTIuMS4yOTUuMDAwOyBjY19lbnRyeXBvaW50PWNsaTsifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDEiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCAyIn1dLCJtZXNzYWdlcyI6W3sicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMSIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgOCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDkifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxMCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDExIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMTIifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxMyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDE0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMTUifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxNiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDE3In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMTgifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxOSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDIwIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjEifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAyMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDIzIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjQifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAyNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDI2In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjciLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjgiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMjkifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAzMCJ9XX1dLCJzdG9wX3NlcXVlbmNlcyI6WyI8L3N0b3A+Il0sInRoaW5raW5nIjp7InR5cGUiOiJkaXNhYmxlZCJ9LCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In19Cg==","header":{"Anthropic-Beta":["claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14"],"Anthropic-Version":["2023-06-01"],"Content-Length":["1816"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"1-fde521","startedAt":"2026-11-09T22:54:43.186877Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.186877Z","direction":"outbound","phase":"response","requestId":"1-fde521","inference":{"model":"claude-sonnet-5","maxTokens":64,"thinkingType":"disabled","anthropicBeta":"claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-sonnet-5_response","path":"/v1/messages"}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true}},"statusCode":502,"error":{"kind":"backend_error","code":"502"},"host":"api.example.com","durationMs":1,"ttfbMs":1,"bytesDown":107,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":502,"header":{"Content-Length":["107"],"Content-Type":["text/html"],"X-Litellm-Response-Cost":["0.0123"]},"body":"PGh0bWw+PGJvZHk+PGgxPjUwMiBCYWQgR2F0ZXdheTwvaDE+ClRoZSBzZXJ2ZXIgcmV0dXJuZWQgYW4gaW52YWxpZCBvciBpbmNvbXBsZXRlIHJlc3BvbnNlLgo8L2JvZHk+PC9odG1sPgo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/html","X-Litellm-Response-Cost":"0.0123"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"2-f02265","startedAt":"2026-11-09T22:54:43.187456Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.187456Z","direction":"outbound","phase":"request","requestId":"2-f02265","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 2\nsystem text 3","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":297},{"role":"system","content":"system text 1","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":1594,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3IiwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XX0seyJyb2xlIjoic3lzdGVtIiwiY29udGVudCI6InN5c3RlbSB0ZXh0IDEifV0sInN5c3RlbSI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoieC1hbnRocm9waWMtYmlsbGluZy1oZWFkZXI6IGNjX3ZlcnNpb249Mi4xLjI5NS4wMDA7IGNjX2VudHJ5cG9pbnQ9Y2xpOyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgMiIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDMiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dLCJ0b29scyI6W3sibmFtZSI6IlNraWxsIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJza2lsbCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImFyZ3MiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsic2tpbGwiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0seyJuYW1lIjoibWNwX19leGFtcGxlX190b29sXzEiLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7Im51bV9yZXN1bHRzIjp7ImRlZmF1bHQiOjEsImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsIm1heGltdW0iOjEwLCJtaW5pbXVtIjoxLCJ0eXBlIjoiaW50ZWdlciJ9LCJxdWVyeSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJxdWVyeSJdfX1dLCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In0sIm1heF90b2tlbnMiOjEyODAwMCwidGhpbmtpbmciOnsidHlwZSI6ImFkYXB0aXZlIn0sIm91dHB1dF9jb25maWciOnsiZWZmb3J0IjoiaGlnaCJ9LCJzdHJlYW0iOnRydWV9Cg==","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24"],"Anthropic-Version":["2023-06-01"],"Content-Length":["1594"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"2-f02265","startedAt":"2026-11-09T22:54:43.188395Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.188395Z","direction":"outbound","phase":"response","requestId":"2-f02265","inference":{"model":"claude-opus-5-5","maxTokens":128000,"stream":true,"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","responseThinking":{"blocks":1,"signatureBytes":12},"agentRole":"main","streamedResponse":true,"finishReason":"tool_use","toolCalls":[{"id":"toolu_fixture_01","name":"Skill","arguments":"{\"skill\": \"value\"}"},{"id":"toolu_fixture_02","name":"Skill","arguments":"{\"skill\": \"value\"}"}],"promptTokens":42987,"completionTokens":239,"totalTokens":43226,"inputTokens":12566,"cacheReadTokens":17449,"cacheWriteTokens":12972,"outputTokens":239,"reasoningTokens":140,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.288498,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"prompt_usd":0.283001,"output_usd":0.005497,"tiers":{"input":0.087962,"cache_write":0.142692,"cache_read":0.052347,"output":0.005497}},"litellm-budget-track":{"cost_usd":0.288498,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"prompt_usd":0.283001,"output_usd":0.005497,"tiers":{"input":0.087962,"cache_write":0.142692,"cache_read":0.052347,"output":0.005497}}},"statusCode":200,"host":"api.example.com","durationMs":1,"bytesDown":3377,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["3377"],"Content-Type":["text/event-stream"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDEyNTY2LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMTI5NzIsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE3NDQ5LCAiY2FjaGVfY3JlYXRpb24iOiB7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOiAxMjk3MiwgImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOiAwfSwgIm91dHB1dF90b2tlbnMiOiA2LCAic2VydmljZV90aWVyIjogInN0YW5kYXJkIn19fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RhcnQiLCAiaW5kZXgiOiAwLCAiY29udGVudF9ibG9jayI6IHsidHlwZSI6ICJ0aGlua2luZyIsICJ0aGlua2luZyI6ICIiLCAic2lnbmF0dXJlIjogIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAwLCAiZGVsdGEiOiB7InR5cGUiOiAidGhpbmtpbmdfZGVsdGEiLCAidGhpbmtpbmciOiAiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDAsICJkZWx0YSI6IHsidHlwZSI6ICJzaWduYXR1cmVfZGVsdGEiLCAic2lnbmF0dXJlIjogImMybG5ibUYwZFhKbCJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RvcApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdG9wIiwgImluZGV4IjogMH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0YXJ0CmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0YXJ0IiwgImluZGV4IjogMSwgImNvbnRlbnRfYmxvY2siOiB7InR5cGUiOiAidG9vbF91c2UiLCAiaWQiOiAidG9vbHVfZml4dHVyZV8wMSIsICJuYW1lIjogIlNraWxsIiwgImlucHV0Ijoge319fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAie1wic2tpIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJsbFwiOiAifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIlwidmFsdSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiZVwifSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RvcApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdG9wIiwgImluZGV4IjogMX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0YXJ0CmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0YXJ0IiwgImluZGV4IjogMiwgImNvbnRlbnRfYmxvY2siOiB7InR5cGUiOiAidG9vbF91c2UiLCAiaWQiOiAidG9vbHVfZml4dHVyZV8wMiIsICJuYW1lIjogIlNraWxsIiwgImlucHV0Ijoge319fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAyLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAie1wic2sifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMiwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogImlsbFwiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDIsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICI6IFwidiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAyLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiYWx1ZSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAyLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiXCJ9In19CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0b3AiLCAiaW5kZXgiOiAyfQoKZXZlbnQ6IG1lc3NhZ2VfZGVsdGEKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2VfZGVsdGEiLCAiZGVsdGEiOiB7InN0b3BfcmVhc29uIjogInRvb2xfdXNlIiwgInN0b3Bfc2VxdWVuY2UiOiBudWxsLCAic3RvcF9kZXRhaWxzIjogbnVsbCwgImNvbnRhaW5lciI6IG51bGx9LCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDEyNTY2LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMTI5NzIsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE3NDQ5LCAib3V0cHV0X3Rva2VucyI6IDIzOSwgIm91dHB1dF90b2tlbnNfZGV0YWlscyI6IHsidGhpbmtpbmdfdG9rZW5zIjogMTQwfSwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMTI5NzIsICJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjogMH19fQoKZXZlbnQ6IG1lc3NhZ2Vfc3RvcApkYXRhOiB7InR5cGUiOiAibWVzc2FnZV9zdG9wIiwgInVzYWdlIjogeyJpbnB1dF90b2tlbnMiOiAxMjU2NiwgIm91dHB1dF90b2tlbnMiOiAyMzksICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE3NDQ5LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMTI5NzJ9fQo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"3-ae9c7c","startedAt":"2026-11-09T22:54:43.189642Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.189642Z","direction":"outbound","phase":"request","requestId":"3-ae9c7c","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 4\nsystem text 5","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"assistant","content":"assistant text 1","contentBytes":104},{"role":"user","content":"user text 8","contentBytes":13},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","content":"assistant text 2","contentBytes":104},{"role":"user","content":"user text 9","contentBytes":13},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","content":"assistant text 3","contentBytes":224,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":142,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":64000,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\",\"timeout\":1}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":3,"signatureBytes":36},"agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":2663,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3In1dfSx7InJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGhpbmtpbmciLCJ0aGlua2luZyI6IiIsInNpZ25hdHVyZSI6ImMybG5ibUYwZFhKbCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoiYXNzaXN0YW50IHRleHQgMSJ9XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOiJ1c2VyIHRleHQgOCJ9LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAxIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6ImFzc2lzdGFudCB0ZXh0IDIifV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjoidXNlciB0ZXh0IDkifSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMiJ9LHsicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOlt7InR5cGUiOiJ0aGlua2luZyIsInRoaW5raW5nIjoiIiwic2lnbmF0dXJlIjoiYzJsbmJtRjBkWEpsIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAzIn0seyJ0eXBlIjoidG9vbF91c2UiLCJpZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJuYW1lIjoiQmFzaCIsImlucHV0Ijp7ImNvbW1hbmQiOiJ2YWx1ZSIsImRlc2NyaXB0aW9uIjoidmFsdWUiLCJ0aW1lb3V0IjoxfX1dfSx7InJvbGUiOiJ1c2VyIiwiY29udGVudCI6W3sidG9vbF91c2VfaWQiOiJ0b29sdV9maXh0dXJlXzAxIiwidHlwZSI6InRvb2xfcmVzdWx0IiwiY29udGVudCI6InRvb2wgcmVzdWx0IHRleHQgMSIsImlzX2Vycm9yIjpmYWxzZSwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XX0seyJyb2xlIjoic3lzdGVtIiwiY29udGVudCI6InN5c3RlbSB0ZXh0IDMifV0sInN5c3RlbSI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoieC1hbnRocm9waWMtYmlsbGluZy1oZWFkZXI6IGNjX3ZlcnNpb249Mi4xLjI5NS4wMDA7IGNjX2VudHJ5cG9pbnQ9Y2xpOyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgNCIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDUiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dLCJ0b29scyI6W3sibmFtZSI6IkJhc2giLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLmV4YW1wbGUuY29tL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImNvbW1hbmQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJ0aW1lb3V0Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJudW1iZXIifSwiZGVzY3JpcHRpb24iOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJydW5faW5fYmFja2dyb3VuZCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoiYm9vbGVhbiJ9LCJkYW5nZXJvdXNseURpc2FibGVTYW5kYm94Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJib29sZWFuIn19LCJyZXF1aXJlZCI6WyJjb21tYW5kIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LHsibmFtZSI6Im1jcF9fZXhhbXBsZV9fdG9vbF8xIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJudW1fcmVzdWx0cyI6eyJkZWZhdWx0IjoxLCJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJtYXhpbXVtIjoxMCwibWluaW11bSI6MSwidHlwZSI6ImludGVnZXIifSwicXVlcnkiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsicXVlcnkiXX19XSwibWV0YWRhdGEiOnsidXNlcl9pZCI6IntcImRldmljZV9pZFwiOiBcImZpeHR1cmUtZGV2aWNlXCIsIFwiYWNjb3VudF91dWlkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxXCIsIFwic2Vzc2lvbl9pZFwiOiBcIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMFwifSJ9LCJtYXhfdG9rZW5zIjo2NDAwMCwidGhpbmtpbmciOnsidHlwZSI6ImFkYXB0aXZlIn0sIm91dHB1dF9jb25maWciOnsiZWZmb3J0IjoiaGlnaCJ9LCJzdHJlYW0iOmZhbHNlfQo=","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24"],"Anthropic-Version":["2023-06-01"],"Content-Length":["2663"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"3-ae9c7c","startedAt":"2026-11-09T22:54:43.189972Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.189972Z","direction":"outbound","phase":"response","requestId":"3-ae9c7c","inference":{"model":"claude-opus-5-5","maxTokens":64000,"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","responseThinking":{"blocks":1,"signatureBytes":12},"agentRole":"main","completion":"assistant text 4","finishReason":"tool_use","toolCalls":[{"id":"toolu_fixture_02","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\",\"timeout\":1}"}],"promptTokens":578214,"completionTokens":1282,"totalTokens":579496,"inputTokens":26,"cacheReadTokens":578188,"outputTokens":1282,"reasoningTokens":435,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages"}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":1.734746,"output_usd":0.029486,"tiers":{"input":0.000182,"cache_read":1.734564,"output":0.029486}},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":1.734746,"output_usd":0.029486,"tiers":{"input":0.000182,"cache_read":1.734564,"output":0.029486}}},"statusCode":200,"host":"api.example.com","bytesDown":668,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["668"],"Content-Type":["application/json"],"X-Litellm-Response-Cost":["0.0123"]},"body":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsImlkIjoibXNnX2ZpeHR1cmUiLCJ0eXBlIjoibWVzc2FnZSIsInJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGhpbmtpbmciLCJ0aGlua2luZyI6IiIsInNpZ25hdHVyZSI6ImMybG5ibUYwZFhKbCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoiYXNzaXN0YW50IHRleHQgNCJ9LHsidHlwZSI6InRvb2xfdXNlIiwiaWQiOiJ0b29sdV9maXh0dXJlXzAyIiwibmFtZSI6IkJhc2giLCJpbnB1dCI6eyJjb21tYW5kIjoidmFsdWUiLCJkZXNjcmlwdGlvbiI6InZhbHVlIiwidGltZW91dCI6MX19XSwiY29udGFpbmVyIjpudWxsLCJzdG9wX3JlYXNvbiI6InRvb2xfdXNlIiwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjI2LCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjo1NzgxODgsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjAsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjoxMjgyLCJvdXRwdXRfdG9rZW5zX2RldGFpbHMiOnsidGhpbmtpbmdfdG9rZW5zIjo0MzV9LCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCJ9fQo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"application/json","X-Litellm-Response-Cost":"0.0123"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"4-9c392b","startedAt":"2026-11-09T22:54:43.190318Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.190318Z","direction":"outbound","phase":"request","requestId":"4-9c392b","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 3\nsystem text 4","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"assistant","content":"assistant text 1","contentBytes":104},{"role":"user","content":"user text 8","contentBytes":13},{"role":"assistant","content":"assistant text 2","contentBytes":212,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","content":"assistant text 3","contentBytes":104},{"role":"user","content":"user text 9","contentBytes":75},{"role":"system","content":"system text 2","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":3,"signatureBytes":36},"agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":2632,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3In1dfSx7InJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGhpbmtpbmciLCJ0aGlua2luZyI6IiIsInNpZ25hdHVyZSI6ImMybG5ibUYwZFhKbCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoiYXNzaXN0YW50IHRleHQgMSJ9XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOiJ1c2VyIHRleHQgOCJ9LHsicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOlt7InR5cGUiOiJ0aGlua2luZyIsInRoaW5raW5nIjoiIiwic2lnbmF0dXJlIjoiYzJsbmJtRjBkWEpsIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAyIn0seyJ0eXBlIjoidG9vbF91c2UiLCJpZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJuYW1lIjoiQmFzaCIsImlucHV0Ijp7ImNvbW1hbmQiOiJ2YWx1ZSIsImRlc2NyaXB0aW9uIjoidmFsdWUifX1dfSx7InJvbGUiOiJ1c2VyIiwiY29udGVudCI6W3sidG9vbF91c2VfaWQiOiJ0b29sdV9maXh0dXJlXzAxIiwidHlwZSI6InRvb2xfcmVzdWx0IiwiY29udGVudCI6InRvb2wgcmVzdWx0IHRleHQgMSIsImlzX2Vycm9yIjpmYWxzZX1dfSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMSJ9LHsicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOlt7InR5cGUiOiJ0aGlua2luZyIsInRoaW5raW5nIjoiIiwic2lnbmF0dXJlIjoiYzJsbmJtRjBkWEpsIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAzIn1dfSx7InJvbGUiOiJ1c2VyIiwiY29udGVudCI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDkiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dfSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMiJ9XSwic3lzdGVtIjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ4LWFudGhyb3BpYy1iaWxsaW5nLWhlYWRlcjogY2NfdmVyc2lvbj0yLjEuMjk1LjAwMDsgY2NfZW50cnlwb2ludD1jbGk7In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCAzIiwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgNCIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV0sInRvb2xzIjpbeyJuYW1lIjoiQmFzaCIsImRlc2NyaXB0aW9uIjoiVG9vbCBkZXNjcmlwdGlvbi4iLCJpbnB1dF9zY2hlbWEiOnsiJHNjaGVtYSI6Imh0dHBzOi8vanNvbi1zY2hlbWEuZXhhbXBsZS5jb20vc2NoZW1hIiwidHlwZSI6Im9iamVjdCIsInByb3BlcnRpZXMiOnsiY29tbWFuZCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sInRpbWVvdXQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6Im51bWJlciJ9LCJkZXNjcmlwdGlvbiI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sInJ1bl9pbl9iYWNrZ3JvdW5kIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJib29sZWFuIn0sImRhbmdlcm91c2x5RGlzYWJsZVNhbmRib3giOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbImNvbW1hbmQiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0seyJuYW1lIjoibWNwX19leGFtcGxlX190b29sXzEiLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7Im51bV9yZXN1bHRzIjp7ImRlZmF1bHQiOjEsImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsIm1heGltdW0iOjEwLCJtaW5pbXVtIjoxLCJ0eXBlIjoiaW50ZWdlciJ9LCJxdWVyeSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJxdWVyeSJdfX1dLCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In0sIm1heF90b2tlbnMiOjEyODAwMCwidGhpbmtpbmciOnsidHlwZSI6ImFkYXB0aXZlIn0sIm91dHB1dF9jb25maWciOnsiZWZmb3J0IjoiaGlnaCJ9LCJzdHJlYW0iOnRydWV9Cg==","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24"],"Anthropic-Version":["2023-06-01"],"Content-Length":["2632"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"4-9c392b","startedAt":"2026-11-09T22:54:43.190644Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.190644Z","direction":"outbound","phase":"response","requestId":"4-9c392b","inference":{"model":"claude-opus-5-5","maxTokens":128000,"stream":true,"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","responseThinking":{"blocks":1},"agentRole":"main","streamedResponse":true,"promptTokens":289421,"totalTokens":289421,"inputTokens":27,"cacheReadTokens":286566,"cacheWriteTokens":2828,"presentKinds":15,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.890995,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"incomplete":true,"incomplete_reason":"output-uncounted","prompt_usd":0.890995,"tiers":{"input":0.000189,"cache_write":0.031108,"cache_read":0.859698}},"litellm-budget-track":{"cost_usd":0.890995,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"incomplete":true,"incomplete_reason":"output-uncounted","prompt_usd":0.890995,"tiers":{"input":0.000189,"cache_write":0.031108,"cache_read":0.859698}}},"statusCode":200,"host":"api.example.com","bytesDown":643,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["643"],"Content-Type":["text/event-stream"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDI3LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMjgyOCwgImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjogMjg2NTY2LCAiY2FjaGVfY3JlYXRpb24iOiB7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOiAyODI4LCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9LCAib3V0cHV0X3Rva2VucyI6IDgsICJzZXJ2aWNlX3RpZXIiOiAic3RhbmRhcmQifX19CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdGFydCIsICJpbmRleCI6IDAsICJjb250ZW50X2Jsb2NrIjogeyJ0eXBlIjogInRoaW5raW5nIiwgInRoaW5raW5nIjogIiIsICJzaWduYXR1cmUiOiAiIn19Cg==","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"5-3ddd1d","startedAt":"2026-11-09T22:54:43.191143Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.191143Z","direction":"outbound","phase":"request","requestId":"5-3ddd1d","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 4\nsystem text 5","contentBytes":249},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6\nuser text 7","contentBytes":260},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":232,"toolCallNames":"Skill, Skill","toolUseIds":"toolu_fixture_01, toolu_fixture_02"},{"role":"user","content":"user text 8\nuser text 9","contentBytes":249,"toolUseIds":"toolu_fixture_01, toolu_fixture_02"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":170,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_03"},{"role":"user","contentBytes":142,"toolUseIds":"toolu_fixture_03"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"AskUserQuestion","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"questions":{"description":"Parameter description.","minItems":1,"maxItems":4,"type":"array","items":{"type":"object","properties":{"question":{"description":"Parameter description.","type":"string"},"header":{"description":"Parameter description.","type":"string"},"options":{"description":"Parameter description.","minItems":2,"maxItems":4,"type":"array","items":{"type":"object","properties":{"label":{"description":"Parameter description.","type":"string"},"description":{"description":"Parameter description.","type":"string"},"preview":{"description":"Parameter description.","type":"string"}},"required":["label","description"],"additionalProperties":false}},"multiSelect":{"description":"Parameter description.","default":false,"type":"boolean"}},"required":["question","header","options","multiSelect"],"additionalProperties":false}},"answers":{"description":"Parameter description.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"annotations":{"description":"Parameter description.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"preview":{"description":"Parameter description.","type":"string"},"notes":{"description":"Parameter description.","type":"string"}},"additionalProperties":false}},"metadata":{"description":"Parameter description.","type":"object","properties":{"source":{"description":"Parameter description.","type":"string"}},"additionalProperties":false}},"required":["questions"],"additionalProperties":false}},{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"},{"toolUseId":"toolu_fixture_03","content":"tool result text 3"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Skill","arguments":"{\"skill\":\"value\"}"},{"id":"toolu_fixture_02","name":"Skill","arguments":"{\"skill\":\"value\"}"},{"id":"toolu_fixture_03","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","historyThinking":{"blocks":2,"signatureBytes":24},"agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":4825,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDYifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA3In1dfSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMSJ9LHsicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOlt7InR5cGUiOiJ0aGlua2luZyIsInRoaW5raW5nIjoiIiwic2lnbmF0dXJlIjoiYzJsbmJtRjBkWEpsIn0seyJ0eXBlIjoidG9vbF91c2UiLCJpZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJuYW1lIjoiU2tpbGwiLCJpbnB1dCI6eyJza2lsbCI6InZhbHVlIn19LHsidHlwZSI6InRvb2xfdXNlIiwiaWQiOiJ0b29sdV9maXh0dXJlXzAyIiwibmFtZSI6IlNraWxsIiwiaW5wdXQiOnsic2tpbGwiOiJ2YWx1ZSJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidG9vbF9yZXN1bHQiLCJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAxIn0seyJ0eXBlIjoidG9vbF9yZXN1bHQiLCJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDIiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAyIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgOCJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDkifV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAyIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfZml4dHVyZV8wMyIsIm5hbWUiOiJCYXNoIiwiaW5wdXQiOnsiY29tbWFuZCI6InZhbHVlIiwiZGVzY3JpcHRpb24iOiJ2YWx1ZSJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDMiLCJ0eXBlIjoidG9vbF9yZXN1bHQiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAzIiwiaXNfZXJyb3IiOmZhbHNlLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dfSx7InJvbGUiOiJzeXN0ZW0iLCJjb250ZW50Ijoic3lzdGVtIHRleHQgMyJ9XSwic3lzdGVtIjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ4LWFudGhyb3BpYy1iaWxsaW5nLWhlYWRlcjogY2NfdmVyc2lvbj0yLjEuMjk1LjAwMDsgY2NfZW50cnlwb2ludD1jbGk7In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCA0IiwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgNSIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV0sInRvb2xzIjpbeyJuYW1lIjoiQXNrVXNlclF1ZXN0aW9uIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJxdWVzdGlvbnMiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwibWluSXRlbXMiOjEsIm1heEl0ZW1zIjo0LCJ0eXBlIjoiYXJyYXkiLCJpdGVtcyI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJxdWVzdGlvbiI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImhlYWRlciI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sIm9wdGlvbnMiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwibWluSXRlbXMiOjIsIm1heEl0ZW1zIjo0LCJ0eXBlIjoiYXJyYXkiLCJpdGVtcyI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJsYWJlbCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImRlc2NyaXB0aW9uIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifSwicHJldmlldyI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJsYWJlbCIsImRlc2NyaXB0aW9uIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LCJtdWx0aVNlbGVjdCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJkZWZhdWx0IjpmYWxzZSwidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInF1ZXN0aW9uIiwiaGVhZGVyIiwib3B0aW9ucyIsIm11bHRpU2VsZWN0Il0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LCJhbnN3ZXJzIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0eU5hbWVzIjp7InR5cGUiOiJzdHJpbmcifSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOnsidHlwZSI6InN0cmluZyJ9fSwiYW5ub3RhdGlvbnMiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6Im9iamVjdCIsInByb3BlcnR5TmFtZXMiOnsidHlwZSI6InN0cmluZyJ9LCJhZGRpdGlvbmFsUHJvcGVydGllcyI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJwcmV2aWV3Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifSwibm90ZXMiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0sIm1ldGFkYXRhIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7InNvdXJjZSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2V9fSwicmVxdWlyZWQiOlsicXVlc3Rpb25zIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LHsibmFtZSI6IkJhc2giLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLmV4YW1wbGUuY29tL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImNvbW1hbmQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJ0aW1lb3V0Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJudW1iZXIifSwiZGVzY3JpcHRpb24iOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJydW5faW5fYmFja2dyb3VuZCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoiYm9vbGVhbiJ9LCJkYW5nZXJvdXNseURpc2FibGVTYW5kYm94Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJib29sZWFuIn19LCJyZXF1aXJlZCI6WyJjb21tYW5kIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LHsibmFtZSI6IlNraWxsIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJza2lsbCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImFyZ3MiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsic2tpbGwiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0seyJuYW1lIjoibWNwX19leGFtcGxlX190b29sXzEiLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7Im51bV9yZXN1bHRzIjp7ImRlZmF1bHQiOjEsImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsIm1heGltdW0iOjEwLCJtaW5pbXVtIjoxLCJ0eXBlIjoiaW50ZWdlciJ9LCJxdWVyeSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJxdWVyeSJdfX1dLCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In0sIm1heF90b2tlbnMiOjEyODAwMCwidGhpbmtpbmciOnsidHlwZSI6ImFkYXB0aXZlIn0sIm91dHB1dF9jb25maWciOnsiZWZmb3J0IjoiaGlnaCJ9LCJzdHJlYW0iOnRydWV9Cg==","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24"],"Anthropic-Version":["2023-06-01"],"Content-Length":["4825"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"5-3ddd1d","startedAt":"2026-11-09T22:54:43.193029Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.193029Z","direction":"outbound","phase":"response","requestId":"5-3ddd1d","inference":{"model":"claude-opus-5-5","maxTokens":128000,"stream":true,"servedModel":"gateway/claude-opus-5-5","thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24","responseThinking":{"blocks":1,"signatureBytes":12},"agentRole":"main","streamedResponse":true,"finishReason":"tool_use","toolCalls":[{"id":"toolu_fixture_04","name":"Bash","arguments":"{\"command\": \"value\", \"description\": \"value\"}"}],"promptTokens":50279,"completionTokens":275,"totalTokens":50554,"inputTokens":26,"cacheReadTokens":49941,"cacheWriteTokens":312,"outputTokens":275,"reasoningTokens":58,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.153437,"output_usd":0.006325,"tiers":{"input":0.000182,"cache_write":0.003432,"cache_read":0.149823,"output":0.006325}},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.153437,"output_usd":0.006325,"tiers":{"input":0.000182,"cache_write":0.003432,"cache_read":0.149823,"output":0.006325}}},"statusCode":200,"host":"api.example.com","durationMs":2,"bytesDown":8595,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["8595"],"Content-Type":["text/event-stream; charset=utf-8"],"X-Litellm-Model-Name":["gateway/claude-opus-5-5"],"X-Litellm-Response-Cost":["0.0123"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDI2LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMzEyLCAiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiOiA0OTk0MSwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMzEyLCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9LCAib3V0cHV0X3Rva2VucyI6IDgsICJzZXJ2aWNlX3RpZXIiOiAic3RhbmRhcmQifX19CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdGFydCIsICJpbmRleCI6IDAsICJjb250ZW50X2Jsb2NrIjogeyJ0eXBlIjogInRoaW5raW5nIiwgInRoaW5raW5nIjogIiIsICJzaWduYXR1cmUiOiAiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDAsICJkZWx0YSI6IHsidHlwZSI6ICJ0aGlua2luZ19kZWx0YSIsICJ0aGlua2luZyI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMCwgImRlbHRhIjogeyJ0eXBlIjogInNpZ25hdHVyZV9kZWx0YSIsICJzaWduYXR1cmUiOiAiYzJsbmJtRjBkWEpsIn19CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0b3AiLCAiaW5kZXgiOiAwfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RhcnQiLCAiaW5kZXgiOiAxLCAiY29udGVudF9ibG9jayI6IHsidHlwZSI6ICJ0b29sX3VzZSIsICJpZCI6ICJ0b29sdV9maXh0dXJlXzA0IiwgIm5hbWUiOiAiQmFzaCIsICJpbnB1dCI6IHt9fX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogInsifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIlwiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJjIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJvIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJtIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJtIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJhIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJuIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJkIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJcIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiOiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiICJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiXCIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogInYifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogImEifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogImwifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogInUifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogImUifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIlwiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICIsIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICIgIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJcIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiZCJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiZSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAicyJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiYyJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiciJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiaSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAicCJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAidCJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiaSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAibyJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAibiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiXCIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIjoifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIiAifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIlwiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJ2In19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJhIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJsIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJ1In19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJlIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICJcIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAifSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAxLCAiZGVsdGEiOiB7InR5cGUiOiAiaW5wdXRfanNvbl9kZWx0YSIsICJwYXJ0aWFsX2pzb24iOiAiIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDEsICJkZWx0YSI6IHsidHlwZSI6ICJpbnB1dF9qc29uX2RlbHRhIiwgInBhcnRpYWxfanNvbiI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RvcCIsICJpbmRleCI6IDF9CgpldmVudDogbWVzc2FnZV9kZWx0YQpkYXRhOiB7InR5cGUiOiAibWVzc2FnZV9kZWx0YSIsICJkZWx0YSI6IHsic3RvcF9yZWFzb24iOiAidG9vbF91c2UiLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAiY29udGFpbmVyIjogbnVsbH0sICJ1c2FnZSI6IHsiaW5wdXRfdG9rZW5zIjogMjYsICJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOiAzMTIsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDQ5OTQxLCAib3V0cHV0X3Rva2VucyI6IDI3NSwgIm91dHB1dF90b2tlbnNfZGV0YWlscyI6IHsidGhpbmtpbmdfdG9rZW5zIjogNTh9LCAiY2FjaGVfY3JlYXRpb24iOiB7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOiAzMTIsICJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjogMH19fQoKZXZlbnQ6IG1lc3NhZ2Vfc3RvcApkYXRhOiB7InR5cGUiOiAibWVzc2FnZV9zdG9wIiwgInVzYWdlIjogeyJpbnB1dF90b2tlbnMiOiAyNiwgIm91dHB1dF90b2tlbnMiOiAyNzUsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDQ5OTQxLCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMzEyfX0K","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream; charset=utf-8","X-Litellm-Model-Name":"gateway/claude-opus-5-5","X-Litellm-Response-Cost":"0.0123"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"6-3ebd4d","startedAt":"2026-11-09T22:54:43.194141Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.194141Z","direction":"outbound","phase":"request","requestId":"6-3ebd4d","inference":{"model":"claude-sonnet-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1\nsystem text 2","contentBytes":212},{"role":"user","content":"user text 1","contentBytes":75},{"role":"user","content":"user text 2\nuser text 3\nuser text 4\nuser text 5\nuser text 6","contentBytes":260}],"maxTokens":64,"thinkingType":"disabled","anthropicBeta":"claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-sonnet-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":907,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNSIsIm1heF90b2tlbnMiOjY0LCJzeXN0ZW0iOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6IngtYW50aHJvcGljLWJpbGxpbmctaGVhZGVyOiBjY192ZXJzaW9uPTIuMS4yOTUuMDAwOyBjY19lbnRyeXBvaW50PWNsaTsifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDEiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCAyIn1dLCJtZXNzYWdlcyI6W3sicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMSIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNCIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA1In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNiJ9XX1dLCJzdG9wX3NlcXVlbmNlcyI6WyI8L3N0b3A+Il0sInRoaW5raW5nIjp7InR5cGUiOiJkaXNhYmxlZCJ9LCJtZXRhZGF0YSI6eyJ1c2VyX2lkIjoie1wiZGV2aWNlX2lkXCI6IFwiZml4dHVyZS1kZXZpY2VcIiwgXCJhY2NvdW50X3V1aWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDFcIiwgXCJzZXNzaW9uX2lkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAwXCJ9In19Cg==","header":{"Anthropic-Beta":["claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14"],"Anthropic-Version":["2023-06-01"],"Content-Length":["907"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"6-3ebd4d","startedAt":"2026-11-09T22:54:43.194439Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.194439Z","direction":"outbound","phase":"response","requestId":"6-3ebd4d","inference":{"model":"claude-sonnet-5","maxTokens":64,"servedModel":"gateway/claude-sonnet-5","thinkingType":"disabled","anthropicBeta":"claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14","agentRole":"main","completion":"assistant text 1","finishReason":"stop_sequence","promptTokens":60457,"completionTokens":9,"totalTokens":60466,"inputTokens":90,"cacheWriteTokens":60367,"outputTokens":9,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-sonnet-5_response","path":"/v1/messages"}]},"plugins":{"cost":{"cost_usd":0.664874,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"prompt_usd":0.664667,"output_usd":0.000207,"tiers":{"input":0.00063,"cache_write":0.664037,"output":0.000207}},"litellm-budget-track":{"cost_usd":0.664874,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"prompt_usd":0.664667,"output_usd":0.000207,"tiers":{"input":0.00063,"cache_write":0.664037,"output":0.000207}}},"statusCode":200,"host":"api.example.com","bytesDown":495,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["495"],"Content-Type":["application/json"],"X-Litellm-Model-Name":["gateway/claude-sonnet-5"]},"body":"eyJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNSIsImlkIjoibXNnX2ZpeHR1cmUiLCJ0eXBlIjoibWVzc2FnZSIsInJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAxIn1dLCJjb250YWluZXIiOm51bGwsInN0b3BfcmVhc29uIjoic3RvcF9zZXF1ZW5jZSIsInN0b3Bfc2VxdWVuY2UiOiI8L3N0b3A+Iiwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjkwLCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjYwMzY3LCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MCwiY2FjaGVfY3JlYXRpb24iOnsiZXBoZW1lcmFsXzVtX2lucHV0X3Rva2VucyI6NjAzNjcsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjo5LCJvdXRwdXRfdG9rZW5zX2RldGFpbHMiOnsidGhpbmtpbmdfdG9rZW5zIjowfSwic2VydmljZV90aWVyIjoic3RhbmRhcmQifX0K","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"application/json","X-Litellm-Model-Name":"gateway/claude-sonnet-5"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"7-839a2c","startedAt":"2026-11-09T22:54:43.194717Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.194717Z","direction":"outbound","phase":"request","requestId":"7-839a2c","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli;\nsystem text 1","contentBytes":173},{"role":"user","content":"user text 1","contentBytes":13}],"maxTokens":3072,"anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14","agentRole":"main","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":461,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1heF90b2tlbnMiOjMwNzIsInN5c3RlbSI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoieC1hbnRocm9waWMtYmlsbGluZy1oZWFkZXI6IGNjX3ZlcnNpb249Mi4xLjI5NS4wMDA7IGNjX2VudHJ5cG9pbnQ9Y2xpOyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgMSIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fV0sIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOiJ1c2VyIHRleHQgMSJ9XSwibWV0YWRhdGEiOnsidXNlcl9pZCI6IntcImRldmljZV9pZFwiOiBcImZpeHR1cmUtZGV2aWNlXCIsIFwiYWNjb3VudF91dWlkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxXCIsIFwic2Vzc2lvbl9pZFwiOiBcIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMFwifSJ9fQo=","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14"],"Anthropic-Version":["2023-06-01"],"Content-Length":["461"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"7-839a2c","startedAt":"2026-11-09T22:54:43.195013Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.195013Z","direction":"outbound","phase":"response","requestId":"7-839a2c","inference":{"model":"claude-opus-5-5","maxTokens":3072,"anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14","agentRole":"main","completion":"assistant text 1","finishReason":"end_turn","promptTokens":6407,"completionTokens":62,"totalTokens":6469,"inputTokens":173,"cacheReadTokens":6234,"outputTokens":62,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages"}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.019913,"output_usd":0.001426,"tiers":{"input":0.001211,"cache_read":0.018702,"output":0.001426}},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.019913,"output_usd":0.001426,"tiers":{"input":0.001211,"cache_read":0.018702,"output":0.001426}}},"statusCode":200,"host":"api.example.com","bytesDown":482,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["482"],"Content-Type":["application/json"],"X-Litellm-Response-Cost":["0.0123"]},"body":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsImlkIjoibXNnX2ZpeHR1cmUiLCJ0eXBlIjoibWVzc2FnZSIsInJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidGV4dCIsInRleHQiOiJhc3Npc3RhbnQgdGV4dCAxIn1dLCJjb250YWluZXIiOm51bGwsInN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsLCJzdG9wX2RldGFpbHMiOm51bGwsInVzYWdlIjp7ImlucHV0X3Rva2VucyI6MTczLCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjo2MjM0LCJjYWNoZV9jcmVhdGlvbiI6eyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjowLCJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjowfSwib3V0cHV0X3Rva2VucyI6NjIsIm91dHB1dF90b2tlbnNfZGV0YWlscyI6eyJ0aGlua2luZ190b2tlbnMiOjB9LCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCJ9fQo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"application/json","X-Litellm-Response-Cost":"0.0123"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"8-3639c8","startedAt":"2026-11-09T22:54:43.195394Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.195394Z","direction":"outbound","phase":"request","requestId":"8-3639c8","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli; cc_is_subagent=true;\nsystem text 4\nsystem text 5","contentBytes":270},{"role":"user","content":"user text 1\nuser text 2\nuser text 3\nuser text 4\nuser text 5","contentBytes":186},{"role":"assistant","contentBytes":182,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":170,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_02"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_02"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":184,"toolCallNames":"Skill","toolUseIds":"toolu_fixture_03"},{"role":"user","content":"user text 6\nuser text 7","contentBytes":162,"toolUseIds":"toolu_fixture_03"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"Skill","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"skill":{"description":"Parameter description.","type":"string"},"args":{"description":"Parameter description.","type":"string"}},"required":["skill"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}},{"name":"SubagentHandback","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"message":{"description":"Parameter description.","type":"string"}},"required":["message"],"additionalProperties":false}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"},{"toolUseId":"toolu_fixture_03","content":"tool result text 3"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\",\"timeout\":1}"},{"id":"toolu_fixture_02","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"},{"id":"toolu_fixture_03","name":"Skill","arguments":"{\"skill\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01","historyThinking":{"blocks":3,"signatureBytes":36},"agentRole":"subagent","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":3527,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA0In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNSJ9XX0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfZml4dHVyZV8wMSIsIm5hbWUiOiJCYXNoIiwiaW5wdXQiOnsiY29tbWFuZCI6InZhbHVlIiwiZGVzY3JpcHRpb24iOiJ2YWx1ZSIsInRpbWVvdXQiOjF9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDEiLCJ0eXBlIjoidG9vbF9yZXN1bHQiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAxIiwiaXNfZXJyb3IiOmZhbHNlfV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAxIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfZml4dHVyZV8wMiIsIm5hbWUiOiJCYXNoIiwiaW5wdXQiOnsiY29tbWFuZCI6InZhbHVlIiwiZGVzY3JpcHRpb24iOiJ2YWx1ZSJ9fV19LHsicm9sZSI6InVzZXIiLCJjb250ZW50IjpbeyJ0b29sX3VzZV9pZCI6InRvb2x1X2ZpeHR1cmVfMDIiLCJ0eXBlIjoidG9vbF9yZXN1bHQiLCJjb250ZW50IjoidG9vbCByZXN1bHQgdGV4dCAyIiwiaXNfZXJyb3IiOmZhbHNlfV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAyIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRoaW5raW5nIiwidGhpbmtpbmciOiIiLCJzaWduYXR1cmUiOiJjMmxuYm1GMGRYSmwifSx7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfZml4dHVyZV8wMyIsIm5hbWUiOiJTa2lsbCIsImlucHV0Ijp7InNraWxsIjoidmFsdWUifSwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0b29sX3Jlc3VsdCIsInRvb2xfdXNlX2lkIjoidG9vbHVfZml4dHVyZV8wMyIsImNvbnRlbnQiOiJ0b29sIHJlc3VsdCB0ZXh0IDMifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCA2In0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgNyJ9XX0seyJyb2xlIjoic3lzdGVtIiwiY29udGVudCI6InN5c3RlbSB0ZXh0IDMifV0sInN5c3RlbSI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoieC1hbnRocm9waWMtYmlsbGluZy1oZWFkZXI6IGNjX3ZlcnNpb249Mi4xLjI5NS4wMDA7IGNjX2VudHJ5cG9pbnQ9Y2xpOyBjY19pc19zdWJhZ2VudD10cnVlOyJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3lzdGVtIHRleHQgNCIsImNhY2hlX2NvbnRyb2wiOnsidHlwZSI6ImVwaGVtZXJhbCJ9fSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDUiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX1dLCJ0b29scyI6W3sibmFtZSI6IkJhc2giLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLmV4YW1wbGUuY29tL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImNvbW1hbmQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJ0aW1lb3V0Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJudW1iZXIifSwiZGVzY3JpcHRpb24iOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9LCJydW5faW5fYmFja2dyb3VuZCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoiYm9vbGVhbiJ9LCJkYW5nZXJvdXNseURpc2FibGVTYW5kYm94Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJib29sZWFuIn19LCJyZXF1aXJlZCI6WyJjb21tYW5kIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19LHsibmFtZSI6IlNraWxsIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJza2lsbCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn0sImFyZ3MiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsic2tpbGwiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0seyJuYW1lIjoibWNwX19leGFtcGxlX190b29sXzEiLCJkZXNjcmlwdGlvbiI6IlRvb2wgZGVzY3JpcHRpb24uIiwiaW5wdXRfc2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7Im51bV9yZXN1bHRzIjp7ImRlZmF1bHQiOjEsImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsIm1heGltdW0iOjEwLCJtaW5pbXVtIjoxLCJ0eXBlIjoiaW50ZWdlciJ9LCJxdWVyeSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJxdWVyeSJdfX0seyJuYW1lIjoiU3ViYWdlbnRIYW5kYmFjayIsImRlc2NyaXB0aW9uIjoiVG9vbCBkZXNjcmlwdGlvbi4iLCJpbnB1dF9zY2hlbWEiOnsiJHNjaGVtYSI6Imh0dHBzOi8vanNvbi1zY2hlbWEuZXhhbXBsZS5jb20vc2NoZW1hIiwidHlwZSI6Im9iamVjdCIsInByb3BlcnRpZXMiOnsibWVzc2FnZSI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoic3RyaW5nIn19LCJyZXF1aXJlZCI6WyJtZXNzYWdlIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX19XSwibWV0YWRhdGEiOnsidXNlcl9pZCI6IntcImRldmljZV9pZFwiOiBcImZpeHR1cmUtZGV2aWNlXCIsIFwiYWNjb3VudF91dWlkXCI6IFwiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxXCIsIFwic2Vzc2lvbl9pZFwiOiBcIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMFwifSJ9LCJtYXhfdG9rZW5zIjoxMjgwMDAsInRoaW5raW5nIjp7InR5cGUiOiJhZGFwdGl2ZSJ9LCJvdXRwdXRfY29uZmlnIjp7ImVmZm9ydCI6ImhpZ2gifSwic3RyZWFtIjp0cnVlfQo=","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01"],"Anthropic-Version":["2023-06-01"],"Content-Length":["3527"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"8-3639c8","startedAt":"2026-11-09T22:54:43.195878Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.195878Z","direction":"outbound","phase":"response","requestId":"8-3639c8","inference":{"model":"claude-opus-5-5","maxTokens":128000,"stream":true,"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01","responseThinking":{"blocks":1,"signatureBytes":12},"agentRole":"subagent","streamedResponse":true,"toolCalls":[{"id":"toolu_fixture_04","name":"SubagentHandback"}],"promptTokens":227618,"totalTokens":227618,"inputTokens":5678,"cacheReadTokens":221800,"cacheWriteTokens":140,"presentKinds":15,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.706686,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"incomplete":true,"incomplete_reason":"output-uncounted","prompt_usd":0.706686,"tiers":{"input":0.039746,"cache_write":0.00154,"cache_read":0.6654}},"litellm-budget-track":{"cost_usd":0.706686,"source":"usage-fallback","daily_total_usd":0,"daily_max_usd":0,"provenance":"configured","settled":true,"incomplete":true,"incomplete_reason":"output-uncounted","prompt_usd":0.706686,"tiers":{"input":0.039746,"cache_write":0.00154,"cache_read":0.6654}}},"statusCode":200,"host":"api.example.com","bytesDown":1388,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["1388"],"Content-Type":["text/event-stream"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDU2NzgsICJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOiAxNDAsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDIyMTgwMCwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMTQwLCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9LCAib3V0cHV0X3Rva2VucyI6IDYsICJzZXJ2aWNlX3RpZXIiOiAic3RhbmRhcmQifX19CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdGFydCIsICJpbmRleCI6IDAsICJjb250ZW50X2Jsb2NrIjogeyJ0eXBlIjogInRoaW5raW5nIiwgInRoaW5raW5nIjogIiIsICJzaWduYXR1cmUiOiAiIn19CgpldmVudDogcGluZwpkYXRhOiB7InR5cGUiOiAicGluZyJ9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDAsICJkZWx0YSI6IHsidHlwZSI6ICJ0aGlua2luZ19kZWx0YSIsICJ0aGlua2luZyI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMCwgImRlbHRhIjogeyJ0eXBlIjogInNpZ25hdHVyZV9kZWx0YSIsICJzaWduYXR1cmUiOiAiYzJsbmJtRjBkWEpsIn19CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX3N0b3AiLCAiaW5kZXgiOiAwfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RhcnQiLCAiaW5kZXgiOiAxLCAiY29udGVudF9ibG9jayI6IHsidHlwZSI6ICJ0b29sX3VzZSIsICJpZCI6ICJ0b29sdV9maXh0dXJlXzA0IiwgIm5hbWUiOiAiU3ViYWdlbnRIYW5kYmFjayIsICJpbnB1dCI6IHt9fX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMSwgImRlbHRhIjogeyJ0eXBlIjogImlucHV0X2pzb25fZGVsdGEiLCAicGFydGlhbF9qc29uIjogIiJ9fQoKZXZlbnQ6IHBpbmcKZGF0YTogeyJ0eXBlIjogInBpbmcifQo=","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream"}}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"9-59ec16","startedAt":"2026-11-09T22:54:43.196366Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.196366Z","direction":"outbound","phase":"request","requestId":"9-59ec16","inference":{"model":"claude-opus-5-5","messages":[{"role":"system","content":"x-anthropic-billing-header: cc_version=2.1.295.000; cc_entrypoint=cli; cc_is_subagent=true;\nsystem text 4\nsystem text 5","contentBytes":270},{"role":"user","content":"user text 1\nuser text 2\nuser text 3","contentBytes":112},{"role":"system","content":"system text 1","contentBytes":15},{"role":"assistant","contentBytes":109,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_01"},{"role":"user","contentBytes":105,"toolUseIds":"toolu_fixture_01"},{"role":"system","content":"system text 2","contentBytes":15},{"role":"assistant","contentBytes":146,"toolCallNames":"Bash","toolUseIds":"toolu_fixture_02"},{"role":"user","content":"user text 4","contentBytes":142,"toolUseIds":"toolu_fixture_02"},{"role":"system","content":"system text 3","contentBytes":15}],"maxTokens":128000,"stream":true,"tools":[{"name":"Bash","description":"Tool description.","parameters":{"$schema":"https://json-schema.example.com/schema","type":"object","properties":{"command":{"description":"Parameter description.","type":"string"},"timeout":{"description":"Parameter description.","type":"number"},"description":{"description":"Parameter description.","type":"string"},"run_in_background":{"description":"Parameter description.","type":"boolean"},"dangerouslyDisableSandbox":{"description":"Parameter description.","type":"boolean"}},"required":["command"],"additionalProperties":false}},{"name":"mcp__example__tool_1","description":"Tool description.","parameters":{"type":"object","properties":{"num_results":{"default":1,"description":"Parameter description.","maximum":10,"minimum":1,"type":"integer"},"query":{"description":"Parameter description.","type":"string"}},"required":["query"]}}],"toolResults":[{"toolUseId":"toolu_fixture_01","content":"tool result text 1"},{"toolUseId":"toolu_fixture_02","content":"tool result text 2"}],"historyToolCalls":[{"id":"toolu_fixture_01","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"},{"id":"toolu_fixture_02","name":"Bash","arguments":"{\"command\":\"value\",\"description\":\"value\"}"}],"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01","agentRole":"subagent","isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"request","reason":"matched_claude-opus-5-5","path":"/v1/messages"}]},"host":"api.example.com","bytesUp":2379,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"},"sessionHeader":"X-Claude-Code-Session-Id"},"capture":{"original":"eyJtb2RlbCI6ImNsYXVkZS1vcHVzLTUtNSIsIm1lc3NhZ2VzIjpbeyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InVzZXIgdGV4dCAxIn0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJ1c2VyIHRleHQgMiJ9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDMifV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAxIn0seyJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRvb2xfdXNlIiwiaWQiOiJ0b29sdV9maXh0dXJlXzAxIiwibmFtZSI6IkJhc2giLCJpbnB1dCI6eyJjb21tYW5kIjoidmFsdWUiLCJkZXNjcmlwdGlvbiI6InZhbHVlIn19XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InRvb2xfdXNlX2lkIjoidG9vbHVfZml4dHVyZV8wMSIsInR5cGUiOiJ0b29sX3Jlc3VsdCIsImNvbnRlbnQiOiJ0b29sIHJlc3VsdCB0ZXh0IDEiLCJpc19lcnJvciI6ZmFsc2V9XX0seyJyb2xlIjoic3lzdGVtIiwiY29udGVudCI6InN5c3RlbSB0ZXh0IDIifSx7InJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjpbeyJ0eXBlIjoidG9vbF91c2UiLCJpZCI6InRvb2x1X2ZpeHR1cmVfMDIiLCJuYW1lIjoiQmFzaCIsImlucHV0Ijp7ImNvbW1hbmQiOiJ2YWx1ZSIsImRlc2NyaXB0aW9uIjoidmFsdWUifSwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XX0seyJyb2xlIjoidXNlciIsImNvbnRlbnQiOlt7InRvb2xfdXNlX2lkIjoidG9vbHVfZml4dHVyZV8wMiIsInR5cGUiOiJ0b29sX3Jlc3VsdCIsImNvbnRlbnQiOiJ0b29sIHJlc3VsdCB0ZXh0IDIiLCJpc19lcnJvciI6ZmFsc2V9LHsidHlwZSI6InRleHQiLCJ0ZXh0IjoidXNlciB0ZXh0IDQifV19LHsicm9sZSI6InN5c3RlbSIsImNvbnRlbnQiOiJzeXN0ZW0gdGV4dCAzIn1dLCJzeXN0ZW0iOlt7InR5cGUiOiJ0ZXh0IiwidGV4dCI6IngtYW50aHJvcGljLWJpbGxpbmctaGVhZGVyOiBjY192ZXJzaW9uPTIuMS4yOTUuMDAwOyBjY19lbnRyeXBvaW50PWNsaTsgY2NfaXNfc3ViYWdlbnQ9dHJ1ZTsifSx7InR5cGUiOiJ0ZXh0IiwidGV4dCI6InN5c3RlbSB0ZXh0IDQiLCJjYWNoZV9jb250cm9sIjp7InR5cGUiOiJlcGhlbWVyYWwifX0seyJ0eXBlIjoidGV4dCIsInRleHQiOiJzeXN0ZW0gdGV4dCA1IiwiY2FjaGVfY29udHJvbCI6eyJ0eXBlIjoiZXBoZW1lcmFsIn19XSwidG9vbHMiOlt7Im5hbWUiOiJCYXNoIiwiZGVzY3JpcHRpb24iOiJUb29sIGRlc2NyaXB0aW9uLiIsImlucHV0X3NjaGVtYSI6eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5leGFtcGxlLmNvbS9zY2hlbWEiLCJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJjb21tYW5kIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifSwidGltZW91dCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoibnVtYmVyIn0sImRlc2NyaXB0aW9uIjp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifSwicnVuX2luX2JhY2tncm91bmQiOnsiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwidHlwZSI6ImJvb2xlYW4ifSwiZGFuZ2Vyb3VzbHlEaXNhYmxlU2FuZGJveCI6eyJkZXNjcmlwdGlvbiI6IlBhcmFtZXRlciBkZXNjcmlwdGlvbi4iLCJ0eXBlIjoiYm9vbGVhbiJ9fSwicmVxdWlyZWQiOlsiY29tbWFuZCJdLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2V9fSx7Im5hbWUiOiJtY3BfX2V4YW1wbGVfX3Rvb2xfMSIsImRlc2NyaXB0aW9uIjoiVG9vbCBkZXNjcmlwdGlvbi4iLCJpbnB1dF9zY2hlbWEiOnsidHlwZSI6Im9iamVjdCIsInByb3BlcnRpZXMiOnsibnVtX3Jlc3VsdHMiOnsiZGVmYXVsdCI6MSwiZGVzY3JpcHRpb24iOiJQYXJhbWV0ZXIgZGVzY3JpcHRpb24uIiwibWF4aW11bSI6MTAsIm1pbmltdW0iOjEsInR5cGUiOiJpbnRlZ2VyIn0sInF1ZXJ5Ijp7ImRlc2NyaXB0aW9uIjoiUGFyYW1ldGVyIGRlc2NyaXB0aW9uLiIsInR5cGUiOiJzdHJpbmcifX0sInJlcXVpcmVkIjpbInF1ZXJ5Il19fV0sIm1ldGFkYXRhIjp7InVzZXJfaWQiOiJ7XCJkZXZpY2VfaWRcIjogXCJmaXh0dXJlLWRldmljZVwiLCBcImFjY291bnRfdXVpZFwiOiBcIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMVwiLCBcInNlc3Npb25faWRcIjogXCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDBcIn0ifSwibWF4X3Rva2VucyI6MTI4MDAwLCJ0aGlua2luZyI6eyJ0eXBlIjoiYWRhcHRpdmUifSwib3V0cHV0X2NvbmZpZyI6eyJlZmZvcnQiOiJoaWdoIn0sInN0cmVhbSI6dHJ1ZX0K","header":{"Anthropic-Beta":["claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01"],"Anthropic-Version":["2023-06-01"],"Content-Length":["2379"],"Content-Type":["application/json"],"User-Agent":["claude-cli/2.1.295 (external, cli)"],"X-Claude-Code-Session-Id":["00000000-0000-4000-8000-000000000000"]},"query":"beta=true"}} +{"v":1,"schemaVersion":1,"op":"append","requestId":"9-59ec16","startedAt":"2026-11-09T22:54:43.196781Z","session":"00000000-0000-4000-8000-000000000000","event":{"at":"2026-11-09T22:54:43.196781Z","direction":"outbound","phase":"response","requestId":"9-59ec16","inference":{"model":"claude-opus-5-5","maxTokens":128000,"stream":true,"thinkingType":"adaptive","effort":"high","anthropicBeta":"claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01","agentRole":"subagent","streamedResponse":true,"completion":"word word word ","finishReason":"end_turn","promptTokens":29877,"completionTokens":14,"totalTokens":29891,"inputTokens":11093,"cacheReadTokens":18525,"cacheWriteTokens":259,"outputTokens":14,"presentKinds":31,"isAction":true},"invocations":{"outbound":[{"plugin":"inference-parser","action":"observe","phase":"response","reason":"matched_claude-opus-5-5_response","path":"/v1/messages","late":true}]},"plugins":{"cost":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.136075,"output_usd":0.000322,"tiers":{"input":0.077651,"cache_write":0.002849,"cache_read":0.055575,"output":0.000322}},"litellm-budget-track":{"cost_usd":0.0123,"source":"gateway-header","daily_total_usd":0,"daily_max_usd":0,"provenance":"authoritative","settled":true,"prompt_usd":0.136075,"output_usd":0.000322,"tiers":{"input":0.077651,"cache_write":0.002849,"cache_read":0.055575,"output":0.000322}}},"statusCode":200,"host":"api.example.com","bytesDown":1671,"httpMethod":"POST","httpPath":"/v1/messages","client":{"name":"claude-code","version":"2.1.295","raw":"claude-cli/2.1.295 (external, cli)"}},"capture":{"response":{"status":200,"header":{"Content-Length":["1671"],"Content-Type":["text/event-stream"],"X-Litellm-Response-Cost":["0.0123"]},"body":"ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RhcnQiLCAibWVzc2FnZSI6IHsibW9kZWwiOiAiY2xhdWRlLW9wdXMtNS01IiwgImlkIjogIm1zZ19maXh0dXJlIiwgInR5cGUiOiAibWVzc2FnZSIsICJyb2xlIjogImFzc2lzdGFudCIsICJjb250ZW50IjogW10sICJjb250YWluZXIiOiBudWxsLCAic3RvcF9yZWFzb24iOiBudWxsLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAidXNhZ2UiOiB7ImlucHV0X3Rva2VucyI6IDExMDkzLCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMjU5LCAiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiOiAxODUyNSwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMjU5LCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9LCAib3V0cHV0X3Rva2VucyI6IDIsICJzZXJ2aWNlX3RpZXIiOiAic3RhbmRhcmQifX19CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19zdGFydCIsICJpbmRleCI6IDAsICJjb250ZW50X2Jsb2NrIjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICIifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ICJjb250ZW50X2Jsb2NrX2RlbHRhIiwgImluZGV4IjogMCwgImRlbHRhIjogeyJ0eXBlIjogInRleHRfZGVsdGEiLCAidGV4dCI6ICJ3b3JkICJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfZGVsdGEiLCAiaW5kZXgiOiAwLCAiZGVsdGEiOiB7InR5cGUiOiAidGV4dF9kZWx0YSIsICJ0ZXh0IjogIndvcmQgIn19CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiAiY29udGVudF9ibG9ja19kZWx0YSIsICJpbmRleCI6IDAsICJkZWx0YSI6IHsidHlwZSI6ICJ0ZXh0X2RlbHRhIiwgInRleHQiOiAid29yZCAifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjogImNvbnRlbnRfYmxvY2tfc3RvcCIsICJpbmRleCI6IDB9CgpldmVudDogbWVzc2FnZV9kZWx0YQpkYXRhOiB7InR5cGUiOiAibWVzc2FnZV9kZWx0YSIsICJkZWx0YSI6IHsic3RvcF9yZWFzb24iOiAiZW5kX3R1cm4iLCAic3RvcF9zZXF1ZW5jZSI6IG51bGwsICJzdG9wX2RldGFpbHMiOiBudWxsLCAiY29udGFpbmVyIjogbnVsbH0sICJ1c2FnZSI6IHsiaW5wdXRfdG9rZW5zIjogMTEwOTMsICJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOiAyNTksICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE4NTI1LCAib3V0cHV0X3Rva2VucyI6IDE0LCAib3V0cHV0X3Rva2Vuc19kZXRhaWxzIjogeyJ0aGlua2luZ190b2tlbnMiOiAwfSwgImNhY2hlX2NyZWF0aW9uIjogeyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjogMjU5LCAiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6IDB9fX0KCmV2ZW50OiBtZXNzYWdlX3N0b3AKZGF0YTogeyJ0eXBlIjogIm1lc3NhZ2Vfc3RvcCIsICJ1c2FnZSI6IHsiaW5wdXRfdG9rZW5zIjogMTEwOTMsICJvdXRwdXRfdG9rZW5zIjogMTQsICJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6IDE4NTI1LCAiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjogMjU5fX0K","decoded":false,"truncated":false,"ended":true,"incomplete":false}},"cost":{"headers":{"Content-Type":"text/event-stream","X-Litellm-Response-Cost":"0.0123"}}} diff --git a/docs/session-api.md b/docs/session-api.md index a54ce7b83..3519caaf4 100644 --- a/docs/session-api.md +++ b/docs/session-api.md @@ -357,6 +357,14 @@ A request row carries `original`, `header` and `query`; a response row carries `response` only. A capture the producer dropped is `{"dropped": true}`. `cost` is on response rows only. +A response row's `inference` leaves out the request-only fields: +`messages`, `tools`, `toolResults`, `historyToolCalls` and +`historyThinking`. They are most of a line's bytes, and the request row +carries them already. Cortex puts them back from the request row of the same +`requestId`, so the stored response row is the same as in direct mode. A +field the response row carries is kept. When the producer dropped the +request row, the response row is stored without them. + **What Cortex does with a line:** - Files it under `session`; an empty `session` goes to the active session, @@ -367,6 +375,8 @@ carries `response` only. A capture the producer dropped is - Keeps a capture only for an event with `inference`, removes credentials from its headers and query again, and keeps a response capture only after the request capture of the same `requestId` (held for up to 1 hour). +- Decodes up to 8 lines of a stream at the same time (at most one per CPU), + and files them one at a time, in stream order. - Drops a line whose `startedAt` is before an erase of its session (`DELETE /v1/sessions/{id}` or `DELETE /v1/sessions`) in the last 10 minutes, so a queued row cannot bring an erased session back. diff --git a/praxis/filters/src/record/row.rs b/praxis/filters/src/record/row.rs index 4a9ea474e..8721f19fa 100644 --- a/praxis/filters/src/record/row.rs +++ b/praxis/filters/src/record/row.rs @@ -508,7 +508,17 @@ impl Exchange { fn response_row(&mut self, status_code: i64, error: Option) -> Line { let mut event = Event { - inference: self.observer.as_ref().and_then(|o| o.extension()).cloned(), + // Line format v1: the request row carries the request-only fields, and Cortex puts + // them back on this row. They are most of a line's bytes (docs/session-api.md). + inference: self.observer.as_ref().and_then(|o| o.extension()).map(|i| { + let mut i = i.clone(); + i.messages = Vec::new(); + i.tools = Vec::new(); + i.tool_results = Vec::new(); + i.history_tool_calls = Vec::new(); + i.history_thinking = None; + i + }), status_code, error, duration_ms: self.start.elapsed().as_millis() as i64, @@ -670,6 +680,27 @@ mod tests { assert!(started.is_ok()); } + #[test] + fn response_row_leaves_out_request_fields() { + let f = fixture(); + let (req, resp, _) = run(&f.resp_body, &[], 4096); + let (req, resp) = (json_of(&req), json_of(&resp)); + let (qi, ri) = (&req["event"]["inference"], &resp["event"]["inference"]); + assert!(qi["messages"].as_array().is_some_and(|m| !m.is_empty())); + assert!(qi["tools"].as_array().is_some_and(|t| !t.is_empty())); + for k in [ + "messages", + "tools", + "toolResults", + "historyToolCalls", + "historyThinking", + ] { + assert!(ri.get(k).is_none(), "the response row carries {k}"); + } + assert_eq!(ri["model"], qi["model"]); + assert!(ri["outputTokens"].as_i64().unwrap() > 0); + } + #[test] fn capture_headers_have_no_hop_or_credential_headers() { let f = fixture(); diff --git a/scripts/dev/praxis-measure.sh b/scripts/dev/praxis-measure.sh index 9fd66906d..44d453890 100755 --- a/scripts/dev/praxis-measure.sh +++ b/scripts/dev/praxis-measure.sh @@ -10,7 +10,8 @@ # each one with the largest claude-code fixture response. # # The script prints, for each process: the CPU time (ps -o time), the peak RSS -# (sampled every 0.2 s) and, for Cortex, the heap in use from /debug/pprof/heap. +# (sampled every 0.2 s) and, for Cortex, the heap in use after a GC, from +# /debug/pprof/heap?gc=1. # cortex-gateway serves no pprof. # # Every process runs under a temp HOME on its own ports. The script never touches @@ -286,7 +287,7 @@ EOF fi sleep 0.5 # one more RSS sample after the last request - heap=$(curl -s --noproxy '*' "http://127.0.0.1:$stats/debug/pprof/heap?debug=1" | + heap=$(curl -s --noproxy '*' "http://127.0.0.1:$stats/debug/pprof/heap?gc=1&debug=1" | awk '/^# HeapInuse = / {printf "%.1f", $4 / 1048576}') row "$mode" cortex "$cortex_pid" "$dir/cortex.rss" "${heap:-n/a}" "$secs" if [ -n "$gateway_pid" ]; then From e11d6de58632e6baf6e780b84abe70634664bb49 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 18:18:19 -0500 Subject: [PATCH 23/26] Fix: Make praxis-measure.sh fail when Cortex files fewer rows than it expects Fixes review: the praxis run printed its table and exited 0 when rows were missing, so its numbers looked comparable with direct mode (clawgenti on row.rs, item 2; clawgenti and CodeRabbit on praxis-measure.sh) Files: - scripts/dev/praxis-measure.sh Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- scripts/dev/praxis-measure.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/dev/praxis-measure.sh b/scripts/dev/praxis-measure.sh index 44d453890..ed57d313b 100755 --- a/scripts/dev/praxis-measure.sh +++ b/scripts/dev/praxis-measure.sh @@ -284,6 +284,10 @@ EOF echo "praxis: $(($(date +%s) - start)) s to ingest; stats: $(curl -s --noproxy '*' "http://127.0.0.1:$api/v1/ingest/stats")" >&2 echo "praxis: cortex-gateway emitter counters:" >&2 curl -s --noproxy '*' "http://127.0.0.1:$gw_metrics/metrics" | grep '^cortex_' >&2 || true + if [ "$lines" -lt $((2 * n)) ]; then + echo "praxis-measure: ROWS DROPPED: Cortex filed $lines of $((2 * n)) rows, so the praxis numbers do not compare with direct mode" >&2 + exit 1 + fi fi sleep 0.5 # one more RSS sample after the last request From adbe7a6e25f99bc24636fa4abf2a50ea89ca7dfb Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 18:30:57 -0500 Subject: [PATCH 24/26] Docs: Name #1721 for the hop, and record the accepted edge cases The plan and six comments said #1722 for the hop, the egress listener and the tunnel joins. #1722 is agentop and the installer. These belong to #1721. praxis/README.md now records two decisions: the response row leaves out the request-only fields, and the gateway removes X-Cortex-Dest and X-Cortex-Token until #1721 adds its egress listener. It also records the four edge cases from the PR #1717 review that this PR accepts. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- core/config/config.go | 2 +- core/sessionapi/ingest.go | 2 +- ...-11-09-praxis-mode-2-gateway-and-ingest.md | 12 +++++------ praxis/README.md | 21 ++++++++++++++++++- praxis/filters/src/record/attribution.rs | 2 +- praxis/filters/src/record/line.rs | 2 +- 6 files changed, 30 insertions(+), 11 deletions(-) diff --git a/core/config/config.go b/core/config/config.go index 3f26928e2..38cab768f 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -336,7 +336,7 @@ func (b *TLSBridgeConfig) Validate() error { } // PraxisConfig is the top-level `praxis:` block. listen and egress land with -// their callers (#1720, #1722). +// their callers (#1720, #1721). type PraxisConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` } diff --git a/core/sessionapi/ingest.go b/core/sessionapi/ingest.go index abdcf4782..4f4db1e09 100644 --- a/core/sessionapi/ingest.go +++ b/core/sessionapi/ingest.go @@ -339,7 +339,7 @@ func decodeIngestLine(b []byte) decodedLine { func (s *Server) fileIngest(l ingestLine) { in := s.ingest sid := attribution.FiledSession(l.Session, s.store.ActiveSession) - // #1722 releases the line's tunnel join here. + // #1721 releases the line's tunnel join here. if in.marks.erasedAfter(sid, l.StartedAt) { in.refuse(refusedErased) return diff --git a/docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md b/docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md index 833e74a94..65b601b9d 100644 --- a/docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md +++ b/docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md @@ -16,14 +16,14 @@ Each one closes a question that the spec or the issue leaves open. 1. **No Praxis test helper exists outside Praxis** (`praxis_filter::test_utils` is `pub(crate)`), and the spec forbids an `HttpFilterContext` struct literal [T F10]. So the filters stay thin, and all logic is in `record` and `parse`, which take plain values. The filter behavior is tested end to end: the tests start the real `cortex-gateway` binary with a mock origin and a mock ingest endpoint. 2. **The token file path.** Cortex reads it from the env var `CORTEX_GATEWAY_TOKEN_FILE`. The gateway reads it from the `token_file` key of its filter config. The supervisor (#1720) creates the file, sets the env var and writes `gateway.yaml`. In this PR, tests create the file. Cortex never creates it. -3. **The `praxis` config block has only `enabled` in this PR.** `listen` and `egress` land with their callers (#1720, #1722). This PR also adds the two startup refusals (`praxis.enabled` with `mtls`, or without `bind_loopback_only`) and the restart rule for `praxis.*`, because the field lands here. +3. **The `praxis` config block has only `enabled` in this PR.** `listen` and `egress` land with their callers (#1720, #1721). This PR also adds the two startup refusals (`praxis.enabled` with `mtls`, or without `bind_loopback_only`) and the restart rule for `praxis.*`, because the field lands here. 4. **The ingest counters** are served at `GET /v1/ingest/stats` on the session API, in praxis mode only. The spec names `/stats`, but `/stats` serves `auth.Stats` from plugins and is built before the session API. The route uses the loopback guards of the session API. -5. **`X-Cortex-Attribution` JSON**, base64url without padding. Keys: `agent`, `proc`, `clientProc`, `session`, `source`, `sessionHeader`, `chainSession`, `recordSession`. All are strings, all optional. The Go encoder (`attribution.Wire`) lands with the hop (#1722). A missing or bad header with a valid token gives `Attribution::default()`. Then `session` is empty, and ingestion files the row with `attribution.FiledSession` (the active session, then the default bucket), as a listener does. -6. **Fields the gateway cannot know in v1** stay unset: `identity` (no workload identity on a laptop), `tls` (the TLS bridge state is in Cortex; #1722 decides if the hop carries it), `invocations` (no plugins run), `requestedHost` (no router), `upgradeStripped` (Cortex strips upgrades before the hop), `tunnel`, `tunnelReason`, `dialAddr` (Cortex's own tunnel rows), `a2a`, `mcp` (step 6). The drift test lists each one with this reason. `plugins` is set by ingestion (cost). +5. **`X-Cortex-Attribution` JSON**, base64url without padding. Keys: `agent`, `proc`, `clientProc`, `session`, `source`, `sessionHeader`, `chainSession`, `recordSession`. All are strings, all optional. The Go encoder (`attribution.Wire`) lands with the hop (#1721). A missing or bad header with a valid token gives `Attribution::default()`. Then `session` is empty, and ingestion files the row with `attribution.FiledSession` (the active session, then the default bucket), as a listener does. +6. **Fields the gateway cannot know in v1** stay unset: `identity` (no workload identity on a laptop), `tls` (the TLS bridge state is in Cortex; #1721 decides if the hop carries it), `invocations` (no plugins run), `requestedHost` (no router), `upgradeStripped` (Cortex strips upgrades before the hop), `tunnel`, `tunnelReason`, `dialAddr` (Cortex's own tunnel rows), `a2a`, `mcp` (step 6). The drift test lists each one with this reason. `plugins` is set by ingestion (cost). 7. **`client`, `subagent` and `parentSubagent` are ported to Rust** (`model.ParseUserAgent`, `subagentHeader`), because the end state needs them in Rust. A Go test writes a golden table that the Rust test reads. 8. **Messages of `client_canceled` and `upstream_error` rows** are Rust text (`"client closed the connection"`, `"upstream closed the connection"`). Go puts the Go error text there. Only the message differs. This is a known difference, as the 413 text is not one: the 413 text is copied exactly. 9. **The response-capture guard at ingestion** keeps the request ids of kept request captures until the response line arrives, and removes entries older than 1 hour. A 60 s window would drop the response capture of every long stream. -10. **Tunnel joins** (`requestId → tunnel`, Section 1 "Joins") land with the hop (#1722). This PR has no join to release when a line is dropped as erased. #1722 adds the release at that drop point (`ingest.go`, the `erased` branch). +10. **Tunnel joins** (`requestId → tunnel`, Section 1 "Joins") land with the hop (#1721). This PR has no join to release when a line is dropped as erased. #1721 adds the release at that drop point (`ingest.go`, the `erased` branch). 11. **The replay test.** The "live run" is a direct-mode forward proxy with `inference-parser` and a rate table, over every `claude-code` fixture. A Go test converts its rows and captures to NDJSON lines, as the producer writes them, and commits the file. The same file is the golden for the Rust row builder (Task 10). So one file pins both sides. 12. **The emitter has its own thread** with a current-thread Tokio runtime, started once per process. It does not depend on the Praxis runtime, and `from_config` can run outside a runtime. 13. **`praxis-proxy` without default features**: `features = ["pingora", "admin-api", "forward-proxy"]`. This drops the policy engine and `aws-lc-sys` from the build. OpenSSL is vendored now (spec Section 4, "A standalone binary"), so CI needs no system OpenSSL. @@ -328,7 +328,7 @@ Handler rules, in order: 2. No `Authorization: Bearer ` (`subtle.ConstantTimeCompare`) → 401. 3. Read the body line by line with a `bufio.Reader` and a 64 MiB cap per line. A line over the cap is discarded up to its `\n` and refused as `line_cap`. The connection stays open after any refusal. The answer, when the body ends, is 204. 4. For each line: unmarshal (`bad_json`); `v != 1` → `version`; `schemaVersion != SchemaVersion` → `schema`; `op != "append"` → `op`. -5. `sid := attribution.FiledSession(line.Session, store.ActiveSession)`. When the erase marks hold a mark for `sid` (or a clear) later than `startedAt` → `erased`. (#1722 releases the tunnel join here: Decision 10.) +5. `sid := attribution.FiledSession(line.Session, store.ActiveSession)`. When the erase marks hold a mark for `sid` (or a clear) later than `startedAt` → `erased`. (#1721 releases the tunnel join here: Decision 10.) 6. Remove `Plugins["cost"]` and `Plugins["litellm-budget-track"]`. Zero `Seq` and `SessionID`. Set `At = time.Now()`. 7. A response row (`Phase == SessionResponse`): `inference.SettleServedModel(ev.Inference, hdr)`, then `settle.SettleInput(settle.Input{Host: ev.Host, Path: ev.HTTPPath, Inference: ev.Inference, ResponseHeaders: hdr}, Rates)`, with `hdr` built from `cost.headers`. When the result is `Priced`, `HasPrompt` or has a `RejectedReason`, write `json.Marshal(settle.NewRecord(s, nil))` to `Plugins[event.Key]` and `Plugins[event.PluginName]`. This is the gate of `inferenceparser/cost.go`. 8. Capture: `dropped` → count `CapturesDropped`, no capture. No `ev.Inference` → no capture. A request capture: redact `Header` and `Query` again, keep it, and remember `requestId`. A response capture: keep it only when `requestId` is remembered (then forget it), with `Header` redacted again. Entries older than 1 hour are removed when a response line is read (Decision 9). @@ -415,5 +415,5 @@ Depends on: Task 6, Task 9 ## Self-review notes -- Spec coverage: Section 1 → Tasks 4, 5, 8, 10. Section 2 → Tasks 5, 6. Section 3 (chain, record, emitter, drift test, binary) → Tasks 1, 4, 5, 6. Section 4 parts in scope (config refusals, vendored OpenSSL) → Tasks 1, 7. Section 5 Rust filter tests and Go contract tests in scope → Tasks 6, 8, 9, 10. Out of scope, by delivery step: tunnel pairing, Cortex cuts, the hop, the egress listener (#1722); supervision, the token file, `gateway.yaml` (#1720); agentop and install (step 5); MCP and A2A (step 6); the release job (step 7). +- Spec coverage: Section 1 → Tasks 4, 5, 8, 10. Section 2 → Tasks 5, 6. Section 3 (chain, record, emitter, drift test, binary) → Tasks 1, 4, 5, 6. Section 4 parts in scope (config refusals, vendored OpenSSL) → Tasks 1, 7. Section 5 Rust filter tests and Go contract tests in scope → Tasks 6, 8, 9, 10. Out of scope, by delivery step: tunnel pairing, Cortex cuts, the hop, the egress listener (#1721); supervision, the token file, `gateway.yaml` (#1720); agentop and install (step 5); MCP and A2A (step 6); the release job (step 7). - Known edge cases from the PR #1717 review are accepted as the issue lists them: an in-memory erase mark, a check that is not atomic with the append, streams longer than 10 minutes, and `At` set at append. diff --git a/praxis/README.md b/praxis/README.md index 082b3fde4..a22ced938 100644 --- a/praxis/README.md +++ b/praxis/README.md @@ -63,7 +63,7 @@ gives the full text. 7. `client`, `subagent` and `parentSubagent` are ported to Rust, pinned by a Go golden table. 8. `client_canceled` and `upstream_error` messages are Rust text. Only the message differs from Go. 9. Ingestion keeps the request id of a kept request capture for up to 1 hour, until its response line arrives. -10. Tunnel joins land with the hop (#1722). +10. Tunnel joins land with the hop (#1721). 11. One replay NDJSON file is the golden for both the Go ingestion and the Rust row builder. 12. The emitter has its own thread and a current-thread Tokio runtime. 13. `praxis-proxy` builds without default features, with vendored OpenSSL. @@ -72,6 +72,25 @@ gives the full text. 16. `CortexObserve` keeps its exchange in filter state as `Arc>`. 17. The ingest stream ends on Cortex shutdown, so restarts stay quick. 18. A stream cut after the response head is `client_canceled`, also when the upstream cut it. Praxis 0.8.0 gives no error source after the head. +19. A response row leaves out the request-only inference fields (`messages`, `tools`, `toolResults`, `historyToolCalls`, `historyThinking`). Cortex puts them back from the request row (`docs/session-api.md`, line format v1). +20. The gateway removes `X-Cortex-Dest` and `X-Cortex-Token` before the upstream, against the issue text. No egress listener exists yet, so they would send the token to the origin. #1721 keeps them again for its egress listener. + +## Accepted edge cases (#1719) + +These come from the PR #1717 review. This PR accepts them: + +1. **A restart loses the erase marks.** They are in memory. A line that the + gateway queued before an erase, and sends after a Cortex restart, can bring + the erased session back. +2. **The erase check and the append are two steps.** An erase between them + lets that one line create the session again. A full fix checks under the + store's lock. +3. **A stream longer than 10 minutes can record after an erase.** Its + response line arrives after the erase mark expired. Direct mode has the + same window for a response in flight at an erase. +4. **`at` is set when Cortex appends the row.** So a request that names the + session, and arrives before an earlier request that is still pending, can + change which rows are linked to the session. ## The parity rule diff --git a/praxis/filters/src/record/attribution.rs b/praxis/filters/src/record/attribution.rs index 4c3fe67ed..1211cbc76 100644 --- a/praxis/filters/src/record/attribution.rs +++ b/praxis/filters/src/record/attribution.rs @@ -25,7 +25,7 @@ pub const TOKEN_HEADER: &str = "X-Cortex-Token"; /// The hop headers. The upstream and the request capture get none of them. /// `X-Cortex-Token` is the gateway's secret, so it must not reach an origin. -/// #1722 keeps `X-Cortex-Dest` and the token for its egress listener, which +/// #1721 keeps `X-Cortex-Dest` and the token for its egress listener, which /// removes them itself. pub const HOP_HEADERS: [&str; 12] = [ "X-Cortex-Agent", diff --git a/praxis/filters/src/record/line.rs b/praxis/filters/src/record/line.rs index a59640e8b..3c66ae66a 100644 --- a/praxis/filters/src/record/line.rs +++ b/praxis/filters/src/record/line.rs @@ -29,7 +29,7 @@ pub const NOT_SET_IN_PRAXIS_MODE: &[&str] = &[ "plugins", // No workload identity on a laptop. "identity", - // The TLS bridge state is in Cortex; #1722 decides if the hop carries it. + // The TLS bridge state is in Cortex; #1721 decides if the hop carries it. "tls", // No plugins run in the gateway. "invocations", From 455b9d4f876ad92b9288e40272165a7f6b0dd495 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 19:34:34 -0500 Subject: [PATCH 25/26] Fix: Keep the client's status on a cut body, and copy request bodies less Fixes review: - A buffered response body over the cap after the response head gave a row with status 502. The client had the upstream status and a cut body. The row now has the upstream status. 502 stays for a Content-Length over the cap, which the gateway refuses at the head. - The request row and the response row each copied the request messages and tools. The request row now moves them out of the parser state, and the response row copies only the small fields. - A line with a capture was serialized two times, and the queue kept both copies. The line is now serialized one time. The queue keeps the row and the capture bytes, and the emitter sends them joined. - No test sent a response with no body. A gateway test now sends a 204. Files: - praxis/filters/src/parse/inference/observer.rs - praxis/filters/src/record/emitter.rs - praxis/filters/src/record/line.rs - praxis/filters/src/record/row.rs - praxis/README.md - praxis/server/tests/gateway.rs Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- praxis/README.md | 1 + .../filters/src/parse/inference/observer.rs | 4 + praxis/filters/src/record/emitter.rs | 149 ++++++++++-------- praxis/filters/src/record/line.rs | 32 ++-- praxis/filters/src/record/row.rs | 92 ++++++++--- praxis/server/tests/gateway.rs | 64 ++++++++ 6 files changed, 236 insertions(+), 106 deletions(-) diff --git a/praxis/README.md b/praxis/README.md index a22ced938..481b75ff8 100644 --- a/praxis/README.md +++ b/praxis/README.md @@ -74,6 +74,7 @@ gives the full text. 18. A stream cut after the response head is `client_canceled`, also when the upstream cut it. Praxis 0.8.0 gives no error source after the head. 19. A response row leaves out the request-only inference fields (`messages`, `tools`, `toolResults`, `historyToolCalls`, `historyThinking`). Cortex puts them back from the request row (`docs/session-api.md`, line format v1). 20. The gateway removes `X-Cortex-Dest` and `X-Cortex-Token` before the upstream, against the issue text. No egress listener exists yet, so they would send the token to the origin. #1721 keeps them again for its egress listener. +21. A buffered response body that goes over the 10 MiB cap after the response head gives a row with the upstream status, not 502. The client has that status and a cut body. Direct mode reads the body before it sends the head, so its client and its row get 502. A `Content-Length` over the cap gives 502 in both modes. ## Accepted edge cases (#1719) diff --git a/praxis/filters/src/parse/inference/observer.rs b/praxis/filters/src/parse/inference/observer.rs index 325ffb28d..1aede6910 100644 --- a/praxis/filters/src/parse/inference/observer.rs +++ b/praxis/filters/src/parse/inference/observer.rs @@ -82,6 +82,10 @@ impl InferenceObserver { pub fn extension(&self) -> Option<&InferenceExtension> { self.ext.as_ref() } + + pub fn extension_mut(&mut self) -> Option<&mut InferenceExtension> { + self.ext.as_mut() + } } #[cfg(test)] diff --git a/praxis/filters/src/record/emitter.rs b/praxis/filters/src/record/emitter.rs index c8e239fbe..74bbca64b 100644 --- a/praxis/filters/src/record/emitter.rs +++ b/praxis/filters/src/record/emitter.rs @@ -1,12 +1,42 @@ //! The emitter: one thread per process that streams NDJSON lines to Cortex. -/// One line, serialized twice: without and with its capture. -/// -/// Each ends in `\n`. `row` holds `"capture":{"dropped":true}` when the line -/// has a capture, and no `capture` key otherwise. +/// One line, serialized once. `row` ends in `\n`. Without a capture, `row` is +/// the line. With one, `row` ends in `DROPPED_TAIL` and `capture` holds the +/// capture's JSON: the line is `row` with `capture` in place of +/// `{"dropped":true}`. Dropping the capture leaves `row`, a valid line. pub struct Queued { pub row: Vec, - pub with_capture: Option>, + pub capture: Option>, +} + +/// The end of `Queued::row` when the line has a capture. +pub const DROPPED_TAIL: &[u8] = b"{\"dropped\":true}}\n"; + +impl Queued { + /// The part of `row` before the capture. + fn head(&self) -> &[u8] { + &self.row[..self.row.len() - DROPPED_TAIL.len()] + } + + /// The length of the line that goes on the wire. + fn wire_len(&self) -> usize { + match &self.capture { + Some(c) => self.head().len() + c.len() + 2, + None => self.row.len(), + } + } + + /// The bytes in memory. + fn size(&self) -> usize { + self.row.len() + self.capture.as_ref().map_or(0, Vec::len) + } + + /// The line with its capture, as one buffer. + #[cfg(test)] + pub fn with_capture(&self) -> Option> { + let c = self.capture.as_ref()?; + Some([self.head(), c, b"}\n"].concat()) + } } use std::collections::VecDeque; @@ -37,23 +67,10 @@ pub struct EmitterStats { pub queued_bytes: usize, } -/// A queued line. `row` is the line without its capture, kept so that a later -/// row can take the capture's place in the queue. -struct Entry { - line: Vec, - row: Option>, -} - -impl Entry { - fn size(&self) -> usize { - self.line.len() + self.row.as_ref().map_or(0, Vec::len) - } -} - -/// `bytes` counts each entry's line and its kept row: both are in memory. +/// `bytes` counts each line's row and capture. #[derive(Default)] struct Queue { - lines: VecDeque, + lines: VecDeque, bytes: usize, } @@ -66,9 +83,8 @@ impl Queue { if self.bytes + need <= MAX_QUEUE_BYTES { break; } - if let Some(row) = e.row.take() { - self.bytes -= e.line.len(); - e.line = row; + if let Some(c) = e.capture.take() { + self.bytes -= c.len(); dropped += 1; } } @@ -129,35 +145,19 @@ impl Emitter { /// Queues one line. It never blocks and never fails. A line that does not /// fit loses its capture first. Then queued lines lose their captures, /// newest first. Only then is the line dropped and counted. - pub fn submit(&self, q: Queued) { - let Queued { row, with_capture } = q; - let mut entry = match with_capture { - Some(c) if c.len() > MAX_LINE => { - bump_capture(&self.captures_dropped_line_cap, "line_cap"); - Entry { - line: row, - row: None, - } - } - Some(c) => Entry { - line: c, - row: Some(row), - }, - None => Entry { - line: row, - row: None, - }, - }; + pub fn submit(&self, mut entry: Queued) { + debug_assert!(entry.capture.is_none() || entry.row.ends_with(DROPPED_TAIL)); + if entry.capture.is_some() && entry.wire_len() > MAX_LINE { + entry.capture = None; + bump_capture(&self.captures_dropped_line_cap, "line_cap"); + } let mut captures_dropped = 0; let queued = { let mut q = self.queue.lock().unwrap_or_else(|p| p.into_inner()); if q.lines.len() >= MAX_QUEUE_LINES { false } else { - if q.bytes + entry.size() > MAX_QUEUE_BYTES - && let Some(r) = entry.row.take() - { - entry.line = r; + if q.bytes + entry.size() > MAX_QUEUE_BYTES && entry.capture.take().is_some() { captures_dropped += 1; } captures_dropped += q.drop_captures(entry.size()); @@ -194,16 +194,16 @@ impl Emitter { } } - fn pop(&self) -> Option> { + fn pop(&self) -> Option { let mut q = self.queue.lock().unwrap_or_else(|p| p.into_inner()); let e = q.lines.pop_front()?; q.bytes -= e.size(); - Some(e.line) + Some(e) } /// Waits for the next line. Cancel-safe: the pop and the return are in /// one poll. `notify_one` keeps a permit, so no wake-up is lost. - async fn next_line(&self) -> Vec { + async fn next_line(&self) -> Queued { loop { if let Some(line) = self.pop() { return line; @@ -247,9 +247,16 @@ impl Emitter { } }; let sent = async { - wr.write_all(format!("{:x}\r\n", line.len()).as_bytes()) + wr.write_all(format!("{:x}\r\n", line.wire_len()).as_bytes()) .await?; - wr.write_all(&line).await?; + match &line.capture { + Some(c) => { + wr.write_all(line.head()).await?; + wr.write_all(c).await?; + wr.write_all(b"}\n").await?; + } + None => wr.write_all(&line.row).await?, + } wr.write_all(b"\r\n").await } .await; @@ -288,7 +295,17 @@ mod tests { fn line(s: &str) -> Queued { Queued { row: format!("{s}\n").into_bytes(), - with_capture: None, + capture: None, + } + } + + /// A line with a capture of `n` bytes of `x`. + fn with_capture(n: usize) -> Queued { + let mut row = b"{\"capture\":".to_vec(); + row.extend_from_slice(DROPPED_TAIL); + Queued { + row, + capture: Some(vec![b'x'; n]), } } @@ -368,16 +385,22 @@ mod tests { assert_eq!(e.stats().rows_emitted, 3); } + #[test] + fn capture_goes_on_the_wire_in_its_line() { + let (addr, rx) = serve(vec![usize::MAX]); + let e = Emitter::start(addr, "t".into()); + e.submit(with_capture(3)); + assert_eq!(recv(&rx), "auth Bearer t"); + assert_eq!(recv(&rx), "{\"capture\":xxx}\n"); + } + #[test] fn capture_over_line_cap_is_dropped_row_kept() { let (addr, rx) = serve(vec![usize::MAX]); let e = Emitter::start(addr, "t".into()); - e.submit(Queued { - row: b"row\n".to_vec(), - with_capture: Some(vec![b'x'; MAX_LINE + 1]), - }); + e.submit(with_capture(MAX_LINE + 1)); assert_eq!(recv(&rx), "auth Bearer t"); - assert_eq!(recv(&rx), "row\n"); + assert_eq!(recv(&rx), "{\"capture\":{\"dropped\":true}}\n"); assert_eq!(e.stats().captures_dropped_line_cap, 1); } @@ -385,10 +408,7 @@ mod tests { fn queue_drops_captures_before_rows() { let e = Emitter::start(closed_addr(), "t".into()); for _ in 0..30 { - e.submit(Queued { - row: b"row\n".to_vec(), - with_capture: Some(vec![b'x'; 10 << 20]), - }); + e.submit(with_capture(10 << 20)); } let s = e.stats(); assert!(s.queued_bytes <= MAX_QUEUE_BYTES, "{}", s.queued_bytes); @@ -401,16 +421,13 @@ mod tests { fn queued_captures_make_room_for_a_row() { let e = Emitter::start(closed_addr(), "t".into()); for _ in 0..25 { - e.submit(Queued { - row: b"row\n".to_vec(), - with_capture: Some(vec![b'x'; 10 << 20]), - }); + e.submit(with_capture(10 << 20)); } assert_eq!(e.stats().captures_dropped_queue_bytes, 0); // A 15 MiB row with no capture: the newest queued capture gives it room. e.submit(Queued { row: vec![b'r'; 15 << 20], - with_capture: None, + capture: None, }); let s = e.stats(); assert_eq!(s.rows_dropped, 0); diff --git a/praxis/filters/src/record/line.rs b/praxis/filters/src/record/line.rs index 3c66ae66a..af982ab07 100644 --- a/praxis/filters/src/record/line.rs +++ b/praxis/filters/src/record/line.rs @@ -170,9 +170,10 @@ struct Wire<'a> { session: &'a str, event: &'a Event, #[serde(skip_serializing_if = "Option::is_none")] - capture: Option<&'a Capture>, - #[serde(skip_serializing_if = "Option::is_none")] cost: Option<&'a Cost>, + // Last: `Queued` puts the capture's bytes in place of `{"dropped":true}`. + #[serde(skip_serializing_if = "Option::is_none")] + capture: Option<&'a Capture>, } const DROPPED: Capture = Capture::Dropped { dropped: true }; @@ -192,23 +193,16 @@ impl Line { } } - /// The two serializations the emitter chooses from, each ending in `\n`. + /// The line for the emitter, serialized once (see `Queued`). pub fn queued(&self) -> Queued { - let line = |c: Option<&Capture>| { - // shortcut: serializing these plain types cannot fail. - let mut b = serde_json::to_vec(&self.wire(c)).expect("line serializes"); - b.push(b'\n'); - b - }; - match &self.capture { - None => Queued { - row: line(None), - with_capture: None, - }, - Some(c) => Queued { - row: line(Some(&DROPPED)), - with_capture: Some(line(Some(c))), - }, + // shortcut: serializing these plain types cannot fail. + let capture = self.capture.as_ref(); + let mut row = + serde_json::to_vec(&self.wire(capture.map(|_| &DROPPED))).expect("line serializes"); + row.push(b'\n'); + Queued { + row, + capture: capture.map(|c| serde_json::to_vec(c).expect("capture serializes")), } } } @@ -331,7 +325,7 @@ pub(crate) mod tests { cost: None, }; let q = l.queued(); - let full: Value = serde_json::from_slice(q.with_capture.as_ref().unwrap()).unwrap(); + let full: Value = serde_json::from_slice(&q.with_capture().unwrap()).unwrap(); assert_eq!(full["v"], 1); assert_eq!(full["schemaVersion"], 1); assert_eq!(full["op"], "append"); diff --git a/praxis/filters/src/record/row.rs b/praxis/filters/src/record/row.rs index 8721f19fa..d26035f28 100644 --- a/praxis/filters/src/record/row.rs +++ b/praxis/filters/src/record/row.rs @@ -19,6 +19,7 @@ use super::redact; use crate::parse::MAX_BODY_SIZE; use crate::parse::decode::{Coding, DecodeError, Decoder}; use crate::parse::inference::{InferenceObserver, is_event_stream}; +use crate::parse::model::InferenceExtension; use crate::parse::sse::FrameReader; /// `maxRequestBodySize` in core/listener/forwardproxy. @@ -46,7 +47,11 @@ const UPSTREAM_GONE: &str = "upstream closed the connection"; /// Why a response stopped before its end. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Abort { - /// A buffered response over `MAX_BODY_SIZE` decoded bytes. + /// A buffered response whose `Content-Length` is over `MAX_BODY_SIZE`, at its head. + /// The client gets 502. + LengthOverCap, + /// A buffered response over `MAX_BODY_SIZE` decoded bytes, after its head. + /// The client has the upstream status already. BufferedOverCap, /// One SSE frame over `MAX_BODY_SIZE`. FrameOverCap, @@ -296,7 +301,19 @@ impl Exchange { fn request_row(&mut self, body: &[u8]) -> Line { self.request_built = true; - let inference = self.observer.as_ref().and_then(|o| o.extension()).cloned(); + // The request-only fields move to this row: nothing on the response side reads them. + let inference = self + .observer + .as_mut() + .and_then(|o| o.extension_mut()) + .map(|i| InferenceExtension { + messages: std::mem::take(&mut i.messages), + tools: std::mem::take(&mut i.tools), + tool_results: std::mem::take(&mut i.tool_results), + history_tool_calls: std::mem::take(&mut i.history_tool_calls), + history_thinking: i.history_thinking.take(), + ..i.clone() + }); // Only inference is kept: the body of any other request can be a credential. let capture = inference.is_some().then(|| { let mut h = self.headers.clone(); @@ -366,7 +383,7 @@ impl Exchange { corrupt: false, }); if over_by_length { - return Err(Abort::BufferedOverCap); + return Err(Abort::LengthOverCap); } Ok(()) } @@ -437,10 +454,14 @@ impl Exchange { pub fn on_abort(&mut self, a: Abort) -> Line { let status = self.resp.as_ref().map_or(0, |r| r.status); let (status_code, message) = match a { - Abort::BufferedOverCap => ( + Abort::LengthOverCap => ( 502, format!("response body over the {MAX_BODY_SIZE}-byte buffer limit"), ), + Abort::BufferedOverCap => ( + status as i64, + format!("response body over the {MAX_BODY_SIZE}-byte buffer limit"), + ), Abort::FrameOverCap => ( status as i64, format!("single SSE frame over the {MAX_BODY_SIZE}-byte frame cap"), @@ -510,15 +531,7 @@ impl Exchange { let mut event = Event { // Line format v1: the request row carries the request-only fields, and Cortex puts // them back on this row. They are most of a line's bytes (docs/session-api.md). - inference: self.observer.as_ref().and_then(|o| o.extension()).map(|i| { - let mut i = i.clone(); - i.messages = Vec::new(); - i.tools = Vec::new(); - i.tool_results = Vec::new(); - i.history_tool_calls = Vec::new(); - i.history_thinking = None; - i - }), + inference: self.observer.as_ref().and_then(|o| o.extension()).cloned(), status_code, error, duration_ms: self.start.elapsed().as_millis() as i64, @@ -882,11 +895,8 @@ mod tests { "content-length", (MAX_BODY_SIZE + 1).to_string().parse().unwrap(), ); - assert_eq!( - ex.on_response_head(200, &mut h), - Err(Abort::BufferedOverCap) - ); - let r = json_of(&ex.on_abort(Abort::BufferedOverCap)); + assert_eq!(ex.on_response_head(200, &mut h), Err(Abort::LengthOverCap)); + let r = json_of(&ex.on_abort(Abort::LengthOverCap)); assert_eq!(r["event"]["statusCode"], 502); assert_eq!( r["event"]["error"], @@ -907,7 +917,7 @@ mod tests { } #[test] - fn chunked_over_cap_row_is_502_proxy_error() { + fn chunked_over_cap_row_keeps_upstream_status() { let mut ex = json_exchange(); let mut h = HeaderMap::new(); h.insert("content-type", "application/json".parse().unwrap()); @@ -922,7 +932,10 @@ mod tests { } assert_eq!(got, Err(Abort::BufferedOverCap)); let r = json_of(&ex.on_abort(Abort::BufferedOverCap)); - assert_eq!(r["event"]["statusCode"], 502); + assert_eq!( + r["event"]["statusCode"], 200, + "the client has the 200 head already" + ); assert_eq!(r["event"]["error"]["kind"], "proxy_error"); assert_eq!(r["event"]["error"]["code"], "200"); let c = &r["capture"]["response"]; @@ -1045,6 +1058,43 @@ mod tests { } } + #[test] + fn request_fields_leave_the_exchange_with_the_request_row() { + let f = fixture(); + let mut ex = exchange("POST", "api.example.com", &f.target, &f.req_headers); + ex.on_request_body(&f.req_body); + let i = ex.observer.as_ref().unwrap().extension().unwrap(); + assert!( + i.messages.is_empty(), + "the exchange still holds the messages" + ); + assert!(i.tools.is_empty(), "the exchange still holds the tools"); + assert!(!i.model.is_empty(), "the light fields stay"); + } + + #[test] + fn a_queued_capture_line_costs_the_queue_one_line() { + let text = "a".repeat(4 << 20); + let body = serde_json::to_vec(&json!({ + "model": "claude-opus-5", + "messages": [{"role": "user", "content": text}], + })) + .unwrap(); + let mut ex = exchange("POST", "api.example.com", "/v1/messages", &HeaderMap::new()); + let l = ex.on_request_body(&body); + let full = serde_json::to_vec(&l).unwrap().len(); + let e = crate::record::emitter::Emitter::start( + std::net::SocketAddr::from(([127, 0, 0, 1], 0)), + "t".into(), + ); + e.submit(l.queued()); + let queued = e.stats().queued_bytes; + assert!( + queued < full + 1024, + "queued {queued} bytes for a {full}-byte line" + ); + } + #[test] fn line_with_capture_over_cap_has_dropped_row() { let text = "a".repeat(30 << 20); @@ -1055,7 +1105,7 @@ mod tests { .unwrap(); let mut ex = exchange("POST", "api.example.com", "/v1/messages", &HeaderMap::new()); let q = ex.on_request_body(&body).queued(); - assert!(q.with_capture.as_ref().unwrap().len() > MAX_LINE); + assert!(q.with_capture().unwrap().len() > MAX_LINE); let row: Value = serde_json::from_slice(&q.row).unwrap(); assert_eq!(row["capture"], json!({"dropped": true})); assert_eq!(row["event"]["inference"]["model"], "claude-opus-5"); diff --git a/praxis/server/tests/gateway.rs b/praxis/server/tests/gateway.rs index a4f3d1d34..db82eecd1 100644 --- a/praxis/server/tests/gateway.rs +++ b/praxis/server/tests/gateway.rs @@ -746,6 +746,70 @@ fn buffered_over_cap_with_content_length_is_502() { ); } +#[test] +fn buffered_over_cap_after_the_head_keeps_upstream_status() { + let o = origin(|_, s| { + let _ = s.write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n", + ); + let chunk = vec![b' '; 1 << 20]; + for _ in 0..12 { + if write_chunk(s, &chunk).is_err() { + return; + } + } + let _ = s.write_all(b"0\r\n\r\n"); + }); + let i = ingest(); + let g = gateway(&o, &i, true); + let mut s = g.connect(); + let h = fixture_headers(); + let req = fixture("request.json"); + s.write_all(&head("POST", "/v1/messages", &as_refs(&h), req.len())) + .expect("head"); + s.write_all(&req).expect("body"); + let (rh, rest) = read_head(&mut s).expect("response head"); + assert_eq!(status(&rh), 200); + let body = Body::new(&mut s, &rh, rest).read_to_end(); + assert!(body.len() < 12 << 20, "the body was not cut"); + let lines = i.take(2); + let resp = response_row(&lines); + assert_eq!( + resp["event"]["statusCode"], 200, + "the status the client got" + ); + assert_eq!(resp["event"]["error"]["kind"], "proxy_error"); + assert_eq!(resp["event"]["error"]["code"], "200"); + assert_eq!( + resp["event"]["error"]["message"], + "response body over the 10485760-byte buffer limit" + ); + assert_eq!(i.quiet(), Vec::::new(), "a second response row"); +} + +#[test] +fn bodyless_response_ends_its_row() { + let o = origin(|_, s| { + let _ = s.write_all(b"HTTP/1.1 204 No Content\r\n\r\n"); + }); + let i = ingest(); + let g = gateway(&o, &i, true); + let (rh, _) = send( + &g, + "POST", + "/v1/messages", + &as_refs(&fixture_headers()), + &fixture("request.json"), + ); + assert_eq!(status(&rh), 204); + let lines = i.take(2); + let resp = response_row(&lines); + assert_eq!(resp["event"]["statusCode"], 204); + assert!(resp["event"].get("error").is_none(), "{resp}"); + assert_eq!(resp["capture"]["response"]["ended"], true); + assert_eq!(i.quiet(), Vec::::new(), "a second response row"); +} + #[test] fn frame_over_cap_aborts_stream() { let o = origin(|_, s| { From b559a2ed3cbd743935004cf6f0e4101f6b690ae1 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 19:52:55 -0500 Subject: [PATCH 26/26] Fix: Cap the held request rows, and remove a dead loopback check A request row keeps its request-only fields in reqInf until its response row comes. The kept copy is the decoded wire copy, so it is a second copy of the conversation. When the gateway drops the response row, the entry stayed for the full 1-hour TTL, and nothing limited the number of entries. Under load this held hundreds of MiB. holdRequest now keeps at most 256 entries. When the map is full, it drops the oldest entry. The response row of that entry is filed without the request-only fields. ingestOptions no longer checks listener.bind_loopback_only. config.Validate refuses praxis mode without it, so the branch never ran. If it ran, it turned ingestion off with no error. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/cortex/ingest.go | 2 +- cmd/cortex/ingest_test.go | 19 ++++++++----------- core/sessionapi/ingest.go | 19 +++++++++++++++++++ core/sessionapi/ingest_test.go | 20 ++++++++++++++++++++ 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/cmd/cortex/ingest.go b/cmd/cortex/ingest.go index e66efc6fc..66671a958 100644 --- a/cmd/cortex/ingest.go +++ b/cmd/cortex/ingest.go @@ -16,7 +16,7 @@ const gatewayTokenEnv = "CORTEX_GATEWAY_TOKEN_FILE" // ingestOptions turns on the ingestion endpoint in praxis mode. In direct mode it returns // nil and reads neither the environment nor a file. func ingestOptions(cfg *config.Config, rates pricing.Resolver, getenv func(string) string, readFile func(string) ([]byte, error)) ([]sessionapi.Option, error) { - if !cfg.PraxisEnabled() || !cfg.Listener.BindLoopbackOnly { + if !cfg.PraxisEnabled() { return nil, nil } path := getenv(gatewayTokenEnv) diff --git a/cmd/cortex/ingest_test.go b/cmd/cortex/ingest_test.go index b0383d46d..4d7c423c9 100644 --- a/cmd/cortex/ingest_test.go +++ b/cmd/cortex/ingest_test.go @@ -12,9 +12,8 @@ import ( "github.com/rossoctl/cortex/core/sessionapi" ) -func praxisCfg(enabled, loopback bool) *config.Config { +func praxisCfg(enabled bool) *config.Config { cfg := &config.Config{} - cfg.Listener.BindLoopbackOnly = loopback if enabled { cfg.Praxis = &config.PraxisConfig{Enabled: true} } @@ -24,21 +23,19 @@ func praxisCfg(enabled, loopback bool) *config.Config { func TestIngestOptions_DirectModeNone(t *testing.T) { getenv := func(string) string { t.Fatal("getenv called in direct mode"); return "" } readFile := func(string) ([]byte, error) { t.Fatal("readFile called in direct mode"); return nil, nil } - for _, cfg := range []*config.Config{praxisCfg(false, true), praxisCfg(true, false)} { - opts, err := ingestOptions(cfg, nil, getenv, readFile) - if err != nil || opts != nil { - t.Fatalf("got %v, %v; want nil, nil", opts, err) - } + opts, err := ingestOptions(praxisCfg(false), nil, getenv, readFile) + if err != nil || opts != nil { + t.Fatalf("got %v, %v; want nil, nil", opts, err) } } func TestIngestOptions_NeedsTokenFile(t *testing.T) { - _, err := ingestOptions(praxisCfg(true, true), nil, func(string) string { return "" }, + _, err := ingestOptions(praxisCfg(true), nil, func(string) string { return "" }, func(string) ([]byte, error) { return nil, nil }) if err == nil || !strings.Contains(err.Error(), "CORTEX_GATEWAY_TOKEN_FILE") { t.Fatalf("err = %v, want one that names CORTEX_GATEWAY_TOKEN_FILE", err) } - _, err = ingestOptions(praxisCfg(true, true), nil, func(string) string { return "/x" }, + _, err = ingestOptions(praxisCfg(true), nil, func(string) string { return "/x" }, func(string) ([]byte, error) { return nil, errors.New("no such file") }) if err == nil { t.Fatal("an unreadable token file must be an error") @@ -46,7 +43,7 @@ func TestIngestOptions_NeedsTokenFile(t *testing.T) { } func TestIngestOptions_EmptyToken(t *testing.T) { - _, err := ingestOptions(praxisCfg(true, true), nil, func(string) string { return "/x" }, + _, err := ingestOptions(praxisCfg(true), nil, func(string) string { return "/x" }, func(string) ([]byte, error) { return []byte(" \n"), nil }) if err == nil { t.Fatal("an empty token must be an error") @@ -54,7 +51,7 @@ func TestIngestOptions_EmptyToken(t *testing.T) { } func TestIngestOptions_ReadsToken(t *testing.T) { - opts, err := ingestOptions(praxisCfg(true, true), nil, + opts, err := ingestOptions(praxisCfg(true), nil, func(k string) string { if k != "CORTEX_GATEWAY_TOKEN_FILE" { t.Fatalf("getenv(%q)", k) diff --git a/core/sessionapi/ingest.go b/core/sessionapi/ingest.go index 4f4db1e09..30f056fca 100644 --- a/core/sessionapi/ingest.go +++ b/core/sessionapi/ingest.go @@ -38,6 +38,9 @@ const ( // maxIngestDecoders caps how many lines of one stream decode at the same time. Each one // can hold a 64 MiB line and its decoded form. maxIngestDecoders = 8 + // maxHeldRequests caps the held request rows. Each one keeps a second copy of its + // conversation, and a response row the gateway drops never releases it. + maxHeldRequests = 256 ) // The refusal reasons of ingest_refused_total. @@ -117,11 +120,27 @@ func (in *ingester) holdRequest(id, sid string, inf *model.InferenceExtension) { in.reqCapsMu.Lock() defer in.reqCapsMu.Unlock() in.sweepLocked(time.Now()) + if _, ok := in.reqInf[id]; !ok && len(in.reqInf) >= maxHeldRequests { + in.dropOldestHeldLocked() + } in.reqInf[id] = heldRequest{sid: sid, at: time.Now(), inf: model.InferenceExtension{ Messages: inf.Messages, Tools: inf.Tools, ToolResults: inf.ToolResults, HistoryToolCalls: inf.HistoryToolCalls, HistoryThinking: inf.HistoryThinking}} } +// dropOldestHeldLocked drops the oldest held request. Its response row, if it comes, is +// filed without the request-only fields. +func (in *ingester) dropOldestHeldLocked() { + var oldest string + var at time.Time + for id, h := range in.reqInf { + if at.IsZero() || h.at.Before(at) { + oldest, at = id, h.at + } + } + delete(in.reqInf, oldest) +} + // restoreRequest puts the request-only fields back on a response row. A field the row // carries is kept. With no request row (the producer dropped it), the row stays as it is. func (in *ingester) restoreRequest(id string, inf *model.InferenceExtension) { diff --git a/core/sessionapi/ingest_test.go b/core/sessionapi/ingest_test.go index c3ba82701..f25ed5166 100644 --- a/core/sessionapi/ingest_test.go +++ b/core/sessionapi/ingest_test.go @@ -577,3 +577,23 @@ func TestIngest_ParallelDecodeKeepsOrder(t *testing.T) { t.Errorf("stats = %+v", st) } } + +func TestIngest_HeldRequestsAreCapped(t *testing.T) { + in := newIngester(Ingest{Token: "t"}) + inf := &model.InferenceExtension{Messages: []model.InferenceMessage{{Role: "user", Content: "hi"}}} + for i := range maxHeldRequests + 10 { + in.holdRequest(fmt.Sprintf("r%d", i), "s1", inf) + } + if n := len(in.reqInf); n != maxHeldRequests { + t.Errorf("held %d requests, want the cap %d", n, maxHeldRequests) + } + last := fmt.Sprintf("r%d", maxHeldRequests+9) + if _, ok := in.reqInf[last]; !ok { + t.Errorf("the newest request %s was dropped", last) + } + // A repeat of a held id replaces it and drops nothing. + in.holdRequest(last, "s1", inf) + if n := len(in.reqInf); n != maxHeldRequests { + t.Errorf("a repeated id changed the count to %d", n) + } +}