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 44c621aab..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/` | @@ -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/cmd/cortex/ingest.go b/cmd/cortex/ingest.go new file mode 100644 index 000000000..66671a958 --- /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() { + 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..4d7c423c9 --- /dev/null +++ b/cmd/cortex/ingest_test.go @@ -0,0 +1,86 @@ +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 bool) *config.Config { + cfg := &config.Config{} + 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 } + 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), 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), 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), 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), 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() { diff --git a/core/config/config.go b/core/config/config.go index 5346bfc2e..38cab768f 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, #1721). +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..961af9ffd --- /dev/null +++ b/core/config/praxis_test.go @@ -0,0 +1,65 @@ +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) + } +} + +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 { + 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..ad33cdc19 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,26 @@ 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") + } + // 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 +} + // 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/listener/forwardproxy/ingest_replay_test.go b/core/listener/forwardproxy/ingest_replay_test.go new file mode 100644 index 000000000..c6da4dee6 --- /dev/null +++ b/core/listener/forwardproxy/ingest_replay_test.go @@ -0,0 +1,385 @@ +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} + } + 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) + 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/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/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) + } +} diff --git a/core/sessionapi/erase_marks.go b/core/sessionapi/erase_marks.go new file mode 100644 index 000000000..769ce46ff --- /dev/null +++ b/core/sessionapi/erase_marks.go @@ -0,0 +1,81 @@ +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 + // 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 { + return &eraseMarks{now: time.Now, ids: map[string]time.Time{}} +} + +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() + 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() + 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 +// 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..30f056fca --- /dev/null +++ b/core/sessionapi/ingest.go @@ -0,0 +1,454 @@ +package sessionapi + +import ( + "bufio" + "bytes" + "crypto/subtle" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "runtime" + "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/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 + // 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. +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 + // 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 + streams map[*http.ResponseController]struct{} +} + +func newIngester(cfg Ingest) *ingester { + 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()) + 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) { + 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) { + 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"` +} + +// 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 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() + }() + + // 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) + switch { + case over: + in.refuse(refusedLineCap) + case len(bytes.TrimSpace(b)) > 0: + 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 + } + 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 + } +} + +// 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: + return decodedLine{refused: refusedBadJSON} + case l.V != ingestLineVersion: + return decodedLine{refused: refusedVersion} + case l.SchemaVersion != SchemaVersion: + return decodedLine{refused: refusedSchema} + case l.Op != "append": + 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) + // #1721 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.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) + } + 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)} + } + in.sweepLocked(time.Now()) + 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) { + 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..f25ed5166 --- /dev/null +++ b/core/sessionapi/ingest_test.go @@ -0,0 +1,599 @@ +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) + } +} + +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) + } +} + +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) + } +} diff --git a/core/sessionapi/server.go b/core/sessionapi/server.go index 7cc86222a..902d3e43d 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,13 @@ 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) + // 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 if s.loopbackOnly { @@ -239,6 +250,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/core/sessionapi/testdata/ingest/replay.ndjson b/core/sessionapi/testdata/ingest/replay.ndjson new file mode 100644 index 000000000..2c5c08f6e --- /dev/null +++ b/core/sessionapi/testdata/ingest/replay.ndjson @@ -0,0 +1,18 @@ +{"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 f1b08a817..3519caaf4 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,82 @@ 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. + +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, + 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). +- 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. + +**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 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..65b601b9d --- /dev/null +++ b/docs/superpowers/plans/2026-11-09-praxis-mode-2-gateway-and-ingest.md @@ -0,0 +1,419 @@ +# 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, #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 (#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 (#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. +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 + +- **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`). +- 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`. + +## 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/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) +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/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:** +- 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 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/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". + +### 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/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, made `pub(crate)` in Task 1). + +- [ ] **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 ./pipeline/ -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, 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. + +`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 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`. + - `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` 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`). + - `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), 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 `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`. + - `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 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`. +- `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 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. + - `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` (`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`. +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). +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. `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. + - `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. + - `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. +- [ ] **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`, 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 (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`, `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". + +### 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 (#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/Cargo.lock b/praxis/Cargo.lock index d01cfbb21..31ca47e12 100644 --- a/praxis/Cargo.lock +++ b/praxis/Cargo.lock @@ -8,227 +8,3118 @@ 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 = [ + "base64 0.22.1", + "cortex-praxis-filters", + "flate2", + "openssl", + "praxis-proxy", + "praxis-proxy-filter", + "serde_json", + "yaml_serde", +] + +[[package]] +name = "cortex-praxis-filters" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "flate2", + "http", + "metrics", + "praxis-proxy-filter", + "serde", + "serde_json", + "tokio", + "yaml_serde", + "zstd", +] + +[[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 = "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 = "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", +] + +[[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", + "zstd-safe", +] + +[[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 = "cc" -version = "1.11.1" +name = "tokio-util" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ - "find-msvc-tools", - "jobserver", + "bytes", + "futures-core", + "futures-sink", + "futures-util", "libc", - "shlex", + "pin-project-lite", + "tokio", ] [[package]] -name = "cfg-if" -version = "1.0.5" +name = "tracing" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] [[package]] -name = "cortex-praxis-filters" -version = "0.1.0" +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 = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" dependencies = [ - "flate2", - "http", "serde", - "serde_json", - "zstd", + "tracing-core", ] [[package]] -name = "crc32fast" -version = "1.5.4" +name = "tracing-subscriber" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ - "cfg-if", + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", ] [[package]] -name = "find-msvc-tools" -version = "0.1.15" +name = "typenum" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] -name = "flate2" -version = "1.1.11" +name = "unicase" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" +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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" 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..481b75ff8 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,75 @@ 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. + +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` +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 (#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. +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. +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) + +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 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 e6e185fdd..6cb51667b 100644 --- a/praxis/filters/Cargo.toml +++ b/praxis/filters/Cargo.toml @@ -12,3 +12,11 @@ serde_json = { version = "1", features = ["raw_value"] } http = "1" flate2 = "1" zstd = "0.14" +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"] } +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..1ac6c4c0d --- /dev/null +++ b/praxis/filters/src/filters/attribution.rs @@ -0,0 +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, HOP_HEADERS, TOKEN_HEADER, 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( + HOP_HEADERS + .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/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..aabfc6942 --- /dev/null +++ b/praxis/filters/src/filters/observe.rs @@ -0,0 +1,275 @@ +//! 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; + // 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/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..f42f8bd84 100644 --- a/praxis/filters/src/parse/inference/mod.rs +++ b/praxis/filters/src/parse/inference/mod.rs @@ -11,10 +11,11 @@ mod openai; mod responses; mod served_model; mod subagent; -mod text; +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..1aede6910 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()) @@ -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/attribution.rs b/praxis/filters/src/record/attribution.rs new file mode 100644 index 000000000..1211cbc76 --- /dev/null +++ b/praxis/filters/src/record/attribution.rs @@ -0,0 +1,115 @@ +//! 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. 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. +/// #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", + "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", + "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"")); + } +} diff --git a/praxis/filters/src/record/client.rs b/praxis/filters/src/record/client.rs new file mode 100644 index 000000000..9969b42cc --- /dev/null +++ b/praxis/filters/src/record/client.rs @@ -0,0 +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), ""); + } +} diff --git a/praxis/filters/src/record/emitter.rs b/praxis/filters/src/record/emitter.rs new file mode 100644 index 000000000..74bbca64b --- /dev/null +++ b/praxis/filters/src/record/emitter.rs @@ -0,0 +1,477 @@ +//! The emitter: one thread per process that streams NDJSON lines to Cortex. + +/// 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 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; +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, +} + +/// `bytes` counts each line's row and capture. +#[derive(Default)] +struct Queue { + 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(c) = e.capture.take() { + self.bytes -= c.len(); + 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 { + 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 queued lines lose their captures, + /// newest first. Only then is the line dropped and counted. + 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 && entry.capture.take().is_some() { + 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 + } + }; + 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(); + } + + 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 e = q.lines.pop_front()?; + q.bytes -= e.size(); + 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) -> Queued { + 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.wire_len()).as_bytes()) + .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; + 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(), + 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]), + } + } + + /// 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 { + SocketAddr::from(([127, 0, 0, 1], 0)) + } + + /// 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_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(with_capture(MAX_LINE + 1)); + assert_eq!(recv(&rx), "auth Bearer t"); + assert_eq!(recv(&rx), "{\"capture\":{\"dropped\":true}}\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(with_capture(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 queued_captures_make_room_for_a_row() { + let e = Emitter::start(closed_addr(), "t".into()); + for _ in 0..25 { + 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], + 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()); + 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)); + } + } +} diff --git a/praxis/filters/src/record/line.rs b/praxis/filters/src/record/line.rs new file mode 100644 index 000000000..af982ab07 --- /dev/null +++ b/praxis/filters/src/record/line.rs @@ -0,0 +1,349 @@ +//! 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; #1721 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")] + 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 }; + +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 line for the emitter, serialized once (see `Queued`). + pub fn queued(&self) -> Queued { + // 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")), + } + } +} + +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().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/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..083a70549 --- /dev/null +++ b/praxis/filters/src/record/redact.rs @@ -0,0 +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:?})"); + } + } +} diff --git a/praxis/filters/src/record/row.rs b/praxis/filters/src/record/row.rs new file mode 100644 index 000000000..d26035f28 --- /dev/null +++ b/praxis/filters/src/record/row.rs @@ -0,0 +1,1113 @@ +//! 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, HOP_HEADERS}; +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::model::InferenceExtension; +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 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, +} + +/// 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; + // 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(); + h.remove(HOST); + for n in HOP_HEADERS { + 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::LengthOverCap); + } + 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::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"), + ), + }; + 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 { + // 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()).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 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(); + 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::LengthOverCap)); + let r = json_of(&ex.on_abort(Abort::LengthOverCap)); + 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_keeps_upstream_status() { + 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"], 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"]; + assert_eq!(c["truncated"], true); + assert_eq!(b64(&c["body"]).len(), MAX_RESPONSE_CAPTURE); + } + + #[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 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); + 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().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/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")); + } +} diff --git a/praxis/server/Cargo.toml b/praxis/server/Cargo.toml new file mode 100644 index 000000000..75cb0a71c --- /dev/null +++ b/praxis/server/Cargo.toml @@ -0,0 +1,24 @@ +[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"] } +# 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 new file mode 100644 index 000000000..fb80d640f --- /dev/null +++ b/praxis/server/src/main.rs @@ -0,0 +1,65 @@ +//! `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. + +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. + 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); + } +} diff --git a/praxis/server/tests/gateway.rs b/praxis/server/tests/gateway.rs new file mode 100644 index 000000000..db82eecd1 --- /dev/null +++ b/praxis/server/tests/gateway.rs @@ -0,0 +1,953 @@ +//! 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, + /// A line, or what the mock found wrong, so that the test sees it. + 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; + }; + 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 { + 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) + .map_err(|e| format!("ingest line is not JSON: {e}")), + ); + 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")) + .unwrap_or_else(|e| panic!("{e}")) + }) + .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.unwrap_or_else(|e| panic!("{e}"))); + } + 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); + } +} + +/// 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 { + 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 { + 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; + // 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}"); + } + assert!(Instant::now() < deadline, "cortex-gateway is not ready"); + std::thread::sleep(Duration::from_millis(20)); + } + 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"); + 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(); + // 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!( + l["requestId"], "rid-1", + "the id comes from X-Cortex-Request-Id" + ); + } +} + +#[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 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| { + 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:?}" + ); +} + +/// 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_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\":", + ); + 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"]["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"); +} 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}}"] diff --git a/scripts/dev/praxis-measure.sh b/scripts/dev/praxis-measure.sh new file mode 100755 index 000000000..ed57d313b --- /dev/null +++ b/scripts/dev/praxis-measure.sh @@ -0,0 +1,321 @@ +#!/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 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 +# 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 + 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 + + 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 + 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"