diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 1b26fa0ccd..f731acb88d 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -5,14 +5,8 @@ [advisories] ignore = [ - # protobuf 2.28.0: uncontrolled recursion crash - "RUSTSEC-2024-0437", - # daemonize 0.5.0: unmaintained - "RUSTSEC-2025-0069", # derivative 2.2.0: unmaintained "RUSTSEC-2024-0388", # rustls-pemfile 2.2.0: unmaintained (via pingora-rustls) "RUSTSEC-2025-0134", - # instant 0.1.13: unmaintained (via notify -> notify-types) - "RUSTSEC-2024-0384", ] diff --git a/.cargo/config.toml b/.cargo/config.toml index c47f550278..597d27edaf 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,3 +3,9 @@ xtask = "run --package xtask --" [build] rustdocflags = ["-D", "warnings"] + +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "force-frame-pointers=yes"] + +[target.aarch64-unknown-linux-gnu] +rustflags = ["-C", "force-frame-pointers=yes"] diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index e61305fb77..0000000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,266 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code -(claude.ai/code) when working with code in this -repository. - -## Requirements - -- Rust stable 1.94+ -- Rust nightly (for `rustfmt`) -- CMake 3.31+ -- Docker 29.3.0+ or Podman (for container builds) - -## Quick Reference - -```console -make setup-hooks # install git pre-commit hook (fmt + lint) -make build # workspace build (includes benches + fuzz) -make test # all tests (downloads h2spec if needed) -make fmt # format with nightly rustfmt -make lint # clippy + nightly fmt check + xtask lint-deps -make doc # rustdoc with -D warnings, including private items -make audit # cargo audit + cargo deny check -make coverage-check # fail if line coverage < 90% -make container # container image build -cargo run -p praxis # run the proxy -``` - -Run a single test: - -```console -cargo test -p praxis-tests-integration --test suite -- test_name -make test-integration V=1 # with --nocapture -``` - -Individual test suites: - -```console -make test-unit # core, filter, protocol, server -make test-schema # config parsing + example validation -make test-integration # end-to-end filter and proxy tests -make test-conformance # RFC conformance (h2spec, HTTP semantics) -make test-security # request smuggling, header injection -make test-resilience # load, failure recovery, throughput -make test-smoke # quick startup and round-trip sanity -``` - -See `docs/developing/getting-started.md` for the full -command reference and dev tool usage. - -## Architecture - -See `docs/architecture/overview.md` for the full design. - -**Crate dependency flow:** - -```text -server -> protocol -> filter -> core -> tls -``` - -- **server** (`praxis`): binary entry point, config - loading, pipeline resolution, hot-reload watcher -- **core** (`praxis-core`): YAML config (serde), - validation, error types, health state, KV store - registry, `PingoraServerRuntime` -- **filter** (`praxis-filter`): `HttpFilter` and - `TcpFilter` traits, pipeline engine, condition - evaluation, body access/buffering, all built-in - filter implementations, `FilterRegistry` -- **protocol** (`praxis-protocol`): `Protocol` trait, - Pingora HTTP/TCP adapters, health check probes, - admin endpoints -- **tls** (`praxis-tls`): TLS config types, SNI - resolution (including wildcards), cert loading -- **proto** (`praxis-proto`): vendored Envoy ext_proc - protobuf definitions (opt-in `ext-proc` feature) - -**Test crates** (under `tests/`): - -- `tests/utils`: shared test harness (`free_port`, - `start_backend`, `start_proxy_with_registry`) -- `tests/schema`: config parsing and example validation -- `tests/integration`: end-to-end filter and proxy tests -- `tests/conformance`: RFC conformance (h2spec) -- `tests/security`: request smuggling, header injection -- `tests/resilience`: load, failure recovery -- `tests/smoke`: quick startup round-trip - -## Conventions - -See `docs/developing/conventions.md` for the full -coding style guide. Key points: - -- `unsafe_code = "deny"` in workspace lints -- All items (public and private) require `///` doc - comments; enforced by `missing_docs` and - `missing_docs_in_private_items` lints -- Comments answer "why?", never "what?"; use - `tracing` for runtime narration -- Prefer `to_owned()` over `to_string()` for - `&str` to `String` -- Use inline format args: `format!("{var}")` -- Use let-chains, `is_some_and()`, `strip_prefix()` -- Reference-style rustdoc links, not inline -- Do not document memory efficiency in rustdoc - (e.g. "avoids allocation", "zero-copy", "cheap - clone"). Correct memory use is expected; it does - not need narration. -- Do not create re-export-only files. Import - directly from the source module. -- Pre-computed numeric literals with trailing - comments for human-readable meaning -- Use enums, not strings, for fixed value sets - in config; `#[serde(deny_unknown_fields)]` on - config structs; `#[serde(try_from)]` for - constrained numerics; `#[serde(default)]` - instead of `Option` with `unwrap_or`. - See `docs/developing/type-design.md`. - (e.g. `10_485_760; // 10 MiB`) - -## Workspace Lints - -The workspace enforces an extensive lint policy in -`Cargo.toml` under `[workspace.lints.rust]` and -`[workspace.lints.clippy]`. Key constraints: - -- `#[clippy::unwrap_used]` is denied; use `?` or - explicit error handling -- `clippy::too_many_lines` and - `clippy::cognitive_complexity` are denied -- All cast operations (`cast_lossless`, - `cast_possible_truncation`, etc.) are denied -- `clippy::dbg_macro`, `print_stdout`, - `print_stderr` are denied -- `missing_assert_message` is denied: every - `assert!` needs a message string -- `clippy::str_to_string` is denied: use - `to_owned()` for `&str` to `String` - -## File Ordering - -1. Constants (with separator comment) -2. Public types, impls, functions -3. Private types and impls -4. Private utility functions (with separator) -5. `#[cfg(test)] mod tests` (always last) - -Inside `mod tests`: imports, test functions, then -test utilities (with `// Test Utilities` separator). - -Struct fields: `name` first (if present), then -alphabetical. Impl blocks: `new()` first, then -`name()`, then alphabetical. - -## Test Requirements - -New capabilities require: - -1. Unit tests -2. Integration tests -3. Example config in `examples/configs/` -4. Functional integration test for the example config - in `tests/integration/tests/suite/examples/` -5. Update `examples/README.md` to list any new or - renamed example configs - -Example config tests must exercise the actual -functionality end-to-end (e.g. a WebSocket config -must perform a real WebSocket handshake and message -exchange). Parse-only validation is not sufficient; -every example must prove its feature works with all -configured variants. - -See `docs/developing/conventions.md` for full test -conventions (no inline comments in test bodies, no -doc comments on test functions, full-width separators -only). - -## Adding a Filter - -See `docs/filters/extensions.md` for the full guide. - -1. Create module under - `filter/src/builtins///` -2. Implement `HttpFilter` or `TcpFilter` with a - `from_config` factory (`fn(&serde_yaml::Value) - -> Result, FilterError>`) -3. Register in `filter/src/registry.rs` -4. Add unit tests and doctests -5. Add example config in `examples/configs//` -6. Add functional integration test in - `tests/integration/tests/suite/examples/` -7. Update `examples/README.md` - -## Adding a Protocol - -1. Implement `Protocol` trait under `protocol/src/` -2. Add variant to `ProtocolKind` in - `core/src/config/listener.rs` -3. Wire in `server/src/server.rs` - -## Branch Chains - -Conditional branching in filter pipelines based on -filter results. Key files: - -- `core/src/config/branch_chain.rs`: config types -- `core/src/config/chain_ref.rs`: `ChainRef` enum -- `core/src/config/validate/branch_chain.rs`: validation -- `filter/src/results.rs`: `FilterResultSet` type -- `filter/src/pipeline/filter.rs`: `PipelineFilter` -- `filter/src/pipeline/branch.rs`: runtime types -- `filter/src/pipeline/build_branch.rs`: resolution -- `filter/src/pipeline/evaluate.rs`: execution - -Filters write results to `FilterResultSet` without -knowing about branches. The pipeline executor reads -results to evaluate branch conditions and dispatch. -Branches rejoin at configurable points (next, -terminal, named filter, re-entrance with iteration -limits). - -## Filter Organization - -Filters live under -`filter/src/builtins///`. -See `docs/filters/README.md` for the filter system -documentation and `docs/operating/filter-reference.md` -for built-in filter configurations. - -Categories: `ai`, `observability`, -`payload_processing`, `security`, -`traffic_management`, `transformation` (HTTP); -`observability`, `traffic_management` (TCP). - -Example configs: `examples/configs//`. - -## Dynamic Config Reload - -Praxis swaps filter pipelines at runtime without -restarting. Each handler holds -`Arc>`; a file watcher -(500ms debounce) monitors the config file, validates, -rebuilds pipelines, and swaps atomically. Listener -topology, protocol type, and TLS toggle changes -cannot be applied dynamically (logged as warnings). - -## CI Workflows - -CI workflows that post PR comments must use the -`PRAXIS_BOT` secret as the token, not the default -`github.token`. - -## Pingora Boundary - -See `docs/operating/security-hardening.md` for details. - -Pingora handles: request smuggling prevention, H2 -backpressure, connection pool safety, HTTP/1.1 -upgrade detection and bidirectional forwarding -(WebSocket, etc.). - -Praxis handles: hop-by-hop header stripping (with -conditional preservation for upgrade requests), -Host validation, X-Forwarded-* injection, retry -logic. diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 120000 index 0000000000..be77ac83a1 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/.containerignore b/.containerignore index 0f1cd2b8cb..4f2426b8ef 100644 --- a/.containerignore +++ b/.containerignore @@ -16,5 +16,4 @@ Makefile tests/ xtask/ examples/ -!examples/configs/operations/container-default.yaml benchmarks/ diff --git a/.gitattributes b/.gitattributes index f484bc427e..4e46476613 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,9 @@ *.toml linguist-detectable=false *.md linguist-detectable=false *.cfg linguist-detectable=false +*.sh linguist-detectable=false Makefile linguist-detectable=false Containerfile linguist-detectable=false Dockerfile linguist-detectable=false +tests/integration/fixtures/**/*.json linguist-generated=true +tests/integration/fixtures/inference/scenarios/**/*.yaml linguist-generated=true diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..d2b0483f83 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Maintainers +* @praxis-proxy/ai-maintainers +docs/proposals/* @shaneutt diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000000..52bac0427b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,44 @@ +name: Bug Report +description: Report a bug or unexpected behavior +labels: ["bug", "triage/needs-triage"] +body: + - type: textarea + id: description + attributes: + label: Description + description: What happened? + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to Reproduce + description: Minimal steps to trigger the bug + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Behavior + - type: input + id: version + attributes: + label: Praxis AI Version + description: Release tag, container tag, or commit SHA + placeholder: "v0.1.0" + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - AI / Inference + - Core / Config + - Filter Pipeline + - Observability + - Protocol + - Security / TLS + - Traffic Management + - Transformation + - Other diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..24f4edd0ef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Praxis Discussions + url: https://github.com/praxis-proxy/praxis/discussions + about: Ask questions or start a discussion with the Praxis community + - name: Feature Request + url: https://github.com/orgs/praxis-proxy/discussions + about: Feature requests start as a Discussion, then move to a proposal and issue when accepted diff --git a/.github/ISSUE_TEMPLATE/epic.yml b/.github/ISSUE_TEMPLATE/epic.yml new file mode 100644 index 0000000000..cc6cdd607e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/epic.yml @@ -0,0 +1,40 @@ +name: Epic +description: Track a large body of related work +labels: ["triage/needs-triage"] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What does this epic accomplish? + validations: + required: true + - type: textarea + id: success-criteria + attributes: + label: Success Criteria + description: How do we know this is done? + validations: + required: true + - type: textarea + id: sub-tasks + attributes: + label: Sub-Tasks + description: Known work items (will become sub-issues) + placeholder: | + - [ ] Task 1 + - [ ] Task 2 + - type: dropdown + id: area + attributes: + label: Area + options: + - AI / Inference + - Core / Config + - Filter Pipeline + - Observability + - Protocol + - Security / TLS + - Traffic Management + - Transformation + - Other diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000000..44916cdba8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,22 @@ +name: Question +description: Ask for help using or configuring Praxis AI +labels: ["question"] +body: + - type: textarea + id: question + attributes: + label: Question + description: What are you trying to accomplish? + validations: + required: true + - type: textarea + id: attempted + attributes: + label: What have you tried? + description: Include relevant configuration, commands, and errors. + - type: input + id: version + attributes: + label: Praxis AI Version + description: Release tag, container tag, or commit SHA, if relevant + placeholder: "v0.1.0" diff --git a/.github/ISSUE_TEMPLATE/spike.yml b/.github/ISSUE_TEMPLATE/spike.yml new file mode 100644 index 0000000000..53975123a3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/spike.yml @@ -0,0 +1,61 @@ +name: Spike +description: Time-boxed investigation to reduce uncertainty +labels: ["spike", "triage/needs-triage"] +body: + - type: textarea + id: question + attributes: + label: Question + description: What do we need to learn? + validations: + required: true + - type: textarea + id: context + attributes: + label: Context + description: Why is this unknown blocking progress? + validations: + required: true + - type: textarea + id: approach + attributes: + label: Approach + description: How will we investigate? + placeholder: | + - [ ] Step 1 + - [ ] Step 2 + - type: textarea + id: deliverables + attributes: + label: Deliverables + description: What artifacts come out of this spike? + placeholder: | + - Decision record or comment summarizing findings + - Prototype branch (if applicable) + - Follow-up issues for implementation + - type: input + id: timebox + attributes: + label: Timebox + description: Maximum effort before reporting findings + placeholder: "2 days" + - type: dropdown + id: area + attributes: + label: Area + options: + - AI / Inference + - Core / Config + - Filter Pipeline + - Observability + - Protocol + - Security / TLS + - Traffic Management + - Transformation + - Other + - type: input + id: parent-epic + attributes: + label: Parent Epic (optional) + description: "Issue number if this belongs under an epic" + placeholder: "#484" diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..57f57f27a3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,29 @@ +## Summary + + + +## Related issue + + + +Closes # + +## Validation + + + +- [ ] Unit tests +- [ ] Integration or functional tests +- [ ] `make lint` + +## Checklist + +- [ ] I reviewed every changed line and can explain the change. +- [ ] New capabilities include an example config and functional example test. +- [ ] User-facing behavior and generated documentation are updated. +- [ ] Performance-sensitive changes include appropriate benchmark or load-test evidence. +- [ ] Commits are signed and include a `Signed-off-by` trailer. + +## Breaking changes + + diff --git a/.github/actions/clone-praxis/action.yml b/.github/actions/clone-praxis/action.yml new file mode 100644 index 0000000000..12f7f3a1d2 --- /dev/null +++ b/.github/actions/clone-praxis/action.yml @@ -0,0 +1,20 @@ +name: Clone Praxis core +description: >- + Clone praxis core repo as a sibling directory so + path-based [patch.crates-io] overrides resolve. + +inputs: + ref: + description: "Git ref to checkout (branch, tag, or SHA)" + required: false + default: "main" + +runs: + using: composite + steps: + - name: Clone praxis core + run: >- + git clone --depth 1 --branch "${{ inputs.ref }}" + https://github.com/praxis-proxy/praxis.git + "$GITHUB_WORKSPACE/../praxis" + shell: bash diff --git a/.github/actions/install-actionlint/action.yml b/.github/actions/install-actionlint/action.yml new file mode 100644 index 0000000000..57e8a07fa2 --- /dev/null +++ b/.github/actions/install-actionlint/action.yml @@ -0,0 +1,23 @@ +name: Install actionlint +description: Install the pinned actionlint release with checksum verification + +runs: + using: composite + steps: + - name: Download actionlint + shell: bash + env: + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" + run: | + set -euo pipefail + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}" + + curl -fsSL "${base_url}/${archive}" -o "/tmp/${archive}" + echo "${ACTIONLINT_SHA256} /tmp/${archive}" | sha256sum --check --strict + + mkdir -p /tmp/actionlint-bin + tar -xzf "/tmp/${archive}" -C /tmp/actionlint-bin actionlint + chmod +x /tmp/actionlint-bin/actionlint + echo "/tmp/actionlint-bin" >> "$GITHUB_PATH" diff --git a/.github/actions/install-nightly-rust/action.yml b/.github/actions/install-nightly-rust/action.yml new file mode 100644 index 0000000000..8495792c7f --- /dev/null +++ b/.github/actions/install-nightly-rust/action.yml @@ -0,0 +1,29 @@ +name: Install nightly Rust +description: Install the pinned nightly Rust toolchain + +inputs: + components: + description: "Comma-separated list of components to install (e.g. rustfmt)" + required: false + default: "" + +runs: + using: composite + steps: + - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly-2026-03-28 (rustc 1.96.0) + with: + components: ${{ inputs.components }} + + - name: Register toolchain under date-pinned name + run: | + NIGHTLY=$(grep -oE 'nightly-[0-9]{4}-[0-9]{2}-[0-9]{2}' "${{ github.action_path }}/action.yml" | head -1) + if [ -z "$NIGHTLY" ]; then + echo "::error::Could not extract nightly version from action.yml" + exit 1 + fi + if [ -n "${{ inputs.components }}" ]; then + rustup toolchain install "$NIGHTLY" --profile minimal --component "${{ inputs.components }}" + else + rustup toolchain install "$NIGHTLY" --profile minimal + fi + shell: bash diff --git a/.github/actions/patch-praxis/action.yml b/.github/actions/patch-praxis/action.yml new file mode 100644 index 0000000000..63982a18f4 --- /dev/null +++ b/.github/actions/patch-praxis/action.yml @@ -0,0 +1,20 @@ +name: Patch Praxis dependencies +description: >- + Override crates.io praxis dependencies with local + path dependencies from the cloned praxis repo. + +runs: + using: composite + steps: + - name: Append [patch.crates-io] to Cargo.toml + run: | + cat >> Cargo.toml << 'PATCH' + + [patch.crates-io] + praxis-proxy-core = { path = "../praxis/core" } + praxis-proxy-filter = { path = "../praxis/filter" } + praxis-proxy-protocol = { path = "../praxis/protocol" } + praxis-proxy-tls = { path = "../praxis/tls" } + praxis-proxy = { path = "../praxis/server" } + PATCH + shell: bash diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 84016705f8..0000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,38 +0,0 @@ -# Copilot Instructions - -## Code Review Protocol - -Review the entire changeset on the first pass. -Report **Critical** and **Major** issues. If -none found, mention that with bold text. **Minor** -issues can be reported too, as long as they are -not pedantic, or nitpicks. - -- **Critical**: security vulnerabilities, data loss, - panics, silent failures, breaking API changes (unless in v0.x.x) -- **Major**: logic errors, race conditions, resource - leaks, missing tests for core functionality, - incorrect filter behavior, broken config validation -- **Minor**: Anything else that is low impact, but still - has some substance. - -## Project Overview - -Praxis is a high-performance proxy server. - -See the following before reviewing: - -* `docs/architecture/overview.md`, -* `docs/developing/conventions.md` -* All other `docs/` -* `.claude/CLAUDE.md` - -To make sure you understand the architecture, conventions, and preferences. - -## Review Checklist - -1. **Correctness**: edge cases, error paths, panics -2. **Testing**: coverage of new or changed behavior -3. **Security**: input validation, header leakage -4. **Performance**: hot-path allocations, unnecessary clones -5. **Conventions**: `docs/developing/conventions.md` compliance diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 9b4e8d0ba9..16c90cceb3 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -18,3 +18,8 @@ updates: directory: "/" schedule: interval: "weekly" + + - package-ecosystem: "github-actions" + directory: ".github/actions/install-nightly-rust" + schedule: + interval: "weekly" diff --git a/.github/prompts/automated-review.md b/.github/prompts/automated-review.md new file mode 100644 index 0000000000..110ebef074 --- /dev/null +++ b/.github/prompts/automated-review.md @@ -0,0 +1,220 @@ +# Automated PR Review Instructions + +You are reviewing a pull request for the Praxis +project - a Rust proxy server and framework. The PR +number is available as the `PR_NUMBER` environment +variable. Follow every step below. + +## Step 1: Gather Context + +Fetch the PR metadata and full diff: + +```bash +gh pr view "$PR_NUMBER" \ + --json title,body,baseRefName,headRefName,additions,deletions,changedFiles +gh pr diff "$PR_NUMBER" +``` + +Read the project's .claude/CLAUDE.md and all +documents in the `docs/` directory for conventions +and test requirements (it is already checked out +in the working directory). + +## Step 2: Read Changed Files in Full + +For every file listed in the diff, read the complete +file - not just the diff hunks. Understanding the +surrounding code is essential for detecting missing +tests and edge cases. + +Use the GitHub API to fetch each changed file at the +PR's head ref: + +```bash +gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}/files" \ + --jq '.[].filename' +``` + +Then for each file, read its full contents from the +PR branch using `gh api` with the raw media type, or +read from the local checkout if the file also exists +on the base branch (most files will). + +## Step 3: Correctness Review + +For each logic change, check: + +- Edge cases and boundary conditions +- Error handling completeness +- Off-by-one errors, overflow, underflow +- Input validation gaps (missing checks, uncapped + values, unvalidated formats) +- Panic/crash vectors (`unwrap`, indexing, division) +- Concurrency safety (races, deadlocks) +- Whether the implementation matches the PR's stated + intent + +## Step 4: Test Coverage Gap Analysis + +This is the most critical analysis step. Perform a +systematic audit of test coverage for all changed +code: + +### a) Function-level coverage + +For each new or modified function/method, verify at +least one test exercises it. Flag any function with +zero test coverage. + +### b) Error path coverage + +For each validation or error path (rejections, parse +failures, constraint checks), verify a negative test +triggers that specific path. Example: if code rejects +`max_bytes == 0`, there must be a test passing 0 and +asserting the error message. + +### c) Branch coverage + +For branching logic (match arms, if/else chains, +pattern matching, wildcard handling), verify each +distinct branch has a test case. Check edge cases: +empty input, maximum values, boundary conditions, +special characters, zero-length matches. + +### d) Config coverage + +For new config types or fields: + +- Valid config parses correctly (positive test) +- Each invalid variant is rejected with a clear error + (negative test per variant) +- Default values work when the field is omitted +- Serde round-trip if applicable + +### e) Integration coverage + +For new example configs or features, verify a +functional integration test exists that exercises +the actual behavior end-to-end (not just parsing). + +### f) Ratio check + +Count new/modified logic functions vs new test +functions. A large disparity signals gaps. Example: +6 new validation checks with only 2 negative tests +is a red flag. + +Report every gap. Be specific: name the function, the +untested scenario, and what the test should verify. + +## Step 5: Convention and Security Review + +- Project convention violations (per CLAUDE.md and + the project style guide) +- Idiomatic Rust: proper error handling with + `thiserror`, ownership patterns, clippy-clean code, + combinator chains over if/else when appropriate +- Security issues: injection, DoS vectors, unbounded + resource allocation, missing input validation, + information leakage in error messages +- Missing or inaccurate documentation +- API design issues (leaky abstractions, unclear + interfaces, missing validation at boundaries) +- Style nits: naming, formatting, minor readability + improvements + +## Step 6: Classify Findings + +For each finding, record: severity, file path, line +number (in the new version of the file), and a clear +description. + +Severity guide (report ALL levels): + +- **Critical**: Bugs, security vulnerabilities, data + corruption, crash/panic reachable from external + input +- **Large**: Missing test coverage for important code + paths, significant logic concerns, design issues + with concrete impact, uncapped resource limits +- **Medium**: Convention violations, incomplete error + handling, missing edge-case tests, unclear + interfaces, inaccurate documentation +- **Small**: Minor readability improvements, slightly + better naming, small documentation gaps, minor + inconsistencies +- **Nit**: Style preferences, trivial formatting, + optional polish, cosmetic suggestions + +Format each inline comment as: +`**[Severity]** Description...` + +## Step 7: Post the Review + +Determine the repository owner and name: + +```bash +gh repo view --json owner,name \ + --jq '"\(.owner.login)/\(.name)"' +``` + +Fetch the diff again to determine which lines are +commentable (in diff hunks, RIGHT side). Findings +referencing lines outside the diff go in the review +body instead. + +Write a review body that provides: + +1. A one-line summary of the PR's purpose +2. An overall assessment (what works well, what needs + attention) +3. A table of findings by severity: + + ```text + | Severity | Count | + |----------|-------| + | Critical | 0 | + | Large | 2 | + | Medium | 3 | + | Small | 1 | + | Nit | 2 | + ``` + +4. Any findings that could not be placed on + commentable diff lines, listed under "Findings + without inline placement" + +Construct a JSON file and post it as a submitted +review: + +```bash +gh api "repos/OWNER/REPO/pulls/${PR_NUMBER}/reviews" \ + --method POST \ + --input /tmp/review.json +``` + +The JSON file must contain: + +```json +{ + "event": "COMMENT", + "body": "## Automated Review\n\n...", + "comments": [ + { + "path": "relative/file.rs", + "line": 42, + "side": "RIGHT", + "body": "**[Critical]** Description..." + } + ] +} +``` + +The `"event": "COMMENT"` field is required - it +submits the review immediately rather than leaving +it pending. + +If you have no findings at all, still post a review +with an approving summary and an empty comments +array. diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000000..e3bc3c9ef2 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,16 @@ +changelog: + categories: + - title: "Breaking Changes" + labels: ["breaking"] + - title: "Features" + labels: ["enhancement"] + - title: "Bug Fixes" + labels: ["bug"] + - title: "Documentation" + labels: ["documentation"] + - title: "Dependencies" + labels: ["dependencies"] + - title: "Other" + labels: ["*"] + exclude: + labels: ["skip/changelog"] diff --git a/.github/workflows/benchmarks.yaml b/.github/workflows/benchmarks.yaml deleted file mode 100644 index 34856ac25b..0000000000 --- a/.github/workflows/benchmarks.yaml +++ /dev/null @@ -1,123 +0,0 @@ -name: Benchmarks - -# ------------------------------------------------------------------------------ -# Workflow Settings -# ------------------------------------------------------------------------------ - -on: - push: - branches: [main] - workflow_dispatch: - inputs: - threshold: - description: "Regression threshold (fraction, e.g. 0.01 = 1%)" - required: false - type: string - default: "0.1" - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: {} - -env: - CARGO_TERM_COLOR: always - THRESHOLD: ${{ inputs.threshold || '0.10' }} - -jobs: - # ----------------------------------------------------------------- - # Comparative Benchmarks (Praxis vs Envoy) - # ----------------------------------------------------------------- - - benchmark: - runs-on: ubuntu-24.04 - permissions: - contents: read - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install stable Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - - - name: Install benchmark tools - run: | - make tools - echo "${{ github.workspace }}/target/praxis-binutils" >> "$GITHUB_PATH" - - - name: Download baseline - id: baseline - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - run_id=$(gh api "repos/${{ github.repository }}/actions/workflows/benchmarks.yaml/runs?branch=main&status=success&per_page=1" \ - --jq '.workflow_runs[0].id // empty') - if [ -z "$run_id" ]; then - echo "status=missing" >> "$GITHUB_OUTPUT" - exit 0 - fi - - artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${run_id}/artifacts" \ - --jq '.artifacts[] | select(.name == "benchmark-baseline") | .id // empty') - if [ -z "$artifact_id" ]; then - echo "status=missing" >> "$GITHUB_OUTPUT" - exit 0 - fi - - gh api "repos/${{ github.repository }}/actions/artifacts/${artifact_id}/zip" > baseline.zip - unzip -o baseline.zip -d . - mv benchmark-results.yaml baseline.yaml - echo "status=ok" >> "$GITHUB_OUTPUT" - - - name: Run benchmarks - env: - THRESHOLD: ${{ env.THRESHOLD }} - run: | - cargo xtask benchmark \ - --proxy envoy \ - --threshold "$THRESHOLD" \ - --runs 5 \ - --duration 60 \ - --warmup 30 \ - --format yaml \ - --output benchmark-results.yaml - - - name: Compare against baseline - id: compare - if: steps.baseline.outputs.status == 'ok' - continue-on-error: true - env: - THRESHOLD: ${{ env.THRESHOLD }} - run: | - cargo xtask benchmark compare \ - baseline.yaml benchmark-results.yaml \ - --threshold "$THRESHOLD" | tee compare-output.txt - - - name: Post comparison to job summary - if: steps.compare.outcome != 'skipped' - run: | - { - echo "## Benchmark Comparison" - echo '```' - cat compare-output.txt 2>/dev/null || echo "No comparison output" - echo '```' - if [ "${{ steps.compare.outcome }}" = "failure" ]; then - echo "" - echo "> **Warning**: Regression detected. Review results for CI noise vs genuine regression." - fi - } >> "$GITHUB_STEP_SUMMARY" - - - name: Upload baseline - if: steps.compare.outcome != 'failure' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: benchmark-baseline - path: benchmark-results.yaml - overwrite: true - - - name: Upload timestamped results - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: benchmark-${{ github.sha }} - path: benchmark-results.yaml diff --git a/.github/workflows/ci-status.yaml b/.github/workflows/ci-status.yaml new file mode 100644 index 0000000000..9e4e8f0149 --- /dev/null +++ b/.github/workflows/ci-status.yaml @@ -0,0 +1,127 @@ +name: CI Status + +# ------------------------------------------------------------------------------ +# Workflow Settings +# ------------------------------------------------------------------------------ + +on: + pull_request: + branches: [main] + merge_group: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + # ---------------------------------------------------------------------------- + # Aggregate CI Check Status + # ---------------------------------------------------------------------------- + + ci-status: + runs-on: ubuntu-24.04 + timeout-minutes: 180 + permissions: + checks: read + pull-requests: read + steps: + - name: Wait for CI checks to complete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const sha = context.payload.pull_request?.head.sha ?? context.sha; + const owner = context.repo.owner; + const repo = context.repo.repo; + + // Wait for other workflows to get queued + core.info('Waiting 60s for CI workflows to get queued...'); + await new Promise(r => setTimeout(r, 60000)); + + const excludedChecks = new Set([ + 'ci-status', + 'test-praxis-main', + ]); + + const terminalStatuses = new Set(['completed']); + const successConclusions = new Set(['success', 'skipped', 'neutral']); + const failureConclusions = new Set([ + 'failure', 'timed_out', 'cancelled', + 'action_required', 'startup_failure', 'stale', + ]); + + while (true) { + const checkRuns = await github.paginate(github.rest.checks.listForRef, { + owner, + repo, + ref: sha, + per_page: 100, + }); + + // Only include GitHub Actions checks, excluding ourselves + const filtered = checkRuns.filter(cr => { + if (excludedChecks.has(cr.name)) return false; + if (!cr.app || cr.app.slug !== 'github-actions') return false; + return true; + }); + + // Deduplicate by check name, keeping the most recently started run. + // When cancel-in-progress supersedes a run, both the cancelled and + // the replacement run appear as separate check_run entries on the + // same SHA. Without dedup the cancelled run blocks forever. + const latestByName = new Map(); + for (const cr of filtered) { + const prev = latestByName.get(cr.name); + if (!prev || new Date(cr.started_at) > new Date(prev.started_at)) { + latestByName.set(cr.name, cr); + } + } + const relevant = [...latestByName.values()]; + + if (relevant.length === 0) { + core.info('No other CI checks found yet, waiting...'); + await new Promise(r => setTimeout(r, 30000)); + continue; + } + + // A check is done when its status is "completed", OR when the API + // reports a conclusion despite the status still being "in_progress" + // (a known GitHub glitch where status never flips). + const isDone = cr => terminalStatuses.has(cr.status) || cr.conclusion != null; + const pending = relevant.filter(cr => !isDone(cr)); + const completed = relevant.filter(cr => isDone(cr)); + + core.info(`Checks: ${completed.length} completed, ${pending.length} pending out of ${relevant.length} total`); + + for (const cr of completed) { + core.info(` ✓ ${cr.name}: ${cr.conclusion}`); + } + for (const cr of pending) { + core.info(` ⏳ ${cr.name}: ${cr.status}`); + } + + if (pending.length > 0) { + core.info('Waiting 30s for pending checks...'); + await new Promise(r => setTimeout(r, 30000)); + continue; + } + + // All checks completed — evaluate conclusions + const failed = completed.filter(cr => failureConclusions.has(cr.conclusion)); + + if (failed.length > 0) { + for (const cr of failed) { + core.warning(`${cr.name} concluded with: ${cr.conclusion} — waiting for re-run`); + } + core.info(`${failed.length} check(s) failed. Waiting 30s for re-runs before giving up...`); + core.info('Re-run the failed job(s) and ci-status will pick up the result automatically.'); + await new Promise(r => setTimeout(r, 30000)); + continue; + } + + const succeeded = completed.filter(cr => successConclusions.has(cr.conclusion)); + core.info(`All ${succeeded.length} CI checks passed.`); + return; + } diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 82890cb771..75db6fb91e 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -5,18 +5,10 @@ name: CodeQL # ------------------------------------------------------------------------------ on: - push: - branches: [ main ] - pull_request: - branches: [ main ] schedule: - - cron: "0 6 * * 1" + - cron: "2 3 * * *" workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - permissions: {} jobs: @@ -35,28 +27,14 @@ jobs: matrix: language: [ actions, rust ] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install stable Rust - if: matrix.language == 'rust' - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Cache Cargo registry and build artifacts + - name: Setup Rust if: matrix.language == 'rust' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-rust-1.94.0-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-1.94.0-cargo- - save-always: true + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 - name: Initialize CodeQL - uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: languages: ${{ matrix.language }} @@ -65,6 +43,6 @@ jobs: run: cargo build --workspace - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml deleted file mode 100644 index bef33260ff..0000000000 --- a/.github/workflows/conformance.yaml +++ /dev/null @@ -1,55 +0,0 @@ -name: Conformance - -# ------------------------------------------------------------------------------ -# Workflow Settings -# ------------------------------------------------------------------------------ - -on: - push: - branches: [main] - pull_request: - branches: [main] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: {} - -env: - CARGO_TERM_COLOR: always - -jobs: - # ----------------------------------------------------------------- - # Conformance tests (h2spec, RFC compliance) - # ----------------------------------------------------------------- - - http2-conformance: - runs-on: ubuntu-24.04 - permissions: - contents: read - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install stable Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - - - name: Cache Cargo registry and build artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-rust-1.94.0-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-1.94.0-cargo- - save-always: true - - - name: Install Tools - run: make target/praxis-binutils/h2spec - - - name: Conformance tests - run: make test-conformance diff --git a/.github/workflows/container.yaml b/.github/workflows/container.yaml index 4058db38b5..7dc5e3eb73 100644 --- a/.github/workflows/container.yaml +++ b/.github/workflows/container.yaml @@ -7,48 +7,43 @@ name: Container on: push: branches: [main] - pull_request: - branches: [main] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true permissions: {} jobs: - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- # Build & Run Container - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- build-and-run: runs-on: ubuntu-24.04 permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Build container image - run: docker build -t praxis:ci -f Containerfile . + run: docker build -t praxis-ai:ci -f Containerfile . - name: Run container - run: docker run -d --name praxis praxis:ci + run: docker run -d --name praxis-ai praxis-ai:ci - name: Wait for healthy run: | for i in $(seq 1 30); do - status=$(docker inspect --format='{{.State.Health.Status}}' praxis 2>/dev/null || echo "starting") + status=$(docker inspect --format='{{.State.Health.Status}}' praxis-ai 2>/dev/null || echo "starting") if [ "${status}" = "healthy" ]; then echo "Container is healthy" exit 0 fi + echo "Waiting for container health (${i}/30): ${status}" sleep 2 done echo "Container failed to become healthy" - docker inspect --format='{{json .State.Health}}' praxis - docker logs praxis + docker inspect --format='{{json .State.Health}}' praxis-ai + docker logs praxis-ai exit 1 - name: Stop container if: always() - run: docker rm -f praxis + run: docker rm -f praxis-ai diff --git a/.github/workflows/conventions.yaml b/.github/workflows/conventions.yaml index 70f39bcb75..11046be8de 100644 --- a/.github/workflows/conventions.yaml +++ b/.github/workflows/conventions.yaml @@ -17,73 +17,42 @@ permissions: {} jobs: # ---------------------------------------------------------------------------- - # PR Size Check + # PR Description Check # ---------------------------------------------------------------------------- - pr-size-check: + pr-description-check: if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-24.04 steps: - - name: Check PR size + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.PRAXIS_BOT_APP_ID }} + private-key: ${{ secrets.PRAXIS_BOT_APP_PRIVATE_KEY }} + + - name: Check for description env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_NUMBER: ${{ github.event.pull_request.number }} - MAX_ADDITIONS: "500" + PR_BODY: ${{ github.event.pull_request.body }} run: | labels=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels -q '.labels[].name') - if echo "$labels" | grep -q '^skip/pr-hygiene$'; then - echo "skip/pr-hygiene label present — skipping size check" + if echo "$labels" | grep -q '^skip/pr-conventions$'; then + echo "skip/pr-conventions label present — skipping description check" exit 0 fi - if [ "${{ github.event.action }}" = "reopened" ]; then - perm=$(gh api repos/${{ github.repository }}/collaborators/${{ github.event.sender.login }}/permission -q .permission) - if [ "$perm" = "admin" ] || [ "$perm" = "maintain" ]; then - gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "skip/pr-hygiene" - echo "Maintainer reopened — added skip/pr-hygiene label" - exit 0 - fi - fi - - added=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/files" --paginate \ - --jq '[.[] | select(.filename | test("^(Cargo\\.lock|examples/)") | not) | .additions] | add // 0') - - if [ "$added" -gt "$MAX_ADDITIONS" ]; then - marker="" + word_count=$(echo "$PR_BODY" | wc -w) + if [ "$word_count" -lt 2 ]; then + marker="" already=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ --jq "[.[] | select(.body | contains(\"${marker}\"))] | length") if [ "$already" -eq 0 ]; then gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" \ --body "${marker} - **PR too large**: ${added} lines added (limit: ${MAX_ADDITIONS}). Please split into smaller PRs. Add \`skip/pr-hygiene\` label to override." + Please add a description to this PR explaining what it does and why. See our [coding conventions](docs/developing/conventions.md)." fi - gh pr close "$PR_NUMBER" --repo "${{ github.repository }}" - fi - - # ---------------------------------------------------------------------------- - # PR Description Check - # ---------------------------------------------------------------------------- - - pr-description-check: - if: ${{ !github.event.pull_request.draft }} - runs-on: ubuntu-24.04 - steps: - - name: Check for description - env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_BODY: ${{ github.event.pull_request.body }} - run: | - labels=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels -q '.labels[].name') - if echo "$labels" | grep -q '^skip/pr-hygiene$'; then - echo "skip/pr-hygiene label present — skipping description check" - exit 0 - fi - - word_count=$(echo "$PR_BODY" | wc -w) - if [ "$word_count" -lt 2 ]; then # minimum "fixes " - gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" \ - --body "Please add a description to this PR explaining what it does and why. See our [coding conventions](docs/developing/conventions.md)." fi # ---------------------------------------------------------------------------- @@ -94,9 +63,16 @@ jobs: if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-24.04 steps: + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.PRAXIS_BOT_APP_ID }} + private-key: ${{ secrets.PRAXIS_BOT_APP_PRIVATE_KEY }} + - name: Check workspace dependency versions env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_NUMBER: ${{ github.event.pull_request.number }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | @@ -168,9 +144,16 @@ jobs: github.event.pull_request.user.login != 'github-actions[bot]' runs-on: ubuntu-24.04 steps: + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.PRAXIS_BOT_APP_ID }} + private-key: ${{ secrets.PRAXIS_BOT_APP_PRIVATE_KEY }} + - name: Check signed commits in PR env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | labels=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels -q '.labels[].name') @@ -180,7 +163,7 @@ jobs: fi unsigned=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" \ - --paginate --jq '[.[] | select(.commit.verification.verified == false) | .sha[:7]] | join(", ")') + --paginate --jq '[.[] | select(.parents | length == 1) | select(.commit.verification.verified == false) | .sha[:7]] | join(", ")') if [ -n "$unsigned" ]; then marker="" @@ -203,9 +186,16 @@ jobs: if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-24.04 steps: + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.PRAXIS_BOT_APP_ID }} + private-key: ${{ secrets.PRAXIS_BOT_APP_PRIVATE_KEY }} + - name: Check commits for Signed-off-by env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | labels=$(gh pr view "$PR_NUMBER" \ @@ -219,7 +209,7 @@ jobs: missing=$(gh api \ "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" \ --paginate \ - --jq '[.[] | select(.commit.message | test("^Signed-off-by: "; "m") | not) | .sha[:7]] | join(", ")') + --jq '[.[] | select(.parents | length == 1) | select(.commit.message | test("(?m)^Signed-off-by: ") | not) | .sha[:7]] | join(", ")') if [ -n "$missing" ]; then marker="" @@ -244,9 +234,16 @@ jobs: if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-24.04 steps: + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.PRAXIS_BOT_APP_ID }} + private-key: ${{ secrets.PRAXIS_BOT_APP_PRIVATE_KEY }} + - name: Check for AI tool authorship env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | labels=$(gh pr view "$PR_NUMBER" \ @@ -274,7 +271,8 @@ jobs: --arg credit "$CREDIT" \ --arg any_trailer "$ANY_TRAILER" ' [ - (.[] | .sha[:7] as $sha | + (.[] | select(.parents | length == 1) | + .sha[:7] as $sha | select( (.commit.author.email | test($ai_email; "i")) or @@ -282,7 +280,8 @@ jobs: test($ai_email; "i")) ) | "- `\($sha)`: AI tool email in author/committer"), - (.[] | .sha[:7] as $sha | + (.[] | select(.parents | length == 1) | + .sha[:7] as $sha | select( (.commit.author.name | test($ai_author; "i")) or @@ -290,7 +289,8 @@ jobs: test($ai_author; "i")) ) | "- `\($sha)`: AI tool in author/committer name"), - (.[] | .sha[:7] as $sha | + (.[] | select(.parents | length == 1) | + .sha[:7] as $sha | .commit.message | split("\n")[] | select( (test($any_trailer; "i") and @@ -316,83 +316,9 @@ jobs: ${violations} Sorry, this project does not accept commits authored by tools as valid. - Commits need to be authored by and signed-off by the human(s) responsible for the PR, with their name and contact. + Commits need to be authored by and signed-off by the human(s) responsible for the PR, with their name and contact." fi echo "::error::AI tool authorship found in PR commits" exit 1 fi - # ---------------------------------------------------------------------------- - # Proposal Check - # ---------------------------------------------------------------------------- - - proposal-check: - if: ${{ !github.event.pull_request.draft }} - runs-on: ubuntu-24.04 - steps: - - name: Validate proposal files - env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} - PR_NUMBER: ${{ github.event.pull_request.number }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - labels=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels -q '.labels[].name') - if echo "$labels" | grep -q '^skip/proposals$'; then - echo "skip/proposals label present — skipping proposal check" - exit 0 - fi - - proposal_files=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/files" --paginate \ - --jq '[.[] | select(.filename | startswith("docs/proposals/")) | select(.filename | endswith(".md")) | select(.filename != "docs/proposals/template.md")]') - - count=$(echo "$proposal_files" | jq 'length') - if [ "$count" -eq 0 ]; then - echo "No proposal files in this PR — skipping" - exit 0 - fi - - errors="" - - while IFS= read -r file; do - status=$(echo "$proposal_files" | jq -r --arg f "$file" '.[] | select(.filename == $f) | .status') - - content=$(gh api "repos/${{ github.repository }}/contents/${file}?ref=$HEAD_SHA" \ - --jq '.content' | base64 -d) - - frontmatter=$(echo "$content" | sed -n '/^---$/,/^---$/p' | sed '1d;$d') - - discussion=$(echo "$frontmatter" | grep -E '^discussion:' | sed 's/^discussion:[[:space:]]*//') - if [ -z "$discussion" ]; then - errors="${errors}\n- \`${file}\`: missing \`discussion\` field in frontmatter" - fi - - issue=$(echo "$frontmatter" | grep -E '^issue:' | sed 's/^issue:[[:space:]]*//') - if [ -z "$issue" ]; then - errors="${errors}\n- \`${file}\`: missing \`issue\` field in frontmatter" - fi - - has_authors=$(echo "$frontmatter" | grep -E '^ - ' | head -1) - if [ -z "$has_authors" ]; then - errors="${errors}\n- \`${file}\`: missing or empty \`authors\` list in frontmatter" - fi - - if [ "$status" = "added" ]; then - if echo "$content" | grep -qF '## How?'; then - errors="${errors}\n- \`${file}\`: new proposals must not include the \`## How?\` section in the first PR. Submit What? and Why? first; add How? in a follow-up." - fi - fi - done < <(echo "$proposal_files" | jq -r '.[].filename') - - if [ -n "$errors" ]; then - marker="" - already=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ - --jq "[.[] | select(.body | contains(\"${marker}\"))] | length") - if [ "$already" -eq 0 ]; then - gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" \ - --body "${marker} - **Proposal validation failed**: - $(echo -e "$errors") - See [proposal process](docs/proposals.md) for requirements. Fix and re-open." - fi - gh pr close "$PR_NUMBER" --repo "${{ github.repository }}" - fi diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index a409987ba5..4d96be0cb2 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -9,11 +9,13 @@ on: branches: [main] pull_request: branches: [main] + merge_group: + branches: [main] workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: {} @@ -26,120 +28,8 @@ jobs: runs-on: ubuntu-24.04 permissions: contents: read - pull-requests: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - with: - toolchain: stable - components: llvm-tools-preview - - - name: Cache cargo-llvm-cov binary - id: cache-llvm-cov - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.cargo/bin/cargo-llvm-cov - key: cargo-llvm-cov-${{ runner.os }} - - - name: Install cargo-llvm-cov - if: steps.cache-llvm-cov.outputs.cache-hit != 'true' - run: cargo install cargo-llvm-cov --locked - - - name: Check coverage (≥90% lines) - run: make coverage-check - - - name: Upload baseline - if: github.event_name == 'push' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: coverage-baseline - path: coverage.json - overwrite: true - - - name: Rename PR coverage - if: github.event_name == 'pull_request' - run: cp coverage.json pr-coverage.json - - - name: Download baseline - if: github.event_name == 'pull_request' - id: baseline - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - run_id=$(gh api "repos/${{ github.repository }}/actions/workflows/coverage.yaml/runs?branch=main&status=success&per_page=1" \ - --jq '.workflow_runs[0].id // empty') - if [ -z "$run_id" ]; then - echo "status=missing" >> "$GITHUB_OUTPUT" - exit 0 - fi - - artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${run_id}/artifacts" \ - --jq '.artifacts[] | select(.name == "coverage-baseline") | .id // empty') - if [ -z "$artifact_id" ]; then - echo "status=missing" >> "$GITHUB_OUTPUT" - exit 0 - fi - - gh api "repos/${{ github.repository }}/actions/artifacts/${artifact_id}/zip" > baseline.zip - unzip -o baseline.zip -d . - mv coverage.json base-coverage.json - echo "status=ok" >> "$GITHUB_OUTPUT" - - - name: Post coverage comment - if: >- - github.event_name == 'pull_request' && - steps.baseline.outputs.status == 'ok' - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - pr_lines=$(jq '.data[0].totals.lines.percent' pr-coverage.json) - pr_functions=$(jq '.data[0].totals.functions.percent' pr-coverage.json) - base_lines=$(jq '.data[0].totals.lines.percent' base-coverage.json) - base_functions=$(jq '.data[0].totals.functions.percent' base-coverage.json) - - pr_fmt=$(awk "BEGIN {printf \"%.2f\", ${pr_lines}}") - base_fmt=$(awk "BEGIN {printf \"%.2f\", ${base_lines}}") - delta=$(awk "BEGIN {d=${pr_lines}-${base_lines}; printf \"%+.2f\", d}") - fn_pr=$(awk "BEGIN {printf \"%.2f\", ${pr_functions}}") - fn_base=$(awk "BEGIN {printf \"%.2f\", ${base_functions}}") - fn_delta=$(awk "BEGIN {d=${pr_functions}-${base_functions}; printf \"%+.2f\", d}") - - marker="" - existing_id=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ - --jq "[.[] | select(.body | contains(\"${marker}\"))] | .[0].id // empty") - if [ -n "$existing_id" ]; then - gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${existing_id}" - fi - - gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" \ - --body "${marker} - ### Coverage - Lines: ${base_fmt}% → ${pr_fmt}% (${delta}%) | Functions: ${fn_base}% → ${fn_pr}% (${fn_delta}%)" - - - name: Post comment (no baseline) - if: >- - github.event_name == 'pull_request' && - steps.baseline.outputs.status == 'missing' - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - pr_lines=$(jq '.data[0].totals.lines.percent' pr-coverage.json) - pr_fmt=$(awk "BEGIN {printf \"%.2f\", ${pr_lines}}") - - marker="" - existing_id=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ - --jq "[.[] | select(.body | contains(\"${marker}\"))] | .[0].id // empty") - if [ -n "$existing_id" ]; then - gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${existing_id}" - fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" \ - --body "${marker} - ### Coverage - Lines: ${pr_fmt}%. No baseline from \`main\`; comparison skipped." + - name: Coverage check + uses: praxis-proxy/conventions/.github/actions/coverage-check@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml index c1eddd9690..9e5e4a9b28 100644 --- a/.github/workflows/documentation.yaml +++ b/.github/workflows/documentation.yaml @@ -9,10 +9,12 @@ on: branches: [main] pull_request: branches: [main] + merge_group: + branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: {} @@ -30,23 +32,12 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install stable Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Cache Cargo registry and build artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + - name: Setup Rust + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-rust-1.94.0-doc-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-1.94.0-doc- - save-always: true + cache-suffix: doc - name: Build documentation run: make doc diff --git a/.github/workflows/fuzz.yaml b/.github/workflows/fuzz.yaml deleted file mode 100644 index f900d75ec0..0000000000 --- a/.github/workflows/fuzz.yaml +++ /dev/null @@ -1,50 +0,0 @@ -name: Fuzz - -# ------------------------------------------------------------------------------ -# Workflow Settings -# ------------------------------------------------------------------------------ - -on: - push: - branches: [main] - pull_request: - branches: [main] - schedule: - - cron: "23 3 * * *" - workflow_dispatch: - inputs: - duration: - description: "Fuzz duration per target in seconds" - required: false - type: number - default: 120 - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: {} - -env: - FUZZ_DURATION: ${{ github.event_name == 'schedule' && '1800' || inputs.duration || '120' }} - -jobs: - # ----------------------------------------------------------------- - # Fuzz - # ----------------------------------------------------------------- - - fuzz: - runs-on: ubuntu-24.04 - permissions: - contents: read - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install nightly Rust - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly-2026-03-28 (rustc 1.96.0) - - - name: Install cargo-fuzz - run: cargo install cargo-fuzz - - - name: Run fuzz targets - run: make fuzz diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 9696416468..0043e49d47 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -7,8 +7,33 @@ name: Tests (Integration) on: push: branches: [main] + paths: + - "apis/**" + - "filters/**" + - "server/**" + - "tests/**" + - "xtask/**" + - "examples/configs/**" + - "Cargo.toml" + - "Cargo.lock" + - "Makefile" + - ".github/workflows/integration.yaml" pull_request: branches: [main] + types: [opened, synchronize, reopened] + paths: + - "apis/**" + - "filters/**" + - "server/**" + - "tests/**" + - "xtask/**" + - "examples/configs/**" + - "Cargo.toml" + - "Cargo.lock" + - "Makefile" + - ".github/workflows/integration.yaml" + merge_group: + branches: [main] workflow_dispatch: inputs: debug: @@ -16,10 +41,9 @@ on: required: false type: boolean default: false - concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: {} @@ -28,41 +52,125 @@ env: V: ${{ inputs.debug && '1' || '' }} jobs: - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- + # Path filter for merge_group (which does not support on.paths) + # ---------------------------------------------------------------------------- + + changes: + if: github.event_name == 'merge_group' + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + relevant: ${{ steps.filter.outputs.relevant }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Detect relevant path changes + id: filter + run: | + CHANGED=$(git diff --name-only "${{ github.event.merge_group.base_sha }}" \ + "${{ github.event.merge_group.head_sha }}" -- \ + 'apis/' \ + 'filters/' \ + 'server/' \ + 'tests/' \ + 'xtask/' \ + 'examples/configs/' \ + 'Cargo.toml' \ + 'Cargo.lock' \ + 'Makefile' \ + '.github/workflows/integration.yaml') + if [ -n "$CHANGED" ]; then + echo "relevant=true" >> "$GITHUB_OUTPUT" + else + echo "relevant=false" >> "$GITHUB_OUTPUT" + fi + + # ---------------------------------------------------------------------------- # Integration test suites (tests/ crates) - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- test: + needs: [changes] + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.relevant == 'true') runs-on: ubuntu-24.04 permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install stable Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - - - name: Cache Cargo registry and build artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-rust-1.94.0-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-1.94.0-cargo- - save-always: true + - name: Setup Rust + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 - name: Schema tests run: make test-schema - - name: Security tests - run: make test-security - - - name: Resilience tests - run: make test-resilience + - name: Inference fixture tests + run: make test-inference-fixtures - name: Integration tests run: make test-integration + + # ---------------------------------------------------------------------------- + # Pinned Codex CLI WebSocket acceptance + # ---------------------------------------------------------------------------- + + codex-websocket: + needs: [changes] + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.relevant == 'true') + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + env: + CODEX_ARCHIVE: codex-x86_64-unknown-linux-musl.tar.gz + CODEX_BIN: codex-x86_64-unknown-linux-musl + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Rust + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 + with: + cache-suffix: codex-websocket-cargo + + - name: Cache pinned Codex CLI + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.tool_cache }}/codex/0.144.1/x86_64-unknown-linux-musl + key: codex-0.144.1-x86_64-unknown-linux-musl-84091ae20c65fcc7d4120db97d1bd57d7ff8df9c7609fb781c78c2ebbd4f5a28 + + - name: Download pinned Codex CLI + run: | + codex_cache_dir="$RUNNER_TOOL_CACHE/codex/0.144.1/x86_64-unknown-linux-musl" + mkdir -p "$codex_cache_dir" + if [ ! -f "$codex_cache_dir/$CODEX_ARCHIVE" ]; then + curl --proto '=https' --tlsv1.2 --fail --location --retry 3 \ + --output "$codex_cache_dir/$CODEX_ARCHIVE" \ + "https://github.com/openai/codex/releases/download/rust-v0.144.1/$CODEX_ARCHIVE" + fi + + - name: Verify and extract pinned Codex CLI + run: | + codex_cache_dir="$RUNNER_TOOL_CACHE/codex/0.144.1/x86_64-unknown-linux-musl" + cd "$codex_cache_dir" + sha256sum --check \ + "$GITHUB_WORKSPACE/tests/integration/fixtures/codex-cli/0.144.1-x86_64-unknown-linux-musl.sha256" + tar --extract --gzip --file "$CODEX_ARCHIVE" + chmod +x "$CODEX_BIN" + test -x "$CODEX_BIN" + test "$("./$CODEX_BIN" --version)" = "codex-cli 0.144.1" + + - name: Run pinned Codex WebSocket acceptance test + env: + PRAXIS_TEST_CODEX_BIN: ${{ runner.tool_cache }}/codex/0.144.1/x86_64-unknown-linux-musl/${{ env.CODEX_BIN }} + run: | + test -x "$PRAXIS_TEST_CODEX_BIN" + cargo test -p praxis-tests-integration --test suite \ + codex_websocket::pinned_codex_uses_responses_websocket_through_full_flow -- --exact diff --git a/.github/workflows/issue-triage.yaml b/.github/workflows/issue-triage.yaml index d99ed6ce86..2a15d5fb22 100644 --- a/.github/workflows/issue-triage.yaml +++ b/.github/workflows/issue-triage.yaml @@ -16,14 +16,22 @@ jobs: # ---------------------------------------------------------------------------- triage: + if: ${{ !github.event.issue.pull_request }} runs-on: ubuntu-24.04 permissions: issues: write steps: + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.PRAXIS_BOT_APP_ID }} + private-key: ${{ secrets.PRAXIS_BOT_APP_PRIVATE_KEY }} + - name: Add triage/needs-triage on new issue if: github.event.action == 'opened' env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} GH_REPO: ${{ github.repository }} ISSUE: ${{ github.event.issue.number }} run: | @@ -33,7 +41,7 @@ jobs: - name: Accept triaged issue if: github.event.action == 'milestoned' env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} GH_REPO: ${{ github.repository }} ISSUE: ${{ github.event.issue.number }} run: | @@ -44,7 +52,7 @@ jobs: - name: Revert to needs-triage on demilestone if: github.event.action == 'demilestoned' env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} GH_REPO: ${{ github.repository }} ISSUE: ${{ github.event.issue.number }} run: | diff --git a/.github/workflows/microbenchmarks.yaml b/.github/workflows/microbenchmarks.yaml deleted file mode 100644 index ea9debb24b..0000000000 --- a/.github/workflows/microbenchmarks.yaml +++ /dev/null @@ -1,170 +0,0 @@ -name: Microbenchmarks - -# ------------------------------------------------------------------------------ -# Workflow Settings -# ------------------------------------------------------------------------------ - -on: - push: - branches: [main] - pull_request: - branches: [main] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: {} - -jobs: - # ----------------------------------------------------------------- - # Microbenchmarks - # ----------------------------------------------------------------- - - microbenchmarks: - name: microbenchmarks - runs-on: ubuntu-24.04 - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - - - name: Cache cargo - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-bench-${{ hashFiles('**/Cargo.lock') }} - - - name: Install critcmp - if: github.event_name == 'pull_request' - run: | - command -v critcmp || cargo install critcmp --locked - - - name: Download baseline - if: github.event_name == 'pull_request' - id: baseline - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - run_id=$(gh api "repos/${{ github.repository }}/actions/workflows/microbenchmarks.yaml/runs?branch=main&status=success&per_page=1" \ - --jq '.workflow_runs[0].id // empty') - if [ -z "$run_id" ]; then - echo "status=missing" >> "$GITHUB_OUTPUT" - exit 0 - fi - - artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${run_id}/artifacts" \ - --jq '.artifacts[] | select(.name == "microbenchmark-baseline") | .id // empty') - if [ -z "$artifact_id" ]; then - echo "status=missing" >> "$GITHUB_OUTPUT" - exit 0 - fi - - gh api "repos/${{ github.repository }}/actions/artifacts/${artifact_id}/zip" > baseline.zip - mkdir -p target/criterion - unzip -o baseline.zip -d target/criterion - echo "status=ok" >> "$GITHUB_OUTPUT" - - # On push to main: save as named baseline and upload - - name: Run microbenchmarks (baseline) - if: github.event_name == 'push' - run: cargo bench -p benchmarks -- --save-baseline main - - # On PR: save under a different name so critcmp can compare - - name: Run microbenchmarks (PR) - if: github.event_name == 'pull_request' - run: cargo bench -p benchmarks -- --save-baseline pr - - - name: Compare against baseline - if: >- - github.event_name == 'pull_request' && - steps.baseline.outputs.status == 'ok' - id: compare - run: | - output=$(critcmp main pr --threshold 15 2>&1) || true - { - echo "comparison<> "$GITHUB_OUTPUT" - - if echo "$output" | grep -q "Regressed"; then - echo "has_regression=true" >> "$GITHUB_OUTPUT" - else - echo "has_regression=false" >> "$GITHUB_OUTPUT" - fi - - - name: Post comparison comment - if: >- - github.event_name == 'pull_request' && - steps.baseline.outputs.status == 'ok' - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} - PR_NUMBER: ${{ github.event.pull_request.number }} - COMPARISON: ${{ steps.compare.outputs.comparison }} - HAS_REGRESSION: ${{ steps.compare.outputs.has_regression }} - run: | - marker="" - existing_id=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ - --jq "[.[] | select(.body | contains(\"${marker}\"))] | .[0].id // empty") - if [ -n "$existing_id" ]; then - gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${existing_id}" - fi - - if [ "$HAS_REGRESSION" = "true" ]; then - status="**Regressions detected** (threshold: 15%)" - else - status="No regressions (threshold: 15%)" - fi - - gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" \ - --body "${marker} - ### Microbenchmarks - ${status} - \`\`\` - ${COMPARISON} - \`\`\`" - - - name: Post comment (no baseline) - if: >- - github.event_name == 'pull_request' && - steps.baseline.outputs.status == 'missing' - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - marker="" - existing_id=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ - --jq "[.[] | select(.body | contains(\"${marker}\"))] | .[0].id // empty") - if [ -n "$existing_id" ]; then - gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${existing_id}" - fi - - gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" \ - --body "${marker} - ### Microbenchmarks - No baseline from \`main\`. Results recorded; comparison skipped." - - - name: Upload baseline - if: github.event_name == 'push' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: microbenchmark-baseline - path: target/criterion/ - overwrite: true - - - name: Upload PR results - if: github.event_name == 'pull_request' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: microbenchmark-results - path: target/criterion/ diff --git a/.github/workflows/msrv.yaml b/.github/workflows/msrv.yaml index 451ba7e178..91cedede1d 100644 --- a/.github/workflows/msrv.yaml +++ b/.github/workflows/msrv.yaml @@ -9,11 +9,13 @@ on: branches: [main] pull_request: branches: [main] + merge_group: + branches: [main] workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: {} @@ -21,32 +23,21 @@ env: CARGO_TERM_COLOR: always jobs: - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- # Minimum Supported Rust Version - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- msrv: runs-on: ubuntu-24.04 permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install MSRV Rust (1.94.0) - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Cache Cargo registry and build artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + - name: Setup Rust (MSRV) + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-rust-1.94.0-msrv-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-1.94.0-msrv- - save-always: true + cache-suffix: msrv - name: Check workspace compiles on MSRV run: cargo check --workspace diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index 3b95af0309..533f0ec9fb 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -19,46 +19,30 @@ env: CARGO_TERM_COLOR: always jobs: - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- # Lint (fmt + clippy + dependency lint) - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- lint: runs-on: ubuntu-24.04 permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install nightly Rust (for rustfmt) - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly-2026-03-28 (rustc 1.96.0) - with: - components: rustfmt - - name: Install stable Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - with: - components: clippy + - name: Setup Rust (lint) + uses: praxis-proxy/conventions/.github/actions/setup-rust-lint@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 - - name: Cache Cargo registry and build artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-rust-1.94.0-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-1.94.0-cargo- - save-always: true + - name: Install cargo-machete + run: cargo install cargo-machete --locked - name: Lint run: make lint - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- # Documentation - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- doc: runs-on: ubuntu-24.04 @@ -67,190 +51,61 @@ jobs: env: RUSTDOCFLAGS: "-D warnings" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install stable Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - - name: Cache Cargo registry and build artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + - name: Setup Rust + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-rust-1.94.0-doc-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-1.94.0-doc- - save-always: true + cache-suffix: doc - name: Build documentation run: make doc - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- # Unit tests - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- test-unit: runs-on: ubuntu-24.04 permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install stable Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - - - name: Cache Cargo registry and build artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-rust-1.94.0-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-1.94.0-cargo- - save-always: true - - name: Smoke tests - run: make test-smoke + - name: Setup Rust + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 - name: Unit tests run: make test-unit - # ----------------------------------------------------------------- - # Integration tests - # ----------------------------------------------------------------- - - test-integration: - runs-on: ubuntu-24.04 - permissions: - contents: read - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install stable Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - - - name: Cache Cargo registry and build artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-rust-1.94.0-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-1.94.0-cargo- - save-always: true - - - name: Schema tests - run: make test-schema - - - name: Security tests - run: make test-security - - - name: Resilience tests - run: make test-resilience - - - name: Integration tests - run: make test-integration - - # ----------------------------------------------------------------- - # Conformance tests - # ----------------------------------------------------------------- - - test-conformance: - runs-on: ubuntu-24.04 - permissions: - contents: read - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install stable Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - - - name: Cache Cargo registry and build artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-rust-1.94.0-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-1.94.0-cargo- - save-always: true - - - name: Install Tools - run: make target/praxis-binutils/h2spec - - - name: Conformance tests - run: make test-conformance - - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- # Supply chain audit - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- audit: runs-on: ubuntu-24.04 permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - - - name: Cache cargo binaries - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.cargo/bin - key: ${{ runner.os }}-cargo-bin-supply-chain - - - name: Install audit tools - run: | - command -v cargo-audit || cargo install cargo-audit --locked - command -v cargo-deny || cargo install cargo-deny --locked + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Run cargo audit - run: cargo audit - - name: Run cargo deny - run: cargo deny check + - name: Supply chain audit + uses: praxis-proxy/conventions/.github/actions/supply-chain-audit@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- # Coverage gate - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- coverage: runs-on: ubuntu-24.04 permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - with: - toolchain: stable - components: llvm-tools-preview - - - name: Cache cargo-llvm-cov binary - id: cache-llvm-cov - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.cargo/bin/cargo-llvm-cov - key: cargo-llvm-cov-${{ runner.os }} + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install cargo-llvm-cov - if: steps.cache-llvm-cov.outputs.cache-hit != 'true' - run: cargo install cargo-llvm-cov --locked - - name: Check coverage (≥90% lines) - run: make coverage-check + - name: Coverage check + uses: praxis-proxy/conventions/.github/actions/coverage-check@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 diff --git a/.github/workflows/openai-conformance.yaml b/.github/workflows/openai-conformance.yaml new file mode 100644 index 0000000000..883ca6b733 --- /dev/null +++ b/.github/workflows/openai-conformance.yaml @@ -0,0 +1,108 @@ +name: OpenAI Conformance + +on: + push: + branches: [main] + pull_request: + branches: [main] + types: [opened, synchronize, reopened, labeled, unlabeled] + merge_group: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: {} + +env: + CARGO_TERM_COLOR: always + +jobs: + conformance: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@6190aa5fb88a88ee71c12769924bbe63a9ab152e # 1.96.0 + + - name: Install Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v6.0.0 + with: + go-version: "1.26.x" + + - name: Install pinned oasdiff + run: go install github.com/oasdiff/oasdiff@v1.23.0 + + - name: Verify pinned OpenAI reference + run: cargo xtask openai-conformance-reference --check + + - name: Regenerate conformance report + run: >- + cargo xtask openai-conformance + --output-json docs/conformance/openai-conformance-report.json + + - name: Check generated artifacts + run: >- + git diff --exit-code -- + docs/conformance/openai-conformance-report.json + docs/conformance/specs/openai-openapi.yaml + docs/conformance/specs/openai-openapi-source.json + + - name: Enforce strict OpenAI conformance + id: strict-conformance + continue-on-error: true + run: >- + target/debug/xtask openai-conformance-gate + --report docs/conformance/openai-conformance-report.json + + - name: Evaluate pull request acknowledgement + if: >- + always() && + github.event_name == 'pull_request' && + steps.strict-conformance.outcome == 'failure' + env: + ALLOW_NEW_FAILURES: ${{ contains(github.event.pull_request.labels.*.name, 'conformance-failure-acknowledged') }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + REPORT: docs/conformance/openai-conformance-report.json + run: | + args=(--report "$REPORT" --acknowledge) + base_report="$RUNNER_TEMP/openai-conformance-base-report.json" + if git cat-file -e "$BASE_SHA:$REPORT" 2>/dev/null; then + git show "$BASE_SHA:$REPORT" > "$base_report" + args+=(--base-report "$base_report") + fi + if [[ "$ALLOW_NEW_FAILURES" == "true" ]]; then + args+=(--allow-new-failures) + fi + target/debug/xtask openai-conformance-gate "${args[@]}" + echo "::warning title=OpenAI conformance failure acknowledged::Strict conformance failed; the generated report carries the reviewed failure set." + { + echo "### OpenAI conformance failure acknowledged" + echo + echo "Strict conformance failed, but its exact failure set is recorded in the generated report." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Honor checked-in acknowledgement + if: >- + always() && + github.event_name != 'pull_request' && + steps.strict-conformance.outcome == 'failure' + run: | + target/debug/xtask openai-conformance-gate \ + --report docs/conformance/openai-conformance-report.json \ + --acknowledge \ + --allow-new-failures + echo "::warning title=OpenAI conformance failure acknowledged::Strict conformance failed; the regenerated report matches the checked-in reviewed failure set." + { + echo "### OpenAI conformance failure acknowledged" + echo + echo "Strict conformance failed, but the regenerated report matches the checked-in reviewed failure set." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/postgres.yaml b/.github/workflows/postgres.yaml new file mode 100644 index 0000000000..d2ebd21001 --- /dev/null +++ b/.github/workflows/postgres.yaml @@ -0,0 +1,151 @@ +name: Tests (PostgreSQL Store) + +# ------------------------------------------------------------------------------ +# Workflow Settings +# ------------------------------------------------------------------------------ + +on: + push: + branches: [main] + paths: + - "apis/src/store/**" + - "apis/src/openai/responses/store/**" + - "apis/src/openai/conversations/**" + - "examples/configs/openai/responses/response-store.yaml" + - "tests/utils/src/net/postgres.rs" + - "tests/integration/tests/suite/examples/openai_response_store_postgres.rs" + - "Cargo.toml" + - "Cargo.lock" + - "Makefile" + - ".github/workflows/postgres.yaml" + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + paths: + - "apis/src/store/**" + - "apis/src/openai/responses/store/**" + - "apis/src/openai/conversations/**" + - "examples/configs/openai/responses/response-store.yaml" + - "tests/utils/src/net/postgres.rs" + - "tests/integration/tests/suite/examples/openai_response_store_postgres.rs" + - "Cargo.toml" + - "Cargo.lock" + - "Makefile" + - ".github/workflows/postgres.yaml" + merge_group: + branches: [main] + workflow_dispatch: + inputs: + debug: + description: "Enable verbose test output (V=1)" + required: false + type: boolean + default: false +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: {} + +env: + CARGO_TERM_COLOR: always + V: ${{ inputs.debug && '1' || '' }} + +jobs: + # ---------------------------------------------------------------------------- + # Path filter for merge_group (which does not support on.paths) + # ---------------------------------------------------------------------------- + + changes: + if: github.event_name == 'merge_group' + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + relevant: ${{ steps.filter.outputs.relevant }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Detect relevant path changes + id: filter + run: | + CHANGED=$(git diff --name-only "${{ github.event.merge_group.base_sha }}" \ + "${{ github.event.merge_group.head_sha }}" -- \ + 'apis/src/store/' \ + 'apis/src/openai/responses/store/' \ + 'apis/src/openai/conversations/' \ + 'examples/configs/openai/responses/response-store.yaml' \ + 'tests/utils/src/net/postgres.rs' \ + 'tests/integration/tests/suite/examples/openai_response_store_postgres.rs' \ + 'Cargo.toml' \ + 'Cargo.lock' \ + 'Makefile' \ + '.github/workflows/postgres.yaml') + if [ -n "$CHANGED" ]; then + echo "relevant=true" >> "$GITHUB_OUTPUT" + else + echo "relevant=false" >> "$GITHUB_OUTPUT" + fi + + # ---------------------------------------------------------------------------- + # PostgreSQL store unit tests (ignored tests requiring DATABASE_URL) + # ---------------------------------------------------------------------------- + + unit: + needs: [changes] + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.relevant == 'true') + runs-on: ubuntu-24.04 + permissions: + contents: read + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: praxis + POSTGRES_PASSWORD: praxis + POSTGRES_DB: praxis + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U praxis" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgres://praxis:praxis@127.0.0.1:5432/praxis + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Rust + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 + + - name: PostgreSQL store unit tests + run: make test-postgres-unit + + # ---------------------------------------------------------------------------- + # PostgreSQL store integration tests (spawn their own container) + # ---------------------------------------------------------------------------- + + integration: + needs: [changes] + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.relevant == 'true') + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Rust + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 + + - name: Pull PostgreSQL image + run: docker pull docker.io/library/postgres:17-alpine + + - name: PostgreSQL store integration tests + run: make test-postgres-integration diff --git a/.github/workflows/praxis-compat.yaml b/.github/workflows/praxis-compat.yaml new file mode 100644 index 0000000000..8f455e2ca9 --- /dev/null +++ b/.github/workflows/praxis-compat.yaml @@ -0,0 +1,69 @@ +name: Tests (praxis main) + +# ------------------------------------------------------------------------------ +# Workflow Settings +# ------------------------------------------------------------------------------ + +on: + push: + branches: [main] + pull_request: + branches: [main] + types: [opened, synchronize, reopened, labeled] + workflow_dispatch: + inputs: + praxis_ref: + description: "Praxis core git ref to test against" + required: false + type: string + default: "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: {} + +env: + CARGO_TERM_COLOR: always + +jobs: + # ---------------------------------------------------------------------------- + # Build + test against praxis core main + # ---------------------------------------------------------------------------- + + test-praxis-main: + runs-on: ubuntu-24.04 + continue-on-error: ${{ contains(github.event.pull_request.labels.*.name, 'praxis-known-issue') }} + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Clone Praxis core (path dependency) + uses: ./.github/actions/clone-praxis + with: + ref: ${{ inputs.praxis_ref || 'main' }} + + - name: Patch Praxis dependencies + uses: ./.github/actions/patch-praxis + + - name: Update lockfile for patched dependencies + run: cargo update + + - name: Setup Rust + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 + with: + cache-suffix: praxis-main + + - name: Unit tests + run: | + cargo test -p praxis-ai-apis --features praxis-main + cargo test -p praxis-ai-filters --features praxis-main + cargo test -p praxis-ai-proxy --features praxis-main + + - name: Schema tests + run: cargo test -p praxis-tests-schema --features praxis-main + + - name: Integration tests + run: cargo test -p praxis-tests-integration --features praxis-main diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 7a6980988a..cd9060e258 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -9,14 +9,10 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: {} -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - jobs: # ---------------------------------------------------------------------------- # Container Build & Publish @@ -24,41 +20,16 @@ jobs: container: runs-on: ubuntu-24.04 - if: >- - github.event_name == 'workflow_dispatch' || - github.event_name == 'push' || - github.event.workflow_run.conclusion == 'success' permissions: contents: read packages: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Log in to GHCR - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Extract metadata - id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=sha - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - name: Build and push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + - name: Publish container image + uses: praxis-proxy/conventions/.github/actions/ghcr-publish@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 with: - context: . - file: Containerfile - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + image-name: ${{ github.repository }} diff --git a/.github/workflows/pull-request-conventions.yaml b/.github/workflows/pull-request-conventions.yaml new file mode 100644 index 0000000000..64ecc02938 --- /dev/null +++ b/.github/workflows/pull-request-conventions.yaml @@ -0,0 +1,152 @@ +name: Pull Request Conventions + +# ------------------------------------------------------------------------------ +# Workflow Settings +# ------------------------------------------------------------------------------ + +on: + workflow_dispatch: + +permissions: {} + +jobs: + # ---------------------------------------------------------------------------- + # Draft on Failure + # ---------------------------------------------------------------------------- + + draft-on-failure: + runs-on: ubuntu-24.04 + permissions: + checks: read + pull-requests: write + steps: + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.PRAXIS_BOT_APP_ID }} + private-key: ${{ secrets.PRAXIS_BOT_APP_PRIVATE_KEY }} + + - name: Convert failing PRs to draft + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_REPO: ${{ github.repository }} + run: | + prs=$(gh pr list --base main --state open --limit 100 \ + --json number,isDraft \ + --jq '[.[] | select(.isDraft == false)] | .[].number') + + for number in $prs; do + failed=$(gh pr checks "$number" --required --json bucket \ + --jq '[.[] | select(.bucket == "fail")] | length' 2>/dev/null) || continue + + if [ "$failed" -gt 0 ]; then + pr_id=$(gh pr view "$number" --json id --jq '.id') + gh api graphql -f query=" + mutation { + convertPullRequestToDraft(input: {pullRequestId: \"$pr_id\"}) { + pullRequest { isDraft } + } + }" + + already_commented=$(gh api "repos/${GH_REPO}/issues/${number}/comments" \ + --jq '[.[] | select(.body == "Converted to draft: required checks failing.")] | length') + if [ "$already_commented" -eq 0 ]; then + gh pr comment "$number" --body "Converted to draft: required checks failing." + fi + fi + done + + # ---------------------------------------------------------------------------- + # Close Stale Draft PRs + # ---------------------------------------------------------------------------- + + close-stale-drafts: + runs-on: ubuntu-24.04 + permissions: + issues: read + pull-requests: write + steps: + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.PRAXIS_BOT_APP_ID }} + private-key: ${{ secrets.PRAXIS_BOT_APP_PRIVATE_KEY }} + + - name: Close draft PRs inactive for 5 days + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_REPO: ${{ github.repository }} + run: | + threshold=$(date -u -d "5 days ago" +%Y-%m-%dT%H:%M:%SZ) + + prs=$(gh pr list --base main --state open --limit 100 \ + --json number,isDraft,labels \ + --jq '[.[] | select(.isDraft == true) | + select(.labels | map(.name) | any(. == "skip/stale-close") | not) + ] | .[].number') + + for number in $prs; do + last_activity=$(gh api "repos/${GH_REPO}/issues/${number}/timeline" \ + --paginate \ + --jq '.[] | + select((.event // "") != "labeled" and (.event // "") != "unlabeled") | + (.submitted_at // .created_at // .committer.date // empty)' \ + 2>/dev/null | sort | tail -1) + + if [ -z "$last_activity" ]; then + last_activity=$(gh pr view "$number" --json createdAt --jq '.createdAt') + fi + + if [[ "$last_activity" < "$threshold" ]]; then + gh pr close "$number" --comment "Closed: inactive for 5 days. Please re-open if there's more to do." + fi + done + + # ---------------------------------------------------------------------------- + # Close Stale PRs + # ---------------------------------------------------------------------------- + + close-stale-prs: + runs-on: ubuntu-24.04 + permissions: + issues: read + pull-requests: write + steps: + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.PRAXIS_BOT_APP_ID }} + private-key: ${{ secrets.PRAXIS_BOT_APP_PRIVATE_KEY }} + + - name: Close non-draft PRs inactive for 7 days + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_REPO: ${{ github.repository }} + run: | + threshold=$(date -u -d "7 days ago" +%Y-%m-%dT%H:%M:%SZ) + + prs=$(gh pr list --base main --state open --limit 100 \ + --json number,isDraft,labels \ + --jq '[.[] | select(.isDraft == false) | + select(.labels | map(.name) | any(. == "skip/stale-close") | not) + ] | .[].number') + + for number in $prs; do + last_activity=$(gh api "repos/${GH_REPO}/issues/${number}/timeline" \ + --paginate \ + --jq '.[] | + select((.event // "") != "labeled" and (.event // "") != "unlabeled") | + (.submitted_at // .created_at // .committer.date // empty)' \ + 2>/dev/null | sort | tail -1) + + if [ -z "$last_activity" ]; then + last_activity=$(gh pr view "$number" --json createdAt --jq '.createdAt') + fi + + if [[ "$last_activity" < "$threshold" ]]; then + gh pr close "$number" --comment "Closed: inactive for 7 days. Please re-open if there's more to do." + fi + done diff --git a/.github/workflows/pull-request-hygiene.yaml b/.github/workflows/pull-request-hygiene.yaml deleted file mode 100644 index c7008d0a66..0000000000 --- a/.github/workflows/pull-request-hygiene.yaml +++ /dev/null @@ -1,133 +0,0 @@ -name: Pull Request Hygiene - -# ------------------------------------------------------------------------------ -# Workflow Settings -# ------------------------------------------------------------------------------ - -on: - schedule: - - cron: "*/30 * * * *" - workflow_dispatch: - -permissions: {} - -jobs: - # ---------------------------------------------------------------------------- - # Draft on Failure - # ---------------------------------------------------------------------------- - - draft-on-failure: - runs-on: ubuntu-24.04 - permissions: - checks: read - pull-requests: write - steps: - - name: Convert failing PRs to draft - env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} - GH_REPO: ${{ github.repository }} - run: | - prs=$(gh pr list --base main --state open --limit 100 \ - --json number,isDraft \ - --jq '[.[] | select(.isDraft == false)] | .[].number') - - for number in $prs; do - failed=$(gh pr checks "$number" --required --json bucket \ - --jq '[.[] | select(.bucket == "fail")] | length' 2>/dev/null) || continue - - if [ "$failed" -gt 0 ]; then - pr_id=$(gh pr view "$number" --json id --jq '.id') - gh api graphql -f query=" - mutation { - convertPullRequestToDraft(input: {pullRequestId: \"$pr_id\"}) { - pullRequest { isDraft } - } - }" - - already_commented=$(gh api "repos/${GH_REPO}/issues/${number}/comments" \ - --jq '[.[] | select(.body == "Converted to draft: required checks failing.")] | length') - if [ "$already_commented" -eq 0 ]; then - gh pr comment "$number" --body "Converted to draft: required checks failing." - fi - fi - done - - # ---------------------------------------------------------------------------- - # Close Stale Draft PRs - # ---------------------------------------------------------------------------- - - close-stale-drafts: - runs-on: ubuntu-24.04 - permissions: - issues: read - pull-requests: write - steps: - - name: Close draft PRs inactive for 7 days - env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} - GH_REPO: ${{ github.repository }} - run: | - threshold=$(date -u -d "7 days ago" +%Y-%m-%dT%H:%M:%SZ) - - prs=$(gh pr list --base main --state open --limit 100 \ - --json number,isDraft,labels \ - --jq '[.[] | select(.isDraft == true) | - select(.labels | map(.name) | any(. == "skip/stale-close") | not) - ] | .[].number') - - for number in $prs; do - last_activity=$(gh api "repos/${GH_REPO}/issues/${number}/timeline" \ - --paginate \ - --jq '.[] | - select((.event // "") != "labeled" and (.event // "") != "unlabeled") | - (.submitted_at // .created_at // .committer.date // empty)' \ - 2>/dev/null | sort | tail -1) - - if [ -z "$last_activity" ]; then - last_activity=$(gh pr view "$number" --json createdAt --jq '.createdAt') - fi - - if [[ "$last_activity" < "$threshold" ]]; then - gh pr close "$number" --comment "Closed: inactive for 7 days. Please re-open if there's more to do." - fi - done - - # ---------------------------------------------------------------------------- - # Close Stale PRs - # ---------------------------------------------------------------------------- - - close-stale-prs: - runs-on: ubuntu-24.04 - permissions: - issues: read - pull-requests: write - steps: - - name: Close non-draft PRs inactive for 14 days - env: - GH_TOKEN: ${{ secrets.PRAXIS_BOT }} - GH_REPO: ${{ github.repository }} - run: | - threshold=$(date -u -d "14 days ago" +%Y-%m-%dT%H:%M:%SZ) - - prs=$(gh pr list --base main --state open --limit 100 \ - --json number,isDraft,labels \ - --jq '[.[] | select(.isDraft == false) | - select(.labels | map(.name) | any(. == "skip/stale-close") | not) - ] | .[].number') - - for number in $prs; do - last_activity=$(gh api "repos/${GH_REPO}/issues/${number}/timeline" \ - --paginate \ - --jq '.[] | - select((.event // "") != "labeled" and (.event // "") != "unlabeled") | - (.submitted_at // .created_at // .committer.date // empty)' \ - 2>/dev/null | sort | tail -1) - - if [ -z "$last_activity" ]; then - last_activity=$(gh pr view "$number" --json createdAt --jq '.createdAt') - fi - - if [[ "$last_activity" < "$threshold" ]]; then - gh pr close "$number" --comment "Closed: inactive for 14 days. Please re-open if there's more to do." - fi - done diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 658ec3356c..92b744ed64 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -15,14 +15,10 @@ concurrency: permissions: {} -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - jobs: - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- # Validate the tag matches Cargo.toml - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- validate: runs-on: ubuntu-24.04 @@ -31,7 +27,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Extract version from tag id: version @@ -46,9 +42,9 @@ jobs: fi echo "version=$TAG_VERSION" >> "$GITHUB_OUTPUT" - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- # Run the full test suite - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- test: needs: [validate] @@ -56,33 +52,24 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install stable Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - - name: Cache Cargo registry and build artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-rust-1.94.0-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-1.94.0-cargo- - save-always: true - - - name: Install h2spec - run: make target/praxis-binutils/h2spec + - name: Setup Rust + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 - name: Run all tests run: make test - # ----------------------------------------------------------------- + - name: Schema tests + run: make test-schema + + - name: Integration tests + run: make test-integration + + # ---------------------------------------------------------------------------- # Build and publish container image - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- container: needs: [validate, test] @@ -91,40 +78,20 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Log in to GHCR - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha - - - name: Build and push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Publish container image + uses: praxis-proxy/conventions/.github/actions/ghcr-publish@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 with: - context: . - file: Containerfile - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - # ----------------------------------------------------------------- + image-name: ${{ github.repository }} + + # ---------------------------------------------------------------------------- # Create GitHub release - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- github-release: needs: [validate, container] @@ -132,7 +99,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Generate release notes env: diff --git a/.github/workflows/supply-chain.yaml b/.github/workflows/supply-chain.yaml index e5243971b5..925d1257fb 100644 --- a/.github/workflows/supply-chain.yaml +++ b/.github/workflows/supply-chain.yaml @@ -9,11 +9,13 @@ on: branches: [main] pull_request: branches: [main] + merge_group: + branches: [main] workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: {} @@ -27,22 +29,12 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Cache cargo binaries - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + - name: Supply chain audit + uses: praxis-proxy/conventions/.github/actions/supply-chain-audit@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 with: - path: ~/.cargo/bin - key: ${{ runner.os }}-cargo-bin-audit - - - name: Install cargo-audit - run: command -v cargo-audit || cargo install cargo-audit --locked - - - name: Run cargo audit - run: cargo audit + run-deny: "false" # ---------------------------------------------------------------------------- # License & Dependency Check @@ -53,19 +45,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - - - name: Cache cargo binaries - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.cargo/bin - key: ${{ runner.os }}-cargo-bin-deny - - - name: Install cargo-deny - run: command -v cargo-deny || cargo install cargo-deny --locked + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Run cargo deny - run: cargo deny check + - name: Supply chain audit + uses: praxis-proxy/conventions/.github/actions/supply-chain-audit@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f5a4023505..b7383e75ed 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,4 +1,4 @@ -name: Tests (Unit) +name: Tests # ------------------------------------------------------------------------------ # Workflow Settings @@ -9,6 +9,9 @@ on: branches: [main] pull_request: branches: [main] + types: [opened, synchronize, reopened] + merge_group: + branches: [main] workflow_dispatch: inputs: debug: @@ -16,10 +19,9 @@ on: required: false type: boolean default: false - concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: {} @@ -28,72 +30,49 @@ env: V: ${{ inputs.debug && '1' || '' }} jobs: - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- # Lint (fmt + clippy) - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- lint: runs-on: ubuntu-24.04 permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install nightly Rust (for rustfmt) - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly-2026-03-28 (rustc 1.96.0) - with: - components: rustfmt + - name: Install actionlint + uses: ./.github/actions/install-actionlint - - name: Install stable Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 - with: - components: clippy + - name: Lint GitHub Actions workflows + run: actionlint - - name: Cache Cargo registry and build artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + - name: Setup Rust (lint) + uses: praxis-proxy/conventions/.github/actions/setup-rust-lint@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-rust-1.94.0-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-1.94.0-cargo- - save-always: true + cache-suffix: lint-cargo + + - name: Install cargo-machete + run: cargo install cargo-machete --locked - name: Lint run: make lint - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- # Build + test - # ----------------------------------------------------------------- + # ---------------------------------------------------------------------------- test: runs-on: ubuntu-24.04 permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install stable Rust - uses: dtolnay/rust-toolchain@10c3493d811a9096cee4fdf287e41e852f6a51ba # 1.94.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Cache Cargo registry and build artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + - name: Setup Rust + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-rust-1.94.0-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-1.94.0-cargo- - save-always: true - - - name: Smoke tests - run: make test-smoke + cache-suffix: test-cargo - name: Unit tests run: make test-unit diff --git a/.github/workflows/unicode-safety.yaml b/.github/workflows/unicode-safety.yaml new file mode 100644 index 0000000000..c7c32986c8 --- /dev/null +++ b/.github/workflows/unicode-safety.yaml @@ -0,0 +1,79 @@ +name: Unicode Safety Check + +# ------------------------------------------------------------------------------ +# Workflow Settings +# ------------------------------------------------------------------------------ +# +# Scans PR diffs for hidden Unicode (tag chars, zero-width joiners, bidi +# overrides, PUA) used in ASCII smuggling / AI context poisoning attacks. +# +# Uses unicode-safety-check v3.0.0 via its release binary rather than the +# composite action: praxis-proxy org policy only allows GitHub-verified +# Marketplace actions (see odh-model-controller#823 for the upstream pattern). + +on: + pull_request: + branches: [main] + merge_group: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: {} + +jobs: + # ---------------------------------------------------------------------------- + # Detect hidden unicode characters + # ---------------------------------------------------------------------------- + + unicode-safety: + name: Detect hidden unicode characters + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Detect hidden unicode characters + shell: bash + env: + SCANNER_VERSION: "3.0.0" + SCANNER_RELEASE_SHA: "0ee6d0bec92c640765f97a0514a9cf1613a34162" + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + else + git fetch origin main + BASE_SHA="$(git merge-base origin/main HEAD)" + fi + + TARBALL="unicode-safety-check-x86_64-unknown-linux-gnu.tar.gz" + BASE_URL="https://github.com/dcondrey/unicode-safety-check/releases/download/v${SCANNER_VERSION}" + curl -fsSL "${BASE_URL}/${TARBALL}" -o "/tmp/${TARBALL}" + curl -fsSL "${BASE_URL}/SHA256SUMS" -o /tmp/SHA256SUMS + EXPECTED_SHA="$(grep "${TARBALL}" /tmp/SHA256SUMS | awk '{print $1}')" + ACTUAL_SHA="$(sha256sum "/tmp/${TARBALL}" | awk '{print $1}')" + if [ -z "${EXPECTED_SHA}" ] || [ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]; then + echo "::error::Checksum mismatch for ${TARBALL} (release ${SCANNER_RELEASE_SHA})" + exit 1 + fi + tar -xzf "/tmp/${TARBALL}" -C /tmp + chmod +x /tmp/unicode-safety-check + + git diff --name-only --diff-filter=AMR "${BASE_SHA}" HEAD > /tmp/unicode_check_files.txt + if [ ! -s /tmp/unicode_check_files.txt ]; then + echo "No changed files to scan." + exit 0 + fi + + /tmp/unicode-safety-check \ + --file-list /tmp/unicode_check_files.txt \ + --base-sha "${BASE_SHA}" \ + --no-color diff --git a/.github/workflows/vllm-integration.yaml b/.github/workflows/vllm-integration.yaml new file mode 100644 index 0000000000..7c0aa0a5ad --- /dev/null +++ b/.github/workflows/vllm-integration.yaml @@ -0,0 +1,299 @@ +name: vLLM Integration + +# ------------------------------------------------------------------------------ +# Workflow Settings +# ------------------------------------------------------------------------------ + +on: + push: + branches: [main] + paths: + - "apis/src/openai/responses/**" + - "apis/src/openai/conversations/**" + - "apis/src/openai/sse/**" + - "apis/src/store/**" + - "server/**" + - "tests/integration/sdk/openai/**" + - "examples/configs/openai/responses/**" + - "Cargo.toml" + - "Cargo.lock" + - "Makefile" + - ".github/workflows/vllm-integration.yaml" + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + paths: + - "apis/src/openai/responses/**" + - "apis/src/openai/conversations/**" + - "apis/src/openai/sse/**" + - "apis/src/store/**" + - "server/**" + - "tests/integration/sdk/openai/**" + - "examples/configs/openai/responses/**" + - "Cargo.toml" + - "Cargo.lock" + - "Makefile" + - ".github/workflows/vllm-integration.yaml" + merge_group: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: {} + +env: + CARGO_TERM_COLOR: always + VLLM_IMAGE: "quay.io/opendatahub/vllm-cpu:Qwen3-0.6B-granite-embedding-125m-english@sha256:0212dc82981178033cb71c7477ba8ad1998fb6c0b1ee40646119fb9724ac951b" + VLLM_MODEL: "Qwen/Qwen3-0.6B" + +jobs: + # ---------------------------------------------------------------------------- + # Path filter for merge_group (which does not support on.paths) + # ---------------------------------------------------------------------------- + + changes: + if: github.event_name == 'merge_group' + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + relevant: ${{ steps.filter.outputs.relevant }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Detect relevant path changes + id: filter + run: | + CHANGED=$(git diff --name-only "${{ github.event.merge_group.base_sha }}" \ + "${{ github.event.merge_group.head_sha }}" -- \ + 'apis/src/openai/responses/' \ + 'apis/src/openai/conversations/' \ + 'apis/src/openai/sse/' \ + 'apis/src/store/' \ + 'server/' \ + 'tests/integration/sdk/openai/' \ + 'examples/configs/openai/responses/' \ + 'Cargo.toml' \ + 'Cargo.lock' \ + 'Makefile' \ + '.github/workflows/vllm-integration.yaml') + if [ -n "$CHANGED" ]; then + echo "relevant=true" >> "$GITHUB_OUTPUT" + else + echo "relevant=false" >> "$GITHUB_OUTPUT" + fi + + # ---------------------------------------------------------------------------- + # Responses API integration tests against a real vLLM CPU backend + # ---------------------------------------------------------------------------- + + vllm-responses: + needs: [changes] + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.relevant == 'true') + runs-on: ubuntu-24.04 + timeout-minutes: 25 + permissions: + contents: read + steps: + - name: Free up disk space + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + docker system prune -af + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Rust + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + + - name: Pull vLLM CPU image + run: docker pull "$VLLM_IMAGE" + + - name: Start vLLM + run: | + docker run -d --name vllm \ + -p 8000:8000 \ + --privileged=true \ + "$VLLM_IMAGE" \ + --model /root/.cache/Qwen/Qwen3-0.6B \ + --max-model-len 4096 \ + --served-model-name "$VLLM_MODEL" \ + --enable-auto-tool-choice \ + --tool-call-parser hermes \ + --reasoning-parser deepseek_r1 \ + --gpu-memory-utilization 0.5 + + - name: Start OGX + run: | + uv run --with 'ogx[starter]' ogx run starter --insecure > /tmp/ogx.log 2>&1 & + echo $! > /tmp/ogx.pid + + - name: Build Praxis + run: cargo build -p praxis-ai-proxy + + - name: Wait for vLLM readiness + run: | + echo "Waiting for vLLM health and model endpoints..." + timeout 900 bash -c 'until curl -sf http://127.0.0.1:8000/health > /dev/null 2>&1 && \ + curl -sf http://127.0.0.1:8000/v1/models > /dev/null 2>&1; do + if ! docker inspect --format="{{.State.Running}}" vllm 2>/dev/null | grep -q true; then + echo "::error::vLLM container exited unexpectedly" + docker logs vllm 2>&1 + exit 1 + fi + sleep 5 + done' + echo "vLLM is ready" + + - name: Wait for OGX readiness + run: | + echo "Waiting for OGX server..." + timeout 120 bash -c 'until curl -sf http://127.0.0.1:8321/v1/files > /dev/null 2>&1; do + sleep 2 + done' + echo "OGX is ready" + + - name: Run vLLM integration tests + run: | + uv run tests/integration/sdk/openai/test_openai_responses_vllm.py -s + + - name: vLLM container logs + if: failure() + run: docker logs vllm + + - name: OGX logs + if: failure() + run: cat /tmp/ogx.log 2>/dev/null || echo "No OGX log found" + + - name: Stop vLLM + if: always() + run: docker rm -f vllm || true + + - name: Stop OGX + if: always() + run: | + if [ -f /tmp/ogx.pid ]; then + kill "$(cat /tmp/ogx.pid)" 2>/dev/null || true + fi + + # ---------------------------------------------------------------------------- + # Responses API integration tests with PostgreSQL store backend + # ---------------------------------------------------------------------------- + + vllm-responses-postgres: + needs: [changes] + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.relevant == 'true') + runs-on: ubuntu-24.04 + timeout-minutes: 25 + permissions: + contents: read + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: praxis + POSTGRES_PASSWORD: praxis + POSTGRES_DB: praxis + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U praxis" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - name: Free up disk space + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + docker system prune -af --filter "label!=com.github.actions.runner" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Rust + uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + + - name: Pull vLLM CPU image + run: docker pull "$VLLM_IMAGE" + + - name: Start vLLM + run: | + docker run -d --name vllm \ + -p 8000:8000 \ + --privileged=true \ + "$VLLM_IMAGE" \ + --model /root/.cache/Qwen/Qwen3-0.6B \ + --max-model-len 4096 \ + --served-model-name "$VLLM_MODEL" \ + --enable-auto-tool-choice \ + --tool-call-parser hermes \ + --reasoning-parser deepseek_r1 \ + --gpu-memory-utilization 0.5 + + - name: Start OGX + run: | + uv run --with 'ogx[starter]' ogx run starter --insecure > /tmp/ogx.log 2>&1 & + echo $! > /tmp/ogx.pid + + - name: Build Praxis + run: cargo build -p praxis-ai-proxy + + - name: Wait for vLLM readiness + run: | + echo "Waiting for vLLM health and model endpoints..." + timeout 900 bash -c 'until curl -sf http://127.0.0.1:8000/health > /dev/null 2>&1 && \ + curl -sf http://127.0.0.1:8000/v1/models > /dev/null 2>&1; do + if ! docker inspect --format="{{.State.Running}}" vllm 2>/dev/null | grep -q true; then + echo "::error::vLLM container exited unexpectedly" + docker logs vllm 2>&1 + exit 1 + fi + sleep 5 + done' + echo "vLLM is ready" + + - name: Wait for OGX readiness + run: | + echo "Waiting for OGX server..." + timeout 120 bash -c 'until curl -sf http://127.0.0.1:8321/v1/files > /dev/null 2>&1; do + sleep 2 + done' + echo "OGX is ready" + + - name: Run vLLM integration tests (PostgreSQL store) + env: + DATABASE_URL: postgres://praxis:praxis@127.0.0.1:5432/praxis + run: | + uv run tests/integration/sdk/openai/test_openai_responses_vllm.py -s + + - name: vLLM container logs + if: failure() + run: docker logs vllm + + - name: OGX logs + if: failure() + run: cat /tmp/ogx.log 2>/dev/null || echo "No OGX log found" + + - name: Stop vLLM + if: always() + run: docker rm -f vllm || true + + - name: Stop OGX + if: always() + run: | + if [ -f /tmp/ogx.pid ]; then + kill "$(cat /tmp/ogx.pid)" 2>/dev/null || true + fi diff --git a/.gitignore b/.gitignore index 40b371f7c7..1b71306775 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,26 @@ # Cargo build output target +# Default rustc output +rust_out + +# Test artifacts +**/*.db +**/postgres.rs.tmp* + # Rustfmt backup files **/*.rs.bk +# Python +__pycache__/ +*.pyc + # MSVC debugging symbols *.pdb # Cargo mutants output **/mutants.out*/ -# Fuzz artifacts (keep seed corpus) -tests/fuzz/artifacts/ -tests/fuzz/corpus/ - # LLVM coverage artifacts coverage/ coverage.json @@ -36,7 +43,14 @@ coverage.json docs/**/ !docs/ !docs/architecture/ +!docs/conformance/ +!docs/conformance/**/ +!docs/conformance/** !docs/developing/ !docs/filters/ +!docs/filters/http/ +!docs/filters/http/*/ +!docs/filters/tcp/ +!docs/filters/tcp/*/ !docs/operating/ !docs/proposals/ diff --git a/.hooks/pre-commit b/.hooks/pre-commit index 852b5541f3..3958528e5e 100755 --- a/.hooks/pre-commit +++ b/.hooks/pre-commit @@ -7,9 +7,6 @@ if [ "$(git config --get commit.gpgsign 2>/dev/null)" != "true" ]; then exit 1 fi -echo "pre-commit: formatting..." -make fmt - echo "pre-commit: linting..." make lint diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..4100cff1b0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,186 @@ +# AGENTS.md + +This file provides guidance to coding agents when working +with code in this repository. + +## Requirements + +- Rust stable 1.96+ +- Rust nightly (for `rustfmt`) +- CMake 3.31+ (for Pingora build via praxis dep) +- Docker 29.3.0+ or Podman (for container builds) +- Optional Praxis core checkout at `../praxis` for testing local core + changes through `make patch-praxis` + +## Rust Data Ownership + +Avoid cloning request, response, header, body, or SSE data +unless the copy is necessary for correctness. This is a +high-performance proxy, so prefer borrowing, moving, or +sharing data through existing ownership boundaries before +adding `.clone()`, `to_vec()`, `to_string()`, or full-body +buffering. + +When a clone is necessary, keep it close to the boundary +that requires ownership and make the reason clear in the +surrounding code or test. Do not clone streaming chunks or +provider payloads just to satisfy local control flow; instead +reshape the code to move completed buffers, borrow parsed +fields, or process data incrementally. + +## Quick Reference + +```console +make setup-hooks # install git pre-commit hook +make build # workspace build +make test # all tests +make fmt # format with nightly rustfmt +make lint # clippy, fmt, dependency, docs, and example checks +make doc # rustdoc with -D warnings +make audit # cargo audit + cargo deny check +make container # build praxis-ai container image +``` + +Run a single test: + +```console +cargo test -p praxis-ai-apis -- test_name +cargo test -p praxis-ai-filters -- test_name +cargo test -p praxis-ai-proxy -- test_name +``` + +## Architecture + +**Crate dependency flow:** + +```text +server (praxis-ai-proxy) + -> filters (praxis-ai-filters) + -> apis (praxis-ai-apis) + -> praxis-filter (versioned Praxis core dependency) +``` + +- **server** (`praxis-ai-proxy`): binary entry point, + registers AI filters on top of core builtins, + injects `ResponseStoreRegistry` as pipeline extension +- **apis** (`praxis-ai-apis`): provider-specific API + types (OpenAI, Anthropic), request classification, + response storage backends (SQLite, PostgreSQL), + token usage extraction, SSE parsing +- **filters** (`praxis-ai-filters`): cross-cutting AI + filter implementations (A2A, MCP, guardrails, + inference routing, prompt enrichment, token usage + header injection) + +**Dependencies on Praxis core** use the versioned crates in the root +`Cargo.toml`: `praxis-filter` for `HttpFilter`, pipeline, and registry; +`praxis-core` for config types; `praxis-protocol` for HTTP/TCP adapters; +and `praxis-tls` for TLS. Use `make patch-praxis` only when testing a +local sibling checkout at `../praxis`. + +## Conventions + +Follows the same conventions as +[praxis core](https://github.com/praxis-proxy/praxis). +See [CONTRIBUTING.md] for the full coding style guide, +including the +[PR review process](CONTRIBUTING.md#pr-review-process) +for handling `praxis-bot` automated review comments. + +[CONTRIBUTING.md]: https://github.com/praxis-proxy/ai/blob/main/CONTRIBUTING.md + +## Git Workflow + +- **Never amend commits** — when addressing PR + review feedback, add new fixup commits on top + of the branch. +- PRs are merged with **squash and merge**, so the + final commit on the target branch is always clean + regardless of intermediate fixup commits. + +## Test Requirements + +New capabilities require: + +1. Unit tests +2. Integration tests +3. Example config in `examples/configs/` +4. Functional integration test for the example config +5. Generated example and filter documentation kept in sync + +## Inference Fixture Maintenance + +When an inference transformation behavior changes, update its tests and +fixture coverage in the same change: + +1. Add or update the scenario and its focused unit/integration tests. +2. Add truthful provider recordings or controlled synthetic evidence. +3. Update `tests/integration/fixtures/inference/coverage.yaml`, including its + feature, scope, scenario, provider, and status links. +4. Run `cargo xtask sync-inference-readme --fix`; do not hand-edit the + generated coverage block in the inference fixture README. +5. Run `cargo xtask check-inference` and `make test-inference-fixtures`. + +Do not enumerate Rust test function names in the README. The manifest is the +stable coverage inventory; test names may change without changing behavior. +Credentialed live recordings require explicit authorization and must retain +truthful provider, model, and provenance metadata. + +## Adding a Filter + +1. Create module under `filters/src/` or `apis/src/` +2. Implement `HttpFilter` from `praxis-filter` +3. Register in `praxis_ai_filters::register_ai_filters` + (`filters/src/register.rs`) via `register_filters!` +4. Add unit tests and doctests +5. Add example config in `examples/configs/` + +## Key Patterns + +- **Classify → route → branch**: classifier filters + promote facts to internal headers + (`x-praxis-ai-*`) and the router matches those + headers to select clusters. +- **Do not buffer full streaming responses**: + streaming and SSE filters should use + `BodyMode::Stream` and process chunks + incrementally. +- **Validate only proxy-needed fields**: let the + backend handle parameter ranges, model + availability, and role ordering. + +## Filter Organization + +- `apis/src/anthropic/` — Anthropic Messages API +- `apis/src/openai/` — OpenAI Responses, Conversations, + SSE, model rewrite, store, rehydrate, validate, proxy +- `apis/src/classifier/` — AI request format detection +- `apis/src/json_body.rs` — Shared JSON body mutation + helper (serialize, replace, tracing events) +- `apis/src/store/` — ResponseStore trait, SQLite/Postgres +- `filters/src/agentic/` — A2A, MCP protocol filters +- `filters/src/guardrails/` — AI content safety (NeMo) +- `filters/src/inference/` — Model-to-header routing +- `filters/src/prompt_enrich/` — Prompt enrichment +- `filters/src/token_usage/` — Token counting and headers + +## Dynamic Config Reload + +Praxis swaps filter pipelines at runtime without +restarting. The AI server inherits this from +praxis-protocol. The `ResponseStoreRegistry` is +injected as a `PipelineExtension` and created fresh +per pipeline build. + +## Pingora Boundary + +See praxis core documentation. Pingora handles: +request smuggling prevention, H2 backpressure, +connection pool safety, HTTP/1.1 upgrade detection. + +## CI Workflows + +CI workflows that post PR comments must use the +`praxis-bot-app` GitHub App token (via +`actions/create-github-app-token`), not the default +`github.token`. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..babb58b1c6 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,12 @@ +# Code of Conduct + +Praxis follows the +[CNCF Code of Conduct][cncf-coc]. + +To report an issue, contact the +[project reviewers][reviewers] or the +[CNCF Code of Conduct Committee][conduct-email]. + +[cncf-coc]: https://github.com/cncf/foundation/blob/main/code-of-conduct.md +[reviewers]: MAINTAINERS.md +[conduct-email]: mailto:conduct@cncf.io diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..14ffeccbf7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,384 @@ +# Development Conventions + +## Coding Style + +### General Principles + +- Brevity is a component of quality. Keep code lean and + complete; no bloat. +- Small, composable, single-purpose functions are the + default unit of organization. Split code into small + files with focused responsibilities. +- Minimize side effects. Prefer pure transformations when + feasible: data in, data out. Resist mutable state when + feasible and outside the critical paths. +- Keep functions short enough to reason about in isolation. + +### Important Tools + +- **Clippy**: Enforce idiomatic Rust and catch common mistakes +- **rustfmt**: Ensure consistent code formatting +- **cargo-audit**: Check for vulnerable dependencies +- **cargo-deny**: Enforce supply chain safety policies +- **cargo-machete**: Detect unused dependencies +- **rustdoc**: Generate the API documentation +- **cargo xtask**: Developer checks for dependency versions, + generated docs, example coverage, Markdown links, and debug utilities +- **load testing**: Scenario-based evidence with tools such as + [Fortio] or [Vegeta] when a change affects a performance-critical path + +[Fortio]: https://github.com/fortio/fortio +[Vegeta]: https://github.com/tsenart/vegeta + +### Comments vs Tracing + +Comments answer **"why?"**, never **"what?"**. + +**"What?" belongs in `tracing`**, not comments. If a +comment describes what the code is doing at runtime +("parse the config", "reject the request", "skip this +filter"), replace it with a `tracing::debug!`, +`tracing::trace!`, or `tracing::info!` call. Runtime +narration (what the code did, what it decided, what it +skipped) is structured logging, not commentary. + +**"Why?" belongs in comments**, but only when +non-obvious. A hidden constraint, a subtle invariant, a +workaround for a specific bug, or behavior that would +surprise a reader: these justify a comment. If removing +the comment would not confuse a future reader, do not +write it. + +**"What?" at the code level needs neither.** Well-named +identifiers already explain what the code does. Do not +write comments that restate what names already convey. + +### Testing + +**New capabilities require all of the following:** + +1. Unit tests covering the implementation +2. Integration tests proving end-to-end behavior +3. An example config in `examples/configs/` +4. A functional integration test for the example config + in `tests/integration/tests/suite/examples/` +5. Update `examples/README.md` to list any new or + renamed example configs +6. Significant changes need benchmark or load-test evidence + appropriate to the affected filter path. + +This is not optional. A feature without tests and an +example is not complete. + +Prefer more doctests when in doubt. Duplicative coverage +between doctests and unit/integration tests is fine. + +Prefer assertion messages over inline comments. Put the +explanation in the assertion's message argument so it +prints on failure: + +```rust +// Bad: +// ACL should block loopback +assert_eq!(status, 403); + +// Good: +assert_eq!(status, 403, "ACL should block loopback"); +``` + +### RFC Conformance + +When implementing protocol-level behavior (HTTP semantics, +header handling, TLS, etc.), identify the governing RFCs +and verify conformance against them. + +- Cite the specific RFC number and section in test names + or doc comments for protocol conformance tests. +- RFC references in doc comments must use reference-style + rustdoc links to the IETF datatracker: + ```rust + /// Safe methods per [RFC 9110 Section 9.2.1]. + /// + /// [RFC 9110 Section 9.2.1]: https://datatracker.ietf.org/doc/html/rfc9110#section-9.2.1 + ``` +- When in doubt about an edge case, the RFC is the + authority, not other proxy implementations. +- Add dedicated integration or schema tests when implementing + RFC-specified behavior. Keep protocol-specific coverage near + the affected filter or test suite. + +When protocol behavior depends on core proxy behavior, document +the boundary in the relevant architecture doc or PR description. + +### Rules, Practices & Lints + +Security is enforced at the lint level. See lints in +[Cargo.toml] for the full set. + +- `#![deny(unsafe_code)]` in all crate roots (no + exceptions; unsafe belongs upstream) +- Clippy runs with `-D warnings` (zero tolerance) +- Errors via `thiserror` +- Logging via `tracing` +- Use workspace dependencies (`[workspace.dependencies]`) + to keep versions consistent across crates +- Keep dependencies light. Avoid new dependencies + when feasible +- Only add dependencies with well-established + reputation +- `cargo audit` and `cargo deny check` enforce supply + chain safety (see [getting-started.md]) + +[Cargo.toml]:Cargo.toml +[getting-started.md]:docs/developing/getting-started.md + +### Lint Suppression Policy + +Use `#[expect(...)]` instead of `#[allow(...)]`. The +`allow_attributes` lint enforces this mechanically. +Every suppression must include a `reason`: + +```rust +// Good: +#[expect( + clippy::too_many_lines, + reason = "pipeline setup is inherently sequential" +)] +fn build_pipeline() { /* ... */ } + +// Bad — denied by allow_attributes: +#[allow(clippy::too_many_lines)] +fn build_pipeline() { /* ... */ } +``` + +`#[expect]` is self-cleaning: if the suppressed lint +stops firing (because the code changed), the compiler +warns that the expectation is unfulfilled. This +prevents stale suppressions from accumulating. + +### Async Safety + +Do not hold synchronization guards across `.await` +points. Holding a `Mutex`, `RefCell`, or `RwLock` +guard across a suspension point risks deadlocks or +runtime panics. The `await_holding_lock` and +`await_holding_refcell_ref` lints enforce this. + +```rust +// Bad — guard held across await: +let guard = mutex.lock().await; +let result = some_async_call().await; +drop(guard); + +// Good — drop guard before awaiting: +let data = { + let guard = mutex.lock().await; + guard.clone() +}; +let result = some_async_call().await; +``` + +Never silently drop futures or `#[must_use]` values. +`let _ = async_fn()` drops the future without polling +it. The `let_underscore_future` and +`let_underscore_must_use` lints catch this. + +### String Safety + +Raw string indexing (`&s[n..m]`) panics on non-char +boundaries and is denied by the `string_slice` and +`indexing_slicing` lints. Use safe alternatives: + +- `.get(range)` for fallible substring access +- `.chars().nth(n)` for character-level access +- `.char_indices()` for iterating with byte offsets + +### Trait Import Convention + +When importing a trait only for its methods (not +naming the trait type), use `as _` to keep the name +out of scope. The `unused_trait_names` lint enforces +this. + +```rust +// Good — trait name unused, import anonymously: +use std::io::Write as _; + +// Bad — trait name pollutes scope unnecessarily: +use std::io::Write; +``` + +### Additional Coding Conventions + +- **Separator comments** visually separate distinct + sections of code. Each separator line must be exactly + 80 columns wide (indent + `// ` + dashes). Adjust the + dash count for the indentation level: + Top-level (77 dashes = 80 cols): + ```rust + // ----------------------------------------------------------------------------- + ``` + Inside `mod tests` with 4-space indent (73 dashes = 80 cols): + ```rust + // ------------------------------------------------------------------------- + ``` + `cargo xtask lint-separators` enforces this. +- **No re-export-only files.** If a file exists solely + to `pub use` items from another crate or module, + inline the import at the call site instead. +- **Constants** must be at the top of the file (after + imports), never inside functions or impl blocks. + Give them their own separator comment + (e.g. `// Constants`). +- **File ordering**: + 1. Constants (with separator comment) + 2. Public types, impls, and functions + 3. Private types and impls (below their public + consumers) + 4. Private utility/helper functions (with separator) + 5. `#[cfg(test)] mod tests` block (always last) +- **Field and method ordering**: Alphabetical, with + `name` pinned first on structs and `new()`/`name()` + pinned first in impl blocks. +- **Inside `#[cfg(test)] mod tests`**: + 1. Imports + 2. All test functions (`#[test]` / `#[tokio::test]`) + 3. Test utilities at the end (with `// Test Utilities` + separator) +- **Attribute formatting on structs, enums, fields, + and variants**: + - Place a blank line between each `#[...]` attribute + annotation. + - Order items within `#[derive(...)]` alphabetically. + - Order parameters within `#[serde(...)]` alphabetically. + + ```rust + // Good: + #[derive(Clone, Debug, Default, Deserialize, Serialize)] + + #[serde(default, deny_unknown_fields)] + pub struct Foo { + + // Bad (no blank lines, non-alphabetical): + #[derive(Debug, Clone, Default, Serialize, Deserialize)] + #[serde(deny_unknown_fields, default)] + pub struct Foo { + ``` +- Separate distinct logical actions with blank lines. Function + calls, variable bindings that begin a new step, and expression + blocks that perform a discrete operation should have some newline space. +- Prefer pre-computed numeric literals over expressions + like `1024 * 10`. Always add a trailing comment with + the human-readable size or meaning (e.g. + `const MAX_BODY: usize = 10_485_760; // 10 MiB`). + +See also [Type Design](docs/developing/type-design.md) for serde patterns +and data modeling conventions. + +## Code Responsibility + +This project does not distinguish between code written by +hand, generated by a tool (e.g. lint), or produced by any +other means. **Every contributor is responsible for the +code they submit**, and *all* code MUST be human reviewed +before submission, or merging. + +Signed-off commits (`Signed-off-by:`) are required and +represent your assertion that you have reviewed and fully +understand the changes you are submitting. + +PRs from a bot or tool (with the exception of GitHub-specific +ones like `dependabot`) will not be accepted. + +Before submitting or merging PRs, ensure that you have: + +- Read every line of the diff. If you cannot explain why something exists, do not submit it. +- Verified that the change does what you intended and nothing more. +- Run the test suite *locally* first. The CI pipeline is not a substitute for local verification. + +> **Note**: `Draft` pull requests are not exempt from these guidelines. +> They are still expected to be reviewed before submission. + +## Issue Assignment + +If you are interested in working on an issue, comment on +it to express your interest. A reviewer will assign it +to you. **Do not open a pull request for an issue you are +not assigned to.** Unassigned PRs will be closed. + +This avoids duplicate effort and gives reviewers +visibility into who is working on what. + +## PR Review Process + +The `praxis-bot` GitHub App runs automated code review +on every pull request. Its comments are treated with the +same weight as human reviewer comments — **every finding +must be addressed** before the PR can be merged. This +ensures reviewers can quickly verify that all feedback +has been handled and keeps the review process moving +smoothly. + +### Responding to praxis-bot comments + +Each comment must be resolved in one of two ways: + +1. **Acknowledge and fix**: apply the suggested change + (or an equivalent fix), reply to the comment with + a reference to the commit that addresses it + (e.g. `Fixed in `), then resolve the + conversation. + +2. **Reject with justification**: if the finding does + not apply or the current code is intentionally + correct, reply with a comment explaining why the + suggestion is being declined, then resolve the + conversation. + +Leaving praxis-bot comments unresolved blocks merge. +Do not dismiss or ignore findings without an explicit +response. + +## Community Interactions + +Contributors interact with each other through issues, +pull requests, discussions, code reviews, and other +project channels. These interactions must be genuine +and human. + +### Write Your Own Words + +When you reply to someone — a code review comment, a +discussion thread, a question on an issue — write it +yourself. These are conversations between people, and +they should read that way. + +AI-generated boilerplate is easy to spot and erodes +trust. A short, honest sentence you wrote yourself is +worth more than three polished paragraphs a model +produced. Say what you mean, ask what you do not +understand, and skip what you have nothing to add to. + +This applies to: + +- Code review comments and replies +- Discussion posts and responses + +Using AI tools to assist with *code* (generation, +refactoring, debugging) or to help draft structured +content like issue reports, PR descriptions, commit +messages, and pull request titles is fine. The line is +person-to-person communication: when you are replying +to another human, do the talking yourself. + +### Be Present + +Engaging with a project means engaging with its people. +Read what others wrote before replying. Ask genuine +questions. Give feedback that shows you understood the +context. If you disagree, explain why in your own +reasoning. + +Conversations that feel like talking to a wall drive +people away. Conversations that feel like talking to a +person keep communities alive. diff --git a/Cargo.lock b/Cargo.lock index 77f0726df3..6fb50b27a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,9 +24,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -45,22 +45,13 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] -[[package]] -name = "alloca" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" -dependencies = [ - "cc", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -69,19 +60,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - [[package]] name = "anstream" version = "1.0.0" @@ -134,24 +119,24 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "asn1-rs" @@ -181,7 +166,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", ] @@ -193,7 +178,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] @@ -205,7 +190,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] @@ -217,18 +202,37 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", ] [[package]] @@ -245,9 +249,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -255,14 +259,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -277,6 +282,8 @@ dependencies = [ "http", "http-body", "http-body-util", + "hyper", + "hyper-util", "itoa", "matchit", "memchr", @@ -285,6 +292,7 @@ dependencies = [ "pin-project-lite", "serde_core", "sync_wrapper", + "tokio", "tower", "tower-layer", "tower-service", @@ -315,29 +323,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "beef" -version = "0.5.2" +name = "base64" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] -name = "benchmarks" -version = "0.3.1" +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "chrono", - "criterion", - "http", - "praxis-proxy-core", - "praxis-proxy-filter", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tracing", - "yaml_serde", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -349,15 +354,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "1.3.2" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "blake2" @@ -365,7 +364,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -377,6 +376,15 @@ dependencies = [ "generic-array", ] +[[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" @@ -400,13 +408,13 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -415,23 +423,56 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] -name = "cast" -version = "0.3.0" +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +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 = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] [[package]] name = "cc" -version = "1.2.62" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -447,54 +488,38 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] -name = "chrono" -version = "0.4.44" +name = "chacha20" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] -name = "ciborium" -version = "0.2.2" +name = "chrono" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ - "ciborium-io", - "ciborium-ll", + "iana-time-zone", + "num-traits", "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", + "windows-link", ] [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -502,9 +527,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -514,14 +539,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -539,12 +564,34 @@ 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 = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -575,6 +622,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -591,83 +648,61 @@ dependencies = [ ] [[package]] -name = "crc32fast" -version = "1.5.0" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ - "cfg-if", + "libc", ] [[package]] -name = "criterion" -version = "0.8.2" +name = "crc" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ - "alloca", - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "itertools 0.13.0", - "num-traits", - "oorandom", - "page_size", - "plotters", - "rayon", - "regex", - "serde", - "serde_json", - "tinytemplate", - "tokio", - "walkdir", + "crc-catalog", ] [[package]] -name = "criterion-plot" -version = "0.8.2" +name = "crc-catalog" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" -dependencies = [ - "cast", - "itertools 0.13.0", -] +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] -name = "crossbeam-deque" -version = "0.8.6" +name = "crc32fast" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", + "cfg-if", ] [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -686,10 +721,28 @@ dependencies = [ ] [[package]] -name = "daemonize" -version = "0.5.0" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "daemonix" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8bfdaacb3c887a54d41bdf48d3af8873b3f5566469f8ba21b92057509f116e" +checksum = "f0b747381562a10fd2104e9333ad72fe5158d79de81b64426fc9e229c6d3fb38" dependencies = [ "libc", ] @@ -700,7 +753,54 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70def8d72740e44d9f676d8dab2c933a236663d86dd24319b57a2bed4d694774" dependencies = [ - "petgraph 0.7.1", + "petgraph", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", ] [[package]] @@ -719,9 +819,27 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] name = "der-parser" @@ -756,9 +874,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derivative" @@ -777,33 +892,60 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] [[package]] name = "equivalent" @@ -811,17 +953,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - [[package]] name = "errno" version = "0.3.14" @@ -833,27 +964,58 @@ dependencies = [ ] [[package]] -name = "evmap" -version = "11.0.0" +name = "etcetera" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ - "hashbag", - "left-right", + "cfg-if", + "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 = "evmap" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8" +dependencies = [ + "hashbag", + "left-right", "smallvec", ] +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fixedbitset" @@ -872,6 +1034,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "flurry" version = "0.5.2" @@ -892,15 +1065,18 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "foldhash" -version = "0.2.0" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] [[package]] name = "fs_extra" @@ -919,9 +1095,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -934,9 +1110,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -944,55 +1120,66 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1007,9 +1194,9 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" dependencies = [ "cc", "cfg-if", @@ -1037,8 +1224,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1055,22 +1244,23 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] name = "h2" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -1085,17 +1275,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - [[package]] name = "hashbag" version = "0.1.13" @@ -1114,15 +1293,6 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -1131,7 +1301,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1142,7 +1312,16 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", ] [[package]] @@ -1169,11 +1348,29 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1181,9 +1378,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1191,9 +1388,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1214,11 +1411,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -1237,15 +1443,17 @@ dependencies = [ ] [[package]] -name = "hyper-timeout" -version = "0.5.2" +name = "hyper-rustls" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ + "http", "hyper", "hyper-util", - "pin-project-lite", + "rustls", "tokio", + "tokio-rustls", "tower-service", ] @@ -1255,13 +1463,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -1294,10 +1505,114 @@ dependencies = [ ] [[package]] -name = "id-arena" +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] [[package]] name = "indexmap" @@ -1323,32 +1638,29 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.1" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags 2.11.1", + "bitflags", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] [[package]] -name = "inventory" -version = "0.3.24" +name = "ipnet" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" -dependencies = [ - "rustversion", -] +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_terminal_polyfill" @@ -1357,56 +1669,86 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "itertools" -version = "0.13.0" +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "either", + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", ] [[package]] -name = "itertools" -version = "0.14.0" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "either", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "jni-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -1418,7 +1760,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.11.1", + "bitflags", "libc", ] @@ -1428,17 +1770,11 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "left-right" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a" +checksum = "8bc015ded5d9b3054dbbdb63332cdd6ee42352ccef19e911e25117490e2f48ee" dependencies = [ "crossbeam-utils", "loom", @@ -1447,9 +1783,20 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] [[package]] name = "libyaml-rs" @@ -1459,9 +1806,9 @@ checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" [[package]] name = "libz-ng-sys" -version = "1.1.28" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be734b33b7bc6a42d92d23e25e69758f866cf564a88d0bf80866fcf5a52c2255" +checksum = "879917b256f6317769b9f374b435805a8697013098aacce5a38ac106cd6a9469" dependencies = [ "cmake", "libc", @@ -1473,6 +1820,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "lock_api" version = "0.4.14" @@ -1484,124 +1837,74 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] -name = "logos" -version = "0.15.1" +name = "loom" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" dependencies = [ - "logos-derive 0.15.1", + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", ] [[package]] -name = "logos" -version = "0.16.1" +name = "lru" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ - "logos-derive 0.16.1", + "hashbrown 0.16.1", ] [[package]] -name = "logos-codegen" -version = "0.15.1" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c" -dependencies = [ - "beef", - "fnv", - "lazy_static", - "proc-macro2", - "quote", - "regex-syntax", - "rustc_version", - "syn 2.0.117", -] +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] -name = "logos-codegen" -version = "0.16.1" +name = "matchers" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "fnv", - "proc-macro2", - "quote", "regex-automata", - "regex-syntax", - "syn 2.0.117", ] [[package]] -name = "logos-derive" -version = "0.15.1" +name = "matchit" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470" -dependencies = [ - "logos-codegen 0.15.1", -] +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] -name = "logos-derive" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" -dependencies = [ - "logos-codegen 0.16.1", -] - -[[package]] -name = "loom" -version = "0.7.2" +name = "md-5" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "generator", - "scoped-tls", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", + "digest 0.11.3", ] -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" -version = "0.6.5" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ "autocfg", ] @@ -1622,13 +1925,13 @@ version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" dependencies = [ - "base64", + "base64 0.22.1", "evmap", "indexmap 2.14.0", "metrics", "metrics-util", "quanta", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -1640,36 +1943,16 @@ dependencies = [ "crossbeam-epoch", "crossbeam-utils", "hashbrown 0.16.1", + "indexmap 2.14.0", "metrics", + "ordered-float", "quanta", - "rand 0.9.4", + "rand 0.9.5", "rand_xoshiro", "rapidhash", "sketches-ddsketch", ] -[[package]] -name = "miette" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" -dependencies = [ - "cfg-if", - "miette-derive", - "unicode-width", -] - -[[package]] -name = "miette-derive" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "mime" version = "0.3.17" @@ -1694,9 +1977,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -1704,34 +1987,17 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "multimap" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" - -[[package]] -name = "nix" -version = "0.24.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", - "memoffset", -] - [[package]] name = "nix" version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags", "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -1756,7 +2022,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags", "fsevent-sys", "inotify", "kqueue", @@ -1774,7 +2040,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags", ] [[package]] @@ -1788,9 +2054,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -1804,9 +2070,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -1861,16 +2127,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "oorandom" -version = "11.1.5" +name = "openssl-probe" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] [[package]] name = "ouroboros" @@ -1893,18 +2168,14 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] -name = "page_size" -version = "0.6.0" +name = "parking" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" -dependencies = [ - "libc", - "winapi", -] +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" @@ -1929,13 +2200,19 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -1956,387 +2233,221 @@ dependencies = [ ] [[package]] -name = "petgraph" -version = "0.8.3" +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap 2.14.0", -] +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pin-project" -version = "1.1.13" +name = "pkg-config" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] -name = "pin-project-internal" -version = "1.1.13" +name = "potential_utf" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "zerovec", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "pingora-cache" -version = "0.8.0" -source = "git+https://github.com/praxis-proxy/pingora.git?rev=d066f6818038dc296326566afb8e7bf39fb463ff#d066f6818038dc296326566afb8e7bf39fb463ff" +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "ahash", - "async-trait", - "blake2", - "bstr", - "bytes", - "hex", - "http", - "httparse", - "httpdate", - "indexmap 1.9.3", - "log", - "lru", - "once_cell", - "parking_lot", - "pingora-core", - "pingora-error", - "pingora-header-serde", - "pingora-http", - "pingora-lru", - "pingora-timeout", - "rand 0.8.6", - "regex", - "rmp", - "rmp-serde", - "serde", - "strum", - "tokio", + "zerocopy", ] [[package]] -name = "pingora-core" -version = "0.8.0" -source = "git+https://github.com/praxis-proxy/pingora.git?rev=d066f6818038dc296326566afb8e7bf39fb463ff#d066f6818038dc296326566afb8e7bf39fb463ff" +name = "praxis-ai-apis" +version = "0.2.0" dependencies = [ - "ahash", "async-trait", - "brotli", - "bstr", + "axum", + "base64 0.23.1", "bytes", - "chrono", - "clap", - "daemonize", - "daggy", - "derivative", - "flate2", - "flurry", + "dashmap 6.2.1", "futures", - "h2", "http", - "httparse", - "httpdate", - "libc", - "log", - "nix 0.24.3", - "once_cell", - "openssl-probe", - "ouroboros", - "parking_lot", "percent-encoding", - "pingora-error", - "pingora-http", - "pingora-pool", - "pingora-runtime", - "pingora-rustls", - "pingora-timeout", - "rand 0.8.6", - "regex", + "praxis-proxy-core", + "praxis-proxy-filter", + "quixotic-plecostomus-core", + "reqwest", + "rmcp", + "schemars", + "secrecy", "serde", - "serde_yaml", - "sfv", - "socket2", - "strum", - "strum_macros", + "serde_json", + "sqlx", + "tempfile", + "thiserror 2.0.20", + "tiktoken-rs", "tokio", - "tokio-stream", - "tokio-test", - "unicase", - "windows-sys 0.59.0", - "x509-parser 0.16.0", - "zstd", + "tokio-util", + "tracing", + "url", + "utoipa", + "yaml_serde", ] [[package]] -name = "pingora-error" -version = "0.8.0" -source = "git+https://github.com/praxis-proxy/pingora.git?rev=d066f6818038dc296326566afb8e7bf39fb463ff#d066f6818038dc296326566afb8e7bf39fb463ff" - -[[package]] -name = "pingora-header-serde" -version = "0.8.0" -source = "git+https://github.com/praxis-proxy/pingora.git?rev=d066f6818038dc296326566afb8e7bf39fb463ff#d066f6818038dc296326566afb8e7bf39fb463ff" +name = "praxis-ai-build-support" +version = "0.2.0" dependencies = [ - "bytes", - "http", - "httparse", - "pingora-error", - "pingora-http", - "thread_local", - "zstd", - "zstd-safe", + "cargo_metadata", + "tempfile", ] [[package]] -name = "pingora-http" -version = "0.8.0" -source = "git+https://github.com/praxis-proxy/pingora.git?rev=d066f6818038dc296326566afb8e7bf39fb463ff#d066f6818038dc296326566afb8e7bf39fb463ff" +name = "praxis-ai-filters" +version = "0.2.0" dependencies = [ + "arc-swap", + "async-trait", + "base64 0.23.1", "bytes", + "chrono", + "dashmap 6.2.1", + "futures", "http", - "pingora-error", + "metrics", + "metrics-util", + "notify", + "praxis-ai-apis", + "praxis-proxy-core", + "praxis-proxy-filter", + "reqwest", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sha2 0.11.0", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "wiremock", + "yaml_serde", + "zeroize", ] [[package]] -name = "pingora-lru" -version = "0.8.0" -source = "git+https://github.com/praxis-proxy/pingora.git?rev=d066f6818038dc296326566afb8e7bf39fb463ff#d066f6818038dc296326566afb8e7bf39fb463ff" +name = "praxis-ai-proxy" +version = "0.2.0" dependencies = [ - "arrayvec", - "hashbrown 0.17.1", - "parking_lot", - "rand 0.8.6", + "cargo_metadata", + "clap", + "nix", + "notify", + "praxis-ai-apis", + "praxis-ai-build-support", + "praxis-ai-filters", + "praxis-proxy-core", + "praxis-proxy-filter", + "praxis-proxy-protocol", + "praxis-proxy-tls", + "serde", + "tempfile", + "tikv-jemallocator", + "tokio", + "tokio-util", + "tracing", + "yaml_serde", ] [[package]] -name = "pingora-pool" -version = "0.8.0" -source = "git+https://github.com/praxis-proxy/pingora.git?rev=d066f6818038dc296326566afb8e7bf39fb463ff#d066f6818038dc296326566afb8e7bf39fb463ff" +name = "praxis-proxy" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ec89978e6b37d982f7b4e80816d473637cecaa548d5a0f640a6d8cfecf73904" dependencies = [ - "crossbeam-queue", - "log", - "lru", - "parking_lot", - "pingora-timeout", - "thread_local", + "cargo_metadata", + "clap", + "nix", + "notify", + "praxis-proxy-core", + "praxis-proxy-filter", + "praxis-proxy-protocol", + "serde", + "tikv-jemallocator", "tokio", + "tokio-util", + "tracing", + "yaml_serde", ] [[package]] -name = "pingora-proxy" -version = "0.8.0" -source = "git+https://github.com/praxis-proxy/pingora.git?rev=d066f6818038dc296326566afb8e7bf39fb463ff#d066f6818038dc296326566afb8e7bf39fb463ff" +name = "praxis-proxy-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55832a48bd2c8e8079b9e1023f14594ac4583b5b2803e3a0c334e5f73fe81c30" dependencies = [ - "async-trait", "bytes", - "clap", - "futures", - "h2", + "dashmap 6.2.1", "http", - "log", - "once_cell", - "pingora-cache", - "pingora-core", - "pingora-error", - "pingora-http", - "rand 0.8.6", - "regex", - "tokio", -] - -[[package]] -name = "pingora-runtime" -version = "0.8.0" -source = "git+https://github.com/praxis-proxy/pingora.git?rev=d066f6818038dc296326566afb8e7bf39fb463ff#d066f6818038dc296326566afb8e7bf39fb463ff" -dependencies = [ - "once_cell", - "rand 0.8.6", - "thread_local", - "tokio", -] - -[[package]] -name = "pingora-rustls" -version = "0.8.0" -source = "git+https://github.com/praxis-proxy/pingora.git?rev=d066f6818038dc296326566afb8e7bf39fb463ff#d066f6818038dc296326566afb8e7bf39fb463ff" -dependencies = [ - "log", - "no_debug", - "pingora-error", - "ring", - "rustls", - "rustls-native-certs", - "rustls-pemfile", - "rustls-pki-types", - "tokio-rustls", -] - -[[package]] -name = "pingora-timeout" -version = "0.8.0" -source = "git+https://github.com/praxis-proxy/pingora.git?rev=d066f6818038dc296326566afb8e7bf39fb463ff#d066f6818038dc296326566afb8e7bf39fb463ff" -dependencies = [ - "once_cell", - "parking_lot", - "pin-project-lite", - "thread_local", - "tokio", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[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" -version = "0.3.1" -dependencies = [ - "clap", - "nix 0.31.3", - "notify", - "praxis-proxy-core", - "praxis-proxy-filter", - "praxis-proxy-protocol", - "serde", - "tempfile", - "tikv-jemallocator", - "tokio", - "tokio-util", - "tracing", - "yaml_serde", -] - -[[package]] -name = "praxis-proxy-core" -version = "0.3.1" -dependencies = [ - "dashmap", - "pingora-core", + "metrics", "praxis-proxy-tls", + "quixotic-plecostomus-core", + "quixotic-plecostomus-http", + "rand 0.10.2", "regex", "serde", - "thiserror 2.0.18", - "tracing", - "tracing-subscriber", - "yaml_serde", -] - -[[package]] -name = "praxis-proxy-ext-proc" -version = "0.3.1" -dependencies = [ - "async-trait", - "praxis-proxy-filter", - "serde", + "thiserror 2.0.20", "tokio", - "tonic", "tracing", + "tracing-subscriber", "yaml_serde", ] [[package]] name = "praxis-proxy-filter" -version = "0.3.1" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be5f77647237cae66bd8c28a9dac8a7dbf72c6c8f9d769f1987fede0bd16dd4" dependencies = [ "async-trait", "bytes", - "dashmap", + "dashmap 6.2.1", "http", + "metrics", "percent-encoding", "praxis-proxy-core", - "praxis-proxy-proto", - "prost-wkt-types", - "rand 0.9.4", + "praxis-proxy-tls", + "quixotic-plecostomus-core", + "rand 0.10.2", "regex", + "secrecy", "serde", "serde_json", - "thiserror 2.0.18", + "smallvec", + "thiserror 2.0.20", "tokio", - "tonic", "tracing", "yaml_serde", -] - -[[package]] -name = "praxis-proxy-proto" -version = "0.3.1" -dependencies = [ - "prost", - "prost-build", - "prost-types", - "prost-wkt-types", - "protox", - "tonic", - "tonic-prost", - "tonic-prost-build", + "zeroize", ] [[package]] name = "praxis-proxy-protocol" -version = "0.3.1" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7aebef930606ffa665975cc1101d1581ba86e2976e9f36e674c3ddd9d07d919" dependencies = [ "arc-swap", "async-trait", @@ -2345,365 +2456,425 @@ dependencies = [ "http", "metrics", "metrics-exporter-prometheus", - "pingora-core", - "pingora-http", - "pingora-proxy", "praxis-proxy-core", "praxis-proxy-filter", "praxis-proxy-tls", - "rcgen", - "rustls", + "quixotic-plecostomus-core", + "quixotic-plecostomus-http", + "quixotic-plecostomus-proxy", + "serde", "serde_json", - "tempfile", "tokio", "tokio-util", "tracing", - "yaml_serde", ] [[package]] name = "praxis-proxy-tls" -version = "0.3.1" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90c7769f7d662c1cc13a868b24576d604cc30ea0fc0907153f16ca837402d91e" dependencies = [ "arc-swap", "notify", - "rcgen", "rustls", "rustls-pemfile", "serde", - "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", - "yaml_serde", "zeroize", ] [[package]] name = "praxis-test-utils" -version = "0.3.1" +version = "0.2.0" dependencies = [ "arc-swap", "async-trait", + "base64 0.23.1", "bytes", "futures", "h2", "http", - "pingora-core", - "praxis", + "http-body-util", + "hyper", + "hyper-util", + "praxis-ai-apis", + "praxis-ai-proxy", + "praxis-proxy", "praxis-proxy-core", "praxis-proxy-filter", "praxis-proxy-protocol", + "quixotic-plecostomus-core", + "rand 0.10.2", "rcgen", + "reqwest", "rustls", "rustls-pemfile", + "serde", "serde_json", "tempfile", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-tungstenite", "tracing", + "url", "yaml_serde", ] -[[package]] -name = "praxis-tests-conformance" -version = "0.3.1" -dependencies = [ - "h2", - "http", - "praxis-proxy-core", - "praxis-test-utils", - "rustls", - "tokio", - "tokio-rustls", -] - [[package]] name = "praxis-tests-integration" -version = "0.3.1" +version = "0.2.0" dependencies = [ - "arc-swap", "async-trait", + "base64 0.23.1", "bytes", "futures", "http", + "nix", + "praxis-ai-proxy", "praxis-proxy-core", "praxis-proxy-filter", - "praxis-proxy-protocol", "praxis-test-utils", - "rustls", "rustls-pemfile", "serde", "serde_json", + "sha2 0.11.0", + "sqlx", "tempfile", "tokio", - "tokio-rustls", "tokio-tungstenite", - "yaml_serde", -] - -[[package]] -name = "praxis-tests-resilience" -version = "0.3.1" -dependencies = [ - "async-trait", - "benchmarks", - "bytes", - "praxis", - "praxis-proxy-core", - "praxis-proxy-filter", - "praxis-proxy-protocol", - "praxis-test-utils", - "tokio", - "yaml_serde", ] [[package]] name = "praxis-tests-schema" -version = "0.3.1" -dependencies = [ - "async-trait", - "bytes", - "praxis-proxy-core", - "praxis-proxy-filter", - "praxis-proxy-tls", - "praxis-test-utils", - "serde", - "serde_json", - "yaml_serde", -] - -[[package]] -name = "praxis-tests-security" -version = "0.3.1" +version = "0.2.0" dependencies = [ "praxis-proxy-core", "praxis-proxy-filter", "praxis-test-utils", + "tempfile", "yaml_serde", ] [[package]] -name = "praxis-tests-smoke" -version = "0.3.1" +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ - "praxis", - "praxis-proxy-core", - "praxis-test-utils", + "unicode-ident", ] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "proc-macro2-diagnostics" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", - "syn 2.0.117", + "quote", + "syn 2.0.119", + "version_check", + "yansi", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "quanta" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" dependencies = [ - "unicode-ident", + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", ] [[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" +name = "quinn" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "version_check", - "yansi", + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", ] [[package]] -name = "prost" -version = "0.14.3" +name = "quinn-proto" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ + "aws-lc-rs", "bytes", - "prost-derive", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", ] [[package]] -name = "prost-build" -version = "0.14.3" +name = "quinn-udp" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "heck 0.5.0", - "itertools 0.14.0", - "log", - "multimap", - "petgraph 0.8.3", - "prettyplease", - "prost", - "prost-types", - "pulldown-cmark", - "pulldown-cmark-to-cmark", - "regex", - "syn 2.0.117", - "tempfile", + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", ] [[package]] -name = "prost-derive" -version = "0.14.3" +name = "quixotic-plecostomus-cache" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "2a820a1a3e59644b38bc03606b638b41f1c5a474215efcec37f94b15871a7e1c" dependencies = [ - "anyhow", - "itertools 0.14.0", - "proc-macro2", - "quote", - "syn 2.0.117", + "ahash", + "async-trait", + "blake2", + "bstr", + "bytes", + "hex", + "http", + "httparse", + "httpdate", + "indexmap 1.9.3", + "log", + "lru", + "once_cell", + "parking_lot", + "quixotic-plecostomus-core", + "quixotic-plecostomus-error", + "quixotic-plecostomus-header-serde", + "quixotic-plecostomus-http", + "quixotic-plecostomus-lru", + "quixotic-plecostomus-timeout", + "rand 0.8.7", + "regex", + "rmp", + "rmp-serde", + "serde", + "strum", + "tokio", ] [[package]] -name = "prost-reflect" -version = "0.16.4" +name = "quixotic-plecostomus-core" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590aa145fee8f7a26b5a6055365e7c5e89a5c1caae9869de76ec0ee73181a2f9" +checksum = "d7d6d9e04414ba6819c51ec210eddf17865c505431a492c86c6356338c2b4f60" dependencies = [ - "logos 0.16.1", - "miette", - "prost", - "prost-types", + "ahash", + "async-trait", + "brotli", + "bstr", + "bytes", + "chrono", + "clap", + "daemonix", + "daggy", + "derivative", + "flate2", + "flurry", + "futures", + "h2", + "http", + "httparse", + "httpdate", + "libc", + "log", + "nix", + "once_cell", + "openssl-probe 0.1.6", + "ouroboros", + "parking_lot", + "percent-encoding", + "quixotic-plecostomus-error", + "quixotic-plecostomus-http", + "quixotic-plecostomus-pool", + "quixotic-plecostomus-runtime", + "quixotic-plecostomus-rustls", + "quixotic-plecostomus-timeout", + "rand 0.8.7", + "regex", + "serde", + "serde_yaml", + "sfv", + "socket2", + "strum", + "strum_macros", + "tokio", + "tokio-stream", + "tokio-test", + "unicase", + "windows-sys 0.59.0", + "x509-parser 0.16.0", + "zstd", ] [[package]] -name = "prost-types" -version = "0.14.3" +name = "quixotic-plecostomus-error" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" -dependencies = [ - "prost", -] +checksum = "8af30024450d6dc4785ccadf33e7cfd91f837bc6a226f133f9e77b397b4206c5" [[package]] -name = "prost-wkt" -version = "0.7.1" +name = "quixotic-plecostomus-header-serde" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3de5e9c9e84fcb5efa204b8e283d23e615a8bc8c777bf1d6622bb01dc61445" +checksum = "a9fe7db0e7899a6b2d2cf06e71adacfad4c1bcfe3b5f238494dd3b3eafb2c1b1" dependencies = [ - "chrono", - "inventory", - "prost", - "serde", - "serde_derive", - "serde_json", - "typetag", + "bytes", + "http", + "httparse", + "quixotic-plecostomus-error", + "quixotic-plecostomus-http", + "thread_local", + "zstd", + "zstd-safe", ] [[package]] -name = "prost-wkt-build" -version = "0.7.1" +name = "quixotic-plecostomus-http" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe500dc80e757a75e1e8fb7290e448d62dfba3105ece1d058579cb00b58151cd" +checksum = "9f337131150ff271246df2584da8bdb0280b8424646d2c76e3926faa70409dc9" dependencies = [ - "heck 0.5.0", - "prost", - "prost-build", - "prost-types", - "quote", + "bytes", + "http", + "quixotic-plecostomus-error", ] [[package]] -name = "prost-wkt-types" -version = "0.7.1" +name = "quixotic-plecostomus-lru" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13807eaa7e15833d06e899008371926201cdcd11d74b6d490f49130cdb3f415e" +checksum = "ec55d3bcbd56e52b3424bc62739a237d43c14df8ed04aaf294ae187b60da348a" dependencies = [ - "chrono", - "prost", - "prost-build", - "prost-types", - "prost-wkt", - "prost-wkt-build", - "protox", - "regex", - "serde", - "serde_derive", - "serde_json", + "arrayvec", + "hashbrown 0.17.1", + "parking_lot", + "rand 0.8.7", ] [[package]] -name = "protox" -version = "0.9.1" +name = "quixotic-plecostomus-pool" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f25a07a73c6717f0b9bbbd685918f5df9815f7efba450b83d9c9dea41f0e3a1" +checksum = "a6383d2998b2298a0f34bb8a7330f5fea4bb0fb55671db2dc788b5f703c2009f" dependencies = [ - "bytes", - "miette", - "prost", - "prost-reflect", - "prost-types", - "protox-parse", - "thiserror 2.0.18", + "crossbeam-queue", + "dashmap 5.5.3", + "futures", + "log", + "lru", + "parking_lot", + "quixotic-plecostomus-timeout", + "tokio", ] [[package]] -name = "protox-parse" -version = "0.9.0" +name = "quixotic-plecostomus-proxy" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "072eee358134396a4643dff81cfff1c255c9fbd3fb296be14bdb6a26f9156366" +checksum = "a953dcda74a2c196497ddc7892e571cdaa0bdb8acc8f7afb6d7b97339b28c658" dependencies = [ - "logos 0.15.1", - "miette", - "prost-types", - "thiserror 2.0.18", + "async-trait", + "bytes", + "clap", + "futures", + "h2", + "http", + "log", + "once_cell", + "quixotic-plecostomus-cache", + "quixotic-plecostomus-core", + "quixotic-plecostomus-error", + "quixotic-plecostomus-http", + "rand 0.8.7", + "regex", + "tokio", ] [[package]] -name = "pulldown-cmark" -version = "0.13.4" +name = "quixotic-plecostomus-runtime" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +checksum = "51621ff504cfde18409889d6d5b8773e0b0de0e4defa610006a36c27adc06a10" dependencies = [ - "bitflags 2.11.1", - "memchr", - "unicase", + "log", + "once_cell", + "rand 0.8.7", + "serde", + "thread_local", + "tokio", ] [[package]] -name = "pulldown-cmark-to-cmark" -version = "22.0.0" +name = "quixotic-plecostomus-rustls" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +checksum = "bc6ba2674ecab001d9ebdc6dc9944326857c07f9c65e7c0779f25db17f96ee84" dependencies = [ - "pulldown-cmark", + "log", + "no_debug", + "quixotic-plecostomus-error", + "ring", + "rustls", + "rustls-native-certs 0.7.3", + "rustls-pemfile", + "rustls-pki-types", + "tokio-rustls", ] [[package]] -name = "quanta" -version = "0.12.6" +name = "quixotic-plecostomus-timeout" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +checksum = "5f76d183bcbdae3c0f4b0547e0fb7afdc658853a5e7421bbae1b59e48c25e667" dependencies = [ - "crossbeam-utils", - "libc", "once_cell", - "raw-cpuid", - "wasi", - "web-sys", - "winapi", + "parking_lot", + "pin-project-lite", + "thread_local", + "tokio", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2722,9 +2893,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -2733,14 +2904,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2779,6 +2961,21 @@ 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_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xoshiro" version = "0.7.0" @@ -2790,9 +2987,9 @@ dependencies = [ [[package]] name = "rapidhash" -version = "4.4.1" +version = "4.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" dependencies = [ "rustversion", ] @@ -2803,34 +3000,14 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", + "bitflags", ] [[package]] name = "rcgen" -version = "0.14.8" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" dependencies = [ "pem", "ring", @@ -2846,14 +3023,34 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2863,9 +3060,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2874,9 +3071,50 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] [[package]] name = "ring" @@ -2892,6 +3130,52 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a15bc53261a9dc37e105df006e4656c598379a8f9581f8950debb130f27a7cf" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "indexmap 2.14.0", + "pastey", + "pin-project-lite", + "rand 0.10.2", + "reqwest", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a85d45508e9b4ba024fe996c2638799635d75b6dd0ba8f32ccf08f8026f0c780" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.3", +] + [[package]] name = "rmp" version = "0.8.15" @@ -2913,14 +3197,20 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.42.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ "arrayvec", "num-traits", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2945,7 +3235,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -2954,9 +3244,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -2974,11 +3264,23 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pemfile", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", ] [[package]] @@ -2992,18 +3294,46 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs 0.8.4", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "aws-lc-rs", "ring", @@ -3013,9 +3343,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -3023,6 +3353,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -3041,6 +3377,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3053,14 +3415,37 @@ 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 2.11.1", - "core-foundation", + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3087,12 +3472,16 @@ 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.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3100,30 +3489,42 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -3131,6 +3532,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -3150,20 +3562,42 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fa1f336066b758b7c9df34ed049c0e693a426afe2b27ff7d5b14f410ab1a132" dependencies = [ - "base64", + "base64 0.22.1", "indexmap 2.14.0", "rust_decimal", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3177,9 +3611,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -3193,9 +3627,25 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "sketches-ddsketch" @@ -3211,26 +3661,212 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.20", + "tracing", + "url", +] + +[[package]] +name = "sse-stream" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3256,7 +3892,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3278,9 +3914,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +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.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -3292,6 +3939,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -3301,7 +3951,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3311,7 +3961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3328,11 +3978,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -3343,34 +3993,49 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] +[[package]] +name = "tiktoken-rs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027853bbf8c7763b77c5c595f1c271c7d536ced7d6f83452911b944621e57fc2" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bstr", + "fancy-regex", + "lazy_static", + "regex", + "rustc-hash", +] + [[package]] name = "tikv-jemalloc-sys" -version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" +version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b" +checksum = "1a2825c78386b4ae0314074867860ba9577875de945f05992c38815cbec327f0" dependencies = [ "cc", "libc", @@ -3378,9 +4043,9 @@ dependencies = [ [[package]] name = "tikv-jemallocator" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a" +checksum = "249f09e49ab1609436f34c776e84231bead18d6a955f119f939bdc1d847561bd" dependencies = [ "libc", "tikv-jemalloc-sys", @@ -3388,12 +4053,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -3403,15 +4067,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3427,20 +4091,35 @@ dependencies = [ ] [[package]] -name = "tinytemplate" -version = "1.2.1" +name = "tinystr" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ - "serde", - "serde_json", + "displaydoc", + "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3454,13 +4133,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -3475,9 +4154,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -3488,104 +4167,37 @@ dependencies = [ name = "tokio-test" version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" -dependencies = [ - "futures-core", - "tokio", - "tokio-stream", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tonic" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" -dependencies = [ - "async-trait", - "axum", - "base64", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "socket2", - "sync_wrapper", - "tokio", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-build" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" dependencies = [ - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", + "futures-core", + "tokio", + "tokio-stream", ] [[package]] -name = "tonic-prost" -version = "0.14.6" +name = "tokio-tungstenite" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ - "bytes", - "prost", - "tonic", + "futures-util", + "log", + "tokio", + "tungstenite", ] [[package]] -name = "tonic-prost-build" -version = "0.14.6" +name = "tokio-util" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ - "prettyplease", - "proc-macro2", - "prost-build", - "prost-types", - "quote", - "syn 2.0.117", - "tempfile", - "tonic-build", + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", ] [[package]] @@ -3596,15 +4208,29 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.14.0", "pin-project-lite", - "slab", "sync_wrapper", "tokio", - "tokio-util", "tower-layer", "tower-service", - "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", ] [[package]] @@ -3625,6 +4251,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3638,7 +4265,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3701,55 +4328,25 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.9.4", + "rand 0.10.2", "sha1", - "thiserror 2.0.18", + "thiserror 2.0.20", ] -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - [[package]] name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "typetag" -version = "0.2.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a897b12c6c1151ad0b138b8db50252dc301f93bc3b027db05eec82aeed298c" -dependencies = [ - "erased-serde", - "inventory", - "once_cell", - "serde", - "typetag-impl", -] - -[[package]] -name = "typetag-impl" -version = "0.2.22" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" @@ -3757,6 +4354,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3764,16 +4367,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-width" -version = "0.1.14" +name = "unicode-normalization" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "unicode-properties" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unsafe-libyaml" @@ -3787,18 +4393,76 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[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" @@ -3832,27 +4496,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -3861,11 +4516,21 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3873,70 +4538,83 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "wasm-streams" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ - "leb128fmt", - "wasmparser", + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "web-sys" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "wasmparser" -version = "0.244.0" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "web-sys" -version = "0.3.99" +name = "webpki-root-certs" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ - "js-sys", - "wasm-bindgen", + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + [[package]] name = "winapi" version = "0.3.9" @@ -3989,7 +4667,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4000,7 +4678,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4193,12 +4871,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] -name = "wit-bindgen" -version = "0.51.0" +name = "wiremock" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ - "wit-bindgen-rust-macro", + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", ] [[package]] @@ -4208,83 +4900,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" +name = "writeable" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "x509-parser" @@ -4317,23 +4936,25 @@ dependencies = [ "oid-registry 0.8.1", "ring", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", ] [[package]] name = "xtask" -version = "0.3.1" +version = "0.2.0" dependencies = [ - "benchmarks", - "chrono", "clap", - "nix 0.31.3", - "plotters", - "praxis", + "praxis-ai-apis", + "praxis-ai-proxy", "praxis-proxy-core", + "praxis-test-utils", + "quote", + "reqwest", "serde", "serde_json", + "sha2 0.11.0", + "syn 3.0.3", "tempfile", "tokio", "tracing", @@ -4343,9 +4964,9 @@ dependencies = [ [[package]] name = "yaml_serde" -version = "0.10.4" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c7c1b1a6a7c8a6b2741a6c21a4f8918e51899b111cfa08d1288202656e3975" +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918" dependencies = [ "indexmap 2.14.0", "itoa", @@ -4366,41 +4987,121 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", "time", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zerocopy" -version = "0.8.49" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.49" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", + "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" diff --git a/Cargo.toml b/Cargo.toml index 07b2f84b89..ec4138667b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,104 +1,104 @@ [workspace] resolver = "3" members = [ - # Praxis Main "server", - - # Praxis Proxy Framework - "core", - "filter", - "filter/ext-proc", - "filter/proto", - "protocol", - "tls", - - # Praxis Developer Tooling - "benchmarks", + "server/build_support", + "filters", + "apis", "xtask", - - # Praxis Testing - "tests/schema", - "tests/conformance", "tests/integration", - "tests/resilience", - "tests/security", - "tests/smoke", + "tests/schema", "tests/utils", ] -exclude = ["tests/fuzz"] [workspace.package] -version = "0.3.1" +version = "0.2.0" edition = "2024" -rust-version = "1.94" +rust-version = "1.96" authors = ["Shane Utt "] license = "MIT" -repository = "https://github.com/praxis-proxy/praxis" +repository = "https://github.com/praxis-proxy/ai" [workspace.dependencies] -arc-swap = "1.9.1" -async-trait = "0.1.89" -benchmarks = { path = "benchmarks" } -bytes = "1.11.1" -chrono = { version = "0.4.44", default-features = false, features = ["clock"] } +async-trait = "0.1.92" +base64 = "0.23.1" +bytes = "1.12.1" +axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] } +chrono = { version = "0.4.45", default-features = false, features = ["clock"] } +clap = { version = "4.6.6", features = ["derive"] } dashmap = "6.2.1" -clap = { version = "4.6.1", features = ["derive"] } -criterion = { version = "0.8.2", features = ["async_tokio"] } -futures = "0.3.32" -h2 = "0.4.14" -http = "1.4.1" -metrics = "0.24.6" -metrics-exporter-prometheus = { version = "0.18.3", default-features = false } +futures = "0.3.34" +h2 = "0.4.18" +http = "1.5.0" +http-body-util = "0.1.5" +hyper = { version = "1.11.0", features = ["http1", "server"] } +hyper-util = { version = "0.1.20", features = ["tokio"] } +jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } nix = { version = "0.31.3", default-features = false, features = ["process", "signal", "user"] } -notify = "8.2.0" -# Temporary fork: custom ServerConfig (PR #726) + WrappedX509::parse() for per-cluster CA. -pingora-core = { version = "0.8.0", git = "https://github.com/praxis-proxy/pingora.git", rev = "d066f6818038dc296326566afb8e7bf39fb463ff", features = ["rustls"] } -pingora-http = { version = "0.8.0", git = "https://github.com/praxis-proxy/pingora.git", rev = "d066f6818038dc296326566afb8e7bf39fb463ff" } -pingora-proxy = { version = "0.8.0", git = "https://github.com/praxis-proxy/pingora.git", rev = "d066f6818038dc296326566afb8e7bf39fb463ff", features = ["rustls"] } percent-encoding = "2.3.2" -praxis-ext-proc = { version = "0.3.1", path = "filter/ext-proc", package = "praxis-proxy-ext-proc" } -praxis-proto = { version = "0.3.1", path = "filter/proto", package = "praxis-proxy-proto" } -prost = "0.14.3" -prost-build = "0.14.3" -prost-types = "0.14.3" -prost-wkt-types = { version = "0.7.1", features = ["vendored-protox"] } -protox = "0.9.1" -rand = "0.9.4" -plotters = { version = "0.3.7", default-features = false, features = ["svg_backend", "line_series"] } -praxis = { path = "server" } -praxis-core = { version = "0.3.1", path = "core", package = "praxis-proxy-core" } -praxis-filter = { version = "0.3.1", path = "filter", package = "praxis-proxy-filter" } -praxis-protocol = { version = "0.3.1", path = "protocol", package = "praxis-proxy-protocol" } -praxis-tls = { version = "0.3.1", path = "tls", package = "praxis-proxy-tls" } -praxis-test-utils = { path = "tests/utils" } -serde = { version = "1.0.228", features = ["derive", "rc"] } -serde_json = "1.0.150" -rcgen = "0.14.8" -regex = "1.12.3" -rustls = "0.23.40" +notify = "8.2.0" +quote = "1.0.47" +rand = "0.10.2" +rcgen = "0.14.9" +regex = "1.13.1" +reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "json", "stream"] } +rmcp = { version = "3.1.4", default-features = false, features = ["client", "transport-streamable-http-client-reqwest", "reqwest"] } +rustls = "0.23.43" +schemars = "1.2.2" rustls-pemfile = "2.2.0" -# NOTE: serde_yaml is deprecated; using yaml_serde (yaml.org maintained fork) as drop-in. -serde_yaml = { package = "yaml_serde", version = "0.10.4" } +secrecy = { version = "0.10.3", features = ["serde"] } +serde = { version = "1.0.229", features = ["derive", "rc"] } +serde_json_canonicalizer = "0.3.2" +serde_json = { version = "1.0.151", features = ["preserve_order"] } +sha2 = "0.11.0" +syn = { version = "3.0.3", features = ["full", "extra-traits", "visit"] } +serde_yaml = { package = "yaml_serde", version = "0.10.7" } +sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "tls-rustls-ring", "sqlite", "postgres"] } tempfile = "3.27.0" +thiserror = "2.0.20" +tiktoken-rs = "0.12.0" +tikv-jemallocator = "0.7.0" +tokio = { version = "1.53.1", features = ["macros", "rt"] } tokio-rustls = "0.26.4" -tokio-tungstenite = "0.29.0" -thiserror = "2.0.18" -tikv-jemallocator = "0.6.1" -tonic = "0.14.6" -tonic-prost = "0.14.6" -tonic-prost-build = { version = "0.14.6", features = ["transport"] } -tokio = { version = "1.52.3", features = ["macros", "rt"] } -tokio-util = "0.7.18" +tokio-tungstenite = "0.30.0" +tokio-util = "0.7.19" +url = "2.5.8" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } -zeroize = "1.8.2" +utoipa = "5.5.0" +wiremock = "0.6.5" +zeroize = "1.9.0" + +# Praxis core dependencies +praxis-core = { version = "0.5.3", package = "praxis-proxy-core" } +praxis-filter = { version = "0.5.3", package = "praxis-proxy-filter" } +praxis-protocol = { version = "0.5.3", package = "praxis-proxy-protocol" } +praxis-tls = { version = "0.5.3", package = "praxis-proxy-tls" } +praxis = { version = "0.5.3", package = "praxis-proxy" } +praxis-test-utils = { path = "tests/utils" } + +# Praxis AI workspace dependencies +praxis-ai-proxy = { version = "0.2.0", path = "server" } +praxis-ai-filters = { version = "0.2.0", path = "filters" } +praxis-ai-apis = { version = "0.2.0", path = "apis" } +praxis-ai-build-support = { version = "0.2.0", path = "server/build_support" } + +# Pingora (temporary fork) +pingora-core = { version = "0.8.2", package = "quixotic-plecostomus-core", features = ["rustls"] } +pingora-http = { version = "0.8.2", package = "quixotic-plecostomus-http" } +pingora-proxy = { version = "0.8.2", package = "quixotic-plecostomus-proxy", features = ["rustls"] } +arc-swap = "1.9.2" +metrics = "0.24.6" +metrics-exporter-prometheus = { version = "0.18.3", default-features = false } +metrics-util = { version = "0.20.4", default-features = false, features = ["debugging"] } +cargo_metadata = "0.23.1" [profile.release] -lto = true codegen-units = 1 -strip = true -panic = "abort" +lto = true overflow-checks = true +panic = "abort" +strip = true [profile.profiling] inherits = "release" @@ -116,55 +116,44 @@ missing_docs = "deny" unreachable_pub = "deny" unused_imports = "deny" unused_variables = "deny" -# Cast safety trivial_casts = "deny" trivial_numeric_casts = "deny" -# Import hygiene macro_use_extern_crate = "deny" unused_extern_crates = "deny" -# Lifetime clarity elided_lifetimes_in_paths = "deny" single_use_lifetimes = "deny" -# Qualification hygiene unused_qualifications = "deny" -# Macro hygiene unused_macro_rules = "deny" meta_variable_misuse = "deny" -# Drop safety let_underscore_drop = "deny" -# Identifier safety +keyword_idents_2024 = "deny" non_ascii_idents = "deny" -# Method call correctness noop_method_call = "deny" -# Lifetime clarity (extended) redundant_lifetimes = "deny" -# Debug trait coverage -# missing_debug_implementations: many types contain Box without Debug bounds missing_debug_implementations = "allow" [workspace.lints.clippy] -# Async and concurrency safety await_holding_lock = "deny" +await_holding_refcell_ref = "deny" future_not_send = "deny" large_futures = "deny" -# Memory and allocation efficiency +let_underscore_future = "deny" +let_underscore_must_use = "deny" +assigning_clones = "deny" implicit_clone = "deny" inefficient_to_string = "deny" large_enum_variant = "deny" +large_stack_frames = "deny" large_types_passed_by_value = "deny" needless_pass_by_value = "deny" rc_buffer = "deny" str_to_string = "deny" trivially_copy_pass_by_ref = "deny" vec_init_then_push = "deny" -# API design return_self_not_must_use = "deny" -# Documentation doc_markdown = "deny" missing_docs_in_private_items = "deny" -# Allowed: blank line after attributes is intentional style empty_line_after_outer_attr = "allow" -# Idiomatic Rust checked_conversions = "deny" cloned_instead_of_copied = "deny" explicit_into_iter_loop = "deny" @@ -178,10 +167,12 @@ manual_ok_or = "deny" manual_string_new = "deny" map_unwrap_or = "deny" redundant_closure_for_method_calls = "deny" +ref_patterns = "deny" +set_contains_or_insert = "deny" +try_err = "deny" +unnecessary_get_then_check = "deny" unnested_or_patterns = "deny" -# Borrow hygiene needless_borrow = "deny" -# Code clarity if_not_else = "deny" match_bool = "deny" match_same_arms = "deny" @@ -192,45 +183,40 @@ redundant_else = "deny" semicolon_if_nothing_returned = "deny" uninlined_format_args = "deny" unreadable_literal = "deny" +unseparated_literal_suffix = "deny" unused_self = "deny" -# Import hygiene enum_glob_use = "deny" wildcard_imports = "deny" -# String handling string_add_assign = "deny" -# Function size +string_slice = "deny" too_many_lines = "deny" -# Dev hygiene dbg_macro = "deny" print_stderr = "deny" print_stdout = "deny" -# Cast safety cast_lossless = "deny" cast_possible_truncation = "deny" cast_possible_wrap = "deny" cast_sign_loss = "deny" cast_precision_loss = "deny" ptr_as_ptr = "deny" -# Documentation completeness missing_errors_doc = "deny" missing_panics_doc = "deny" -# Dead code detection unused_async = "deny" unnecessary_wraps = "deny" needless_pass_by_ref_mut = "deny" no_effect_underscore_binding = "deny" used_underscore_binding = "deny" -# API hygiene option_option = "deny" ref_option_ref = "deny" default_trait_access = "deny" expl_impl_clone_on_copy = "deny" -# Code clarity items_after_statements = "deny" macro_use_imports = "deny" +multiple_inherent_impl = "deny" needless_continue = "deny" rest_pat_in_fully_bound_structs = "deny" same_functions_in_if_condition = "deny" +same_name_method = "deny" stable_sort_primitive = "deny" string_add = "deny" string_lit_as_bytes = "deny" @@ -238,53 +224,56 @@ trait_duplication_in_bounds = "deny" type_repetition_in_bounds = "deny" verbose_bit_mask = "deny" zero_sized_map_values = "deny" -# Boolean hygiene fn_params_excessive_bools = "deny" struct_excessive_bools = "deny" -# Complexity (thresholds in clippy.toml) cognitive_complexity = "deny" too_many_arguments = "deny" -# Safety and correctness unwrap_used = "deny" missing_assert_message = "deny" +infinite_loop = "deny" map_err_ignore = "deny" or_fun_call = "deny" clone_on_ref_ptr = "deny" debug_assert_with_mut_call = "deny" collection_is_never_read = "deny" +rc_mutex = "deny" significant_drop_in_scrutinee = "deny" mutex_integer = "deny" mutex_atomic = "deny" +zombie_processes = "deny" +allow_attributes = "deny" allow_attributes_without_reason = "deny" significant_drop_tightening = "warn" -# Diagnostic hygiene -expect_used = "warn" -panic = "warn" -indexing_slicing = "warn" -# String handling (extended) -# string_to_string removed in recent clippy (covered by implicit_clone) -# Redundancy +expect_used = "deny" +panic = "deny" +indexing_slicing = "deny" redundant_type_annotations = "deny" needless_raw_strings = "deny" needless_raw_string_hashes = "deny" -# Idiomatic Rust (extended) inconsistent_struct_constructor = "deny" match_wildcard_for_single_variants = "deny" single_match_else = "deny" empty_drop = "deny" manual_clamp = "deny" -# Safety documentation undocumented_unsafe_blocks = "deny" missing_safety_doc = "deny" -# Restriction: no placeholder macros in production todo = "deny" unimplemented = "deny" -# Restriction: don't silently discard errors unused_result_ok = "deny" -# Restriction: proxy must use graceful shutdown exit = "deny" -# Restriction: prevent resource leaks mem_forget = "deny" +unused_trait_names = "deny" +error_impl_error = "deny" +empty_structs_with_brackets = "deny" +tests_outside_test_module = "deny" +unnecessary_safety_comment = "deny" +renamed_function_params = "deny" +if_then_some_else_none = "deny" +format_push_string = "deny" +deref_by_slicing = "deny" +lossy_float_literal = "deny" +semicolon_inside_block = "deny" +disallowed_methods = "deny" [workspace.lints.rustdoc] broken_intra_doc_links = "deny" @@ -296,3 +285,4 @@ bare_urls = "deny" invalid_html_tags = "deny" missing_crate_level_docs = "deny" private_doc_tests = "allow" + diff --git a/Containerfile b/Containerfile index 4e89684898..95a1057cf1 100644 --- a/Containerfile +++ b/Containerfile @@ -4,7 +4,7 @@ # Stage 1: Build # ------------------------------------------------------------------------------ -FROM rust:1.94-alpine AS builder +FROM rust:1.97-alpine AS builder ENV OPENSSL_STATIC=1 @@ -19,61 +19,53 @@ WORKDIR /src # Cache dependency builds: copy only manifests first, then # create stub source files so `cargo build` resolves and # compiles all dependencies without the real source code. -# See: https://shaneutt.com/blog/rust-fast-small-docker-image-builds/ +# Workspace manifests COPY Cargo.toml Cargo.lock ./ -COPY core/Cargo.toml core/Cargo.toml -COPY filter/Cargo.toml filter/Cargo.toml -COPY filter/proto/Cargo.toml filter/proto/Cargo.toml -COPY protocol/Cargo.toml protocol/Cargo.toml -COPY tls/Cargo.toml tls/Cargo.toml -COPY server/Cargo.toml server/Cargo.toml - -# The proto crate has a build.rs that compiles vendored .proto files, -# so we need the full proto/ directory (not just a stub) for the -# cache-build stage to succeed. -COPY filter/proto/build.rs filter/proto/build.rs -COPY filter/proto/proto filter/proto/proto - -# Strip workspace members not needed for the praxis binary -# so we don't need their Cargo.toml files. -RUN sed -i '/xtask/d; /benchmarks/d; /tests\//d; /filter\/ext-proc/d' Cargo.toml -RUN mkdir -p core/src \ - filter/src \ - filter/proto/src \ - protocol/src \ - tls/src \ - server/src \ - && echo '//! stub' > core/src/lib.rs \ - && echo '//! stub' > filter/src/lib.rs \ - && echo '//! stub' > filter/proto/src/lib.rs \ - && echo '//! stub' > protocol/src/lib.rs \ - && echo '//! stub' > tls/src/lib.rs \ +COPY apis/Cargo.toml ./apis/Cargo.toml +COPY filters/Cargo.toml ./filters/Cargo.toml +COPY server/Cargo.toml ./server/Cargo.toml +COPY server/build_support/Cargo.toml ./server/build_support/Cargo.toml + +# The server crate has a build.rs that discovers external filter +# crates via cargo metadata for build-time auto-registration, +# backed by the praxis-ai-build-support crate. build.rs is a +# build-dependency of the server crate, not an ordinary crate under +# test: cargo compiles it (and everything it depends on) up front, +# so praxis-ai-build-support needs its real source here too, not a +# stub — a stub would compile but export none of the functions +# build.rs calls, breaking compilation of the build script itself. +COPY server/build.rs ./server/build.rs +COPY server/build_support/src ./server/build_support/src + +# Strip workspace members not needed for the binary. +RUN sed -i '/xtask/d; /tests\//d' Cargo.toml + +# Create stub source files for the crates whose real source isn't +# needed until after dependencies are cached. +RUN mkdir -p apis/src filters/src server/src \ + && echo '//! stub' > apis/src/lib.rs \ + && echo '//! stub' > filters/src/lib.rs \ && echo '//! stub' > server/src/lib.rs \ && printf '//! stub\nfn main() {}\n' > server/src/main.rs RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/src/target \ - cargo build --release -p praxis + cargo build --release -p praxis-ai-proxy # ------------------------------------------------------------------------------ # Cache Tricks # ------------------------------------------------------------------------------ -# Replace stubs with real source, then rebuild. Only the -# project crates recompile; all dependencies are cached. -COPY core/src core/src -COPY filter/src filter/src -COPY filter/proto/src filter/proto/src -COPY protocol/src protocol/src -COPY tls/src tls/src -COPY server/src server/src -COPY examples examples - -# Touch the lib/main files so cargo sees them as newer than -# the cached stub artifacts. -RUN find core/src filter/src filter/proto/src \ - protocol/src tls/src server/src \ +# Replace stubs with real source, then rebuild. Only the project +# crates recompile; all external dependencies are cached. +# build_support/src was already real (see above), so it is not +# copied again here. +COPY apis/src ./apis/src +COPY filters/src ./filters/src +COPY server/src ./server/src + +RUN find apis/src filters/src server/src \ -name '*.rs' -exec touch {} + # ------------------------------------------------------------------------------ @@ -82,17 +74,17 @@ RUN find core/src filter/src filter/proto/src \ RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/src/target \ - cargo build --release -p praxis \ - && cp target/release/praxis /usr/local/bin/praxis + cargo build --release -p praxis-ai-proxy \ + && cp target/release/praxis-ai /usr/local/bin/praxis-ai # ------------------------------------------------------------------------------ # Stage 2: Runtime # ------------------------------------------------------------------------------ -FROM alpine:3.23 +FROM alpine:3.24 -LABEL org.opencontainers.image.source="https://github.com/praxis-proxy/praxis" \ - org.opencontainers.image.description="Praxis proxy server" \ +LABEL org.opencontainers.image.source="https://github.com/praxis-proxy/ai" \ + org.opencontainers.image.description="Praxis AI proxy server" \ org.opencontainers.image.licenses="MIT" RUN apk add --no-cache ca-certificates \ @@ -101,11 +93,7 @@ RUN apk add --no-cache ca-certificates \ && mkdir -p /etc/praxis COPY --from=builder --chown=root:root --chmod=0555 \ - /usr/local/bin/praxis /usr/local/bin/praxis - -COPY --chown=praxis:praxis --chmod=0444 \ - examples/configs/operations/container-default.yaml \ - /etc/praxis/config.yaml + /usr/local/bin/praxis-ai /usr/local/bin/praxis-ai USER praxis:praxis @@ -116,4 +104,4 @@ EXPOSE 8080 9901 HEALTHCHECK --interval=5s --timeout=3s --start-period=2s \ CMD wget -qO- http://127.0.0.1:9901/healthy || exit 1 -ENTRYPOINT ["praxis", "-c", "/etc/praxis/config.yaml"] +ENTRYPOINT ["praxis-ai"] diff --git a/Containerfile.test b/Containerfile.test index ba2076ef70..77d43a50e8 100644 --- a/Containerfile.test +++ b/Containerfile.test @@ -12,7 +12,7 @@ # Build: make test-container # Run: make test-container-run -FROM rust:1.94-alpine +FROM rust:1.97-alpine ENV OPENSSL_STATIC=1 @@ -43,7 +43,7 @@ RUN apk add --no-cache \ # Non-root user # ------------------------------------------------------------------- -# Praxis refuses to run as UID 0. +# Praxis AI refuses to run as UID 0. RUN addgroup -S tester && adduser -S -G tester -h /home/tester tester \ && mkdir -p /cache && chown tester:tester /cache diff --git a/Containerfile.test.dockerignore b/Containerfile.test.dockerignore deleted file mode 100644 index 2696f0297f..0000000000 --- a/Containerfile.test.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -# No COPY instructions — ignore everything to minimize build context. -* diff --git a/LICENSE b/LICENSE index f0e8d5a8fb..1818006c99 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Shane Utt +Copyright (c) 2024 Shane Utt and Praxis Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000000..c234316309 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,41 @@ +# Maintainers + +This file lists the maintainers of the Praxis +project. See [GOVERNANCE.md][gov] for role +definitions and processes. + +## Project Leads + +| Name | GitHub | Affiliation | +| Shane Utt | [@shaneutt](https://github.com/shaneutt) | Red Hat | + +## Core Reviewers + +| Name | GitHub | Affiliation | +|------|--------|-------------| +| Shane Utt | [@shaneutt](https://github.com/shaneutt) | Red Hat | +| Tim Walsh | [@twghu](https://github.com/twghu) | Red Hat | + +## Praxis Policy Engine (PPE) Reviewers + +| Name | GitHub | Affiliation | +|------|--------|-------------| +| Fred Araujo | [@araujof](https://github.com/araujof) | IBM | +| Shane Utt | [@shaneutt](https://github.com/shaneutt) | Red Hat | + +## AI Gateway Reviewers + +| Name | GitHub | Affiliation | +|------|--------|-------------| +| Alex Snaps | [@alexsnaps](https://github.com/alexsnaps) | Red Hat | +| Aslak Knutsen | [@aslakknutsen](https://github.com/aslakknutsen) | Red Hat | +| Sebastien Han | [@leseb](https://github.com/leseb) | Red Hat | +| Francisco Arceo | [@franciscojavierarceo](https://github.com/franciscojavierarceo) | Red Hat | +| Alexander Cristurean | [@crstrn13](https://github.com/crstrn13) | Red Hat | +| Didier Di Cesare | [@didierofrivia](https://github.com/didierofrivia) | Red Hat | + +## Emeritus + +None yet. + +[gov]: https://github.com/praxis-proxy/praxis/blob/main/GOVERNANCE.md diff --git a/Makefile b/Makefile index a1b3e848c9..5c238accbb 100644 --- a/Makefile +++ b/Makefile @@ -3,87 +3,30 @@ # ------------------------------------------------------------------- VERSION ?= $(shell perl -ne 'print $$1 if /^version\s*=\s*"(.+)"/' Cargo.toml) -IMAGE ?= praxis +IMAGE ?= praxis-ai CONTAINER_ENGINE ?= $(shell command -v podman 2>/dev/null || command -v docker 2>/dev/null) +OPENAI_CONFORMANCE_ARGS ?= V ?= -UNAME_S := $(shell uname -s | tr A-Z a-z) -UNAME_M := $(shell uname -m) - -# ------------------------------------------------------------------- -# Prerequisite checks -# ------------------------------------------------------------------- - -REQUIRED_CMDS := cargo -RUST_TARGETS := all build release check \ - test test-unit \ - test-schema test-integration test-conformance \ - test-security test-security-suite test-resilience test-smoke \ - test-config-validation test-config \ - bench \ - lint fmt doc audit coverage coverage-check \ - run-echo run-debug -NIGHTLY_TARGETS := lint fmt fuzz fuzz-build -CMAKE_TARGETS := all build release check \ - test test-unit \ - test-schema test-integration test-conformance \ - test-security test-security-suite test-resilience test-smoke \ - test-config-validation test-config \ - bench \ - lint doc coverage coverage-check \ - run-echo run-debug \ - fuzz fuzz-build - ifneq ($(V),) _NOCAPTURE := -- --nocapture endif .PHONY: all build release check clean \ - test test-unit \ - test-schema test-integration test-conformance \ - test-security test-security-suite test-resilience test-smoke \ - bench \ - lint fmt doc audit coverage coverage-check \ - fuzz fuzz-build \ + test test-unit test-schema test-integration test-inference-fixtures \ + test-postgres-unit test-postgres-integration \ + openai-conformance check-openai-conformance-reference test-openai-conformance \ + lint fmt doc audit coverage-check \ require-container-engine \ container container-run \ - test-container test-container-run \ - run-echo run-debug \ - tools clean-tools \ - check-prereqs \ - check-prereqs-cmake \ - check-prereqs-nightly \ - setup-hooks \ - help - -# Uses --version rather than command -v so we catch broken installs. -check-prereqs: - @for cmd in $(REQUIRED_CMDS); do \ - $$cmd --version >/dev/null 2>&1 || { \ - echo "\"$$cmd\" is not installed or broken — install/reinstall it before running make (see docs/development.md)" >&2; \ - exit 1; \ - }; \ - done -check-prereqs-cmake: check-prereqs - @cmake --version >/dev/null 2>&1 || { \ - echo "\"cmake\" is not installed or broken — install/reinstall it before running make (see docs/development.md)" >&2; \ - exit 1; \ - } -check-prereqs-nightly: check-prereqs - @cargo +nightly --version >/dev/null 2>&1 || { \ - echo "Rust nightly toolchain is not installed — run \"rustup toolchain install nightly\" (see docs/development.md)" >&2; \ - exit 1; \ - } - -$(RUST_TARGETS): check-prereqs -$(CMAKE_TARGETS): check-prereqs-cmake -$(NIGHTLY_TARGETS): check-prereqs-nightly + setup-hooks help \ + patch-praxis unpatch-praxis # ------------------------------------------------------------------- # All # ------------------------------------------------------------------- -all: build fmt lint test audit container +all: build fmt lint test audit # ------------------------------------------------------------------- # Build @@ -91,8 +34,6 @@ all: build fmt lint test audit container build: cargo build --workspace - cargo build --workspace --benches - cargo build --manifest-path tests/fuzz/Cargo.toml release: cargo build --workspace --release @@ -118,31 +59,18 @@ container: | require-container-engine container-run: | require-container-engine $(CONTAINER_ENGINE) run --rm --network=host $(IMAGE):$(VERSION) 2>&1 -# ------------------------------------------------------------------- -# Test Container -# ------------------------------------------------------------------- - -test-container: | require-container-engine - $(CONTAINER_ENGINE) build \ - $(if $(findstring podman,$(CONTAINER_ENGINE)),--ignorefile Containerfile.test.dockerignore) \ - -t $(IMAGE)-test:$(VERSION) -f Containerfile.test . - -test-container-run: test-container - $(CONTAINER_ENGINE) run --rm -v $(CURDIR):/src -v praxis-test-cache:/cache \ - $(IMAGE)-test:$(VERSION) 2>&1 - # ------------------------------------------------------------------- # Test # ------------------------------------------------------------------- -test: $(H2SPEC) - PATH="$(BINUTILS_PATH):$(PATH)" cargo test --workspace $(_NOCAPTURE) +test: + cargo test --workspace $(_NOCAPTURE) test-unit: - cargo test -p praxis-proxy-core $(_NOCAPTURE) - cargo test -p praxis-proxy-filter $(_NOCAPTURE) - cargo test -p praxis-proxy-protocol $(_NOCAPTURE) - cargo test -p praxis $(_NOCAPTURE) + cargo test -p praxis-ai-apis $(_NOCAPTURE) + cargo test -p praxis-ai-filters $(_NOCAPTURE) + cargo test -p praxis-ai-proxy $(_NOCAPTURE) + cargo test -p praxis-ai-build-support $(_NOCAPTURE) test-schema: cargo test -p praxis-tests-schema $(_NOCAPTURE) @@ -150,45 +78,24 @@ test-schema: test-integration: cargo test -p praxis-tests-integration $(_NOCAPTURE) -test-conformance: $(H2SPEC) - PATH="$(BINUTILS_PATH):$(PATH)" cargo test -p praxis-tests-conformance $(_NOCAPTURE) - -test-security: test-security-suite - -test-security-suite: - cargo test -p praxis-tests-security $(_NOCAPTURE) +test-inference-fixtures: + cargo test -p praxis-test-utils $(_NOCAPTURE) + cargo test -p xtask inference_fixtures $(_NOCAPTURE) + cargo test -p praxis-tests-integration --test suite inference_fixtures $(_NOCAPTURE) -test-resilience: - cargo test -p praxis-tests-resilience $(_NOCAPTURE) +test-postgres-unit: + cargo test -p praxis-ai-apis store::tests::pg_ -- --ignored $(_NOCAPTURE) -test-config-validation: test-schema +test-postgres-integration: + cargo test -p praxis-tests-integration --test suite openai_response_store_postgres -- --ignored $(_NOCAPTURE) -test-config: test-schema +openai-conformance: + cargo xtask openai-conformance $(OPENAI_CONFORMANCE_ARGS) -test-smoke: - cargo test -p praxis-tests-smoke $(_NOCAPTURE) +check-openai-conformance-reference: + cargo xtask openai-conformance-reference --check -# ------------------------------------------------------------------- -# Bench -# ------------------------------------------------------------------- - -bench: $(VEGETA) $(FORTIO_DEP) - PATH="$(BINUTILS_PATH):$(PATH)" cargo bench -p benchmarks - -# ------------------------------------------------------------------- -# Fuzz -# ------------------------------------------------------------------- - -FUZZ_DURATION ?= 120 - -fuzz: - cargo +nightly fuzz run --fuzz-dir tests/fuzz fuzz_sni -- -max_total_time=$(FUZZ_DURATION) - cargo +nightly fuzz run --fuzz-dir tests/fuzz fuzz_path_sanitize -- -max_total_time=$(FUZZ_DURATION) - cargo +nightly fuzz run --fuzz-dir tests/fuzz fuzz_config_parse -- -max_total_time=$(FUZZ_DURATION) - cargo +nightly fuzz run --fuzz-dir tests/fuzz fuzz_filter_pipeline -- -max_total_time=$(FUZZ_DURATION) - -fuzz-build: - cargo +nightly fuzz build --fuzz-dir tests/fuzz +test-openai-conformance: openai-conformance # ------------------------------------------------------------------- # Quality @@ -197,7 +104,16 @@ fuzz-build: lint: cargo clippy --workspace --all-targets -- -D warnings cargo +nightly fmt --all -- --check + cargo machete --with-metadata . cargo xtask lint-deps + cargo xtask lint-separators + cargo xtask lint-filter-docs + cargo xtask lint-example-tests + cargo xtask lint-markdown-links + cargo xtask sync-example-readme + cargo xtask sync-inference-readme + cargo xtask sync-responses-readme + cargo xtask check-inference fmt: cargo +nightly fmt --all @@ -209,127 +125,54 @@ audit: cargo audit cargo deny check -coverage: - cargo llvm-cov --workspace --html --output-dir target/coverage \ - --exclude praxis-tests-conformance \ - --ignore-filename-regex '(target/|tests/|xtask/|benchmarks/)' \ - --fail-under-lines 90 - coverage-check: cargo llvm-cov --workspace --json \ - --exclude praxis-tests-conformance \ - --ignore-filename-regex '(target/|tests/|xtask/|benchmarks/)' \ - --fail-under-lines 90 \ + --exclude xtask \ + --ignore-filename-regex '(target/|tests/)' \ --output-path coverage.json + @LINE_PCT=$$(jq '.data[0].totals.lines.percent' coverage.json); \ + echo "Line coverage: $${LINE_PCT}%"; \ + if [ $$(echo "$${LINE_PCT} < 95" | bc -l) -eq 1 ]; then \ + echo "FAIL: coverage $${LINE_PCT}% is below 95% threshold"; \ + exit 1; \ + fi # ------------------------------------------------------------------- -# Dev Setup -# ------------------------------------------------------------------- - -setup-hooks: - ln -sf ../../.hooks/pre-commit .git/hooks/pre-commit - @echo "Git hooks installed." - -# ------------------------------------------------------------------- -# Dev tools +# Praxis path override (test against local ../praxis) # ------------------------------------------------------------------- -run-echo: - cargo xtask echo - -run-debug: - cargo xtask debug +patch-praxis: + @if [ ! -d "../praxis" ]; then \ + echo "ERROR: ../praxis not found — clone praxis core as a sibling directory first"; \ + exit 1; \ + fi + @if grep -q '\[patch\.crates-io\]' Cargo.toml; then \ + echo "Already patched — run 'make unpatch-praxis' first"; \ + exit 1; \ + fi + @printf '\n[patch.crates-io]\n\ + praxis-proxy-core = { path = "../praxis/core" }\n\ + praxis-proxy-filter = { path = "../praxis/filter" }\n\ + praxis-proxy-protocol = { path = "../praxis/protocol" }\n\ + praxis-proxy-tls = { path = "../praxis/tls" }\n\ + praxis-proxy = { path = "../praxis/server" }\n' >> Cargo.toml + @echo "Patched Cargo.toml to use ../praxis path dependencies" + +unpatch-praxis: + @if ! grep -q '\[patch\.crates-io\]' Cargo.toml; then \ + echo "Nothing to unpatch"; \ + exit 0; \ + fi + @sed -i.bak '/^\[patch\.crates-io\]/,$$d' Cargo.toml && rm -f Cargo.toml.bak + @echo "Removed [patch.crates-io] from Cargo.toml" # ------------------------------------------------------------------- -# Binutils +# Dev Setup # ------------------------------------------------------------------- -BINUTILS_DIR ?= target/praxis-binutils -BINUTILS_PATH := $(abspath $(BINUTILS_DIR)) - -H2SPEC_VERSION := 2.6.0 -VEGETA_VERSION := 12.13.0 -FORTIO_VERSION := 1.75.1 - -H2SPEC := $(BINUTILS_DIR)/h2spec -VEGETA := $(BINUTILS_DIR)/vegeta -FORTIO := $(BINUTILS_DIR)/fortio - -# The MacOS / OSX sha256 command does not support the needed options. -# On Mac, `brew install coreutils` provides gsha256sum. -SHA256SUM := sha256sum -ifeq ($(UNAME_S),darwin) - SHA256SUM := gsha256sum -endif - - -# Map architecture names -ifeq ($(UNAME_M),x86_64) - ARCH_GO := amd64 -else ifeq ($(UNAME_M),aarch64) - ARCH_GO := arm64 -else - ARCH_GO := $(UNAME_M) -endif - -$(BINUTILS_DIR): - mkdir -p $(BINUTILS_DIR) - -H2SPEC_SHA256_linux_amd64 := 157ee0de702e01ad40e752dbf074b366027e550c8e7504f9450da2809e279318 -H2SPEC_SHA256_darwin_amd64 := 981cb9f90a6f5e36300063022bd4eb7438d3dcf66d63a146a8541359697d1601 - -# h2spec has no arm64 builds; fall back to amd64. -ifeq ($(ARCH_GO),arm64) - H2SPEC_ARCH := amd64 -else - H2SPEC_ARCH := $(ARCH_GO) -endif - -H2SPEC_SHA256 := $(H2SPEC_SHA256_$(UNAME_S)_$(H2SPEC_ARCH)) - -$(H2SPEC): | $(BINUTILS_DIR) - curl -sSfL -o $(BINUTILS_DIR)/h2spec.tar.gz \ - https://github.com/summerwind/h2spec/releases/download/v$(H2SPEC_VERSION)/h2spec_$(UNAME_S)_$(H2SPEC_ARCH).tar.gz - $(if $(H2SPEC_SHA256),echo "$(H2SPEC_SHA256) $(BINUTILS_DIR)/h2spec.tar.gz" | $(SHA256SUM) -c,) - tar xz -C $(BINUTILS_DIR) -f $(BINUTILS_DIR)/h2spec.tar.gz h2spec - rm -f $(BINUTILS_DIR)/h2spec.tar.gz - -VEGETA_SHA256_linux_amd64 := e8759ce45c14e18374bdccd3ba6068197bc3a9f9b7e484db3837f701b9d12e61 -VEGETA_SHA256_linux_arm64 := 950381173a5575e25e8e086f36fc03bf65d61a2433329b48e41e1cb5e4133bba -VEGETA_SHA256_darwin_amd64 := 4e912c83ce07db4e1e394e1cbb657f2396dff2f7ed90f03869a184cc17d0f994 -VEGETA_SHA256_darwin_arm64 := fc408e242c4f4839e6fe536dbf1130bb02f430134827f6d831bf367a0929a799 -VEGETA_SHA256 := $(VEGETA_SHA256_$(UNAME_S)_$(ARCH_GO)) - -$(VEGETA): | $(BINUTILS_DIR) - curl -sSfL -o $(BINUTILS_DIR)/vegeta.tar.gz \ - https://github.com/tsenart/vegeta/releases/download/v$(VEGETA_VERSION)/vegeta_$(VEGETA_VERSION)_$(UNAME_S)_$(ARCH_GO).tar.gz - $(if $(VEGETA_SHA256),echo "$(VEGETA_SHA256) $(BINUTILS_DIR)/vegeta.tar.gz" | $(SHA256SUM) -c,) - tar xz -C $(BINUTILS_DIR) -f $(BINUTILS_DIR)/vegeta.tar.gz vegeta - rm -f $(BINUTILS_DIR)/vegeta.tar.gz - -FORTIO_SHA256_linux_amd64 := 92da34238dee258191a9dc6691c8bc75305b308951e934e2c3b4e658db0d77d1 -FORTIO_SHA256_linux_arm64 := f66275a56ef41e9a5afb2ea8181eb53ca36b34c6d19a201b58aec17dbe95a853 -FORTIO_SHA256 := $(FORTIO_SHA256_$(UNAME_S)_$(ARCH_GO)) - -$(FORTIO): | $(BINUTILS_DIR) - curl -sSfL -o $(BINUTILS_DIR)/fortio.tgz \ - https://github.com/fortio/fortio/releases/download/v$(FORTIO_VERSION)/fortio-$(UNAME_S)_$(ARCH_GO)-$(FORTIO_VERSION).tgz - $(if $(FORTIO_SHA256),echo "$(FORTIO_SHA256) $(BINUTILS_DIR)/fortio.tgz" | $(SHA256SUM) -c,) - tar xz -C $(BINUTILS_DIR) -f $(BINUTILS_DIR)/fortio.tgz usr/bin/fortio --strip-components=2 - rm -f $(BINUTILS_DIR)/fortio.tgz - -# Fortio builds are not available on GitHub for Darwin (Mac OSX). -# On Mac, use `brew install fortio` so it is on $PATH at bench time. -ifeq ($(UNAME_S),darwin) - FORTIO_DEP := -else - FORTIO_DEP := $(FORTIO) -endif - -tools: $(H2SPEC) $(VEGETA) $(FORTIO_DEP) - -clean-tools: - rm -rf $(BINUTILS_DIR) +setup-hooks: + ln -sf ../../.hooks/pre-commit .git/hooks/pre-commit + @echo "Git hooks installed." # ------------------------------------------------------------------- # Help @@ -350,44 +193,25 @@ help: @echo "" @echo "Test:" @echo " test run all tests" - @echo " test-unit unit tests (core, filter, protocol, praxis)" - @echo " test-schema config validation + example tests" - @echo " test-integration integration tests only" - @echo " test-conformance conformance tests only" - @echo " test-security security test suite" - @echo " test-security-suite security tests only" - @echo " test-resilience resilience tests only" - @echo " test-config-validation alias for test-schema" - @echo " test-config alias for test-schema" - @echo " test-smoke smoke tests only" - @echo "" - @echo "Bench:" - @echo " bench Criterion micro-benchmarks" - @echo "" - @echo "Fuzz (requires cargo-fuzz + nightly):" - @echo " fuzz run all fuzz targets (FUZZ_DURATION=60)" - @echo " fuzz-build build fuzz targets without running" + @echo " test-unit unit tests (providers, filters, server)" + @echo " test-schema schema validation tests" + @echo " test-integration integration tests" + @echo " test-inference-fixtures inference fixture and replay tests" + @echo " test-postgres-unit postgres store unit tests (needs DATABASE_URL)" + @echo " test-postgres-integration postgres store integration tests (needs container engine)" + @echo " openai-conformance compare registered API areas with OpenAI's OpenAPI spec" + @echo " check-openai-conformance-reference verify the pinned complete OpenAI reference" @echo "" @echo "Quality:" - @echo " lint clippy + rustfmt check" + @echo " lint clippy + rustfmt + dependency, docs, and example checks" @echo " fmt format with nightly rustfmt" + @echo " doc rustdoc with warnings" @echo " audit cargo audit + cargo deny" - @echo " coverage HTML coverage report" - @echo " coverage-check fail if line coverage < 90%%" @echo "" @echo "Container:" - @echo " container build container image" + @echo " container build praxis-ai container image" @echo " container-run run container in foreground (host network)" - @echo " test-container build test container image" - @echo " test-container-run build and run test suite in container" - @echo "" - @echo "Binutils (target/praxis-binutils/):" - @echo " tools download all external CLI tools" - @echo " clean-tools remove downloaded tools" - @echo "" - @echo "Dev Setup:" - @echo " setup-hooks install git pre-commit hook (fmt + lint)" @echo "" - @echo "Dev tools:" - @echo " run-echo start echo server (xtask)" - @echo " run-debug start debug server (xtask)" + @echo "Praxis override:" + @echo " patch-praxis use ../praxis path deps instead of crates.io" + @echo " unpatch-praxis revert to crates.io praxis deps" diff --git a/README.md b/README.md index c87edc4e2b..532ec94abc 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,143 @@ -image +

+ Praxis AI +

-[![Tests](https://github.com/praxis-proxy/praxis/actions/workflows/tests.yaml/badge.svg)](https://github.com/praxis-proxy/praxis/actions/workflows/tests.yaml) -[![CodeQL](https://github.com/praxis-proxy/praxis/actions/workflows/codeql.yaml/badge.svg)](https://github.com/praxis-proxy/praxis/actions/workflows/codeql.yaml) -[![Conformance](https://github.com/praxis-proxy/praxis/actions/workflows/conformance.yaml/badge.svg)](https://github.com/praxis-proxy/praxis/actions/workflows/conformance.yaml) -[![Supply Chain](https://github.com/praxis-proxy/praxis/actions/workflows/supply-chain.yaml/badge.svg)](https://github.com/praxis-proxy/praxis/actions/workflows/supply-chain.yaml) -[![MSRV: 1.94](https://img.shields.io/badge/MSRV-1.94-brightgreen.svg)](https://blog.rust-lang.org/) +[![Tests](https://github.com/praxis-proxy/ai/actions/workflows/tests.yaml/badge.svg)](https://github.com/praxis-proxy/ai/actions/workflows/tests.yaml) +[![Coverage: ≥95%](https://img.shields.io/badge/Coverage-≥95%25-brightgreen.svg)](https://github.com/praxis-proxy/ai/actions/workflows/coverage.yaml) +[![MSRV: 1.96](https://img.shields.io/badge/MSRV-1.96-brightgreen.svg)](https://blog.rust-lang.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -Praxis is a high-performance and security-first proxy server and framework for AI and cloud-native workloads. +**Praxis AI is a programmable gateway for AI traffic.** It extends +[Praxis](https://github.com/praxis-proxy/praxis) with provider-aware filters, +agentic protocols, response storage, guardrails, and observability—all +configured at the proxy layer. -## Getting Started +Use it to route requests by model or API format, translate between provider +protocols, enrich prompts, manage OpenAI Responses state, broker MCP and A2A +traffic, and expose token usage without coupling those concerns to every +application. -- [Quickstart](docs/quickstart.md) -- [Example configs](examples/README.md) +## What can it do? -## Documentation +- **Route AI traffic** by model, provider format, tool composition, or other + facts extracted from a request. +- **Bridge provider APIs** with support for OpenAI Responses and Conversations, + Anthropic Messages, and streaming event translation. +- **Support agentic workloads** through MCP and A2A classification, brokering, + and routing. +- **Add gateway capabilities** such as credential injection, prompt enrichment, + external guardrails, token accounting, and SQLite or PostgreSQL response + storage. +- **Stay extensible** with custom Rust filters built on Praxis's `HttpFilter` + interface. -Full documentation index: [docs/README.md](docs/README.md) +See the [complete feature overview](docs/features.md) and +[filter reference](docs/filters/README.md) for the full list. -- [Configuration](docs/operating/configuration.md) -- [Features](docs/features.md) -- [Filters](docs/filters/README.md) -- [Extensions](docs/filters/extensions.md) -- [TLS](docs/operating/tls.md) -- [Security Hardening](docs/operating/security-hardening.md) +## Architecture + +Clients keep their provider-native protocols while Praxis AI classifies, +transforms, and routes traffic through one policy-driven gateway. + +![Praxis AI architecture](assets/praxis-ai-architecture.svg) + +### Why is this separate from praxis-proxy/praxis? + +The [praxis-proxy/praxis] repository is considered the "core" framework +and standard server build for Praxis. + +The separation of repositories for clear contiguous themes in the Praxis +organization is a **very explicit** choice at the core of Praxis architectural +philosophy. We practice a very diligent separation of concerns. This +particular separation provides: + +* This Cleanly separates different technical domains and competencies for + our contributors. +* The `praxis-proxy/praxis` core repo no need for dependency on `ai`: we + explicitly support standard use cases without AI. +* Reduces the tendency for overreach from one subsystem to another, which + encourages cleaner APIs and library surfaces. +* AI capabilities generally move and change **much faster** than the standard + networking proxy technology in core, as that ecosystem is very mature. + +See our [conventions] for more details on our development practices and +philosophies. + +[praxis-proxy/praxis]:https://github.com/praxis-proxy/praxis +[conventions]:https://github.com/praxis-proxy/conventions + +## Quick start + +Build and start the gateway with its built-in configuration: + +```console +make release +./target/release/praxis-ai +``` + +Then check that it is running: + +```console +curl http://127.0.0.1:8080/ +``` + +```json +{"status": "ok", "server": "praxis-ai"} +``` + +Ready to connect a backend? Follow the [quickstart](docs/quickstart.md), or +choose from the [example configurations](examples/README.md) for OpenAI, +Anthropic, MCP, A2A, routing, guardrails, token usage, and more. + +## Learn your way around + +| If you want to… | Start here | +| --- | --- | +| Run Praxis AI locally | [Quickstart](docs/quickstart.md) | +| Browse supported capabilities | [Feature overview](docs/features.md) | +| Configure a filter | [Filter reference](docs/filters/README.md) | +| Understand the design | [Architecture docs](docs/README.md#architecture) | +| Build or test the workspace | [Development guide](docs/developing/getting-started.md) | +| Add a new filter | [Adding filters](docs/developing/adding-filters.md) | + +Praxis AI handles the AI-specific layer. For listeners, TLS, load balancing, +rate limiting, health checks, and other core proxy features, visit the +[Praxis repository](https://github.com/praxis-proxy/praxis). + +> [!IMPORTANT] +> Praxis AI is alpha software. APIs, configuration, and operational +> behavior may change before `v1.0.0`. See the [security policy] +> for the supported release line. + +Released container images are available from +[`ghcr.io/praxis-proxy/ai`][container images]. Source builds and local +development instructions are in the [development guide]. + +```console +docker pull ghcr.io/praxis-proxy/ai:0.1 +``` + +Podman can pull the same OCI image. See the [quickstart] for a source build +and the [release documentation] for image contents and tagging. ## Contributing -[Issues] and [pull requests] are welcome. Familiarize yourself -with the following documentation first: +Contributions are welcome, from bug reports and documentation fixes to new +filters and protocol support. Before opening a pull request, please read the +[contributing guide](CONTRIBUTING.md) and +[development setup](docs/developing/getting-started.md). -- [Architecture](docs/architecture/overview.md) -- [Conventions](docs/developing/conventions.md) -- [Development](docs/developing/getting-started.md) -- [Benchmarks](docs/benchmarks.md) +For larger changes, open a [feature request] and follow the +[proposal process](https://github.com/praxis-proxy/enhancements) so we can shape the idea together. -For larger changes, open a [discussion] and follow the -[proposal process](docs/proposals.md). +[Open an issue][issues] · [Request a feature][feature request] · +[Open a pull request][pull requests] -[Issues]:https://github.com/praxis-proxy/praxis/issues/new -[pull requests]:https://github.com/praxis-proxy/praxis/compare -[discussion]:https://github.com/praxis-proxy/praxis/discussions +[issues]: https://github.com/praxis-proxy/ai/issues/new +[pull requests]: https://github.com/praxis-proxy/ai/compare +[container images]: https://github.com/praxis-proxy/ai/pkgs/container/ai +[development guide]: docs/developing/getting-started.md +[feature request]: https://github.com/praxis-proxy/ai/issues/new?template=feature-request.yml +[quickstart]: docs/quickstart.md +[release documentation]: docs/release.md +[security policy]: SECURITY.md diff --git a/SECURITY.md b/SECURITY.md index 143b55962d..44e93d0b6d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,14 +2,13 @@ ## Supported Versions -| Version | Supported | -| --------- | ----------- | -| 0.1.x | No (Alpha) | -| 0.2.x | No (Alpha) | -| 0.3.x | No (Alpha) | +| Version | Supported | +| ------- | --------- | +| 0.1.x | Yes (Alpha) | -Only the latest patch release of each minor version -receives security updates. +Praxis AI is pre-`1.0.0`. Only the latest patch release of the current +minor line receives security updates. Older pre-release lines become +unsupported when a new minor line is released. ## Reporting a Vulnerability @@ -25,8 +24,9 @@ Include: ## Response Timeline -Prior to `v1.0.0` we will work with researchers individually on -timelines. After `v1.0.0` we will have a standardized response timeline. +Prior to `v1.0.0`, we coordinate disclosure and remediation timelines +with researchers individually. We will publish a standardized response +timeline before the first stable release. ## Severity Classification @@ -39,6 +39,8 @@ We use the following severity levels: ## Safe Harbor -We consider security research conducted in good faith to be authorized. We will not pursue legal action -against researchers who follow this policy and report findings responsibly. In fact, we really appreciate -the help in making Praxis more secure, thank you for your efforts! +We consider security research conducted in good faith +to be authorized. We will not pursue legal action +against researchers who follow this policy and report +findings responsibly. We appreciate your help making +Praxis AI more secure. diff --git a/apis/Cargo.toml b/apis/Cargo.toml new file mode 100644 index 0000000000..4261f14114 --- /dev/null +++ b/apis/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "praxis-ai-apis" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +description = "AI provider API types and persistence for Praxis" +license.workspace = true +repository.workspace = true +publish = false + +[lib] +name = "praxis_ai_apis" + +[features] +default = ["store"] +store = ["dep:sqlx", "dep:dashmap"] +praxis-main = [] + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +base64 = { workspace = true } +bytes = { workspace = true } +dashmap = { workspace = true, optional = true } +futures = { workspace = true } +http = { workspace = true } +percent-encoding = { workspace = true } +pingora-core = { workspace = true } +praxis-core = { workspace = true } +praxis-filter = { workspace = true } +reqwest = { workspace = true } +rmcp = { workspace = true } +secrecy = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +sqlx = { workspace = true, optional = true } +thiserror = { workspace = true } +tiktoken-rs = { workspace = true } +tokio = { workspace = true, features = ["rt", "time", "net"] } +tracing = { workspace = true } +url = { workspace = true } +utoipa = { workspace = true } + +[dev-dependencies] +axum = { workspace = true } +rmcp = { workspace = true, features = ["server", "macros", "transport-streamable-http-server"] } +schemars = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread"] } +tokio-util = { workspace = true } diff --git a/apis/src/anthropic/messages_format/config.rs b/apis/src/anthropic/messages_format/config.rs new file mode 100644 index 0000000000..6fb0e7c22e --- /dev/null +++ b/apis/src/anthropic/messages_format/config.rs @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Configuration types for the Anthropic Messages format classifier filter. + +use praxis_filter::{ + FilterError, + builtins::http::payload_processing::{ + OnInvalidBehavior, + config_validation::{validate_header_name, validate_max_body_bytes}, + }, +}; +use serde::Deserialize; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Default maximum request body size for `StreamBuffer` mode (1 MiB). +/// +/// Smaller than the OpenAI Responses default (10 MiB) because Anthropic +/// Messages API payloads are typically text-only and do not carry inline +/// file data URLs. Operators needing larger payloads can override via +/// `max_body_bytes` in config. +const DEFAULT_MAX_BODY_BYTES: usize = 1_048_576; // 1 MiB + +// ----------------------------------------------------------------------------- +// Behavior Enums +// ----------------------------------------------------------------------------- + +// ----------------------------------------------------------------------------- +// AnthropicMessagesFormatHeaders +// ----------------------------------------------------------------------------- + +/// Configurable header names for promoted classification facts. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AnthropicMessagesFormatHeaders { + /// Header name for the detected format. + #[serde(default = "default_format_header")] + pub format: Option, + + /// Header name for the extracted model value. + #[serde(default = "default_model_header")] + pub model: Option, + + /// Header name for the extracted stream flag. + #[serde(default = "default_stream_header")] + pub stream: Option, +} + +impl Default for AnthropicMessagesFormatHeaders { + fn default() -> Self { + Self { + format: default_format_header(), + model: default_model_header(), + stream: default_stream_header(), + } + } +} + +/// Default format header name. +#[expect( + clippy::unnecessary_wraps, + reason = "serde default functions require Option return type" +)] +fn default_format_header() -> Option { + Some("x-praxis-ai-format".to_owned()) +} + +/// Default model header name. +#[expect( + clippy::unnecessary_wraps, + reason = "serde default functions require Option return type" +)] +fn default_model_header() -> Option { + Some("x-praxis-ai-model".to_owned()) +} + +/// Default stream header name. +#[expect( + clippy::unnecessary_wraps, + reason = "serde default functions require Option return type" +)] +fn default_stream_header() -> Option { + Some("x-praxis-ai-stream".to_owned()) +} + +// ----------------------------------------------------------------------------- +// AnthropicMessagesFormatConfig +// ----------------------------------------------------------------------------- + +/// YAML configuration for the [`AnthropicMessagesFormatFilter`]. +/// +/// [`AnthropicMessagesFormatFilter`]: super::AnthropicMessagesFormatFilter +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AnthropicMessagesFormatConfig { + /// Behavior when the body cannot be classified. + #[serde(default = "OnInvalidBehavior::default_continue")] + pub on_invalid: OnInvalidBehavior, + + /// Maximum body size in bytes for `StreamBuffer` mode. + #[serde(default = "default_max_body_bytes")] + pub max_body_bytes: usize, + + /// Header names for promoted classification facts. + #[serde(default)] + pub headers: AnthropicMessagesFormatHeaders, +} + +/// Default max body bytes. +fn default_max_body_bytes() -> usize { + DEFAULT_MAX_BODY_BYTES +} + +// ----------------------------------------------------------------------------- +// Config Validation +// ----------------------------------------------------------------------------- + +/// Validate the parsed configuration. +pub(crate) fn build_config(cfg: AnthropicMessagesFormatConfig) -> Result { + validate_max_body_bytes("anthropic_messages_format", cfg.max_body_bytes)?; + + validate_header_name("anthropic_messages_format", "format", cfg.headers.format.as_deref())?; + validate_header_name("anthropic_messages_format", "model", cfg.headers.model.as_deref())?; + validate_header_name("anthropic_messages_format", "stream", cfg.headers.stream.as_deref())?; + + Ok(cfg) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::needless_raw_strings, + clippy::needless_raw_string_hashes, + reason = "tests" +)] +mod tests { + use super::*; + + // -- Serde defaults ------------------------------------------------------- + + #[test] + fn serde_defaults_anthropic_messages_format_config() { + let cfg: AnthropicMessagesFormatConfig = serde_yaml::from_str("{}").unwrap(); + + assert_eq!(cfg.max_body_bytes, 1_048_576, "default should be 1 MiB"); + assert_eq!(cfg.on_invalid, OnInvalidBehavior::Continue); + } + + #[test] + fn default_max_body_bytes_is_1_mib() { + assert_eq!(DEFAULT_MAX_BODY_BYTES, 1_048_576); + } + + #[test] + fn anthropic_messages_format_headers_defaults() { + let h = AnthropicMessagesFormatHeaders::default(); + assert_eq!(h.format.as_deref(), Some("x-praxis-ai-format")); + assert_eq!(h.model.as_deref(), Some("x-praxis-ai-model")); + assert_eq!(h.stream.as_deref(), Some("x-praxis-ai-stream")); + } + + // -- deny_unknown_fields -------------------------------------------------- + + #[test] + fn deny_unknown_fields_anthropic_messages_format_config() { + let res = serde_yaml::from_str::( + r#" +bogus: true +"#, + ); + assert!(res.is_err()); + } + + #[test] + fn deny_unknown_fields_anthropic_messages_format_headers() { + let res = serde_yaml::from_str::( + r#" +format: x-test +extra: true +"#, + ); + assert!(res.is_err()); + } + + // -- build_config --------------------------------------------------------- + + #[test] + fn build_config_minimal_ok() { + let cfg: AnthropicMessagesFormatConfig = serde_yaml::from_str("{}").unwrap(); + assert!(build_config(cfg).is_ok()); + } + + #[test] + fn build_config_zero_max_body_bytes_rejected() { + let cfg = AnthropicMessagesFormatConfig { + on_invalid: OnInvalidBehavior::default_continue(), + max_body_bytes: 0, + headers: AnthropicMessagesFormatHeaders::default(), + }; + let err = build_config(cfg).unwrap_err(); + assert!( + err.to_string().contains("must be greater than 0"), + "expected 'must be greater than 0' error, got: {err}" + ); + } + + #[test] + fn build_config_invalid_header_name_rejected() { + let cfg = AnthropicMessagesFormatConfig { + on_invalid: OnInvalidBehavior::default_continue(), + max_body_bytes: DEFAULT_MAX_BODY_BYTES, + headers: AnthropicMessagesFormatHeaders { + format: Some("not a valid header!".into()), + model: default_model_header(), + stream: default_stream_header(), + }, + }; + let err = build_config(cfg).unwrap_err(); + assert!( + err.to_string().contains("not a valid HTTP header name"), + "expected invalid header error, got: {err}" + ); + } + + #[test] + fn build_config_valid_custom_headers_ok() { + let cfg = AnthropicMessagesFormatConfig { + on_invalid: OnInvalidBehavior::default_continue(), + max_body_bytes: DEFAULT_MAX_BODY_BYTES, + headers: AnthropicMessagesFormatHeaders { + format: Some("x-custom-format".into()), + model: Some("x-custom-model".into()), + stream: Some("x-custom-stream".into()), + }, + }; + assert!(build_config(cfg).is_ok()); + } + + // -- null header disables promotion --------------------------------------- + + #[test] + fn null_header_disables_promotion() { + let cfg: AnthropicMessagesFormatConfig = serde_yaml::from_str( + r#" +headers: + format: null + model: null + stream: null +"#, + ) + .unwrap(); + + assert!(cfg.headers.format.is_none()); + assert!(cfg.headers.model.is_none()); + assert!(cfg.headers.stream.is_none()); + assert!(build_config(cfg).is_ok()); + } +} diff --git a/apis/src/anthropic/messages_format/mod.rs b/apis/src/anthropic/messages_format/mod.rs new file mode 100644 index 0000000000..af4d955db0 --- /dev/null +++ b/apis/src/anthropic/messages_format/mod.rs @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Anthropic Messages API format classifier filter. +//! +//! Detects Anthropic Messages API requests using the shared +//! [`AiRequestFormat`] classifier and promotes routing facts to +//! configurable headers, durable metadata, and filter results. +//! Uses `anthropic-version` header as a boost signal when +//! body-only heuristics are ambiguous. +//! +//! [`AiRequestFormat`]: crate::classifier::AiRequestFormat + +mod config; + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + clippy::needless_raw_strings, + clippy::needless_raw_string_hashes, + clippy::too_many_lines, + reason = "tests" +)] +mod tests; + +use std::borrow::Cow; + +use async_trait::async_trait; +use bytes::Bytes; +use praxis_filter::{ + BodyAccess, BodyMode, FilterAction, FilterError, HttpFilter, HttpFilterContext, + builtins::http::payload_processing::OnInvalidBehavior, parse_filter_config, +}; +use tracing::{debug, trace}; + +use self::config::{AnthropicMessagesFormatConfig, build_config}; +use crate::{ + anthropic::wire, + classifier::{AiRequestFormat, ClassifiedRequest, classify_request_body}, + promotion::is_promotable_value, +}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Header name sent by Anthropic SDK clients. +const ANTHROPIC_VERSION_HEADER: &str = "anthropic-version"; + +// ----------------------------------------------------------------------------- +// AnthropicMessagesFormatFilter +// ----------------------------------------------------------------------------- + +/// Classifies Anthropic Messages API requests and promotes routing +/// facts to headers, metadata, and filter results. +/// +/// # YAML +/// +/// ```yaml +/// filter: anthropic_messages_format +/// ``` +/// +/// # Full YAML +/// +/// ```yaml +/// filter: anthropic_messages_format +/// on_invalid: continue +/// max_body_bytes: 1048576 +/// headers: +/// format: x-praxis-ai-format +/// model: x-praxis-ai-model +/// stream: x-praxis-ai-stream +/// ``` +pub struct AnthropicMessagesFormatFilter { + /// Parsed and validated configuration. + config: AnthropicMessagesFormatConfig, +} + +impl AnthropicMessagesFormatFilter { + /// Create a filter from parsed YAML config. + /// + /// # Errors + /// + /// Returns [`FilterError`] if the YAML config is invalid. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: AnthropicMessagesFormatConfig = parse_filter_config("anthropic_messages_format", config)?; + let validated = build_config(cfg)?; + Ok(Box::new(Self { config: validated })) + } +} + +#[async_trait] +impl HttpFilter for AnthropicMessagesFormatFilter { + fn name(&self) -> &'static str { + "anthropic_messages_format" + } + + fn request_body_access(&self) -> BodyAccess { + BodyAccess::ReadOnly + } + + fn request_body_mode(&self) -> BodyMode { + BodyMode::StreamBuffer { + max_bytes: Some(self.config.max_body_bytes), + } + } + + async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result { + Ok(FilterAction::Continue) + } + + async fn on_request_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + if !end_of_stream { + return Ok(FilterAction::Continue); + } + + let bytes = match body.as_ref() { + Some(b) => b.as_ref(), + None => &[], + }; + + let mut classified = classify_request_body(bytes); + + let has_anthropic_header = ctx.request.headers.get(ANTHROPIC_VERSION_HEADER).is_some(); + let is_messages_path = is_anthropic_messages_path(ctx.request.uri.path()); + + // Only ChatCompletions is eligible for reclassification: its + // body shape (`messages` without required `max_tokens`) overlaps + // with Anthropic Messages. UnknownJson is not boosted because + // the body lacked `messages` entirely, so the Anthropic signal + // is too weak to override. + if classified.format == AiRequestFormat::ChatCompletions && (has_anthropic_header || is_messages_path) { + classified.format = AiRequestFormat::AnthropicMessages; + } + + debug!( + format = classified.format.as_str(), + model = ?classified.model, + anthropic_header = has_anthropic_header, + messages_path = is_messages_path, + "classified anthropic request body" + ); + + if let Some(action) = handle_invalid_format(classified.format, &self.config) { + return Ok(action); + } + + write_metadata(ctx, &classified); + promote_headers(ctx, &classified, &self.config); + promote_filter_results(ctx, &classified)?; + + Ok(FilterAction::Release) + } +} + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +/// Check whether the format requires rejection. +fn handle_invalid_format(format: AiRequestFormat, config: &AnthropicMessagesFormatConfig) -> Option { + match config.on_invalid { + OnInvalidBehavior::Continue => None, + OnInvalidBehavior::Reject | OnInvalidBehavior::Error => { + let message = match format { + AiRequestFormat::InvalidJson => "invalid JSON body", + AiRequestFormat::NonJson => "request body is not JSON", + AiRequestFormat::UnknownJson => "unrecognized AI API format", + AiRequestFormat::Responses | AiRequestFormat::AnthropicMessages | AiRequestFormat::ChatCompletions => { + return None; + }, + }; + + trace!(reason = message, "rejecting unrecognized body"); + Some(FilterAction::Reject(wire::invalid_request_rejection(message))) + }, + } +} + +/// Write durable metadata. +fn write_metadata(ctx: &mut HttpFilterContext<'_>, classified: &ClassifiedRequest) { + ctx.set_metadata("anthropic_messages_format.format", classified.format.as_str()); + + if let Some(model) = &classified.model + && is_promotable_value(model) + { + ctx.set_metadata("anthropic_messages_format.model", model.clone()); + } + + if let Some(stream) = classified.stream { + ctx.set_metadata( + "anthropic_messages_format.stream", + if stream { "true" } else { "false" }, + ); + } + + if let Some(max_tokens) = classified.max_tokens { + ctx.set_metadata("anthropic_messages_format.max_tokens", max_tokens.to_string()); + } + + if classified.has_tools { + ctx.set_metadata("anthropic_messages_format.has_tools", "true"); + } +} + +/// Promote classification facts to configurable request headers. +fn promote_headers( + ctx: &mut HttpFilterContext<'_>, + classified: &ClassifiedRequest, + config: &AnthropicMessagesFormatConfig, +) { + if let Some(header) = &config.headers.format { + ctx.extra_request_headers + .push((Cow::Owned(header.clone()), classified.format.as_str().to_owned())); + } + + if let Some(header) = &config.headers.model + && let Some(model) = &classified.model + && is_promotable_value(model) + { + ctx.extra_request_headers + .push((Cow::Owned(header.clone()), model.clone())); + } + + if let Some(header) = &config.headers.stream + && let Some(stream) = classified.stream + { + let val = if stream { "true" } else { "false" }; + ctx.extra_request_headers + .push((Cow::Owned(header.clone()), val.to_owned())); + } +} + +/// Check whether the path is the Anthropic Messages endpoint, +/// normalizing a trailing slash. +fn is_anthropic_messages_path(path: &str) -> bool { + let normalized = path.strip_suffix('/').unwrap_or(path); + normalized == "/v1/messages" +} + +/// Promote classification facts to filter results for branch conditions. +fn promote_filter_results(ctx: &mut HttpFilterContext<'_>, classified: &ClassifiedRequest) -> Result<(), FilterError> { + let results = ctx.filter_results.entry("anthropic_messages_format").or_default(); + + results.set("format", classified.format.as_str())?; + + if let Some(model) = &classified.model + && is_promotable_value(model) + { + results.set("model", model.clone())?; + } + + if let Some(stream) = classified.stream { + results.set("stream", if stream { "true" } else { "false" })?; + } + + if let Some(max_tokens) = classified.max_tokens { + results.set("max_tokens", max_tokens.to_string())?; + } + + if classified.has_tools { + results.set("has_tools", "true")?; + } + + Ok(()) +} diff --git a/apis/src/anthropic/messages_format/tests.rs b/apis/src/anthropic/messages_format/tests.rs new file mode 100644 index 0000000000..2fe85afdb5 --- /dev/null +++ b/apis/src/anthropic/messages_format/tests.rs @@ -0,0 +1,469 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Unit tests for the `anthropic_messages_format` filter. + +use bytes::Bytes; + +use super::*; + +// ----------------------------------------------------------------------------- +// Config Parsing +// ----------------------------------------------------------------------------- + +#[test] +fn default_config_parses() { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let filter = AnthropicMessagesFormatFilter::from_config(&yaml).unwrap(); + assert_eq!( + filter.name(), + "anthropic_messages_format", + "filter name should be anthropic_messages_format" + ); +} + +#[test] +fn full_config_parses() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +on_invalid: reject +max_body_bytes: 65536 +headers: + format: x-custom-format + model: x-custom-model + stream: x-custom-stream +"#, + ) + .unwrap(); + let filter = AnthropicMessagesFormatFilter::from_config(&yaml).unwrap(); + assert_eq!( + filter.name(), + "anthropic_messages_format", + "filter should parse full config" + ); +} + +#[test] +fn zero_max_body_bytes_rejected() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_body_bytes: 0").unwrap(); + let result = AnthropicMessagesFormatFilter::from_config(&yaml); + assert!(result.is_err(), "zero max_body_bytes should be rejected"); +} + +#[test] +fn rejects_max_body_bytes_above_ceiling() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_body_bytes: 67108865").unwrap(); + let result = AnthropicMessagesFormatFilter::from_config(&yaml); + + assert!( + result.is_err(), + "max_body_bytes above 64 MiB ceiling should be rejected" + ); +} + +// ----------------------------------------------------------------------------- +// Handle Invalid Format +// ----------------------------------------------------------------------------- + +#[test] +fn anthropic_messages_not_rejected() { + let cfg: AnthropicMessagesFormatConfig = serde_yaml::from_str("on_invalid: reject").unwrap(); + let result = handle_invalid_format(AiRequestFormat::AnthropicMessages, &cfg); + assert!(result.is_none(), "anthropic_messages format should not be rejected"); +} + +#[test] +fn responses_not_rejected() { + let cfg: AnthropicMessagesFormatConfig = serde_yaml::from_str("on_invalid: reject").unwrap(); + let result = handle_invalid_format(AiRequestFormat::Responses, &cfg); + assert!(result.is_none(), "responses format should not be rejected"); +} + +#[test] +fn invalid_json_rejected_in_reject_mode() { + let cfg: AnthropicMessagesFormatConfig = serde_yaml::from_str("on_invalid: reject").unwrap(); + let result = handle_invalid_format(AiRequestFormat::InvalidJson, &cfg); + let Some(FilterAction::Reject(rejection)) = result else { + panic!("invalid JSON should be rejected in reject mode"); + }; + let parsed: serde_json::Value = serde_json::from_slice(rejection.body.as_deref().unwrap()).unwrap(); + + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "invalid_request_error"); + assert!(parsed.get("request_id").is_some()); + assert!(parsed["request_id"].is_null()); +} + +#[test] +fn non_json_rejected_in_reject_mode() { + let cfg: AnthropicMessagesFormatConfig = serde_yaml::from_str("on_invalid: reject").unwrap(); + let result = handle_invalid_format(AiRequestFormat::NonJson, &cfg); + assert!(result.is_some(), "non-JSON body should be rejected in reject mode"); +} + +#[test] +fn unknown_json_rejected_in_reject_mode() { + let cfg: AnthropicMessagesFormatConfig = serde_yaml::from_str("on_invalid: reject").unwrap(); + let result = handle_invalid_format(AiRequestFormat::UnknownJson, &cfg); + assert!(result.is_some(), "unknown JSON should be rejected in reject mode"); +} + +#[test] +fn invalid_json_continues_in_continue_mode() { + let cfg: AnthropicMessagesFormatConfig = serde_yaml::from_str("on_invalid: continue").unwrap(); + let result = handle_invalid_format(AiRequestFormat::InvalidJson, &cfg); + assert!(result.is_none(), "invalid JSON should pass in continue mode"); +} + +#[test] +fn non_json_continues_in_continue_mode() { + let cfg: AnthropicMessagesFormatConfig = serde_yaml::from_str("on_invalid: continue").unwrap(); + let result = handle_invalid_format(AiRequestFormat::NonJson, &cfg); + assert!(result.is_none(), "non-JSON body should pass in continue mode"); +} + +#[test] +fn unknown_json_continues_in_continue_mode() { + let cfg: AnthropicMessagesFormatConfig = serde_yaml::from_str("on_invalid: continue").unwrap(); + let result = handle_invalid_format(AiRequestFormat::UnknownJson, &cfg); + assert!(result.is_none(), "unknown JSON should pass in continue mode"); +} + +// ----------------------------------------------------------------------------- +// Promotion Tests +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn promotes_anthropic_messages_format() { + let ctx = run_filter( + "{}", + r#"{"model":"claude-opus-4-8","max_tokens":1024,"system":"You are helpful.","messages":[{"role":"user","content":"Hi"}]}"#, + ) + .await; + let headers = collect_headers(&ctx); + + assert_eq!( + headers.get("x-praxis-ai-format"), + Some(&"anthropic_messages"), + "format header should be anthropic_messages" + ); + assert_eq!( + headers.get("x-praxis-ai-model"), + Some(&"claude-opus-4-8"), + "model header" + ); +} + +#[tokio::test] +async fn promotes_metadata_for_anthropic_request() { + let ctx = run_filter( + "{}", + r#"{"model":"claude-opus-4-8","max_tokens":512,"system":"Be helpful.","messages":[{"role":"user","content":"Hi"}],"stream":true}"#, + ) + .await; + + assert_eq!( + ctx.filter_metadata + .get("anthropic_messages_format.format") + .map(String::as_str), + Some("anthropic_messages"), + "format metadata" + ); + assert_eq!( + ctx.filter_metadata + .get("anthropic_messages_format.model") + .map(String::as_str), + Some("claude-opus-4-8"), + "model metadata" + ); + assert_eq!( + ctx.filter_metadata + .get("anthropic_messages_format.stream") + .map(String::as_str), + Some("true"), + "stream metadata" + ); + assert_eq!( + ctx.filter_metadata + .get("anthropic_messages_format.max_tokens") + .map(String::as_str), + Some("512"), + "max_tokens metadata" + ); +} + +#[tokio::test] +async fn chat_completions_without_max_tokens_on_non_messages_path() { + let filter = make_filter("{}"); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/chat/completions"); + + let req: &'static praxis_filter::Request = Box::leak(Box::new(req)); + let mut ctx = crate::test_utils::make_filter_context(req); + let mut body = Some(Bytes::from( + r#"{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}]}"#, + )); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!(matches!(action, FilterAction::Release), "filter should release"); + + let headers = collect_headers(&ctx); + assert_eq!( + headers.get("x-praxis-ai-format"), + Some(&"openai_chat_completions"), + "messages without max_tokens on /v1/chat/completions should be chat_completions" + ); +} + +#[tokio::test] +async fn anthropic_version_header_overrides_body_heuristic() { + let filter = make_filter("{}"); + let mut req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + req.headers.insert("anthropic-version", "2023-06-01".parse().unwrap()); + + let req: &'static praxis_filter::Request = Box::leak(Box::new(req)); + let mut ctx = crate::test_utils::make_filter_context(req); + let mut body = Some(Bytes::from( + r#"{"model":"claude-opus-4-8","messages":[{"role":"user","content":"Hi"}]}"#, + )); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!(matches!(action, FilterAction::Release), "filter should release"); + + let headers = collect_headers(&ctx); + assert_eq!( + headers.get("x-praxis-ai-format"), + Some(&"anthropic_messages"), + "anthropic-version header should override to anthropic_messages" + ); +} + +#[tokio::test] +async fn minimal_messages_path_overrides_to_anthropic() { + let ctx = run_filter( + "{}", + r#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":"Hi"}]}"#, + ) + .await; + let headers = collect_headers(&ctx); + + assert_eq!( + headers.get("x-praxis-ai-format"), + Some(&"anthropic_messages"), + "minimal body on /v1/messages path should classify as anthropic_messages" + ); +} + +#[tokio::test] +async fn body_only_anthropic_classification_without_path_boost() { + let filter = make_filter("{}"); + let mut req = crate::test_utils::make_request(http::Method::POST, "/v1/some-other-path"); + req.headers.insert("anthropic-version", "2023-06-01".parse().unwrap()); + + let req: &'static praxis_filter::Request = Box::leak(Box::new(req)); + let mut ctx = crate::test_utils::make_filter_context(req); + let mut body = Some(Bytes::from( + r#"{"model":"claude-opus-4-8","max_tokens":1024,"system":"You are helpful.","messages":[{"role":"user","content":"Hi"}]}"#, + )); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!(matches!(action, FilterAction::Release), "filter should release"); + + let headers = collect_headers(&ctx); + assert_eq!( + headers.get("x-praxis-ai-format"), + Some(&"anthropic_messages"), + "anthropic-version header should classify as anthropic_messages without /v1/messages path" + ); +} + +#[tokio::test] +async fn trailing_slash_messages_path_classifies_as_anthropic() { + let filter = make_filter("{}"); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages/"); + + let req: &'static praxis_filter::Request = Box::leak(Box::new(req)); + let mut ctx = crate::test_utils::make_filter_context(req); + let mut body = Some(Bytes::from( + r#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":"Hi"}]}"#, + )); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!(matches!(action, FilterAction::Release), "filter should release"); + + let headers = collect_headers(&ctx); + assert_eq!( + headers.get("x-praxis-ai-format"), + Some(&"anthropic_messages"), + "/v1/messages/ with trailing slash should classify as anthropic_messages" + ); +} + +#[tokio::test] +async fn stream_false_promoted_to_metadata_and_header() { + let ctx = run_filter( + "{}", + r#"{"model":"claude-opus-4-8","max_tokens":1024,"system":"Hi","messages":[{"role":"user","content":"Hi"}],"stream":false}"#, + ) + .await; + + assert_eq!( + ctx.filter_metadata + .get("anthropic_messages_format.stream") + .map(String::as_str), + Some("false"), + "stream:false should be promoted to metadata" + ); + + let headers = collect_headers(&ctx); + assert_eq!( + headers.get("x-praxis-ai-stream"), + Some(&"false"), + "stream:false should be promoted to header" + ); +} + +#[tokio::test] +async fn null_header_config_suppresses_headers() { + let ctx = run_filter( + "headers:\n format: null\n model: null\n stream: null", + r#"{"model":"claude-opus-4-8","max_tokens":1024,"system":"Hi","messages":[{"role":"user","content":"Hi"}],"stream":true}"#, + ) + .await; + + let headers = collect_headers(&ctx); + assert!( + !headers.contains_key("x-praxis-ai-format"), + "null format header config should suppress format header" + ); + assert!( + !headers.contains_key("x-praxis-ai-model"), + "null model header config should suppress model header" + ); + assert!( + !headers.contains_key("x-praxis-ai-stream"), + "null stream header config should suppress stream header" + ); + + assert_eq!( + ctx.filter_metadata + .get("anthropic_messages_format.format") + .map(String::as_str), + Some("anthropic_messages"), + "metadata should still be written even with null header config" + ); +} + +// ----------------------------------------------------------------------------- +// Oversized / Unsafe Model Values +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn oversized_model_not_promoted_to_header_or_results_or_metadata() { + let long_model = "x".repeat(300); + let body_str = + format!(r#"{{"model":"{long_model}","max_tokens":1024,"messages":[{{"role":"user","content":"Hi"}}]}}"#,); + let ctx = run_filter("{}", &body_str).await; + let headers = collect_headers(&ctx); + + assert!( + !headers.contains_key("x-praxis-ai-model"), + "oversized model not in header" + ); + let results = ctx.filter_results.get("anthropic_messages_format").unwrap(); + assert!(results.get("model").is_none(), "oversized model not in results"); + assert!( + !ctx.filter_metadata.contains_key("anthropic_messages_format.model"), + "oversized model not in metadata" + ); +} + +// ----------------------------------------------------------------------------- +// Body Parsing Edge Cases +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn partial_body_before_eos_continues() { + let filter = make_filter("{}"); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + + let req: &'static praxis_filter::Request = Box::leak(Box::new(req)); + let mut ctx = crate::test_utils::make_filter_context(req); + let mut body = Some(Bytes::from(r#"{"model":"claude-opus-4-8","max_tok"#)); + + let action = filter.on_request_body(&mut ctx, &mut body, false).await.unwrap(); + + assert!( + matches!(action, FilterAction::Continue), + "non-EOS body should return Continue" + ); + assert!( + ctx.extra_request_headers.is_empty(), + "no headers should be promoted before EOS" + ); +} + +#[tokio::test] +async fn none_body_at_eos_continues() { + let filter = make_filter("{}"); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + + let req: &'static praxis_filter::Request = Box::leak(Box::new(req)); + let mut ctx = crate::test_utils::make_filter_context(req); + let mut body: Option = None; + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + assert!( + !matches!(action, FilterAction::Reject(_)), + "None body with default on_invalid:continue should not reject" + ); +} + +#[tokio::test] +async fn on_request_body_rejects_malformed_json() { + let filter = make_filter("on_invalid: reject"); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + + let req: &'static praxis_filter::Request = Box::leak(Box::new(req)); + let mut ctx = crate::test_utils::make_filter_context(req); + let mut body = Some(Bytes::from("not json {{{")); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + assert!( + matches!(action, FilterAction::Reject(_)), + "malformed JSON at EOS should be rejected in reject mode" + ); +} + +// ----------------------------------------------------------------------------- +// Test Utilities +// ----------------------------------------------------------------------------- + +/// Run the filter and return the resulting context. +async fn run_filter(config_yaml: &str, body_str: &str) -> HttpFilterContext<'static> { + let filter = make_filter(config_yaml); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + + let req: &'static praxis_filter::Request = Box::leak(Box::new(req)); + let mut ctx = crate::test_utils::make_filter_context(req); + let mut body = Some(Bytes::from(body_str.to_owned())); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!(matches!(action, FilterAction::Release), "filter should release"); + ctx +} + +/// Collect extra request headers into a map. +fn collect_headers<'a>(ctx: &'a HttpFilterContext<'_>) -> std::collections::HashMap<&'a str, &'a str> { + ctx.extra_request_headers + .iter() + .map(|(k, v)| (k.as_ref(), v.as_str())) + .collect() +} + +/// Build a filter from a YAML snippet. +fn make_filter(yaml_str: &str) -> Box { + let yaml: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap(); + AnthropicMessagesFormatFilter::from_config(&yaml).unwrap() +} diff --git a/apis/src/anthropic/mod.rs b/apis/src/anthropic/mod.rs new file mode 100644 index 0000000000..58c210cf92 --- /dev/null +++ b/apis/src/anthropic/mod.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Anthropic protocol filters. + +mod messages_format; +mod protocol; +mod stream_events; +pub(crate) mod to_openai; +mod validate; +mod web_search; +mod wire; + +pub use messages_format::AnthropicMessagesFormatFilter; +pub use protocol::AnthropicMessagesProtocolFilter; +pub use stream_events::AnthropicStreamEventsFilter; +pub use to_openai::AnthropicToOpenaiFilter; +pub use validate::AnthropicValidateFilter; +pub use web_search::AnthropicWebSearchFilter; diff --git a/apis/src/anthropic/protocol/config.rs b/apis/src/anthropic/protocol/config.rs new file mode 100644 index 0000000000..327829a6ae --- /dev/null +++ b/apis/src/anthropic/protocol/config.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Configuration for the Anthropic Messages protocol filter. + +use praxis_filter::FilterError; +use serde::Deserialize; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Default Anthropic API version header value. +const DEFAULT_VERSION: &str = "2023-06-01"; + +// ----------------------------------------------------------------------------- +// AnthropicMessagesProtocolConfig +// ----------------------------------------------------------------------------- + +/// YAML configuration for the [`AnthropicMessagesProtocolFilter`]. +/// +/// [`AnthropicMessagesProtocolFilter`]: super::AnthropicMessagesProtocolFilter +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AnthropicMessagesProtocolConfig { + /// Default `anthropic-version` header value when absent. + #[serde(default = "default_version")] + pub default_version: String, +} + +/// Default version string. +fn default_version() -> String { + DEFAULT_VERSION.to_owned() +} + +// ----------------------------------------------------------------------------- +// Config Validation +// ----------------------------------------------------------------------------- + +/// Validate the parsed configuration. +pub(crate) fn build_config( + cfg: AnthropicMessagesProtocolConfig, +) -> Result { + if cfg.default_version.is_empty() { + return Err("anthropic_messages_protocol: 'default_version' must not be empty".into()); + } + http::HeaderValue::from_str(&cfg.default_version).map_err(|e| -> FilterError { + format!("anthropic_messages_protocol: 'default_version' must be a valid header value: {e}").into() + })?; + Ok(cfg) +} diff --git a/apis/src/anthropic/protocol/mod.rs b/apis/src/anthropic/protocol/mod.rs new file mode 100644 index 0000000000..7861063bea --- /dev/null +++ b/apis/src/anthropic/protocol/mod.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Anthropic Messages protocol filter for native `/v1/messages` backends. +//! +//! Supplies a gateway-managed `anthropic-version` default for +//! internal or non-SDK callers while preserving caller-provided +//! `anthropic-version` and `anthropic-beta` headers across iterative +//! requests. Does not touch credentials or the request or response +//! body. + +mod config; + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "tests")] +mod tests; + +use std::borrow::Cow; + +use async_trait::async_trait; +use praxis_filter::{ + BodyAccess, BodyMode, FilterAction, FilterError, HttpFilter, HttpFilterContext, IterationState, parse_filter_config, +}; +use tracing::debug; + +use self::config::{AnthropicMessagesProtocolConfig, build_config}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Anthropic API version header name. +const ANTHROPIC_VERSION_HEADER: &str = "anthropic-version"; + +/// Anthropic beta feature header name. +const ANTHROPIC_BETA_HEADER: &str = "anthropic-beta"; + +// ----------------------------------------------------------------------------- +// AnthropicMessagesProtocolFilter +// ----------------------------------------------------------------------------- + +/// Normalizes Anthropic Messages protocol headers for native +/// backends. +/// +/// # YAML +/// +/// ```yaml +/// filter: anthropic_messages_protocol +/// ``` +/// +/// # Full YAML +/// +/// ```yaml +/// filter: anthropic_messages_protocol +/// default_version: "2023-06-01" +/// ``` +pub struct AnthropicMessagesProtocolFilter { + /// Parsed and validated configuration. + config: AnthropicMessagesProtocolConfig, +} + +impl AnthropicMessagesProtocolFilter { + /// Create a filter from parsed YAML config. + /// + /// # Errors + /// + /// Returns [`FilterError`] if the YAML config is invalid. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: AnthropicMessagesProtocolConfig = parse_filter_config("anthropic_messages_protocol", config)?; + let validated = build_config(cfg)?; + Ok(Box::new(Self { config: validated })) + } +} + +#[async_trait] +impl HttpFilter for AnthropicMessagesProtocolFilter { + fn name(&self) -> &'static str { + "anthropic_messages_protocol" + } + + fn request_body_access(&self) -> BodyAccess { + BodyAccess::None + } + + fn request_body_mode(&self) -> BodyMode { + BodyMode::Stream + } + + async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + let has_version = ctx.request.headers.get(ANTHROPIC_VERSION_HEADER).is_some(); + + if !has_version && !restore_original_header(ctx, ANTHROPIC_VERSION_HEADER) { + debug!( + version = self.config.default_version.as_str(), + "injecting default anthropic-version header" + ); + ctx.extra_request_headers.push(( + Cow::Borrowed(ANTHROPIC_VERSION_HEADER), + self.config.default_version.clone(), + )); + } + + if ctx.request.headers.get(ANTHROPIC_BETA_HEADER).is_none() { + restore_original_header(ctx, ANTHROPIC_BETA_HEADER); + } + + Ok(FilterAction::Continue) + } + + async fn on_response(&self, _ctx: &mut HttpFilterContext<'_>) -> Result { + Ok(FilterAction::Continue) + } +} + +/// Restore a caller-supplied Anthropic protocol header when an +/// iterative request starts with a fresh header map. +fn restore_original_header(ctx: &mut HttpFilterContext<'_>, name: &'static str) -> bool { + let Some(value) = ctx + .extensions + .get::() + .and_then(|state| state.original_request.headers.get(name)) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + else { + return false; + }; + + ctx.extra_request_headers.push((Cow::Borrowed(name), value)); + true +} diff --git a/apis/src/anthropic/protocol/tests.rs b/apis/src/anthropic/protocol/tests.rs new file mode 100644 index 0000000000..56f49ae548 --- /dev/null +++ b/apis/src/anthropic/protocol/tests.rs @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Unit tests for the `anthropic_messages_protocol` filter. + +use super::*; + +// ----------------------------------------------------------------------------- +// Config Parsing +// ----------------------------------------------------------------------------- + +#[test] +fn default_config_parses() { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let filter = AnthropicMessagesProtocolFilter::from_config(&yaml).unwrap(); + assert_eq!( + filter.name(), + "anthropic_messages_protocol", + "filter name should be anthropic_messages_protocol" + ); +} + +#[test] +fn empty_version_rejected() { + let yaml: serde_yaml::Value = serde_yaml::from_str("default_version: \"\"").unwrap(); + let result = AnthropicMessagesProtocolFilter::from_config(&yaml); + assert!(result.is_err(), "empty default_version should be rejected"); +} + +#[test] +fn invalid_header_value_rejected() { + let yaml: serde_yaml::Value = serde_yaml::from_str("default_version: \"2023-06-01\\nmalformed\"").unwrap(); + let result = AnthropicMessagesProtocolFilter::from_config(&yaml); + assert!( + result.is_err(), + "invalid default_version header value should be rejected" + ); +} + +// ----------------------------------------------------------------------------- +// Header Injection +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn injects_anthropic_version_when_absent() { + let filter = make_filter("{}"); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + + let req: &'static praxis_filter::Request = Box::leak(Box::new(req)); + let mut ctx = crate::test_utils::make_filter_context(req); + + let action = filter.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue), "filter should continue"); + + let headers: std::collections::HashMap<&str, &str> = ctx + .extra_request_headers + .iter() + .map(|(k, v)| (k.as_ref(), v.as_str())) + .collect(); + + assert_eq!( + headers.get("anthropic-version"), + Some(&"2023-06-01"), + "should inject default anthropic-version" + ); +} + +#[tokio::test] +async fn does_not_inject_when_header_present() { + let filter = make_filter("{}"); + let mut req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + req.headers.insert("anthropic-version", "2024-01-01".parse().unwrap()); + + let req: &'static praxis_filter::Request = Box::leak(Box::new(req)); + let mut ctx = crate::test_utils::make_filter_context(req); + + let _action = filter.on_request(&mut ctx).await.unwrap(); + + assert!( + ctx.extra_request_headers.is_empty(), + "should not inject when header already present" + ); +} + +#[tokio::test] +async fn custom_default_version() { + let filter = make_filter("default_version: \"2024-06-01\""); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + + let req: &'static praxis_filter::Request = Box::leak(Box::new(req)); + let mut ctx = crate::test_utils::make_filter_context(req); + + drop(filter.on_request(&mut ctx).await.unwrap()); + + let headers: std::collections::HashMap<&str, &str> = ctx + .extra_request_headers + .iter() + .map(|(k, v)| (k.as_ref(), v.as_str())) + .collect(); + + assert_eq!( + headers.get("anthropic-version"), + Some(&"2024-06-01"), + "should inject configured version" + ); +} + +// ----------------------------------------------------------------------------- +// Test Utilities +// ----------------------------------------------------------------------------- + +/// Build a filter from a YAML snippet. +fn make_filter(yaml_str: &str) -> Box { + let yaml: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap(); + AnthropicMessagesProtocolFilter::from_config(&yaml).unwrap() +} diff --git a/apis/src/anthropic/stream_events/config.rs b/apis/src/anthropic/stream_events/config.rs new file mode 100644 index 0000000000..c39f8d9348 --- /dev/null +++ b/apis/src/anthropic/stream_events/config.rs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Configuration for the Anthropic stream events filter. + +use praxis_filter::{ + FilterError, + body::{DEFAULT_JSON_BODY_MAX_BYTES, MAX_JSON_BODY_BYTES}, +}; +use serde::Deserialize; + +// ----------------------------------------------------------------------------- +// AnthropicStreamEventsConfig +// ----------------------------------------------------------------------------- + +/// YAML configuration for the [`AnthropicStreamEventsFilter`]. +/// +/// [`AnthropicStreamEventsFilter`]: super::AnthropicStreamEventsFilter +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AnthropicStreamEventsConfig { + /// Maximum incomplete SSE event bytes retained between chunks. + #[serde(default = "default_max_partial_event_bytes")] + pub max_partial_event_bytes: usize, +} + +/// Default maximum partial event bytes. +fn default_max_partial_event_bytes() -> usize { + DEFAULT_JSON_BODY_MAX_BYTES +} + +// ----------------------------------------------------------------------------- +// Config Validation +// ----------------------------------------------------------------------------- + +/// Validate the parsed configuration. +pub(crate) fn build_config(cfg: AnthropicStreamEventsConfig) -> Result { + validate_max_partial_event_bytes(cfg.max_partial_event_bytes)?; + Ok(cfg) +} + +/// Validate the maximum partial SSE event byte limit. +fn validate_max_partial_event_bytes(value: usize) -> Result<(), FilterError> { + if value == 0 { + return Err("anthropic_stream_events: 'max_partial_event_bytes' must be greater than 0".into()); + } + + if value > MAX_JSON_BODY_BYTES { + return Err(format!( + "anthropic_stream_events: max_partial_event_bytes ({value}) exceeds maximum ({MAX_JSON_BODY_BYTES})" + ) + .into()); + } + + Ok(()) +} diff --git a/apis/src/anthropic/stream_events/mod.rs b/apis/src/anthropic/stream_events/mod.rs new file mode 100644 index 0000000000..6d245202f1 --- /dev/null +++ b/apis/src/anthropic/stream_events/mod.rs @@ -0,0 +1,1787 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Anthropic SSE event transformation filter. +//! +//! Transforms `OpenAI` Chat Completions SSE events into Anthropic +//! Messages SSE events per-chunk while buffering partial events. + +mod config; + +use std::borrow::Cow; + +use async_trait::async_trait; +use bytes::Bytes; +use praxis_filter::{ + BodyAccess, BodyMode, FilterAction, FilterError, HttpFilter, HttpFilterContext, parse_filter_config, +}; +use serde_json::Value; +use tracing::debug; + +use self::config::{AnthropicStreamEventsConfig, build_config}; +use crate::{ + anthropic::wire::{ContentBlock, MessageDeltaUsage, MessageUsage}, + is_event_stream_content_type, +}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Metadata key for the partial line buffer between chunks. +const LINE_BUFFER_KEY: &str = "anthropic_stream.line_buffer"; + +/// Metadata key for the internal streaming state. +const STREAM_STATE_KEY: &str = "anthropic_stream.state"; + +/// Internal stream state value recorded after emitting `message_start`. +const STREAM_STATE_STARTED: &str = "started"; + +/// OpenAI Chat Completions SSE sentinel that marks logical stream completion. +const OPENAI_DONE_SENTINEL: &str = "[DONE]"; + +/// Metadata key tracking whether a text content block is open. +const TEXT_BLOCK_OPEN_KEY: &str = "anthropic_stream.text_block_open"; + +/// Metadata key prefix for per-tool-call content block state. +const TOOL_BLOCK_KEY_PREFIX: &str = "anthropic_stream.tool_block."; + +/// Metadata key suffix for a tool call's Anthropic content block index. +const TOOL_BLOCK_INDEX_SUFFIX: &str = ".index"; + +/// Metadata key suffix tracking whether a tool call's content block is open. +const TOOL_BLOCK_OPEN_SUFFIX: &str = ".open"; + +/// Metadata key for the finish reason from the upstream provider. +const FINISH_REASON_KEY: &str = "anthropic_stream.finish_reason"; + +/// Metadata key for accumulated output token count. +const OUTPUT_TOKENS_KEY: &str = "anthropic_stream.output_tokens"; + +/// Metadata key for the current content block index. +const BLOCK_INDEX_KEY: &str = "anthropic_stream.block_index"; + +/// Metadata key for incomplete UTF-8 bytes (hex-encoded) between chunks. +const UTF8_BUFFER_KEY: &str = "anthropic_stream.utf8_buffer"; + +/// Metadata key indicating the filter is armed for streaming transformation. +const ARMED_KEY: &str = "anthropic_stream.armed"; + +// ----------------------------------------------------------------------------- +// AnthropicStreamEventsFilter +// ----------------------------------------------------------------------------- + +/// Transforms streaming SSE responses between `OpenAI` and +/// Anthropic formats, processing each chunk as it arrives. +/// +/// Arms automatically when an upstream classifier or transform +/// filter sets `anthropic_messages_format.stream` or +/// `anthropic_to_openai.streaming` metadata to `"true"` and +/// the backend response has `Content-Type: text/event-stream` +/// (with or without parameters such as `charset=utf-8`). +/// No `response_conditions` configuration is needed. +/// +/// # YAML +/// +/// ```yaml +/// filter: anthropic_stream_events +/// ``` +/// +/// # Full YAML +/// +/// ```yaml +/// filter: anthropic_stream_events +/// max_partial_event_bytes: 10485760 +/// ``` +pub struct AnthropicStreamEventsFilter { + /// Parsed and validated configuration. + config: AnthropicStreamEventsConfig, +} + +impl AnthropicStreamEventsFilter { + /// Create a filter from parsed YAML config. + /// + /// # Errors + /// + /// Returns [`FilterError`] if the YAML config is invalid. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: AnthropicStreamEventsConfig = parse_filter_config("anthropic_stream_events", config)?; + let validated = build_config(cfg)?; + Ok(Box::new(Self { config: validated })) + } +} + +#[async_trait] +impl HttpFilter for AnthropicStreamEventsFilter { + fn name(&self) -> &'static str { + "anthropic_stream_events" + } + + fn request_body_access(&self) -> BodyAccess { + BodyAccess::None + } + + fn request_body_mode(&self) -> BodyMode { + BodyMode::Stream + } + + fn response_body_access(&self) -> BodyAccess { + BodyAccess::ReadWrite + } + + fn response_body_mode(&self) -> BodyMode { + BodyMode::Stream + } + + async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result { + Ok(FilterAction::Continue) + } + + async fn on_response(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + if !should_arm(ctx) { + return Ok(FilterAction::Continue); + } + + ctx.set_metadata(ARMED_KEY, "true".to_owned()); + + if let Some(resp) = &mut ctx.response_header { + resp.headers.remove(http::header::CONTENT_LENGTH); + resp.headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("text/event-stream"), + ); + ctx.response_headers_modified = true; + } + Ok(FilterAction::Continue) + } + + fn on_response_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + if !is_armed(ctx) { + return Ok(FilterAction::Continue); + } + + let Some(bytes) = body.as_ref() else { + if end_of_stream { + let empty = Bytes::new(); + let output = decode_and_process_chunk(ctx, &empty, true, self.config.max_partial_event_bytes)? + .unwrap_or_default(); + if !output.is_empty() { + *body = Some(output); + } + } + return Ok(FilterAction::Continue); + }; + + let Some(output) = decode_and_process_chunk(ctx, bytes, end_of_stream, self.config.max_partial_event_bytes)? + else { + *body = Some(Bytes::new()); + return Ok(FilterAction::Continue); + }; + + *body = Some(output); + Ok(FilterAction::Continue) + } +} + +// ----------------------------------------------------------------------------- +// SSE Chunk Processing +// ----------------------------------------------------------------------------- + +/// Reassemble incomplete UTF-8 bytes from the previous chunk, extract +/// the valid prefix, buffer any trailing incomplete sequence, and +/// run SSE processing on the valid portion. +fn decode_and_process_chunk( + ctx: &mut HttpFilterContext<'_>, + bytes: &Bytes, + end_of_stream: bool, + max_partial_event_bytes: usize, +) -> Result, FilterError> { + let combined = combine_pending_utf8(ctx, bytes); + let Some(valid_up_to) = valid_utf8_prefix_len(ctx, combined.as_slice(), end_of_stream) else { + return Ok(Some(passthrough_with_line_buffer(ctx, combined))); + }; + let Some(valid_bytes) = combined.as_slice().get(..valid_up_to) else { + return Ok(None); + }; + let Some(chunk_str) = std::str::from_utf8(valid_bytes).ok() else { + return Ok(None); + }; + + if chunk_str.is_empty() && !end_of_stream { + return Ok(None); + } + + process_sse_chunk(ctx, chunk_str, end_of_stream, max_partial_event_bytes).map(Some) +} + +/// Prefix any incomplete UTF-8 bytes retained from the previous chunk. +fn combine_pending_utf8<'a>(ctx: &mut HttpFilterContext<'_>, bytes: &'a Bytes) -> CombinedUtf8Chunk<'a> { + match ctx.filter_metadata.remove(UTF8_BUFFER_KEY) { + Some(hex) => { + let mut pending = decode_hex_bytes(&hex); + pending.extend_from_slice(bytes); + CombinedUtf8Chunk::Owned(pending) + }, + None => CombinedUtf8Chunk::Borrowed(bytes), + } +} + +/// Find the valid UTF-8 prefix and retain only a trailing incomplete suffix. +fn valid_utf8_prefix_len(ctx: &mut HttpFilterContext<'_>, combined: &[u8], end_of_stream: bool) -> Option { + match std::str::from_utf8(combined) { + Ok(_) => Some(combined.len()), + Err(e) if e.error_len().is_none() => { + if end_of_stream { + return None; + } + let valid = e.valid_up_to(); + let tail = combined.get(valid..)?; + if tail.len() > 3 { + return None; + } + ctx.filter_metadata + .insert(UTF8_BUFFER_KEY.to_owned(), encode_hex_bytes(tail)); + Some(valid) + }, + Err(_) => None, + } +} + +/// Preserve buffered bytes when a malformed chunk cannot be parsed as UTF-8. +fn passthrough_with_line_buffer(ctx: &mut HttpFilterContext<'_>, bytes: CombinedUtf8Chunk<'_>) -> Bytes { + let Some(buffer) = ctx.filter_metadata.remove(LINE_BUFFER_KEY) else { + return bytes.into_bytes(); + }; + + let mut output = buffer.into_bytes(); + output.extend_from_slice(bytes.as_slice()); + Bytes::from(output) +} + +/// Combined UTF-8 chunk data, borrowed unless a pending suffix had to be prefixed. +enum CombinedUtf8Chunk<'a> { + /// Current chunk borrowed directly from Pingora. + Borrowed(&'a Bytes), + + /// Current chunk prefixed with bytes buffered from the previous chunk. + Owned(Vec), +} + +impl CombinedUtf8Chunk<'_> { + /// View the combined bytes without forcing an allocation. + fn as_slice(&self) -> &[u8] { + match self { + Self::Borrowed(bytes) => bytes.as_ref(), + Self::Owned(bytes) => bytes.as_slice(), + } + } + + /// Convert into output bytes without copying borrowed `Bytes`. + fn into_bytes(self) -> Bytes { + match self { + Self::Borrowed(bytes) => bytes.clone(), + Self::Owned(bytes) => Bytes::from(bytes), + } + } +} + +/// Parse SSE event boundaries from the combined buffer, transform +/// each complete event, and store any leftover partial data. +/// +/// Handles `\r\n`, `\r`, and `\n` line endings per the SSE +/// specification. A single trailing `\r` is held back before +/// end-of-stream because it might be the first half of a `\r\n` +/// pair split across chunks. +fn process_sse_chunk( + ctx: &mut HttpFilterContext<'_>, + chunk_str: &str, + end_of_stream: bool, + max_partial_event_bytes: usize, +) -> Result { + let leftover = ctx.filter_metadata.get(LINE_BUFFER_KEY).cloned().unwrap_or_default(); + let combined = format!("{leftover}{chunk_str}"); + + let defer_trailing_cr = !end_of_stream && combined.ends_with('\r') && !combined.ends_with("\r\r"); + let (to_normalize, pending_cr) = if defer_trailing_cr { + match combined.strip_suffix('\r') { + Some(without) => (without, true), + None => (combined.as_str(), false), + } + } else { + (combined.as_str(), false) + }; + + let normalized = normalize_line_endings(to_normalize); + let mut output = Vec::new(); + let mut remaining = normalized.as_str(); + + while let Some((event_block, rest)) = remaining.split_once("\n\n") { + remaining = rest; + process_event_block(ctx, event_block, &mut output); + } + + let to_buffer = if pending_cr { + format!("{remaining}\r") + } else { + remaining.to_owned() + }; + + store_line_buffer(ctx, to_buffer, max_partial_event_bytes)?; + + if output.is_empty() { + Ok(Bytes::new()) + } else { + Ok(Bytes::from(output)) + } +} + +/// Store bounded incomplete SSE event data between response chunks. +fn store_line_buffer( + ctx: &mut HttpFilterContext<'_>, + buffer: String, + max_partial_event_bytes: usize, +) -> Result<(), FilterError> { + if buffer.is_empty() { + ctx.filter_metadata.remove(LINE_BUFFER_KEY); + return Ok(()); + } + + if buffer.len() > max_partial_event_bytes { + ctx.filter_metadata.remove(LINE_BUFFER_KEY); + let msg = format!("anthropic_stream_events: incomplete SSE event exceeds {max_partial_event_bytes} bytes"); + return Err(msg.into()); + } + + ctx.filter_metadata.insert(LINE_BUFFER_KEY.to_owned(), buffer); + Ok(()) +} + +/// Whether the filter has been armed in the response phase. +fn is_armed(ctx: &HttpFilterContext<'_>) -> bool { + ctx.filter_metadata.get(ARMED_KEY).is_some_and(|v| v == "true") +} + +/// Whether the filter should arm: streaming request, SSE Content-Type, success status. +fn should_arm(ctx: &HttpFilterContext<'_>) -> bool { + if !is_streaming_request(ctx) { + return false; + } + + let is_sse = ctx + .response_header + .as_ref() + .and_then(|r| r.headers.get(http::header::CONTENT_TYPE)) + .and_then(|v| v.to_str().ok()) + .is_some_and(is_event_stream_content_type); + + if !is_sse { + debug!("streaming request but non-SSE response; skipping stream transformation"); + return false; + } + + let is_success = ctx.response_header.as_ref().is_none_or(|r| r.status.is_success()); + if !is_success { + debug!("streaming SSE response with non-2xx status; passing through error body"); + return false; + } + + true +} + +/// Whether an upstream filter classified this as a streaming request. +fn is_streaming_request(ctx: &HttpFilterContext<'_>) -> bool { + ctx.filter_metadata + .get("anthropic_messages_format.stream") + .is_some_and(|v| v == "true") + || ctx + .filter_metadata + .get("anthropic_to_openai.streaming") + .is_some_and(|v| v == "true") +} + + +/// Process a single SSE event block (lines between double-newlines). +/// +/// Collects all `data` fields into one newline-delimited payload before +/// processing it. Accepts bare `data`, `data: value`, and `data:value` +/// per the SSE specification. +fn process_event_block(ctx: &mut HttpFilterContext<'_>, block: &str, output: &mut Vec) { + let mut event_data = None::>; + + for line in block.lines() { + let data = if line == "data" { + Some("") + } else { + line.strip_prefix("data:").map(|d| d.strip_prefix(' ').unwrap_or(d)) + }; + + if let Some(data) = data { + match &mut event_data { + Some(event_data) => { + let event_data = event_data.to_mut(); + event_data.push('\n'); + event_data.push_str(data); + }, + None => event_data = Some(Cow::Borrowed(data)), + } + } + } + + if let Some(data) = event_data { + if data == OPENAI_DONE_SENTINEL { + emit_done(ctx, output); + } else if let Ok(chunk) = serde_json::from_str::(&data) { + transform_chunk(ctx, &chunk, output); + } + } +} + +// ----------------------------------------------------------------------------- +// Per-Chunk Transformation +// ----------------------------------------------------------------------------- + +/// Transform a single `OpenAI` SSE chunk into Anthropic events. +fn transform_chunk(ctx: &mut HttpFilterContext<'_>, chunk: &Value, output: &mut Vec) { + let started = ctx + .filter_metadata + .get(STREAM_STATE_KEY) + .is_some_and(|v| v == STREAM_STATE_STARTED); + + if !started { + emit_message_start(ctx, chunk, output); + } + + if let Some(choice) = extract_first_choice(chunk) { + if let Some(delta) = choice.get("delta") { + transform_delta(ctx, delta, output); + } + if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) { + ctx.set_metadata(FINISH_REASON_KEY, reason.to_owned()); + } + } + + if let Some(ot) = chunk + .get("usage") + .and_then(|u| u.get("completion_tokens")) + .and_then(Value::as_u64) + { + ctx.set_metadata(OUTPUT_TOKENS_KEY, ot.to_string()); + } +} + +/// Emit the initial `message_start` event and mark the stream as started. +fn emit_message_start(ctx: &mut HttpFilterContext<'_>, chunk: &Value, output: &mut Vec) { + let model = chunk.get("model").and_then(Value::as_str).unwrap_or(""); + + emit_event( + output, + "message_start", + &serde_json::json!({ + "type": "message_start", + "message": { + "id": format!("msg_{:016x}", generate_timestamp_id()), + "type": "message", + "role": "assistant", + "model": model, + "content": [], + "stop_reason": null, + "stop_sequence": null, + "stop_details": null, + "usage": message_start_usage(), + "container": null + } + }), + ); + ctx.set_metadata(STREAM_STATE_KEY, STREAM_STATE_STARTED.to_owned()); +} + +/// Extract the first choice from a Chat Completions streaming chunk. +/// +/// Anthropic's response format is structurally single-choice, so only +/// `choices[0]` can be mapped. +fn extract_first_choice(chunk: &Value) -> Option<&Value> { + chunk.get("choices").and_then(Value::as_array).and_then(|c| c.first()) +} + +/// Generate a timestamp-based identifier for message IDs. +fn generate_timestamp_id() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + & 0xFFFF_FFFF_FFFF_FFFF_u128 +} + +// ----------------------------------------------------------------------------- +// Delta Transformation +// ----------------------------------------------------------------------------- + +/// Transform a delta object from a streaming chunk. +fn transform_delta(ctx: &mut HttpFilterContext<'_>, delta: &Value, output: &mut Vec) { + if let Some(content) = delta.get("content").and_then(Value::as_str) { + emit_text_delta(ctx, content, output); + } + + if let Some(Value::Array(tool_calls)) = delta.get("tool_calls") { + close_text_block_if_open(ctx, output); + for tc in tool_calls { + transform_tool_delta(ctx, tc, output); + } + } +} + +/// Emit a text content delta, opening a new block if needed. +fn emit_text_delta(ctx: &mut HttpFilterContext<'_>, content: &str, output: &mut Vec) { + if !is_text_block_open(ctx) { + let idx = get_block_index(ctx); + let content_block = ContentBlock::text(""); + emit_event( + output, + "content_block_start", + &serde_json::json!({ + "type": "content_block_start", + "index": idx, + "content_block": content_block + }), + ); + ctx.set_metadata(TEXT_BLOCK_OPEN_KEY, "true".to_owned()); + } + + let idx = get_block_index(ctx); + emit_event( + output, + "content_block_delta", + &serde_json::json!({ + "type": "content_block_delta", + "index": idx, + "delta": {"type": "text_delta", "text": content} + }), + ); +} + +// ----------------------------------------------------------------------------- +// Tool Delta Transformation +// ----------------------------------------------------------------------------- + +/// Transform a tool call delta into Anthropic content block events. +fn transform_tool_delta(ctx: &mut HttpFilterContext<'_>, tc: &Value, output: &mut Vec) { + let tool_call_key = tool_call_key(tc); + + if let Some(id) = tc.get("id").and_then(Value::as_str) + && !is_tool_block_open(ctx, &tool_call_key) + { + emit_tool_block_start(ctx, &tool_call_key, tc, id, output); + } + + emit_tool_arguments_delta(ctx, &tool_call_key, tc, output); +} + +/// Close any open text content block and advance the block index. +fn close_text_block_if_open(ctx: &mut HttpFilterContext<'_>, output: &mut Vec) { + if !is_text_block_open(ctx) { + return; + } + + let idx = get_block_index(ctx); + emit_event( + output, + "content_block_stop", + &serde_json::json!({"type": "content_block_stop", "index": idx}), + ); + increment_block_index(ctx); + ctx.set_metadata(TEXT_BLOCK_OPEN_KEY, "false".to_owned()); +} + +/// Return the stable key used to associate OpenAI tool-call deltas. +fn tool_call_key(tc: &Value) -> String { + tc.get("index") + .and_then(Value::as_u64) + .map_or_else(|| "legacy".to_owned(), |idx| idx.to_string()) +} + +/// Emit a `content_block_start` for a tool-use block. +fn emit_tool_block_start( + ctx: &mut HttpFilterContext<'_>, + tool_call_key: &str, + tc: &Value, + id: &str, + output: &mut Vec, +) { + let idx = get_block_index(ctx); + let name = tc + .get("function") + .and_then(|f| f.get("name")) + .and_then(Value::as_str) + .unwrap_or(""); + let content_block = ContentBlock::tool_use(id, serde_json::Map::new(), name); + + emit_event( + output, + "content_block_start", + &serde_json::json!({ + "type": "content_block_start", + "index": idx, + "content_block": content_block + }), + ); + set_tool_block_index(ctx, tool_call_key, idx); + set_tool_block_open(ctx, tool_call_key, true); + increment_block_index(ctx); +} + +/// Emit an `input_json_delta` if the tool call has non-empty arguments. +fn emit_tool_arguments_delta(ctx: &HttpFilterContext<'_>, tool_call_key: &str, tc: &Value, output: &mut Vec) { + let Some(args) = tc + .get("function") + .and_then(|f| f.get("arguments")) + .and_then(Value::as_str) + else { + return; + }; + + if args.is_empty() { + return; + } + + let idx = get_tool_block_index(ctx, tool_call_key).unwrap_or_else(|| get_block_index(ctx)); + emit_event( + output, + "content_block_delta", + &serde_json::json!({ + "type": "content_block_delta", + "index": idx, + "delta": {"type": "input_json_delta", "partial_json": args} + }), + ); +} + +/// Close a specific open tool content block. +fn close_tool_block(ctx: &mut HttpFilterContext<'_>, tool_call_key: &str, output: &mut Vec) { + if !is_tool_block_open(ctx, tool_call_key) { + return; + } + + let Some(idx) = get_tool_block_index(ctx, tool_call_key) else { + return; + }; + + emit_event( + output, + "content_block_stop", + &serde_json::json!({"type": "content_block_stop", "index": idx}), + ); + set_tool_block_open(ctx, tool_call_key, false); +} + +// ----------------------------------------------------------------------------- +// Stream Completion +// ----------------------------------------------------------------------------- + +/// Emit final events when `[DONE]` is received. +fn emit_done(ctx: &mut HttpFilterContext<'_>, output: &mut Vec) { + emit_final_block_stop(ctx, output); + emit_message_delta(ctx, output); + emit_event(output, "message_stop", &serde_json::json!({"type": "message_stop"})); + debug!("streaming transformation complete"); +} + +/// Close any open content block at end of stream. +fn emit_final_block_stop(ctx: &mut HttpFilterContext<'_>, output: &mut Vec) { + if is_text_block_open(ctx) { + close_text_block_if_open(ctx, output); + } + for (_, tool_call_key) in open_tool_blocks(ctx) { + close_tool_block(ctx, &tool_call_key, output); + } +} + +/// Emit the `message_delta` event with stop reason and usage. +fn emit_message_delta(ctx: &HttpFilterContext<'_>, output: &mut Vec) { + let stop_reason = ctx + .filter_metadata + .get(FINISH_REASON_KEY) + .map_or("end_turn", |v| map_stop_reason(v)); + + let output_tokens: u64 = ctx + .filter_metadata + .get(OUTPUT_TOKENS_KEY) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + emit_event( + output, + "message_delta", + &serde_json::json!({ + "type": "message_delta", + "delta": { + "container": null, + "stop_details": null, + "stop_reason": stop_reason, + "stop_sequence": null + }, + "usage": message_delta_usage(output_tokens) + }), + ); +} + +/// Build a schema-complete Anthropic `Message.usage` value. +fn message_start_usage() -> MessageUsage { + MessageUsage::new(0, 0, None) +} + +/// Build a schema-complete Anthropic `message_delta.usage` value. +fn message_delta_usage(output_tokens: u64) -> MessageDeltaUsage { + MessageDeltaUsage::new(output_tokens) +} + +/// Map `OpenAI` finish reasons to Anthropic stop reasons. +fn map_stop_reason(reason: &str) -> &str { + match reason { + "tool_calls" => "tool_use", + "length" => "max_tokens", + _ => "end_turn", + } +} + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +/// Check whether a text content block is currently open. +fn is_text_block_open(ctx: &HttpFilterContext<'_>) -> bool { + ctx.filter_metadata + .get(TEXT_BLOCK_OPEN_KEY) + .is_some_and(|v| v == "true") +} + +/// Get the current block index from metadata. +fn get_block_index(ctx: &HttpFilterContext<'_>) -> u32 { + ctx.filter_metadata + .get(BLOCK_INDEX_KEY) + .and_then(|v| v.parse().ok()) + .unwrap_or(0) +} + +/// Increment the block index in metadata. +fn increment_block_index(ctx: &mut HttpFilterContext<'_>) { + let current = get_block_index(ctx); + ctx.set_metadata(BLOCK_INDEX_KEY, (current + 1).to_string()); +} + +/// Build the metadata key for a tool call's Anthropic block index. +fn tool_block_index_key(tool_call_key: &str) -> String { + format!("{TOOL_BLOCK_KEY_PREFIX}{tool_call_key}{TOOL_BLOCK_INDEX_SUFFIX}") +} + +/// Build the metadata key for a tool call's open/closed state. +fn tool_block_open_key(tool_call_key: &str) -> String { + format!("{TOOL_BLOCK_KEY_PREFIX}{tool_call_key}{TOOL_BLOCK_OPEN_SUFFIX}") +} + +/// Record the Anthropic block index assigned to an OpenAI tool-call index. +fn set_tool_block_index(ctx: &mut HttpFilterContext<'_>, tool_call_key: &str, idx: u32) { + ctx.set_metadata(tool_block_index_key(tool_call_key), idx.to_string()); +} + +/// Return the Anthropic block index assigned to an OpenAI tool-call index. +fn get_tool_block_index(ctx: &HttpFilterContext<'_>, tool_call_key: &str) -> Option { + ctx.filter_metadata + .get(&tool_block_index_key(tool_call_key)) + .and_then(|v| v.parse().ok()) +} + +/// Record whether a tool call's Anthropic content block remains open. +fn set_tool_block_open(ctx: &mut HttpFilterContext<'_>, tool_call_key: &str, open: bool) { + ctx.set_metadata(tool_block_open_key(tool_call_key), open.to_string()); +} + +/// Check whether a tool call's Anthropic content block is currently open. +fn is_tool_block_open(ctx: &HttpFilterContext<'_>, tool_call_key: &str) -> bool { + ctx.filter_metadata + .get(&tool_block_open_key(tool_call_key)) + .is_some_and(|v| v == "true") +} + +/// Return open tool blocks ordered by their Anthropic content block index. +fn open_tool_blocks(ctx: &HttpFilterContext<'_>) -> Vec<(u32, String)> { + let mut blocks = ctx + .filter_metadata + .iter() + .filter_map(|(key, value)| { + if value != "true" { + return None; + } + let tool_call_key = key + .strip_prefix(TOOL_BLOCK_KEY_PREFIX)? + .strip_suffix(TOOL_BLOCK_OPEN_SUFFIX)?; + get_tool_block_index(ctx, tool_call_key).map(|idx| (idx, tool_call_key.to_owned())) + }) + .collect::>(); + blocks.sort_by_key(|(idx, _)| *idx); + blocks +} + +/// Write a single SSE event to the output buffer. +fn emit_event(output: &mut Vec, event_type: &str, data: &Value) { + let data_str = serde_json::to_string(data).unwrap_or_default(); + output.extend_from_slice(format!("event: {event_type}\ndata: {data_str}\n\n").as_bytes()); +} + +/// Normalize SSE line endings: `\r\n` → `\n`, standalone `\r` → `\n`. +fn normalize_line_endings(s: &str) -> String { + s.replace("\r\n", "\n").replace('\r', "\n") +} + +/// Hex-encode a byte slice (for buffering incomplete UTF-8 sequences). +fn encode_hex_bytes(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Decode a hex-encoded byte slice. +fn decode_hex_bytes(hex: &str) -> Vec { + hex.as_bytes() + .chunks_exact(2) + .filter_map(|pair| { + let hi = hex_nibble(*pair.first()?)?; + let lo = hex_nibble(*pair.last()?)?; + Some(hi << 4 | lo) + }) + .collect() +} + +/// Convert a single lowercase hex digit to its numeric value. +fn hex_nibble(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + _ => None, + } +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "tests")] +mod tests { + use super::*; + + #[test] + fn default_config_parses() { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let filter = AnthropicStreamEventsFilter::from_config(&yaml).unwrap(); + + assert_eq!(filter.name(), "anthropic_stream_events", "filter name should match"); + } + + #[test] + fn incremental_text_chunks_transformed_immediately() { + let (filter, mut ctx) = make_filter_and_context(); + + let chunk1 = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}\n\n"; + let mut body1 = Some(Bytes::from(chunk1)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + + let out1 = String::from_utf8(body1.unwrap().to_vec()).unwrap(); + assert!( + out1.contains("message_start"), + "first chunk should emit message_start immediately" + ); + + let chunk2 = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"index\":0}]}\n\n"; + let mut body2 = Some(Bytes::from(chunk2)); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + + let out2 = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + assert!( + out2.contains("text_delta"), + "second chunk should emit text_delta immediately" + ); + assert!(out2.contains("Hello"), "text content should be forwarded immediately"); + let start = event_data(&out2, "content_block_start"); + assert_eq!( + start.pointer("/content_block/citations"), + Some(&Value::Null), + "streaming text citations should be null" + ); + } + + #[test] + fn partial_chunk_buffered_until_complete() { + let (filter, mut ctx) = make_filter_and_context(); + + let partial = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"role\":\"assistant\""; + let mut body1 = Some(Bytes::from(partial)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + + let out1 = body1.unwrap(); + assert!(out1.is_empty(), "partial chunk should produce no output"); + + let rest = "},\"index\":0}]}\n\n"; + let mut body2 = Some(Bytes::from(rest)); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + + let out2 = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + assert!( + out2.contains("message_start"), + "completed chunk should emit message_start" + ); + } + + #[test] + fn done_emits_final_events() { + let (filter, mut ctx) = make_filter_and_context(); + + let chunk1 = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"content\":\"Hi\"},\"index\":0,\"finish_reason\":\"stop\"}]}\n\n"; + let mut body1 = Some(Bytes::from(chunk1)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + + let done = "data: [DONE]\n\n"; + let mut body2 = Some(Bytes::from(done)); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + + let out = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + assert!(out.contains("message_delta"), "DONE should emit message_delta"); + assert!(out.contains("message_stop"), "DONE should emit message_stop"); + assert!(out.contains("end_turn"), "stop reason should be end_turn"); + } + + #[test] + fn message_start_usage_matches_anthropic_schema() { + let (filter, mut ctx) = make_filter_and_context(); + + let chunk = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}\n\n"; + let mut body = Some(Bytes::from(chunk)); + drop(filter.on_response_body(&mut ctx, &mut body, false).unwrap()); + + let out = String::from_utf8(body.unwrap().to_vec()).unwrap(); + let event = event_data(&out, "message_start"); + let message = event.get("message").unwrap(); + let usage = message.get("usage").unwrap(); + + assert_null_fields(message, &["stop_details", "container"], "message_start"); + assert_null_fields( + usage, + &[ + "cache_creation", + "cache_creation_input_tokens", + "cache_read_input_tokens", + "inference_geo", + "server_tool_use", + "service_tier", + ], + "message_start usage", + ); + assert_absent_fields(usage, &["output_tokens_details"], "message_start usage"); + assert_u64_field(usage, "input_tokens", 0, "message_start usage"); + assert_u64_field(usage, "output_tokens", 0, "message_start usage"); + } + + #[test] + fn message_delta_usage_matches_anthropic_schema() { + let (filter, mut ctx) = make_filter_and_context(); + + let chunk1 = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"content\":\"Hi\"},\"index\":0,\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":7}}\n\n"; + let mut body1 = Some(Bytes::from(chunk1)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + + let done = "data: [DONE]\n\n"; + let mut body2 = Some(Bytes::from(done)); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + + let out = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + let event = event_data(&out, "message_delta"); + let delta = event.get("delta").unwrap(); + let usage = event.get("usage").unwrap(); + + assert_null_fields(delta, &["container", "stop_details", "stop_sequence"], "message_delta"); + assert_eq!( + delta.get("stop_reason").and_then(Value::as_str), + Some("end_turn"), + "message_delta should include stop_reason" + ); + assert_null_fields( + usage, + &[ + "cache_creation_input_tokens", + "cache_read_input_tokens", + "input_tokens", + "server_tool_use", + ], + "message_delta usage", + ); + assert_absent_fields(usage, &["output_tokens_details"], "message_delta usage"); + assert_u64_field(usage, "output_tokens", 7, "message_delta usage"); + } + + #[test] + fn no_full_response_buffering() { + let (filter, mut ctx) = make_filter_and_context(); + + let chunks = vec![ + "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}\n\n", + "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"content\":\"A\"},\"index\":0}]}\n\n", + "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"content\":\"B\"},\"index\":0}]}\n\n", + ]; + + let mut outputs_with_content = 0; + for chunk in chunks { + let mut body = Some(Bytes::from(chunk)); + drop(filter.on_response_body(&mut ctx, &mut body, false).unwrap()); + if !body.unwrap().is_empty() { + outputs_with_content += 1; + } + } + + assert!( + outputs_with_content >= 3, + "each chunk should produce output immediately, got {outputs_with_content}/3" + ); + } + + #[tokio::test] + async fn on_response_arms_when_streaming_request_and_sse_response() { + let filter = make_filter(); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + let mut ctx = crate::test_utils::make_filter_context(&req); + let mut resp = crate::test_utils::make_response(); + resp.headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("text/event-stream"), + ); + ctx.response_header = Some(&mut resp); + ctx.set_metadata("anthropic_messages_format.stream", "true".to_owned()); + + drop(filter.on_response(&mut ctx).await.unwrap()); + + assert!( + is_armed(&ctx), + "filter should be armed when streaming request meets SSE response" + ); + assert!( + ctx.response_headers_modified, + "response headers should be marked modified" + ); + } + + #[tokio::test] + async fn on_response_arms_with_charset_parameter() { + let filter = make_filter(); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + let mut ctx = crate::test_utils::make_filter_context(&req); + let mut resp = crate::test_utils::make_response(); + resp.headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("text/event-stream; charset=utf-8"), + ); + ctx.response_header = Some(&mut resp); + ctx.set_metadata("anthropic_to_openai.streaming", "true".to_owned()); + + drop(filter.on_response(&mut ctx).await.unwrap()); + + assert!( + is_armed(&ctx), + "filter should arm even with charset parameter in Content-Type" + ); + } + + #[tokio::test] + async fn on_response_arms_with_mixed_case_content_type() { + let filter = make_filter(); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + let mut ctx = crate::test_utils::make_filter_context(&req); + let mut resp = crate::test_utils::make_response(); + resp.headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("Text/Event-Stream"), + ); + ctx.response_header = Some(&mut resp); + ctx.set_metadata("anthropic_to_openai.streaming", "true".to_owned()); + + drop(filter.on_response(&mut ctx).await.unwrap()); + + assert!( + is_armed(&ctx), + "filter should arm with case-insensitive Content-Type matching" + ); + } + + #[tokio::test] + async fn on_response_does_not_arm_for_non_streaming_request() { + let filter = make_filter(); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + let mut ctx = crate::test_utils::make_filter_context(&req); + let mut resp = crate::test_utils::make_response(); + resp.headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("text/event-stream"), + ); + ctx.response_header = Some(&mut resp); + + drop(filter.on_response(&mut ctx).await.unwrap()); + + assert!(!is_armed(&ctx), "filter should not arm without streaming metadata"); + assert!( + !ctx.response_headers_modified, + "response headers should not be modified for non-streaming request" + ); + } + + #[tokio::test] + async fn on_response_does_not_arm_for_non_sse_response() { + let filter = make_filter(); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + let mut ctx = crate::test_utils::make_filter_context(&req); + let mut resp = crate::test_utils::make_response(); + resp.headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + ctx.response_header = Some(&mut resp); + ctx.set_metadata("anthropic_to_openai.streaming", "true".to_owned()); + + drop(filter.on_response(&mut ctx).await.unwrap()); + + let content_type = ctx + .response_header + .as_ref() + .unwrap() + .headers + .get(http::header::CONTENT_TYPE); + assert_eq!( + content_type, + Some(&http::HeaderValue::from_static("application/json")), + "non-SSE response should preserve original content type" + ); + assert!(!is_armed(&ctx), "filter should not arm for non-SSE response"); + } + + #[tokio::test] + async fn on_response_arms_via_messages_format_metadata() { + let filter = make_filter(); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + let mut ctx = crate::test_utils::make_filter_context(&req); + let mut resp = crate::test_utils::make_response(); + resp.headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("text/event-stream"), + ); + ctx.response_header = Some(&mut resp); + ctx.set_metadata("anthropic_messages_format.stream", "true".to_owned()); + + drop(filter.on_response(&mut ctx).await.unwrap()); + + assert!( + is_armed(&ctx), + "filter should arm via anthropic_messages_format.stream metadata" + ); + } + + #[test] + fn on_response_body_passes_through_when_not_armed() { + let filter = make_filter(); + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + let mut ctx = crate::test_utils::make_filter_context(Box::leak(Box::new(req))); + + let chunk = + "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"content\":\"Hi\"},\"index\":0}]}\n\n"; + let mut body = Some(Bytes::from(chunk)); + drop(filter.on_response_body(&mut ctx, &mut body, false).unwrap()); + + let out = String::from_utf8(body.unwrap().to_vec()).unwrap(); + assert_eq!(out, chunk, "unarmed filter should pass through body unchanged"); + } + + #[test] + fn tool_block_is_closed_at_done() { + let (filter, mut ctx) = make_filter_and_context(); + + let tool_start = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_1\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{}\"}}]},\"index\":0}]}\n\n"; + let mut body1 = Some(Bytes::from(tool_start)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + + let done = "data: [DONE]\n\n"; + let mut body2 = Some(Bytes::from(done)); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + + let out = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + assert!( + out.contains("content_block_stop") && out.contains(r#""index":0"#), + "DONE should close the open tool block" + ); + assert!(out.contains("message_stop"), "DONE should still emit message_stop"); + } + + #[test] + fn tool_call_delta_emits_tool_use_block_and_input_delta() { + let (filter, mut ctx) = make_filter_and_context(); + + let tool_start = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_1\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\"}}]},\"index\":0}]}\n\n"; + let mut body = Some(Bytes::from(tool_start)); + drop(filter.on_response_body(&mut ctx, &mut body, false).unwrap()); + + let out = String::from_utf8(body.unwrap().to_vec()).unwrap(); + assert!( + out.contains("content_block_start") && out.contains(r#""type":"tool_use""#), + "tool delta should start an Anthropic tool_use block" + ); + assert!( + out.contains(r#""id":"call_1""#) && out.contains(r#""name":"get_weather""#), + "tool_use block should preserve id and function name" + ); + let start = event_data(&out, "content_block_start"); + assert_eq!( + start + .get("content_block") + .and_then(|block| block.get("caller")) + .and_then(|caller| caller.get("type")) + .and_then(Value::as_str), + Some("direct"), + "streaming tool_use blocks should identify a direct caller" + ); + assert!( + out.contains("input_json_delta") && out.contains(r#""partial_json":"{\"city\":"#), + "tool arguments should stream as input_json_delta" + ); + } + + #[test] + fn interleaved_tool_call_argument_delta_uses_matching_tool_block_index() { + let (filter, mut ctx) = make_filter_and_context(); + + let call_0_start = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"function\":{\"name\":\"first\",\"arguments\":\"{\\\"first\\\":\"}}]},\"index\":0}]}\n\n"; + let mut body1 = Some(Bytes::from(call_0_start)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + + let call_1_start = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_1\",\"function\":{\"name\":\"second\",\"arguments\":\"{\\\"second\\\":\"}}]},\"index\":0}]}\n\n"; + let mut body2 = Some(Bytes::from(call_1_start)); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + + let call_0_args = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"value\\\"}\"}}]},\"index\":0}]}\n\n"; + let mut body3 = Some(Bytes::from(call_0_args)); + drop(filter.on_response_body(&mut ctx, &mut body3, false).unwrap()); + + let out = String::from_utf8(body3.unwrap().to_vec()).unwrap(); + let delta = event_data(&out, "content_block_delta"); + assert_eq!( + delta.get("index").and_then(Value::as_u64), + Some(0), + "id-less argument delta for tool_call index 0 should use call_0's block" + ); + } + + #[test] + fn done_closes_all_open_tool_blocks() { + let (filter, mut ctx) = make_filter_and_context(); + + let call_0_start = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"function\":{\"name\":\"first\",\"arguments\":\"{\\\"first\\\":\"}}]},\"index\":0}]}\n\n"; + let mut body1 = Some(Bytes::from(call_0_start)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + + let call_1_start = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_1\",\"function\":{\"name\":\"second\",\"arguments\":\"{\\\"second\\\":\"}}]},\"index\":0}]}\n\n"; + let mut body2 = Some(Bytes::from(call_1_start)); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + + let done = "data: [DONE]\n\n"; + let mut body3 = Some(Bytes::from(done)); + drop(filter.on_response_body(&mut ctx, &mut body3, false).unwrap()); + + let out = String::from_utf8(body3.unwrap().to_vec()).unwrap(); + assert_eq!( + event_indices(&out, "content_block_stop"), + vec![0, 1], + "DONE should close every open tool block in block-index order" + ); + } + + #[tokio::test] + async fn error_response_passes_through_unchanged() { + let filter = make_filter(); + let (mut ctx, mut resp) = make_error_context(http::StatusCode::TOO_MANY_REQUESTS); + resp.headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("text/event-stream"), + ); + ctx.response_header = Some(&mut resp); + ctx.set_metadata("anthropic_to_openai.streaming", "true".to_owned()); + + drop(filter.on_response(&mut ctx).await.unwrap()); + + assert!(!is_armed(&ctx), "filter should not arm for error response"); + assert!( + !ctx.response_headers_modified, + "error response should not modify headers" + ); + + let error_body = r#"{"type":"error","error":{"type":"rate_limit_error","message":"Rate limited"}}"#; + let mut body = Some(Bytes::from(error_body)); + drop(filter.on_response_body(&mut ctx, &mut body, true).unwrap()); + + let out = String::from_utf8(body.unwrap().to_vec()).unwrap(); + assert_eq!(out, error_body, "error body should pass through unchanged"); + } + + #[test] + fn unknown_config_field_rejected() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_body_bytes: 1048576").unwrap(); + let result = AnthropicStreamEventsFilter::from_config(&yaml); + + assert!( + result.is_err(), + "streaming filter should reject unused buffer-size config" + ); + } + + #[test] + fn split_utf8_character_buffered_across_chunks() { + let (filter, mut ctx) = make_filter_and_context(); + + let mut chunk1 = Vec::new(); + chunk1.extend_from_slice(b"data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"content\":\""); + chunk1.extend_from_slice(&[0xE2, 0x82]); + let mut body1 = Some(Bytes::from(chunk1)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + assert!( + body1.unwrap().is_empty(), + "incomplete UTF-8 at chunk boundary should produce no output" + ); + + let mut chunk2 = vec![0xAC]; + chunk2.extend_from_slice(b"\"},\"index\":0}]}\n\n"); + let mut body2 = Some(Bytes::from(chunk2)); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + let out = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + assert!(out.contains("text_delta"), "completed UTF-8 should emit text_delta"); + assert!(out.contains('\u{20ac}'), "Euro sign should appear in the output"); + } + + #[test] + fn invalid_utf8_passes_through_without_poisoning_next_chunk() { + let (filter, mut ctx) = make_filter_and_context(); + + let mut body1 = Some(Bytes::from(vec![0xFF])); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + assert_eq!( + body1.unwrap().as_ref(), + &[0xFF], + "malformed UTF-8 should pass through unchanged" + ); + assert!( + !ctx.filter_metadata.contains_key(UTF8_BUFFER_KEY), + "malformed UTF-8 should not be buffered as incomplete" + ); + + let chunk2 = + "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"content\":\"ok\"},\"index\":0}]}\n\n"; + let mut body2 = Some(Bytes::from(chunk2)); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + + let out = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + assert!( + out.contains("text_delta"), + "valid chunk after malformed UTF-8 should still transform" + ); + } + + #[test] + fn truncated_utf8_at_end_of_stream_passes_through() { + let (filter, mut ctx) = make_filter_and_context(); + + let mut body = Some(Bytes::from(vec![0xE2, 0x82])); + drop(filter.on_response_body(&mut ctx, &mut body, true).unwrap()); + + assert_eq!( + body.unwrap().as_ref(), + &[0xE2, 0x82], + "truncated final UTF-8 should pass through rather than being buffered" + ); + assert!( + !ctx.filter_metadata.contains_key(UTF8_BUFFER_KEY), + "truncated final UTF-8 should not leave buffered bytes" + ); + } + + #[test] + fn pending_utf8_flushed_by_none_end_of_stream_body() { + let (filter, mut ctx) = make_filter_and_context(); + + let mut body1 = Some(Bytes::from(vec![0xE2])); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + assert!( + body1.unwrap().is_empty(), + "incomplete UTF-8 should wait for the next chunk" + ); + + let mut body2 = None; + drop(filter.on_response_body(&mut ctx, &mut body2, true).unwrap()); + + assert_eq!( + body2.unwrap().as_ref(), + &[0xE2], + "missing final body should flush pending incomplete UTF-8" + ); + assert!( + !ctx.filter_metadata.contains_key(UTF8_BUFFER_KEY), + "flushed pending UTF-8 should clear the buffer" + ); + } + + #[test] + fn malformed_utf8_flushes_partial_sse_buffer() { + let (filter, mut ctx) = make_filter_and_context(); + + let mut chunk1 = Vec::new(); + chunk1.extend_from_slice(b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\""); + chunk1.extend_from_slice(&[0xE2]); + let mut body1 = Some(Bytes::from(chunk1)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + assert!(body1.unwrap().is_empty(), "setup chunk should produce no output"); + assert_stream_buffers_present(&ctx, true); + + let mut body2 = Some(Bytes::from(vec![0xFF])); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + + let output = body2.unwrap(); + assert!( + output.starts_with(b"data: {\"id\":\"c1\""), + "malformed UTF-8 should flush previously buffered SSE data" + ); + assert!( + output.ends_with(&[0xE2, 0xFF]), + "malformed UTF-8 output should include buffered and current malformed bytes" + ); + assert_stream_buffers_present(&ctx, false); + } + + #[test] + fn crlf_event_boundaries_parsed() { + let (filter, mut ctx) = make_filter_and_context(); + + let chunk = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"content\":\"Hi\"},\"index\":0}]}\r\n\r\n"; + let mut body = Some(Bytes::from(chunk)); + drop(filter.on_response_body(&mut ctx, &mut body, false).unwrap()); + + let out = String::from_utf8(body.unwrap().to_vec()).unwrap(); + assert!(out.contains("message_start"), "CRLF boundaries should be recognized"); + assert!( + out.contains("text_delta"), + "event data should parse through CRLF boundaries" + ); + } + + #[test] + fn crlf_split_across_chunks_not_false_boundary() { + let (filter, mut ctx) = make_filter_and_context(); + + let chunk1 = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}\r"; + let mut body1 = Some(Bytes::from(chunk1)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + assert!( + body1.unwrap().is_empty(), + "trailing CR should not prematurely complete an event" + ); + + let chunk2 = "\n\r\n"; + let mut body2 = Some(Bytes::from(chunk2)); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + let out = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + assert!( + out.contains("message_start"), + "completed CRLF-delimited event should produce output" + ); + } + + #[test] + fn data_without_space_after_colon_accepted() { + let (filter, mut ctx) = make_filter_and_context(); + + let chunk = + "data:{\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"content\":\"test\"},\"index\":0}]}\n\n"; + let mut body = Some(Bytes::from(chunk)); + drop(filter.on_response_body(&mut ctx, &mut body, false).unwrap()); + + let out = String::from_utf8(body.unwrap().to_vec()).unwrap(); + assert!( + out.contains("text_delta"), + "data without space after colon should be accepted" + ); + assert!( + out.contains("test"), + "content should be parsed from data: without space" + ); + } + + #[test] + fn multiline_data_fields_are_joined_before_json_parsing() { + let (filter, mut ctx) = make_filter_and_context(); + + let chunk = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\n\ + data: \"choices\":[{\"delta\":{\"content\":\"Hi\"},\"index\":0}]}\n\n"; + let mut body = Some(Bytes::from(chunk)); + drop(filter.on_response_body(&mut ctx, &mut body, false).unwrap()); + + let out = String::from_utf8(body.unwrap().to_vec()).unwrap(); + assert!( + out.contains("text_delta"), + "multi-line SSE data should be parsed as one JSON payload" + ); + assert!(out.contains("Hi"), "joined JSON content should be transformed"); + } + + #[test] + fn done_sentinel_without_space_after_colon() { + let (filter, mut ctx) = make_filter_and_context(); + + let chunk1 = "data:{\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"content\":\"Hi\"},\"index\":0,\"finish_reason\":\"stop\"}]}\n\n"; + let mut body1 = Some(Bytes::from(chunk1)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + + let done = "data:[DONE]\n\n"; + let mut body2 = Some(Bytes::from(done)); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + + let out = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + assert!( + out.contains("message_stop"), + "DONE without space should complete stream" + ); + } + + #[test] + fn bare_data_field_participates_in_event_payload() { + let (filter, mut ctx) = make_filter_and_context(); + + let chunk1 = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"content\":\"Hi\"},\"index\":0,\"finish_reason\":\"stop\"}]}\n\n"; + let mut body1 = Some(Bytes::from(chunk1)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + + let done = "data\ndata: [DONE]\n\n"; + let mut body2 = Some(Bytes::from(done)); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + + let out = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + assert!( + !out.contains("message_stop"), + "a preceding empty data field should prevent DONE sentinel recognition" + ); + } + + #[test] + fn cr_only_done_at_end_of_stream_emits_stop() { + let (filter, mut ctx) = make_filter_and_context(); + + let chunk1 = "data: {\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{\"delta\":{\"content\":\"Hi\"},\"index\":0,\"finish_reason\":\"stop\"}]}\n\n"; + let mut body1 = Some(Bytes::from(chunk1)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + + let done = "data:[DONE]\r\r"; + let mut body2 = Some(Bytes::from(done)); + drop(filter.on_response_body(&mut ctx, &mut body2, true).unwrap()); + + let out = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + assert!( + out.contains("message_stop"), + "CR-only DONE at end of stream should complete stream" + ); + } + + #[test] + fn deferred_cr_flushed_by_empty_end_of_stream_body() { + let (filter, mut ctx) = make_filter_and_context(); + + let done = "data:[DONE]\r\n\r"; + let mut body1 = Some(Bytes::from(done)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + assert!( + body1.unwrap().is_empty(), + "split CRLF delimiter should wait for final boundary" + ); + + let mut body2 = Some(Bytes::new()); + drop(filter.on_response_body(&mut ctx, &mut body2, true).unwrap()); + + let out = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + assert!( + out.contains("message_stop"), + "empty end-of-stream body should flush deferred CR delimiter" + ); + } + + #[test] + fn deferred_cr_flushed_by_none_end_of_stream_body() { + let (filter, mut ctx) = make_filter_and_context(); + + let done = "data:[DONE]\r\n\r"; + let mut body1 = Some(Bytes::from(done)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + + let mut body2 = None; + drop(filter.on_response_body(&mut ctx, &mut body2, true).unwrap()); + + let out = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + assert!( + out.contains("message_stop"), + "missing end-of-stream body should flush deferred CR delimiter" + ); + } + + #[test] + fn split_event_above_64k_uses_configured_default_limit() { + let (filter, mut ctx) = make_filter_and_context(); + let content = "x".repeat(70_000); + let chunk1 = + format!("data: {{\"id\":\"c1\",\"model\":\"gpt-4\",\"choices\":[{{\"delta\":{{\"content\":\"{content}"); + let mut body1 = Some(Bytes::from(chunk1)); + drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); + assert!( + body1.unwrap().is_empty(), + "large split SSE event should be buffered until its delimiter arrives" + ); + + let chunk2 = "\"},\"index\":0}]}\n\n"; + let mut body2 = Some(Bytes::from(chunk2)); + drop(filter.on_response_body(&mut ctx, &mut body2, false).unwrap()); + + let out = String::from_utf8(body2.unwrap().to_vec()).unwrap(); + assert!( + out.contains("text_delta"), + "valid split SSE event larger than 64 KiB should transform" + ); + } + + #[test] + fn configured_oversized_partial_event_rejected() { + let (filter, mut ctx) = make_filter_and_context_from_yaml("max_partial_event_bytes: 32"); + let filler = "x".repeat(33); + let chunk = format!("data: {filler}"); + let mut body = Some(Bytes::from(chunk)); + + let result = filter.on_response_body(&mut ctx, &mut body, false); + + let err = result.unwrap_err(); + assert!( + err.to_string().contains("exceeds 32 bytes"), + "oversized incomplete SSE event should mention the configured limit" + ); + assert!( + !ctx.filter_metadata.contains_key(LINE_BUFFER_KEY), + "oversized incomplete SSE event should not remain buffered" + ); + } + + #[test] + fn zero_partial_event_limit_rejected() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_partial_event_bytes: 0").unwrap(); + let result = AnthropicStreamEventsFilter::from_config(&yaml); + + assert!( + result.is_err(), + "streaming filter should reject a zero partial event limit" + ); + } + + #[test] + fn exceeds_max_partial_event_limit_rejected() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_partial_event_bytes: 67108865").unwrap(); + let result = AnthropicStreamEventsFilter::from_config(&yaml); + + assert!( + result.is_err(), + "streaming filter should reject a limit above MAX_JSON_BODY_BYTES" + ); + } + + // Test Utilities + + fn event_data(output: &str, event_type: &str) -> Value { + let marker = format!("event: {event_type}\n"); + let block = output.split("\n\n").find(|block| block.starts_with(&marker)).unwrap(); + let data = block.lines().find_map(|line| line.strip_prefix("data: ")).unwrap(); + serde_json::from_str(data).unwrap() + } + + fn event_indices(output: &str, event_type: &str) -> Vec { + let marker = format!("event: {event_type}\n"); + output + .split("\n\n") + .filter(|block| block.starts_with(&marker)) + .filter_map(|block| block.lines().find_map(|line| line.strip_prefix("data: "))) + .map(|data| serde_json::from_str::(data).unwrap()) + .map(|event| event.get("index").and_then(Value::as_u64).unwrap()) + .collect() + } + + fn assert_absent_fields(value: &Value, fields: &[&str], label: &str) { + for field in fields { + assert!(value.get(*field).is_none(), "{label} should omit {field}"); + } + } + + fn assert_null_fields(value: &Value, fields: &[&str], label: &str) { + for field in fields { + assert!( + value.get(*field).is_some_and(Value::is_null), + "{label} should include null {field}" + ); + } + } + + fn assert_u64_field(value: &Value, field: &str, expected: u64, label: &str) { + assert_eq!( + value.get(field).and_then(Value::as_u64), + Some(expected), + "{label} should include {field}" + ); + } + + fn assert_stream_buffers_present(ctx: &HttpFilterContext<'_>, expected: bool) { + assert_eq!( + ctx.filter_metadata.contains_key(LINE_BUFFER_KEY), + expected, + "SSE line buffer presence should be {expected}" + ); + assert_eq!( + ctx.filter_metadata.contains_key(UTF8_BUFFER_KEY), + expected, + "UTF-8 buffer presence should be {expected}" + ); + } + + fn make_filter() -> Box { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + AnthropicStreamEventsFilter::from_config(&yaml).unwrap() + } + + fn make_filter_from_yaml(yaml: &str) -> Box { + let yaml: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + AnthropicStreamEventsFilter::from_config(&yaml).unwrap() + } + + fn make_error_context(status: http::StatusCode) -> (HttpFilterContext<'static>, praxis_filter::Response) { + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + let mut resp = crate::test_utils::make_response(); + resp.status = status; + resp.headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + (crate::test_utils::make_filter_context(Box::leak(Box::new(req))), resp) + } + + fn make_filter_and_context() -> (Box, HttpFilterContext<'static>) { + make_filter_and_context_from_yaml("{}") + } + + fn make_filter_and_context_from_yaml(yaml: &str) -> (Box, HttpFilterContext<'static>) { + let req = crate::test_utils::make_request(http::Method::POST, "/v1/messages"); + let mut ctx = crate::test_utils::make_filter_context(Box::leak(Box::new(req))); + ctx.set_metadata(ARMED_KEY, "true".to_owned()); + (make_filter_from_yaml(yaml), ctx) + } +} diff --git a/apis/src/anthropic/to_openai/config.rs b/apis/src/anthropic/to_openai/config.rs new file mode 100644 index 0000000000..0d7b51b49e --- /dev/null +++ b/apis/src/anthropic/to_openai/config.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Configuration for the Anthropic-to-Chat-Completions transformation filter. + +use praxis_filter::{FilterError, builtins::http::payload_processing::config_validation::validate_max_body_bytes}; +use serde::Deserialize; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Default maximum request body size (1 MiB). +const DEFAULT_MAX_BODY_BYTES: usize = 1_048_576; // 1 MiB + +// ----------------------------------------------------------------------------- +// AnthropicToOpenaiConfig +// ----------------------------------------------------------------------------- + +/// YAML configuration for the [`AnthropicToOpenaiFilter`]. +/// +/// [`AnthropicToOpenaiFilter`]: super::AnthropicToOpenaiFilter +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AnthropicToOpenaiConfig { + /// Maximum body size in bytes for `StreamBuffer` mode. + #[serde(default = "default_max_body_bytes")] + pub max_body_bytes: usize, +} + +/// Default max body bytes. +fn default_max_body_bytes() -> usize { + DEFAULT_MAX_BODY_BYTES +} + +// ----------------------------------------------------------------------------- +// Config Validation +// ----------------------------------------------------------------------------- + +/// Validate the parsed configuration. +pub(crate) fn build_config(cfg: AnthropicToOpenaiConfig) -> Result { + validate_max_body_bytes("anthropic_to_openai", cfg.max_body_bytes)?; + Ok(cfg) +} diff --git a/apis/src/anthropic/to_openai/mod.rs b/apis/src/anthropic/to_openai/mod.rs new file mode 100644 index 0000000000..4c392c49e5 --- /dev/null +++ b/apis/src/anthropic/to_openai/mod.rs @@ -0,0 +1,874 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Anthropic Messages to Chat Completions-compatible transformation filter. +//! +//! Rewrites Anthropic Messages request bodies to the Chat Completions +//! request shape, transforms compatible non-streaming successes back, and +//! normalizes pre-stream upstream errors for both request modes. Successful +//! streaming SSE transformation is handled by the separate +//! `anthropic_stream_events` filter. +//! +//! The filter name preserves the proposal/config surface. `OpenAI` here +//! means the Chat Completions wire shape, not the Responses API or +//! OpenAI-only backends. + +mod config; +pub(crate) mod request; +pub(crate) mod response; + +use async_trait::async_trait; +use bytes::Bytes; +use praxis_filter::{ + BodyAccess, BodyMode, FilterAction, FilterError, HttpFilter, HttpFilterContext, parse_filter_config, +}; +use tracing::{debug, warn}; + +use self::config::{AnthropicToOpenaiConfig, build_config}; +use crate::anthropic::wire; + +/// Metadata key selecting success or error response transformation. +const RESPONSE_TRANSFORM_KEY: &str = "anthropic_to_openai.response_transform"; +/// Response transform marker for a successful response. +const RESPONSE_TRANSFORM_SUCCESS: &str = "success"; +/// Response transform marker for an upstream error. +const RESPONSE_TRANSFORM_ERROR: &str = "error"; +/// Metadata key preserving the upstream error status for the body phase. +const RESPONSE_STATUS_KEY: &str = "anthropic_to_openai.response_status"; +/// Metadata key preserving the upstream request ID for the body phase. +const RESPONSE_REQUEST_ID_KEY: &str = "anthropic_to_openai.response_request_id"; + +// ----------------------------------------------------------------------------- +// AnthropicToOpenaiFilter +// ----------------------------------------------------------------------------- + +/// Transforms Anthropic Messages API requests to Chat Completions-compatible +/// request bodies and transforms compatible responses back. The filter name +/// refers to the OpenAI Chat Completions wire shape, not the Responses API; +/// non-OpenAI compatible backends are valid targets. +/// +/// # YAML +/// +/// ```yaml +/// filter: anthropic_to_openai +/// ``` +/// +/// # Full YAML +/// +/// ```yaml +/// filter: anthropic_to_openai +/// max_body_bytes: 1048576 +/// ``` +pub struct AnthropicToOpenaiFilter { + /// Parsed and validated configuration. + config: AnthropicToOpenaiConfig, +} + +impl AnthropicToOpenaiFilter { + /// Create a filter from parsed YAML config. + /// + /// # Errors + /// + /// Returns [`FilterError`] if the YAML config is invalid. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: AnthropicToOpenaiConfig = parse_filter_config("anthropic_to_openai", config)?; + let validated = build_config(cfg)?; + Ok(Box::new(Self { config: validated })) + } +} + +#[async_trait] +impl HttpFilter for AnthropicToOpenaiFilter { + fn name(&self) -> &'static str { + "anthropic_to_openai" + } + + fn request_body_access(&self) -> BodyAccess { + BodyAccess::ReadWrite + } + + fn request_body_mode(&self) -> BodyMode { + BodyMode::StreamBuffer { + max_bytes: Some(self.config.max_body_bytes), + } + } + + fn response_body_access(&self) -> BodyAccess { + BodyAccess::ReadWrite + } + + fn response_body_mode(&self) -> BodyMode { + BodyMode::Stream + } + + async fn on_response(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + let request_id = canonicalize_response_request_id(ctx); + let Some(transform) = response_transform(ctx) else { + return Ok(FilterAction::Continue); + }; + + ctx.set_metadata(RESPONSE_TRANSFORM_KEY, transform); + if transform == RESPONSE_TRANSFORM_ERROR { + let status = ctx + .response_header + .as_ref() + .map_or(500, |response| response.status.as_u16()); + ctx.set_metadata(RESPONSE_STATUS_KEY, status.to_string()); + if let Some(request_id) = request_id { + ctx.set_metadata(RESPONSE_REQUEST_ID_KEY, request_id); + } + } + + ctx.set_response_body_mode(BodyMode::StreamBuffer { + max_bytes: Some(self.config.max_body_bytes), + }); + prepare_transformed_response_headers(ctx); + + Ok(FilterAction::Continue) + } + + async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + ctx.request_headers_to_remove + .push(http::header::HeaderName::from_static("anthropic-version")); + ctx.request_headers_to_remove + .push(http::header::HeaderName::from_static("x-api-key")); + ctx.request_headers_to_remove.push(http::header::ACCEPT_ENCODING); + + Ok(FilterAction::Continue) + } + + async fn on_request_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + if !end_of_stream { + return Ok(FilterAction::Continue); + } + + let bytes = match body.as_ref() { + Some(b) if !b.is_empty() => b.as_ref(), + _ => return Ok(FilterAction::Continue), + }; + + extract_request_metadata(ctx, bytes); + Ok(transform_request_body(body)) + } + + fn on_response_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + let transform_error = match ctx.get_metadata(RESPONSE_TRANSFORM_KEY) { + Some(RESPONSE_TRANSFORM_ERROR) => true, + Some(RESPONSE_TRANSFORM_SUCCESS) => false, + _ => return Ok(FilterAction::Continue), + }; + + if !end_of_stream { + return Ok(FilterAction::Continue); + } + + if transform_error { + let status = ctx + .get_metadata(RESPONSE_STATUS_KEY) + .and_then(|value| value.parse::().ok()) + .and_then(|value| http::StatusCode::from_u16(value).ok()) + .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR); + let request_id = ctx.get_metadata(RESPONSE_REQUEST_ID_KEY); + transform_error_body(body, status, request_id); + } else { + let request_model = ctx + .filter_metadata + .get("anthropic_to_openai.model") + .cloned() + .unwrap_or_default(); + transform_non_streaming_body(ctx, body, &request_model); + } + + Ok(FilterAction::Continue) + } +} + +// ----------------------------------------------------------------------------- +// Request Body Helpers +// ----------------------------------------------------------------------------- + +/// Extract streaming and model metadata from the request body. +fn extract_request_metadata(ctx: &mut HttpFilterContext<'_>, bytes: &[u8]) { + let Ok(value) = serde_json::from_slice::(bytes) else { + ctx.set_metadata("anthropic_to_openai.streaming", "false"); + return; + }; + + let is_streaming = value + .get("stream") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + ctx.set_metadata( + "anthropic_to_openai.streaming", + if is_streaming { "true" } else { "false" }, + ); + + let model = value + .get("model") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .unwrap_or_default(); + ctx.set_metadata("anthropic_to_openai.model", model); +} + +/// Transform the request body and return the appropriate filter action. +fn transform_request_body(body: &mut Option) -> FilterAction { + let Some(bytes) = body.as_ref() else { + return FilterAction::Continue; + }; + + match request::transform_request(bytes) { + Ok(transformed) => { + debug!( + original_len = bytes.len(), + transformed_len = transformed.len(), + "transformed Anthropic request to Chat Completions-compatible format" + ); + *body = Some(Bytes::from(transformed)); + FilterAction::Continue + }, + Err(msg) => { + warn!(error = msg.as_str(), "failed to transform Anthropic request"); + FilterAction::Reject(wire::invalid_request_rejection(&msg)) + }, + } +} + +// ----------------------------------------------------------------------------- +// Response Body Helpers +// ----------------------------------------------------------------------------- + +/// Remove stale representation metadata before replacing a response body. +fn prepare_transformed_response_headers(ctx: &mut HttpFilterContext<'_>) { + if let Some(resp) = &mut ctx.response_header { + resp.headers.remove(http::header::CONTENT_LENGTH); + resp.headers.remove(http::header::CONTENT_ENCODING); + resp.headers.remove(http::header::CONTENT_RANGE); + resp.headers.remove(http::header::ETAG); + for header in ["content-digest", "content-md5", "digest", "repr-digest"] { + resp.headers.remove(header); + } + resp.headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + ctx.response_headers_modified = true; + } +} + +/// Expose the upstream request ID through Anthropic's canonical header. +fn canonicalize_response_request_id(ctx: &mut HttpFilterContext<'_>) -> Option { + let request_id = ctx.response_header.as_ref().and_then(|response| { + response + .headers + .get("request-id") + .or_else(|| response.headers.get("x-request-id")) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }); + if let Some(request_id) = request_id.as_deref() + && let Some(response) = &mut ctx.response_header + && let Ok(value) = http::HeaderValue::from_str(request_id) + { + response.headers.insert("request-id", value); + ctx.response_headers_modified = true; + } + request_id +} + +/// Return true when the response should be buffered and transformed. +#[cfg(test)] +fn should_transform_response(ctx: &HttpFilterContext<'_>) -> bool { + response_transform(ctx).is_some() +} + +/// Select the response transformation while headers are available. +fn response_transform(ctx: &HttpFilterContext<'_>) -> Option<&'static str> { + let is_streaming = ctx + .filter_metadata + .get("anthropic_to_openai.streaming") + .is_some_and(|v| v == "true"); + let status = ctx.response_header.as_ref().map(|response| response.status); + let is_error = status.is_some_and(|status| status.is_client_error() || status.is_server_error()); + let is_complete_success = status.is_none_or(|status| status == http::StatusCode::OK) + && ctx.response_header.as_ref().is_none_or(|response| { + !response.headers.contains_key(http::header::CONTENT_ENCODING) + && !response.headers.contains_key(http::header::CONTENT_RANGE) + && response + .headers + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_none_or(|value| { + let media_type = value.split(';').next().unwrap_or_default().trim(); + media_type.eq_ignore_ascii_case("application/json") + || media_type.to_ascii_lowercase().ends_with("+json") + }) + }); + + if is_error { + Some(RESPONSE_TRANSFORM_ERROR) + } else if !is_streaming && is_complete_success { + Some(RESPONSE_TRANSFORM_SUCCESS) + } else { + None + } +} + +/// Normalize a buffered upstream error response. +fn transform_error_body(body: &mut Option, status: http::StatusCode, request_id: Option<&str>) { + let original = body.as_deref().unwrap_or_default(); + let transformed = response::transform_error_response(original, status, request_id); + + *body = Some(Bytes::from(transformed)); +} + +/// Apply non-streaming JSON transformation to the response body. +fn transform_non_streaming_body(ctx: &mut HttpFilterContext<'_>, body: &mut Option, request_model: &str) { + let bytes = match body.as_ref() { + Some(b) => b.as_ref(), + None => return, + }; + + if bytes.is_empty() { + return; + } + + match response::transform_response(bytes, request_model) { + Ok(result) => { + debug!( + original_len = bytes.len(), + transformed_len = result.body.len(), + original_finish_reason = result.original_finish_reason.as_str(), + "transformed Chat Completions-compatible response to Anthropic" + ); + ctx.set_metadata("openai.finish_reason", result.original_finish_reason); + *body = Some(Bytes::from(result.body)); + }, + Err(msg) => { + warn!( + error = msg.as_str(), + "failed to transform Chat Completions-compatible response" + ); + }, + } +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[expect( + clippy::unwrap_used, + clippy::indexing_slicing, + clippy::panic, + clippy::too_many_lines, + reason = "tests" +)] +mod tests { + use bytes::Bytes; + use http::{Method, StatusCode}; + + use super::*; + use crate::test_utils::{make_filter_context, make_request, make_response}; + + #[test] + fn default_config_parses() { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let filter = AnthropicToOpenaiFilter::from_config(&yaml).unwrap(); + + assert_eq!(filter.name(), "anthropic_to_openai", "filter name should match"); + } + + #[test] + fn unknown_config_field_rejected() { + let yaml: serde_yaml::Value = serde_yaml::from_str("strip_unsupported: true").unwrap(); + let result = AnthropicToOpenaiFilter::from_config(&yaml); + + assert!(result.is_err(), "unknown config fields should be rejected"); + } + + #[test] + fn zero_max_body_bytes_rejected() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_body_bytes: 0").unwrap(); + let result = AnthropicToOpenaiFilter::from_config(&yaml); + + assert!(result.is_err(), "zero max_body_bytes should be rejected"); + } + + #[test] + fn rejects_max_body_bytes_above_ceiling() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_body_bytes: 67108865").unwrap(); + let result = AnthropicToOpenaiFilter::from_config(&yaml); + + assert!( + result.is_err(), + "max_body_bytes above 64 MiB ceiling should be rejected" + ); + } + + #[tokio::test] + async fn error_response_state_survives_body_phase_without_headers() { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let filter = AnthropicToOpenaiFilter::from_config(&yaml).unwrap(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + let mut response = make_response(); + response.status = StatusCode::SERVICE_UNAVAILABLE; + response.headers.insert("x-request-id", "req_header".parse().unwrap()); + response + .headers + .insert(http::header::CONTENT_LENGTH, http::HeaderValue::from_static("72")); + ctx.response_header = Some(&mut response); + ctx.set_metadata("anthropic_to_openai.streaming", "true"); + ctx.set_metadata("anthropic_to_openai.model", "gpt-4"); + let action = filter.on_response(&mut ctx).await.unwrap(); + + assert!(matches!(action, FilterAction::Continue), "filter should continue"); + assert!( + ctx.response_header + .as_ref() + .is_some_and(|response| !response.headers.contains_key(http::header::CONTENT_LENGTH)), + "buffered error should remove content-length during the header phase" + ); + assert_eq!( + ctx.response_header + .as_ref() + .and_then(|response| response.headers.get("request-id")) + .and_then(|value| value.to_str().ok()), + Some("req_header"), + "OpenAI request IDs should be exposed through Anthropic's response header" + ); + ctx.response_header = None; + + let mut body = Some(Bytes::from_static( + br#"{"error":{"message":"unavailable","type":"server_error"}}"#, + )); + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + let parsed: serde_json::Value = serde_json::from_slice(body.as_deref().unwrap()).unwrap(); + + assert!(matches!(action, FilterAction::Continue), "filter should continue"); + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "api_error"); + assert_eq!(parsed["error"]["message"], "unavailable"); + assert_eq!(parsed["request_id"], "req_header"); + } + + #[tokio::test] + async fn rewritten_errors_remove_stale_representation_headers() { + for content_encoding in ["gzip", "br"] { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_body_bytes: 4096").unwrap(); + let filter = AnthropicToOpenaiFilter::from_config(&yaml).unwrap(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + let mut response = make_response(); + response.status = StatusCode::BAD_REQUEST; + response + .headers + .insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("text/plain")); + response.headers.insert( + http::header::CONTENT_ENCODING, + http::HeaderValue::from_str(content_encoding).unwrap(), + ); + response.headers.insert( + http::header::CONTENT_RANGE, + http::HeaderValue::from_static("bytes 0-41/42"), + ); + response + .headers + .insert(http::header::ETAG, http::HeaderValue::from_static("\"upstream\"")); + response + .headers + .insert("content-digest", http::HeaderValue::from_static("sha-256=:abc:")); + ctx.response_header = Some(&mut response); + + drop(filter.on_response(&mut ctx).await.unwrap()); + + assert!( + matches!(ctx.response_body_mode, BodyMode::StreamBuffer { max_bytes: Some(4096) }), + "rewritten errors should use the configured buffer limit" + ); + assert_eq!( + ctx.response_header + .as_ref() + .and_then(|response| response.headers.get(http::header::CONTENT_TYPE)) + .and_then(|value| value.to_str().ok()), + Some("application/json"), + "rewritten errors should advertise JSON" + ); + for header in [ + http::header::CONTENT_ENCODING, + http::header::CONTENT_RANGE, + http::header::ETAG, + http::HeaderName::from_static("content-digest"), + ] { + assert!( + ctx.response_header + .as_ref() + .is_some_and(|response| !response.headers.contains_key(&header)), + "{header} should be removed when rewriting a {content_encoding}-encoded error" + ); + } + } + } + + // --- extract_request_metadata --- + + #[test] + fn extract_request_metadata_streaming_true_with_model() { + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + let bytes = br#"{"stream":true,"model":"claude-opus-4-8"}"#; + + extract_request_metadata(&mut ctx, bytes); + + assert_eq!( + ctx.filter_metadata.get("anthropic_to_openai.streaming").unwrap(), + "true" + ); + assert_eq!( + ctx.filter_metadata.get("anthropic_to_openai.model").unwrap(), + "claude-opus-4-8" + ); + } + + #[test] + fn extract_request_metadata_streaming_false() { + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + let bytes = br#"{"stream":false,"model":"gpt-4"}"#; + + extract_request_metadata(&mut ctx, bytes); + + assert_eq!( + ctx.filter_metadata.get("anthropic_to_openai.streaming").unwrap(), + "false" + ); + assert_eq!(ctx.filter_metadata.get("anthropic_to_openai.model").unwrap(), "gpt-4"); + } + + #[test] + fn extract_request_metadata_invalid_json() { + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + + extract_request_metadata(&mut ctx, b"not json"); + + assert_eq!( + ctx.filter_metadata.get("anthropic_to_openai.streaming").unwrap(), + "false", + "invalid JSON should default streaming to false" + ); + assert!( + !ctx.filter_metadata.contains_key("anthropic_to_openai.model"), + "invalid JSON should not set model" + ); + } + + // --- transform_request_body --- + + #[test] + fn transform_request_body_none_continues() { + let mut body: Option = None; + let action = transform_request_body(&mut body); + + assert!(matches!(action, FilterAction::Continue)); + assert!(body.is_none()); + } + + #[tokio::test] + async fn on_request_prevents_upstream_response_encoding() { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let filter = AnthropicToOpenaiFilter::from_config(&yaml).unwrap(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + + drop(filter.on_request(&mut ctx).await.unwrap()); + + assert!( + ctx.request_headers_to_remove.contains(&http::header::ACCEPT_ENCODING), + "response transformation requires an unencoded upstream representation" + ); + } + + #[test] + fn transform_request_body_valid_transforms() { + let mut body = Some(Bytes::from( + br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":"Hi"}]}"#.to_vec(), + )); + let action = transform_request_body(&mut body); + + assert!(matches!(action, FilterAction::Continue)); + assert!(body.is_some()); + let parsed: serde_json::Value = serde_json::from_slice(body.unwrap().as_ref()).unwrap(); + assert_eq!(parsed["messages"][0]["role"], "user"); + assert_eq!( + parsed["max_completion_tokens"], 1024, + "max_tokens should be mapped to max_completion_tokens" + ); + } + + #[test] + fn transform_request_body_invalid_rejects() { + let mut body = Some(Bytes::from_static(b"not json")); + let action = transform_request_body(&mut body); + + let FilterAction::Reject(rejection) = action else { + panic!("invalid body should produce a rejection"); + }; + let parsed: serde_json::Value = serde_json::from_slice(rejection.body.as_deref().unwrap()).unwrap(); + + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "invalid_request_error"); + assert!(parsed.get("request_id").is_some()); + assert!(parsed["request_id"].is_null()); + } + + // --- should_transform_response --- + + #[test] + fn should_transform_response_streaming_returns_false() { + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + ctx.set_metadata("anthropic_to_openai.streaming", "true"); + let mut response = make_response(); + ctx.response_header = Some(&mut response); + + assert!( + !should_transform_response(&ctx), + "streaming responses should not be transformed" + ); + } + + #[test] + fn should_transform_response_non_streaming_success() { + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + ctx.set_metadata("anthropic_to_openai.streaming", "false"); + let mut response = make_response(); + ctx.response_header = Some(&mut response); + + assert!( + should_transform_response(&ctx), + "non-streaming success should be transformed" + ); + } + + #[test] + fn should_not_transform_encoded_non_streaming_success() { + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + ctx.set_metadata("anthropic_to_openai.streaming", "false"); + let mut response = make_response(); + response + .headers + .insert(http::header::CONTENT_ENCODING, http::HeaderValue::from_static("gzip")); + ctx.response_header = Some(&mut response); + + assert!( + !should_transform_response(&ctx), + "encoded success should pass through with its representation headers intact" + ); + } + + #[tokio::test] + async fn encoded_non_streaming_success_passes_through_unchanged() { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let filter = AnthropicToOpenaiFilter::from_config(&yaml).unwrap(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + ctx.set_metadata("anthropic_to_openai.streaming", "false"); + let mut response = make_response(); + response + .headers + .insert(http::header::CONTENT_ENCODING, http::HeaderValue::from_static("gzip")); + ctx.response_header = Some(&mut response); + + drop(filter.on_response(&mut ctx).await.unwrap()); + + assert_eq!(ctx.response_body_mode, BodyMode::Stream); + assert!( + ctx.response_header + .as_ref() + .is_some_and(|response| response.headers.contains_key(http::header::CONTENT_ENCODING)) + ); + + let encoded = Bytes::from_static(b"\x1f\x8bencoded-response"); + let mut body = Some(encoded.clone()); + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + assert_eq!(body, Some(encoded)); + } + + #[tokio::test] + async fn non_json_success_passes_through_unchanged() { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let filter = AnthropicToOpenaiFilter::from_config(&yaml).unwrap(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + ctx.set_metadata("anthropic_to_openai.streaming", "false"); + let mut response = make_response(); + response + .headers + .insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("text/plain")); + ctx.response_header = Some(&mut response); + + drop(filter.on_response(&mut ctx).await.unwrap()); + + assert_eq!(ctx.response_body_mode, BodyMode::Stream); + assert_eq!( + ctx.response_header + .as_ref() + .and_then(|response| response.headers.get(http::header::CONTENT_TYPE)) + .and_then(|value| value.to_str().ok()), + Some("text/plain") + ); + + let original = Bytes::from_static(b"upstream plaintext"); + let mut body = Some(original.clone()); + drop(filter.on_response_body(&mut ctx, &mut body, true).unwrap()); + assert_eq!(body, Some(original)); + } + + #[tokio::test] + async fn successful_responses_canonicalize_request_id() { + for is_streaming in ["false", "true"] { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let filter = AnthropicToOpenaiFilter::from_config(&yaml).unwrap(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + ctx.set_metadata("anthropic_to_openai.streaming", is_streaming); + let mut response = make_response(); + response.headers.insert("x-request-id", "req_success".parse().unwrap()); + ctx.response_header = Some(&mut response); + + drop(filter.on_response(&mut ctx).await.unwrap()); + + assert_eq!( + ctx.response_header + .as_ref() + .and_then(|response| response.headers.get("request-id")) + .and_then(|value| value.to_str().ok()), + Some("req_success"), + "stream={is_streaming}" + ); + } + } + + #[test] + fn should_not_transform_partial_non_streaming_success() { + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + ctx.set_metadata("anthropic_to_openai.streaming", "false"); + let mut response = make_response(); + response.status = StatusCode::PARTIAL_CONTENT; + response.headers.insert( + http::header::CONTENT_RANGE, + http::HeaderValue::from_static("bytes 0-99/200"), + ); + ctx.response_header = Some(&mut response); + + assert!( + !should_transform_response(&ctx), + "partial success should pass through with its representation headers intact" + ); + } + + #[test] + fn should_transform_response_errors_for_both_request_modes() { + for is_streaming in ["false", "true"] { + for status in [StatusCode::BAD_REQUEST, StatusCode::INTERNAL_SERVER_ERROR] { + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + ctx.set_metadata("anthropic_to_openai.streaming", is_streaming); + let mut response = make_response(); + response.status = status; + ctx.response_header = Some(&mut response); + + assert!( + should_transform_response(&ctx), + "{status} response should be transformed for stream={is_streaming}" + ); + } + } + } + + #[test] + fn should_not_transform_redirect_response() { + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + ctx.set_metadata("anthropic_to_openai.streaming", "false"); + let mut response = make_response(); + response.status = StatusCode::FOUND; + ctx.response_header = Some(&mut response); + + assert!(!should_transform_response(&ctx), "redirect should pass through"); + } + + // --- transform_non_streaming_body --- + + #[test] + fn transform_non_streaming_body_none_is_noop() { + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + let mut body: Option = None; + + transform_non_streaming_body(&mut ctx, &mut body, "gpt-4"); + + assert!(body.is_none()); + } + + #[test] + fn transform_non_streaming_body_empty_bytes_is_noop() { + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + let mut body = Some(Bytes::new()); + + transform_non_streaming_body(&mut ctx, &mut body, "gpt-4"); + + assert_eq!(body.as_ref().unwrap().len(), 0, "empty bytes should not be transformed"); + } + + #[test] + fn transform_non_streaming_body_success() { + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + let response_json = br#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"message":{"role":"assistant","content":"Hello!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#; + let mut body = Some(Bytes::from(response_json.to_vec())); + + transform_non_streaming_body(&mut ctx, &mut body, "gpt-4"); + + assert!(body.is_some()); + let parsed: serde_json::Value = serde_json::from_slice(body.unwrap().as_ref()).unwrap(); + assert_eq!(parsed["type"], "message"); + assert_eq!(parsed["content"][0]["text"], "Hello!"); + assert_eq!( + ctx.filter_metadata.get("openai.finish_reason").unwrap(), + "stop", + "finish_reason should be stored in metadata" + ); + } + + #[test] + fn transform_non_streaming_body_invalid_json_preserves_body() { + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + let original = Bytes::from_static(b"not json"); + let mut body = Some(original.clone()); + + transform_non_streaming_body(&mut ctx, &mut body, "gpt-4"); + + assert_eq!(body, Some(original), "body should not be modified on error"); + } +} diff --git a/apis/src/anthropic/to_openai/request.rs b/apis/src/anthropic/to_openai/request.rs new file mode 100644 index 0000000000..465ac27868 --- /dev/null +++ b/apis/src/anthropic/to_openai/request.rs @@ -0,0 +1,1240 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Anthropic Messages to Chat Completions-compatible request transformation. + +use serde_json::{Map, Value, json}; +use tracing::warn; + +// ----------------------------------------------------------------------------- +// Request Transformation +// ----------------------------------------------------------------------------- + +/// Transform an Anthropic Messages request body into Chat +/// Completions-compatible format. +/// +/// Returns the transformed JSON bytes, or an error message. +pub(crate) fn transform_request(body: &[u8]) -> Result, String> { + let value: Value = serde_json::from_slice(body).map_err(|e| format!("invalid JSON: {e}"))?; + + let Some(obj) = value.as_object() else { + return Err("request body is not a JSON object".to_owned()); + }; + + let mut chat = Map::new(); + + if let Some(model) = obj.get("model") { + chat.insert("model".to_owned(), model.clone()); + } + + let mut messages = Vec::new(); + hoist_system(&mut messages, obj); + convert_messages(&mut messages, obj); + chat.insert("messages".to_owned(), Value::Array(messages)); + + if let Some(max_tokens) = obj.get("max_tokens") { + chat.insert("max_completion_tokens".to_owned(), max_tokens.clone()); + } + + if let Some(stream) = obj.get("stream") { + chat.insert("stream".to_owned(), stream.clone()); + } + + map_parameters(&mut chat, obj); + convert_tools(&mut chat, obj); + convert_parallel_tool_calls(&mut chat, obj); + convert_tool_choice(&mut chat, obj); + + serde_json::to_vec(&Value::Object(chat)).map_err(|e| format!("serialization failed: {e}")) +} + +// ----------------------------------------------------------------------------- +// System Message Hoisting +// ----------------------------------------------------------------------------- + +/// Hoist Anthropic top-level `system` to a Chat Completions system message. +fn hoist_system(messages: &mut Vec, obj: &Map) { + let Some(system) = obj.get("system") else { + return; + }; + + let content = match system { + Value::String(s) => s.clone(), + Value::Array(blocks) => { + let mut parts = Vec::new(); + for block in blocks { + if let Some(text) = block.get("text").and_then(Value::as_str) { + parts.push(text.to_owned()); + } + } + parts.join("\n") + }, + _ => return, + }; + + if !content.is_empty() { + messages.push(json!({"role": "system", "content": content})); + } +} + +// ----------------------------------------------------------------------------- +// Message Conversion +// ----------------------------------------------------------------------------- + +/// Convert Anthropic messages array to Chat Completions messages. +fn convert_messages(messages: &mut Vec, obj: &Map) { + let Some(Value::Array(anthropic_messages)) = obj.get("messages") else { + return; + }; + + for msg in anthropic_messages { + let Some(role) = msg.get("role").and_then(Value::as_str) else { + continue; + }; + + match msg.get("content") { + Some(Value::String(text)) => { + messages.push(json!({"role": role, "content": text})); + }, + Some(Value::Array(blocks)) => { + convert_content_blocks(messages, role, blocks); + }, + _ => { + messages.push(json!({"role": role, "content": ""})); + }, + } + } +} + +/// Convert typed content blocks to Chat Completions-compatible format. +fn convert_content_blocks(messages: &mut Vec, role: &str, blocks: &[Value]) { + let mut text_parts = Vec::new(); + let mut content_parts: Vec = Vec::new(); + let mut tool_calls: Vec = Vec::new(); + + for block in blocks { + let block_type = block.get("type").and_then(Value::as_str).unwrap_or(""); + convert_single_block( + block, + block_type, + messages, + role, + &mut text_parts, + &mut content_parts, + &mut tool_calls, + ); + } + + finalize_content_blocks(messages, role, &mut text_parts, &mut content_parts, tool_calls); +} + +/// Process a single content block within a message. +#[expect( + clippy::too_many_arguments, + reason = "accumulator pattern requires passing all state" +)] +fn convert_single_block( + block: &Value, + block_type: &str, + messages: &mut Vec, + role: &str, + text_parts: &mut Vec, + content_parts: &mut Vec, + tool_calls: &mut Vec, +) { + match block_type { + "text" => convert_text_block(block, text_parts, content_parts), + "image" => convert_image_block(block, content_parts), + "search_result" => convert_search_result_block(block, text_parts, content_parts), + "document" => convert_document_block(block, text_parts, content_parts), + "tool_use" => convert_tool_use_block(block, tool_calls), + "tool_result" => { + flush_text_parts(messages, text_parts, content_parts, role); + convert_tool_result_block(block, messages); + }, + "thinking" | "redacted_thinking" => { + warn!(block_type, "dropping unsupported Anthropic content block"); + }, + _ => { + warn!(block_type, "dropping unknown Anthropic content block type"); + }, + } +} + +/// Convert a text content block. +fn convert_text_block(block: &Value, text_parts: &mut Vec, content_parts: &mut Vec) { + if let Some(text) = block.get("text").and_then(Value::as_str) { + append_text_content(text, text_parts, content_parts); + } +} + +/// Convert an image content block. +fn convert_image_block(block: &Value, content_parts: &mut Vec) { + if let Some(source) = block.get("source") + && let Some(url_val) = convert_image_source(source) + { + content_parts.push(json!({"type": "image_url", "image_url": {"url": url_val}})); + } +} + +/// Convert a `search_result` block to backend-visible text context. +fn convert_search_result_block(block: &Value, text_parts: &mut Vec, content_parts: &mut Vec) { + if let Some(text) = flatten_search_result(block) { + append_text_content(&text, text_parts, content_parts); + } +} + +/// Convert a `document` block to backend-visible text context. +fn convert_document_block(block: &Value, text_parts: &mut Vec, content_parts: &mut Vec) { + if let Some(text) = flatten_document(block) { + append_text_content(&text, text_parts, content_parts); + } +} + +/// Append one Chat Completions text content part and its string equivalent. +fn append_text_content(text: &str, text_parts: &mut Vec, content_parts: &mut Vec) { + text_parts.push(text.to_owned()); + content_parts.push(json!({"type": "text", "text": text})); +} + +/// Convert a `tool_use` content block to a Chat Completions tool call. +fn convert_tool_use_block(block: &Value, tool_calls: &mut Vec) { + let id = block.get("id").and_then(Value::as_str).unwrap_or(""); + let name = block.get("name").and_then(Value::as_str).unwrap_or(""); + let input = block.get("input").cloned().unwrap_or_else(|| Value::Object(Map::new())); + let args = serde_json::to_string(&input).unwrap_or_default(); + + tool_calls.push(json!({ + "id": id, + "type": "function", + "function": {"name": name, "arguments": args} + })); +} + +/// Convert a `tool_result` content block to a Chat Completions tool message. +fn convert_tool_result_block(block: &Value, messages: &mut Vec) { + let tool_call_id = block.get("tool_use_id").and_then(Value::as_str).unwrap_or(""); + let mut result_content = extract_tool_result_content(block); + let image_content = extract_tool_result_image_content(block); + + if block.get("is_error").and_then(Value::as_bool) == Some(true) { + result_content = mark_tool_result_error(result_content); + } + + messages.push(json!({ + "role": "tool", + "tool_call_id": tool_call_id, + "content": result_content + })); + + if !image_content.is_empty() { + messages.push(json!({ + "role": "user", + "content": image_content + })); + } +} + +/// Emit the final message for accumulated content and tool calls. +fn finalize_content_blocks( + messages: &mut Vec, + role: &str, + text_parts: &mut Vec, + content_parts: &mut Vec, + tool_calls: Vec, +) { + if role == "assistant" && !tool_calls.is_empty() { + let mut msg = json!({"role": "assistant"}); + if let Some(obj) = msg.as_object_mut() { + if !text_parts.is_empty() { + obj.insert("content".to_owned(), Value::String(text_parts.join(""))); + } + obj.insert("tool_calls".to_owned(), Value::Array(tool_calls)); + } + messages.push(msg); + } else { + flush_text_parts(messages, text_parts, content_parts, role); + } +} + +/// Flush accumulated text/content parts as a message. +fn flush_text_parts( + messages: &mut Vec, + text_parts: &mut Vec, + content_parts: &mut Vec, + role: &str, +) { + if content_parts.is_empty() && text_parts.is_empty() { + return; + } + + if content_parts.len() == 1 + && content_parts + .first() + .and_then(|p| p.get("type")) + .and_then(Value::as_str) + == Some("text") + { + messages.push(json!({"role": role, "content": text_parts.join("")})); + } else if !content_parts.is_empty() { + messages.push(json!({"role": role, "content": std::mem::take(content_parts)})); + } + + text_parts.clear(); + content_parts.clear(); +} + +// ----------------------------------------------------------------------------- +// Image Source Conversion +// ----------------------------------------------------------------------------- + +/// Convert Anthropic image source to an `image_url` URL string. +fn convert_image_source(source: &Value) -> Option { + let source_type = source.get("type").and_then(Value::as_str)?; + + match source_type { + "base64" => { + let media_type = source.get("media_type").and_then(Value::as_str)?; + let data = source.get("data").and_then(Value::as_str)?; + Some(format!("data:{media_type};base64,{data}")) + }, + "url" => source.get("url").and_then(Value::as_str).map(str::to_owned), + _ => None, + } +} + +// ----------------------------------------------------------------------------- +// Tool Result Content Extraction +// ----------------------------------------------------------------------------- + +/// Extract text content from a `tool_result` block. +fn extract_tool_result_content(block: &Value) -> String { + match block.get("content") { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(parts)) => { + let mut text_parts = Vec::new(); + for part in parts { + match part.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = part.get("text").and_then(Value::as_str) { + text_parts.push(text.to_owned()); + } + }, + Some("search_result") => { + if let Some(text) = flatten_search_result(part) { + text_parts.push(text); + } + }, + Some("document") => { + if let Some(text) = flatten_document(part) { + text_parts.push(text); + } + }, + _ => {}, + } + } + text_parts.join("\n") + }, + _ => String::new(), + } +} + +/// Preserve Anthropic's `tool_result.is_error` semantic in text-only tool messages. +fn mark_tool_result_error(mut content: String) -> String { + if content.is_empty() { + "Anthropic tool_result error".to_owned() + } else { + content.insert_str(0, "Anthropic tool_result error:\n"); + content + } +} + +/// Extract image content from a `tool_result` block. +fn extract_tool_result_image_content(block: &Value) -> Vec { + let Some(Value::Array(parts)) = block.get("content") else { + return Vec::new(); + }; + + let mut image_parts = Vec::new(); + for part in parts { + if part.get("type").and_then(Value::as_str) == Some("image") { + convert_image_block(part, &mut image_parts); + } + } + image_parts +} + +/// Flatten an Anthropic `search_result` block to plain text. +fn flatten_search_result(block: &Value) -> Option { + let title = block + .get("title") + .and_then(Value::as_str) + .filter(|title| !title.is_empty()); + let source = block + .get("source") + .and_then(Value::as_str) + .filter(|source| !source.is_empty()); + let content = extract_text_blocks(block.get("content")); + + if title.is_none() && source.is_none() && content.is_empty() { + return None; + } + + let mut lines = Vec::new(); + + if let Some(title) = title { + lines.push(format!("Search result: {}", quote_label_value(title))); + } else { + lines.push("Search result".to_owned()); + } + + if let Some(source) = source { + lines.push(format!("Source: {}", quote_label_value(source))); + } + + if !content.is_empty() { + lines.push("Content:".to_owned()); + } + + lines.extend(content); + non_empty_lines(&lines) +} + +/// Flatten an Anthropic `document` block to plain text. +fn flatten_document(block: &Value) -> Option { + let title = block + .get("title") + .and_then(Value::as_str) + .filter(|title| !title.is_empty()); + let context = block + .get("context") + .and_then(Value::as_str) + .filter(|context| !context.is_empty()); + let source_text = flatten_document_source(block.get("source")); + + if title.is_none() && context.is_none() && source_text.is_none() { + return None; + } + + let mut lines = Vec::new(); + + if let Some(title) = title { + lines.push(format!("Document: {}", quote_label_value(title))); + } else { + lines.push("Document".to_owned()); + } + + if let Some(context) = context { + lines.push(format!("Context: {}", quote_label_value(context))); + } + + if let Some(source_text) = source_text { + lines.push(source_text); + } + + non_empty_lines(&lines) +} + +/// Flatten a `document.source` value to extractable text or a stable reference. +fn flatten_document_source(source: Option<&Value>) -> Option { + let source = source?; + let source_type = source.get("type").and_then(Value::as_str)?; + + match source_type { + "text" => source + .get("data") + .and_then(Value::as_str) + .filter(|data| !data.is_empty()) + .map(|data| format!("Content:\n{data}")), + "content" => { + let lines = extract_text_blocks(source.get("content")); + non_empty_lines(&lines).map(|content| format!("Content:\n{content}")) + }, + "url" => source + .get("url") + .and_then(Value::as_str) + .filter(|url| !url.is_empty()) + .map(|url| format!("Source: {}", quote_label_value(url))), + "file" => source + .get("file_id") + .and_then(Value::as_str) + .filter(|file_id| !file_id.is_empty()) + .map(|file_id| format!("Source: {}", quote_label_value(&format!("file:{file_id}")))), + "base64" => source + .get("media_type") + .and_then(Value::as_str) + .filter(|media_type| !media_type.is_empty()) + .map(|media_type| format!("Source: {}", quote_label_value(&format!("base64:{media_type}")))), + _ => None, + } +} + +/// Quote metadata values so embedded newlines cannot forge flattening labels. +fn quote_label_value(value: &str) -> String { + serde_json::to_string(value).unwrap_or_else(|_| format!("{value:?}")) +} + +/// Extract text from an array of Anthropic text blocks. +fn extract_text_blocks(value: Option<&Value>) -> Vec { + let Some(Value::Array(blocks)) = value else { + return Vec::new(); + }; + + blocks + .iter() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|block| block.get("text").and_then(Value::as_str)) + .filter(|text| !text.is_empty()) + .map(str::to_owned) + .collect() +} + +/// Join lines if at least one line contains content. +fn non_empty_lines(lines: &[String]) -> Option { + lines.iter().any(|line| !line.is_empty()).then(|| lines.join("\n")) +} + +// ----------------------------------------------------------------------------- +// Parameter Mapping +// ----------------------------------------------------------------------------- + +/// Map Anthropic parameters to Chat Completions-compatible equivalents. +/// +/// `top_k` has no standard Chat Completions equivalent but is preserved +/// as an extra body parameter for backends that support it +/// (e.g. vLLM). +fn map_parameters(chat: &mut Map, obj: &Map) { + if let Some(stop) = obj.get("stop_sequences") { + chat.insert("stop".to_owned(), stop.clone()); + } + + if let Some(temp) = obj.get("temperature") { + chat.insert("temperature".to_owned(), temp.clone()); + } + + if let Some(top_p) = obj.get("top_p") { + chat.insert("top_p".to_owned(), top_p.clone()); + } + + if let Some(top_k) = obj.get("top_k") { + chat.insert("top_k".to_owned(), top_k.clone()); + } +} + +// ----------------------------------------------------------------------------- +// Tool Conversion +// ----------------------------------------------------------------------------- + +/// Convert Anthropic tool definitions to Chat Completions function tools. +fn convert_tools(chat: &mut Map, obj: &Map) { + let Some(Value::Array(tools)) = obj.get("tools") else { + return; + }; + + let mut chat_tools = Vec::new(); + + for tool in tools { + if let Some(chat_tool) = convert_tool_definition(tool) { + chat_tools.push(chat_tool); + } + } + + if !chat_tools.is_empty() { + chat.insert("tools".to_owned(), Value::Array(chat_tools)); + } +} + +/// Convert one Anthropic client tool definition to a Chat Completions tool. +fn convert_tool_definition(tool: &Value) -> Option { + let tool_type = tool.get("type").and_then(Value::as_str).unwrap_or("custom"); + + if tool_type.starts_with("web_search") || tool_type.starts_with("bash") || tool_type.starts_with("text_editor") { + warn!(tool_type, "dropping server-side Anthropic tool"); + return None; + } + + let name = tool.get("name").and_then(Value::as_str).unwrap_or(""); + let description = tool.get("description").and_then(Value::as_str).unwrap_or(""); + let parameters = tool + .get("input_schema") + .cloned() + .unwrap_or_else(|| json!({"type": "object"})); + + let mut function = Map::new(); + function.insert("name".to_owned(), Value::String(name.to_owned())); + function.insert("description".to_owned(), Value::String(description.to_owned())); + function.insert("parameters".to_owned(), parameters); + if let Some(strict) = tool.get("strict").and_then(Value::as_bool) { + function.insert("strict".to_owned(), Value::Bool(strict)); + } + + Some(json!({ + "type": "function", + "function": function + })) +} + +// ----------------------------------------------------------------------------- +// Tool Choice Conversion +// ----------------------------------------------------------------------------- + +/// Convert Anthropic `disable_parallel_tool_use` to Chat Completions format. +fn convert_parallel_tool_calls(chat: &mut Map, obj: &Map) { + let Some(Value::Object(tool_choice)) = obj.get("tool_choice") else { + return; + }; + + if tool_choice + .get("disable_parallel_tool_use") + .and_then(Value::as_bool) + .is_some_and(|disabled| disabled) + { + chat.insert("parallel_tool_calls".to_owned(), Value::Bool(false)); + } +} + +/// Convert Anthropic `tool_choice` to Chat Completions format. +fn convert_tool_choice(chat: &mut Map, obj: &Map) { + let Some(tool_choice) = obj.get("tool_choice") else { + return; + }; + + if obj.contains_key("tools") && !chat.contains_key("tools") { + return; + } + + let chat_choice = match tool_choice { + Value::String(s) => match s.as_str() { + "any" => Value::String("required".to_owned()), + "none" => Value::String("none".to_owned()), + _ => Value::String("auto".to_owned()), + }, + Value::Object(tc) => match tc.get("type").and_then(Value::as_str) { + Some("any") => Value::String("required".to_owned()), + Some("none") => Value::String("none".to_owned()), + Some("tool") => { + if let Some(name) = tc.get("name").and_then(Value::as_str) { + json!({"type": "function", "function": {"name": name}}) + } else { + Value::String("auto".to_owned()) + } + }, + _ => Value::String("auto".to_owned()), + }, + _ => return, + }; + + chat.insert("tool_choice".to_owned(), chat_choice); +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::unwrap_used, clippy::indexing_slicing, reason = "tests")] +mod tests { + use super::*; + + #[test] + fn basic_text_request() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":"Hello"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["model"], "claude-opus-4-8", "model preserved"); + assert_eq!( + parsed["max_completion_tokens"], 1024, + "max_tokens mapped to max_completion_tokens" + ); + assert!( + parsed.get("max_tokens").is_none(), + "max_tokens must not appear in output" + ); + assert_eq!(parsed["messages"][0]["role"], "user", "user message role"); + assert_eq!(parsed["messages"][0]["content"], "Hello", "user message content"); + } + + #[test] + fn system_hoisted() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"system":"Be helpful.","messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["messages"][0]["role"], "system", + "system message should be first" + ); + assert_eq!(parsed["messages"][0]["content"], "Be helpful.", "system content"); + assert_eq!(parsed["messages"][1]["role"], "user", "user message follows system"); + } + + #[test] + fn system_text_blocks_joined() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"system":[{"type":"text","text":"Part 1"},{"type":"text","text":"Part 2"}],"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["messages"][0]["content"], "Part 1\nPart 2", + "text blocks should be joined" + ); + } + + #[test] + fn tool_use_converted() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"get_weather","input":{"city":"NYC"}}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + let msg = &parsed["messages"][0]; + assert_eq!(msg["role"], "assistant", "assistant role"); + assert_eq!(msg["tool_calls"][0]["function"]["name"], "get_weather", "tool name"); + assert!( + msg["tool_calls"][0]["function"]["arguments"] + .as_str() + .unwrap() + .contains("NYC"), + "tool arguments contain city" + ); + } + + #[test] + fn tool_result_converted() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"72F sunny"}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["messages"][0]["role"], "tool", "tool role"); + assert_eq!(parsed["messages"][0]["tool_call_id"], "call_1", "tool_call_id"); + assert_eq!(parsed["messages"][0]["content"], "72F sunny", "tool result content"); + } + + #[test] + fn tool_result_error_marked_in_tool_message_content() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"cat: missing.txt: No such file or directory","is_error":true}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["messages"][0]["content"], + "Anthropic tool_result error:\ncat: missing.txt: No such file or directory", + "OpenAI-compatible tool messages should preserve Anthropic error semantics" + ); + } + + #[test] + fn tool_result_image_promoted_to_followup_user_message() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":[{"type":"text","text":"chart"},{"type":"image","source":{"type":"url","url":"https://example.com/chart.png"}}]}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["messages"][0]["role"], "tool", "first message is tool result"); + assert_eq!(parsed["messages"][0]["content"], "chart", "tool text content"); + assert_eq!( + parsed["messages"][1]["role"], "user", + "image should be promoted to user message" + ); + assert_eq!( + parsed["messages"][1]["content"][0]["type"], "image_url", + "promoted image content type" + ); + assert_eq!( + parsed["messages"][1]["content"][0]["image_url"]["url"], "https://example.com/chart.png", + "promoted image URL" + ); + } + + #[test] + fn top_level_search_result_preserved_as_text_context() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"search_result","source":"https://docs.example.test/product","title":"Product Guide","content":[{"type":"text","text":"The default timeout is 30 seconds."},{"type":"text","text":"The maximum timeout is 120 seconds."}]},{"type":"text","text":"What is the timeout range?"}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + let content = parsed["messages"][0]["content"].as_array().unwrap(); + + assert_eq!(content[0]["type"], "text"); + assert_eq!( + content[0]["text"], + "Search result: \"Product Guide\"\nSource: \"https://docs.example.test/product\"\nContent:\nThe default timeout is 30 seconds.\nThe maximum timeout is 120 seconds.", + "search result metadata and text should remain visible to the backend" + ); + assert_eq!( + content[1]["text"], "What is the timeout range?", + "following user text should remain a separate content part" + ); + } + + #[test] + fn tool_result_search_result_preserved_in_tool_message_content() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":[{"type":"search_result","source":"kb://timeouts","title":"Timeout KB","content":[{"type":"text","text":"Timeouts default to 30 seconds."}]},{"type":"text","text":"Applies to version 2."}]}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["messages"][0]["role"], "tool"); + assert_eq!( + parsed["messages"][0]["content"], + "Search result: \"Timeout KB\"\nSource: \"kb://timeouts\"\nContent:\nTimeouts default to 30 seconds.\nApplies to version 2.", + "tool result search_result content should not be dropped" + ); + } + + #[test] + fn tool_result_document_preserved_in_tool_message_content() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":[{"type":"text","text":"Before document."},{"type":"document","source":{"type":"content","content":[{"type":"text","text":"Nested document fact."}]},"title":"Nested Doc"},{"type":"text","text":"After document."}]}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["messages"][0]["content"], + "Before document.\nDocument: \"Nested Doc\"\nContent:\nNested document fact.\nAfter document.", + "tool result document content should be flattened in order with surrounding text" + ); + } + + #[test] + fn document_text_source_preserved_as_text_context() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"document","source":{"type":"text","media_type":"text/plain","data":"The grass is green. The sky is blue."},"title":"Color Notes","context":"trusted notes","citations":{"enabled":true}},{"type":"text","text":"What color is the grass?"}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + let content = parsed["messages"][0]["content"].as_array().unwrap(); + + assert_eq!( + content[0]["text"], + "Document: \"Color Notes\"\nContext: \"trusted notes\"\nContent:\nThe grass is green. The sky is blue.", + "plain text document contents should remain visible to the backend" + ); + assert_eq!(content[1]["text"], "What color is the grass?"); + } + + #[test] + fn document_file_source_preserved_as_reference_text() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"document","source":{"type":"file","file_id":"file_abc123"},"title":"Uploaded Contract"},{"type":"text","text":"Summarize the uploaded contract."}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + let content = parsed["messages"][0]["content"].as_array().unwrap(); + + assert_eq!( + content[0]["text"], "Document: \"Uploaded Contract\"\nSource: \"file:file_abc123\"", + "file-backed documents should remain visible as references instead of disappearing" + ); + } + + #[test] + fn document_source_variants_preserved_or_dropped_intentionally() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"document","source":{"type":"content","content":[{"type":"text","text":"Content block fact."}]},"title":"Content Doc"},{"type":"document","source":{"type":"url","url":"https://docs.example.test/file.pdf"}},{"type":"document","source":{"type":"base64","media_type":"application/pdf"}},{"type":"document","source":{"type":"unknown","data":"ignored"}}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + let content = parsed["messages"][0]["content"].as_array().unwrap(); + + assert_eq!( + content[0]["text"], "Document: \"Content Doc\"\nContent:\nContent block fact.", + "content document source should flatten nested text blocks" + ); + assert_eq!( + content[1]["text"], "Document\nSource: \"https://docs.example.test/file.pdf\"", + "URL document source should be preserved as a quoted reference" + ); + assert_eq!( + content[2]["text"], "Document\nSource: \"base64:application/pdf\"", + "base64 document source should be preserved as a quoted media reference" + ); + assert_eq!( + content.len(), + 3, + "unknown document source without metadata should be dropped" + ); + } + + #[test] + fn search_result_metadata_values_are_quoted() { + let body = json!({ + "model": "claude-opus-4-8", + "max_tokens": 1024, + "messages": [{ + "role": "user", + "content": [{ + "type": "search_result", + "title": "Title\nSource: forged", + "source": "https://docs.example.test/a\nContext: forged", + "content": [{"type": "text", "text": "Real search text."}] + }] + }] + }) + .to_string(); + let result = transform_request(body.as_bytes()).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["messages"][0]["content"], + "Search result: \"Title\\nSource: forged\"\nSource: \"https://docs.example.test/a\\nContext: forged\"\nContent:\nReal search text.", + "quoted search metadata should not create forged label lines" + ); + } + + #[test] + fn document_metadata_values_are_quoted() { + let body = json!({ + "model": "claude-opus-4-8", + "max_tokens": 1024, + "messages": [{ + "role": "user", + "content": [{ + "type": "document", + "title": "Doc\nContext: forged", + "context": "safe\nSource: forged", + "source": {"type": "text", "data": "Real document text."} + }] + }] + }) + .to_string(); + let result = transform_request(body.as_bytes()).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["messages"][0]["content"], + "Document: \"Doc\\nContext: forged\"\nContext: \"safe\\nSource: forged\"\nContent:\nReal document text.", + "quoted document metadata should not create forged label lines" + ); + } + + #[test] + fn empty_search_result_and_document_blocks_dropped() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"search_result","content":[]},{"type":"document","source":{"type":"content","content":[]}},{"type":"document","source":{"type":"text","data":""}},{"type":"document","source":{"type":"url","url":""}},{"type":"document","source":{"type":"file","file_id":""}},{"type":"document","source":{"type":"base64","media_type":""}}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert!( + parsed["messages"].as_array().unwrap().is_empty(), + "empty metadata-only blocks should not fabricate prompt text" + ); + } + + #[test] + fn stop_sequences_mapped() { + let body = + br#"{"model":"claude-opus-4-8","max_tokens":1024,"stop_sequences":["END"],"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["stop"][0], "END", "stop_sequences mapped to stop"); + } + + #[test] + fn tool_choice_any_mapped() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"tool_choice":"any","messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["tool_choice"], "required", "any maps to required"); + } + + #[test] + fn tool_choice_object_any_mapped() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"tools":[{"name":"get_weather","description":"Get weather","input_schema":{"type":"object","properties":{}}}],"tool_choice":{"type":"any"},"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["tool_choice"], "required", "object-form any maps to required"); + } + + #[test] + fn tool_choice_dropped_when_all_tools_filtered() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"tools":[{"type":"web_search_20250305","name":"web_search"}],"tool_choice":"any","messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert!(parsed.get("tools").is_none(), "server-side tools should be filtered"); + assert!( + parsed.get("tool_choice").is_none(), + "tool_choice without translated tools should be dropped" + ); + } + + #[test] + fn disable_parallel_tool_use_mapped() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"tools":[{"name":"get_weather","description":"Get weather","input_schema":{"type":"object","properties":{}}}],"tool_choice":{"type":"auto","disable_parallel_tool_use":true},"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["parallel_tool_calls"], false, + "disable_parallel_tool_use should disable parallel tool calls" + ); + } + + #[test] + fn tool_definitions_converted() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"tools":[{"name":"get_weather","description":"Get weather","input_schema":{"type":"object","properties":{"city":{"type":"string"}}}}],"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["tools"][0]["type"], "function", "tool type should be function"); + assert_eq!(parsed["tools"][0]["function"]["name"], "get_weather", "tool name"); + } + + #[test] + fn tool_definition_strict_mapped() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"tools":[{"name":"get_weather","description":"Get weather","input_schema":{"type":"object","properties":{"city":{"type":"string"}}},"strict":true},{"name":"get_time","description":"Get time","input_schema":{"type":"object"},"strict":false},{"name":"get_news","description":"Get news","input_schema":{"type":"object"},"strict":"yes"}],"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["tools"][0]["function"]["strict"], true, + "Anthropic strict true should map to Chat Completions function strict" + ); + assert_eq!( + parsed["tools"][1]["function"]["strict"], false, + "Anthropic strict false should remain false" + ); + assert!( + parsed["tools"][2]["function"].get("strict").is_none(), + "non-boolean strict values should be omitted" + ); + } + + #[test] + fn image_base64_converted() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/jpeg","data":"abc123"}},{"type":"text","text":"What is this?"}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + let content = &parsed["messages"][0]["content"]; + assert_eq!(content[0]["type"], "image_url", "image type"); + assert_eq!( + content[0]["image_url"]["url"], "data:image/jpeg;base64,abc123", + "data URL" + ); + assert_eq!(content[1]["type"], "text", "text part follows"); + } + + #[test] + fn top_k_preserved_as_extra_param() { + let body = + br#"{"model":"claude-opus-4-8","max_tokens":1024,"top_k":40,"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["top_k"], 40, "top_k should be preserved as extra body parameter"); + } + + #[test] + fn transform_request_non_json_body() { + let body = b"not json at all"; + let result = transform_request(body); + assert!(result.is_err(), "non-JSON body should return Err"); + assert!( + result.unwrap_err().contains("invalid JSON"), + "error should mention invalid JSON" + ); + } + + #[test] + fn transform_request_json_array_body() { + let body = b"[1,2,3]"; + let result = transform_request(body); + assert!(result.is_err(), "JSON array body should return Err"); + assert!( + result.unwrap_err().contains("not a JSON object"), + "error should mention not a JSON object" + ); + } + + #[test] + fn hoist_system_non_string_non_array_skipped() { + let body = + br#"{"model":"claude-opus-4-8","max_tokens":1024,"system":42,"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["messages"].as_array().unwrap().len(), + 1, + "non-string/non-array system should be skipped" + ); + assert_eq!(parsed["messages"][0]["role"], "user"); + } + + #[test] + fn hoist_system_array_empty_text_skipped() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"system":[{"type":"text","text":""}],"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["messages"].as_array().unwrap().len(), + 1, + "system with single empty text block should be skipped" + ); + assert_eq!(parsed["messages"][0]["role"], "user"); + } + + #[test] + fn convert_messages_missing_role_skipped() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert!( + parsed["messages"].as_array().unwrap().is_empty(), + "message without role should be skipped" + ); + } + + #[test] + fn convert_messages_content_not_string_or_array() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":42}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["messages"][0]["role"], "user"); + assert_eq!( + parsed["messages"][0]["content"], "", + "non-string/non-array content should become empty string" + ); + } + + #[test] + fn thinking_block_dropped() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"Let me think..."}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert!( + parsed["messages"].as_array().unwrap().is_empty(), + "thinking blocks should be dropped entirely" + ); + } + + #[test] + fn unknown_block_type_dropped() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"custom_xyz","data":"something"}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert!( + parsed["messages"].as_array().unwrap().is_empty(), + "unknown block types should be dropped" + ); + } + + #[test] + fn tool_choice_string_none() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"tool_choice":"none","messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["tool_choice"], "none", "string none maps to none"); + } + + #[test] + fn tool_choice_string_unknown_maps_to_auto() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"tool_choice":"foo","messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["tool_choice"], "auto", "unknown string tool_choice maps to auto"); + } + + #[test] + fn tool_choice_object_none() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"tools":[{"name":"f","description":"d","input_schema":{"type":"object","properties":{}}}],"tool_choice":{"type":"none"},"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["tool_choice"], "none", "object-form none maps to none"); + } + + #[test] + fn tool_choice_object_tool_with_name() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"tools":[{"name":"fn","description":"d","input_schema":{"type":"object","properties":{}}}],"tool_choice":{"type":"tool","name":"fn"},"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["tool_choice"]["type"], "function", + "tool type should map to function" + ); + assert_eq!( + parsed["tool_choice"]["function"]["name"], "fn", + "tool name should be preserved" + ); + } + + #[test] + fn tool_choice_object_tool_without_name_maps_to_auto() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"tools":[{"name":"f","description":"d","input_schema":{"type":"object","properties":{}}}],"tool_choice":{"type":"tool"},"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["tool_choice"], "auto", + "tool without name should fallback to auto" + ); + } + + #[test] + fn tool_choice_non_string_non_object_skipped() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"tool_choice":true,"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert!( + parsed.get("tool_choice").is_none(), + "non-string/non-object tool_choice should be skipped" + ); + } + + #[test] + fn multipart_image_and_text_produces_array_content() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"text","text":"Describe this"},{"type":"image","source":{"type":"url","url":"https://example.com/img.png"}}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + let content = &parsed["messages"][0]["content"]; + assert!(content.is_array(), "multipart content should be an array"); + assert_eq!(content.as_array().unwrap().len(), 2, "two content parts"); + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[1]["type"], "image_url"); + } + + #[test] + fn only_tool_result_blocks_produce_tool_messages() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"result1"},{"type":"tool_result","tool_use_id":"call_2","content":"result2"}]}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + let messages = parsed["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2, "two tool messages, no wrapper"); + assert_eq!(messages[0]["role"], "tool"); + assert_eq!(messages[0]["tool_call_id"], "call_1"); + assert_eq!(messages[0]["content"], "result1"); + assert_eq!(messages[1]["role"], "tool"); + assert_eq!(messages[1]["tool_call_id"], "call_2"); + assert_eq!(messages[1]["content"], "result2"); + } + + #[test] + fn extract_tool_result_content_null() { + let block = json!({"type": "tool_result", "tool_use_id": "call_1", "content": null}); + let result = extract_tool_result_content(&block); + assert!(result.is_empty(), "null content should return empty string"); + } + + #[test] + fn extract_tool_result_content_missing() { + let block = json!({"type": "tool_result", "tool_use_id": "call_1"}); + let result = extract_tool_result_content(&block); + assert!(result.is_empty(), "missing content should return empty string"); + } + + #[test] + fn bash_and_text_editor_tools_filtered() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"tools":[{"type":"bash_20241022","name":"bash"},{"type":"text_editor_20241022","name":"text_editor"},{"name":"get_weather","description":"Get weather","input_schema":{"type":"object","properties":{}}}],"messages":[{"role":"user","content":"Hi"}]}"#; + let result = transform_request(body).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + let tools = parsed["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 1, "only non-filtered tools should remain"); + assert_eq!(tools[0]["function"]["name"], "get_weather"); + } +} diff --git a/apis/src/anthropic/to_openai/response.rs b/apis/src/anthropic/to_openai/response.rs new file mode 100644 index 0000000000..b5e82536b4 --- /dev/null +++ b/apis/src/anthropic/to_openai/response.rs @@ -0,0 +1,638 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Chat Completions-compatible response to Anthropic Messages transformation. + +use http::StatusCode; +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::anthropic::wire::{self, ContentBlock, MessageResponse, MessageUsage}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Default response type. +const RESPONSE_TYPE: &str = "message"; + +/// Default response role. +const RESPONSE_ROLE: &str = "assistant"; + +/// Anthropic error types that may be preserved from an upstream response. +const ANTHROPIC_ERROR_TYPES: &[&str] = &[ + "invalid_request_error", + "authentication_error", + "billing_error", + "permission_error", + "not_found_error", + "conflict_error", + "request_too_large", + "rate_limit_error", + "timeout_error", + "api_error", + "overloaded_error", +]; + +/// Minimal upstream error fields needed for Anthropic normalization. +#[derive(Deserialize)] +struct UpstreamError { + /// Nested error details, when present. + error: Option, + /// Top-level error message, when present. + message: Option, + /// Upstream request identifier, when present. + request_id: Option, + /// Top-level response discriminator, when present. + #[serde(rename = "type")] + r#type: Option, +} + +// ----------------------------------------------------------------------------- +// Response Transformation +// ----------------------------------------------------------------------------- + +/// Result of a response transformation. +pub(crate) struct TransformResult { + /// Transformed response body bytes. + pub body: Vec, + /// Original Chat Completions `finish_reason` (preserved for metadata). + pub original_finish_reason: String, +} + +/// Transform a Chat Completions-compatible response body into Anthropic +/// Messages format. +pub(crate) fn transform_response(body: &[u8], request_model: &str) -> Result { + let value: Value = serde_json::from_slice(body).map_err(|e| format!("invalid JSON: {e}"))?; + + let Some(obj) = value.as_object() else { + return Err("response body is not a JSON object".to_owned()); + }; + + let id = match obj.get("id").and_then(Value::as_str) { + Some(id) => format!("msg_{id}"), + None => format!("msg_{}", timestamp_hex_id()), + }; + + let model = obj.get("model").and_then(Value::as_str).unwrap_or(request_model); + + let (stop_reason, original_finish_reason) = map_finish_reason(obj); + let response = MessageResponse { + content: build_content_blocks(obj), + container: None, + id, + model, + role: RESPONSE_ROLE, + stop_details: None, + stop_reason, + stop_sequence: None, + r#type: RESPONSE_TYPE, + usage: build_usage(obj), + }; + + let body = serde_json::to_vec(&response).map_err(|e| format!("serialization failed: {e}"))?; + Ok(TransformResult { + body, + original_finish_reason, + }) +} + +/// Transform an upstream 4xx or 5xx response into Anthropic error format. +pub(crate) fn transform_error_response(body: &[u8], status: StatusCode, header_request_id: Option<&str>) -> Vec { + let parsed = serde_json::from_slice::(body).ok(); + let is_anthropic_error = parsed + .as_ref() + .and_then(|value| value.r#type.as_ref()) + .and_then(Value::as_str) + .is_some_and(|value| value == "error"); + let message = parsed + .as_ref() + .and_then(|value| value.error.as_ref()) + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .or_else(|| parsed.as_ref()?.message.as_ref()?.as_str()) + .unwrap_or("upstream request failed"); + let upstream_error_type = parsed + .as_ref() + .and_then(|value| value.error.as_ref()) + .and_then(|error| error.get("type")) + .and_then(Value::as_str) + .filter(|error_type| is_anthropic_error || ANTHROPIC_ERROR_TYPES.contains(error_type)); + let request_id = parsed + .as_ref() + .and_then(|value| value.request_id.as_ref()) + .and_then(Value::as_str) + .or(header_request_id); + let error_type = upstream_error_type.unwrap_or_else(|| error_type_for_status(status)); + + wire::error_body(error_type, message, request_id) +} + +/// Map an HTTP error status to its Anthropic error type. +fn error_type_for_status(status: StatusCode) -> &'static str { + match status.as_u16() { + 401 => "authentication_error", + 402 => "billing_error", + 403 => "permission_error", + 404 => "not_found_error", + 409 => "conflict_error", + 413 => "request_too_large", + 429 => "rate_limit_error", + 504 => "timeout_error", + 529 => "overloaded_error", + 500..=599 => "api_error", + _ => "invalid_request_error", + } +} + +// ----------------------------------------------------------------------------- +// Content Block Building +// ----------------------------------------------------------------------------- + +/// Extract content blocks from the first choice. +fn build_content_blocks<'a>(obj: &'a Map) -> Vec> { + let mut blocks = Vec::new(); + + let choice = obj.get("choices").and_then(Value::as_array).and_then(|c| c.first()); + + let Some(choice) = choice else { + return blocks; + }; + + let message = choice.get("message"); + extract_text_block(message, &mut blocks); + extract_tool_call_blocks(message, &mut blocks); + + blocks +} + +/// Extract a text content block from the message if present. +fn extract_text_block<'a>(message: Option<&'a Value>, blocks: &mut Vec>) { + if let Some(content) = message.and_then(|m| m.get("content")).and_then(Value::as_str) + && !content.is_empty() + { + blocks.push(ContentBlock::text(content)); + } +} + +/// Extract tool call blocks from the message. +fn extract_tool_call_blocks<'a>(message: Option<&'a Value>, blocks: &mut Vec>) { + let Some(Value::Array(tool_calls)) = message.and_then(|m| m.get("tool_calls")) else { + return; + }; + + for tc in tool_calls { + let id = tc.get("id").and_then(Value::as_str).unwrap_or(""); + let name = tc + .get("function") + .and_then(|f| f.get("name")) + .and_then(Value::as_str) + .unwrap_or(""); + let args_str = tc + .get("function") + .and_then(|f| f.get("arguments")) + .and_then(Value::as_str) + .unwrap_or("{}"); + let input = serde_json::from_str::>(args_str).unwrap_or_default(); + + blocks.push(ContentBlock::tool_use(id, input, name)); + } +} + +// ----------------------------------------------------------------------------- +// Finish Reason Mapping +// ----------------------------------------------------------------------------- + +/// Map Chat Completions `finish_reason` to Anthropic `stop_reason`. +/// +/// Returns `(anthropic_stop_reason, original_finish_reason)`. +/// The `content_filter` to `end_turn` mapping is lossy; the +/// original is preserved so callers can store it in metadata. +fn map_finish_reason(obj: &Map) -> (String, String) { + let finish_reason = obj + .get("choices") + .and_then(Value::as_array) + .and_then(|c| c.first()) + .and_then(|c| c.get("finish_reason")) + .and_then(Value::as_str) + .unwrap_or("stop"); + + let mapped = match finish_reason { + "tool_calls" => "tool_use", + "length" => "max_tokens", + _ => "end_turn", + }; + + (mapped.to_owned(), finish_reason.to_owned()) +} + +// ----------------------------------------------------------------------------- +// Usage Mapping +// ----------------------------------------------------------------------------- + +/// Build Anthropic usage object from Chat Completions usage. +/// +/// Anthropic's `input_tokens` excludes cached tokens (they are reported +/// separately via `cache_read_input_tokens`), whereas OpenAI's +/// `prompt_tokens` includes them. The cached count must be subtracted +/// here so downstream Anthropic-format consumers that sum +/// `input_tokens + cache_read_input_tokens` don't double-count. +fn build_usage(obj: &Map) -> MessageUsage { + let usage = obj.get("usage"); + + let prompt_tokens = usage + .and_then(|u| u.get("prompt_tokens")) + .and_then(Value::as_u64) + .unwrap_or(0); + + let output_tokens = usage + .and_then(|u| u.get("completion_tokens")) + .and_then(Value::as_u64) + .unwrap_or(0); + + let cache_read = usage + .and_then(|u| u.get("prompt_tokens_details")) + .and_then(|d| d.get("cached_tokens")) + .and_then(Value::as_u64); + + let input_tokens = match cache_read { + Some(cached) => prompt_tokens.saturating_sub(cached), + None => prompt_tokens, + }; + + MessageUsage::new(input_tokens, output_tokens, cache_read) +} + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +/// Generate a timestamp-based hex identifier for response IDs. +fn timestamp_hex_id() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + + format!("{nanos:024x}") +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::unwrap_used, clippy::indexing_slicing, reason = "tests")] +mod tests { + use http::StatusCode; + use serde_json::json; + + use super::*; + + fn assert_absent_fields(value: &Value, fields: &[&str]) { + for field in fields { + assert!(value.get(*field).is_none(), "expected {field} to be absent"); + } + } + + fn assert_null_fields(value: &Value, fields: &[&str]) { + for field in fields { + assert!(value.get(*field).is_some(), "expected {field} to be present"); + assert!(value[*field].is_null(), "expected {field} to be null"); + } + } + + #[test] + fn compatible_upstream_error_is_preserved() { + let body = br#"{"error":{"type":"rate_limit_error","message":"slow down"},"request_id":"req_body"}"#; + let output = transform_error_response(body, StatusCode::TOO_MANY_REQUESTS, Some("req_header")); + let parsed: Value = serde_json::from_slice(&output).unwrap(); + + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "rate_limit_error"); + assert_eq!(parsed["error"]["message"], "slow down"); + assert_eq!(parsed["request_id"], "req_body"); + } + + #[test] + fn future_anthropic_error_type_is_preserved() { + let body = br#"{"type":"error","error":{"type":"future_error","message":"new failure"}}"#; + let output = transform_error_response(body, StatusCode::INTERNAL_SERVER_ERROR, None); + let parsed: Value = serde_json::from_slice(&output).unwrap(); + + assert_eq!(parsed["error"]["type"], "future_error"); + assert_eq!(parsed["error"]["message"], "new failure"); + } + + #[test] + fn incompatible_error_type_uses_status_mapping_and_header_request_id() { + let output = transform_error_response( + br#"{"error":{"type":"server_error","message":"failed"}}"#, + StatusCode::SERVICE_UNAVAILABLE, + Some("req_header"), + ); + let parsed: Value = serde_json::from_slice(&output).unwrap(); + + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "api_error"); + assert_eq!(parsed["error"]["message"], "failed"); + assert_eq!(parsed["request_id"], "req_header"); + } + + #[test] + fn top_level_error_message_is_preserved() { + let output = transform_error_response( + br#"{"message":"backend rejected the request"}"#, + StatusCode::BAD_REQUEST, + None, + ); + let parsed: Value = serde_json::from_slice(&output).unwrap(); + + assert_eq!(parsed["error"]["type"], "invalid_request_error"); + assert_eq!(parsed["error"]["message"], "backend rejected the request"); + assert!(parsed["request_id"].is_null()); + } + + #[test] + fn irrelevant_error_fields_are_ignored() { + let body = + br#"{"message":"backend rejected the request","irrelevant":[{"nested":"value"},{"nested":"value"}]}"#; + let output = transform_error_response(body, StatusCode::BAD_REQUEST, None); + let parsed: Value = serde_json::from_slice(&output).unwrap(); + + assert_eq!(parsed["error"]["message"], "backend rejected the request"); + assert_eq!(parsed["error"]["type"], "invalid_request_error"); + } + + #[test] + fn malformed_optional_error_fields_do_not_discard_message() { + let body = br#"{"error":{"type":"rate_limit_error","message":"slow down"},"request_id":123}"#; + let output = transform_error_response(body, StatusCode::TOO_MANY_REQUESTS, None); + let parsed: Value = serde_json::from_slice(&output).unwrap(); + + assert_eq!(parsed["error"]["message"], "slow down"); + assert_eq!(parsed["error"]["type"], "rate_limit_error"); + assert!(parsed["request_id"].is_null()); + } + + #[test] + fn unstructured_errors_do_not_reflect_unknown_text() { + for body in [ + b"".as_slice(), + b"[]".as_slice(), + b"secret backend diagnostic".as_slice(), + ] { + let output = transform_error_response(body, StatusCode::BAD_GATEWAY, None); + let parsed: Value = serde_json::from_slice(&output).unwrap(); + + assert_eq!(parsed["error"]["type"], "api_error"); + assert_eq!(parsed["error"]["message"], "upstream request failed"); + assert!(parsed["request_id"].is_null()); + } + } + + #[test] + fn error_statuses_map_to_anthropic_types() { + for (status, expected) in [ + (StatusCode::BAD_REQUEST, "invalid_request_error"), + (StatusCode::UNAUTHORIZED, "authentication_error"), + (StatusCode::PAYMENT_REQUIRED, "billing_error"), + (StatusCode::FORBIDDEN, "permission_error"), + (StatusCode::NOT_FOUND, "not_found_error"), + (StatusCode::CONFLICT, "conflict_error"), + (StatusCode::PAYLOAD_TOO_LARGE, "request_too_large"), + (StatusCode::TOO_MANY_REQUESTS, "rate_limit_error"), + (StatusCode::GATEWAY_TIMEOUT, "timeout_error"), + (StatusCode::from_u16(529).unwrap(), "overloaded_error"), + (StatusCode::INTERNAL_SERVER_ERROR, "api_error"), + ] { + let output = transform_error_response(b"", status, None); + let parsed: Value = serde_json::from_slice(&output).unwrap(); + + assert_eq!(parsed["error"]["type"], expected, "status {status}"); + } + } + + #[test] + fn basic_text_response() { + let body = br#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"message":{"role":"assistant","content":"Hello!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#; + let tr = transform_response(body, "gpt-4").unwrap(); + let result = tr.body; + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["type"], "message", "type should be message"); + assert_eq!(parsed["role"], "assistant", "role should be assistant"); + assert_eq!(parsed["content"][0]["type"], "text", "content block type"); + assert_eq!(parsed["content"][0]["text"], "Hello!", "content text"); + assert!( + parsed["content"][0].get("citations").is_some(), + "text content should include citations" + ); + assert!(parsed["content"][0]["citations"].is_null(), "citations should be null"); + assert_eq!(parsed["stop_reason"], "end_turn", "stop → end_turn"); + assert_null_fields(&parsed, &["container", "stop_details", "stop_sequence"]); + assert_eq!(parsed["usage"]["input_tokens"], 10, "input tokens"); + assert_eq!(parsed["usage"]["output_tokens"], 5, "output tokens"); + assert_absent_fields(&parsed["usage"], &["output_tokens_details"]); + assert_null_fields( + &parsed["usage"], + &[ + "cache_creation", + "cache_creation_input_tokens", + "cache_read_input_tokens", + "inference_geo", + "server_tool_use", + "service_tier", + ], + ); + } + + #[test] + fn tool_calls_response() { + let body = br#"{"id":"chatcmpl-2","model":"gpt-4","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"NYC\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":20,"completion_tokens":15}}"#; + let tr = transform_response(body, "gpt-4").unwrap(); + let result = tr.body; + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["stop_reason"], "tool_use", "tool_calls → tool_use"); + assert_eq!(parsed["content"][0]["type"], "tool_use", "tool_use block"); + assert_eq!(parsed["content"][0]["name"], "get_weather", "tool name"); + assert_eq!(parsed["content"][0]["input"]["city"], "NYC", "parsed input"); + assert_eq!( + parsed["content"][0]["caller"]["type"], "direct", + "tool_use caller should identify a direct invocation" + ); + } + + #[test] + fn length_finish_reason() { + let body = br#"{"id":"chatcmpl-3","model":"gpt-4","choices":[{"message":{"role":"assistant","content":"truncated..."},"finish_reason":"length"}],"usage":{"prompt_tokens":10,"completion_tokens":100}}"#; + let tr = transform_response(body, "gpt-4").unwrap(); + let result = tr.body; + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["stop_reason"], "max_tokens", "length → max_tokens"); + } + + #[test] + fn cached_tokens_in_usage() { + let body = br#"{"id":"chatcmpl-4","model":"gpt-4","choices":[{"message":{"role":"assistant","content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":80}}}"#; + let tr = transform_response(body, "gpt-4").unwrap(); + let result = tr.body; + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["usage"]["cache_read_input_tokens"], 80, "cached tokens mapped"); + assert_eq!( + parsed["usage"]["input_tokens"], 20, + "input_tokens should exclude cached tokens (100 prompt - 80 cached)" + ); + assert_null_fields( + &parsed["usage"], + &[ + "cache_creation", + "cache_creation_input_tokens", + "inference_geo", + "server_tool_use", + "service_tier", + ], + ); + assert_absent_fields(&parsed["usage"], &["output_tokens_details"]); + } + + #[test] + fn cached_tokens_not_double_counted_when_summed() { + // OpenAI's prompt_tokens (100) includes the 80 cached tokens. Anthropic's + // contract has input_tokens exclude cache, so a downstream consumer that + // sums input_tokens + cache_read_input_tokens must recover the original + // prompt_tokens total, not double-count the cached portion. + let body = br#"{"id":"chatcmpl-5","model":"gpt-4","choices":[{"message":{"role":"assistant","content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":80}}}"#; + let tr = transform_response(body, "gpt-4").unwrap(); + let parsed: Value = serde_json::from_slice(&tr.body).unwrap(); + + let input_tokens = parsed["usage"]["input_tokens"].as_u64().unwrap(); + let cache_read = parsed["usage"]["cache_read_input_tokens"].as_u64().unwrap(); + assert_eq!( + input_tokens + cache_read, + 100, + "input_tokens + cache_read_input_tokens should equal original prompt_tokens" + ); + } + + #[test] + fn no_cached_tokens_leaves_input_tokens_unchanged() { + let body = br#"{"id":"chatcmpl-6","model":"gpt-4","choices":[{"message":{"role":"assistant","content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":42,"completion_tokens":5}}"#; + let tr = transform_response(body, "gpt-4").unwrap(); + let parsed: Value = serde_json::from_slice(&tr.body).unwrap(); + + assert_eq!( + parsed["usage"]["input_tokens"], 42, + "input_tokens should be unchanged when no cache info is present" + ); + assert_null_fields(&parsed["usage"], &["cache_read_input_tokens"]); + } + + #[test] + fn transform_response_non_json_body() { + let result = transform_response(b"not json at all", "gpt-4"); + let err = result.err().unwrap(); + assert!(err.contains("invalid JSON"), "error should mention invalid JSON: {err}"); + } + + #[test] + fn transform_response_json_array_body() { + let result = transform_response(b"[1,2,3]", "gpt-4"); + let err = result.err().unwrap(); + assert!( + err.contains("not a JSON object"), + "error should mention not a JSON object: {err}" + ); + } + + #[test] + fn missing_id_generates_msg_prefixed_id() { + let body = br#"{"model":"gpt-4","choices":[{"message":{"role":"assistant","content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}"#; + let tr = transform_response(body, "gpt-4").unwrap(); + let parsed: Value = serde_json::from_slice(&tr.body).unwrap(); + + let id = parsed["id"].as_str().unwrap(); + assert!( + id.starts_with("msg_"), + "generated ID should start with msg_ but got: {id}" + ); + } + + #[test] + fn empty_choices_produces_empty_content() { + let body = + br#"{"id":"chatcmpl-1","model":"gpt-4","choices":[],"usage":{"prompt_tokens":5,"completion_tokens":0}}"#; + let tr = transform_response(body, "gpt-4").unwrap(); + let parsed: Value = serde_json::from_slice(&tr.body).unwrap(); + + assert!( + parsed["content"].as_array().unwrap().is_empty(), + "empty choices should produce empty content" + ); + } + + #[test] + fn empty_string_content_produces_no_text_block() { + let body = br#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"message":{"role":"assistant","content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":0}}"#; + let tr = transform_response(body, "gpt-4").unwrap(); + let parsed: Value = serde_json::from_slice(&tr.body).unwrap(); + + assert!( + parsed["content"].as_array().unwrap().is_empty(), + "empty content string should not produce a text block" + ); + } + + #[test] + fn invalid_tool_call_arguments_fallback_to_empty_object() { + let body = br#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"not{json"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#; + let tr = transform_response(body, "gpt-4").unwrap(); + let parsed: Value = serde_json::from_slice(&tr.body).unwrap(); + + assert_eq!(parsed["content"][0]["type"], "tool_use"); + assert_eq!( + parsed["content"][0]["input"], + json!({}), + "invalid JSON arguments should fallback to empty object" + ); + } + + #[test] + fn non_object_tool_call_arguments_fallback_to_empty_object() { + for arguments in ["[]", "null", "\"text\""] { + let body = json!({ + "id": "chatcmpl-1", + "model": "gpt-4", + "choices": [{ + "message": { + "role": "assistant", + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": arguments + } + }] + }, + "finish_reason": "tool_calls" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5} + }); + let encoded = serde_json::to_vec(&body).unwrap(); + let transformed = transform_response(&encoded, "gpt-4").unwrap(); + let parsed: Value = serde_json::from_slice(&transformed.body).unwrap(); + + assert_eq!( + parsed["content"][0]["input"], + json!({}), + "{arguments} should not produce a non-object tool input" + ); + } + } +} diff --git a/apis/src/anthropic/validate/config.rs b/apis/src/anthropic/validate/config.rs new file mode 100644 index 0000000000..3cf53432bb --- /dev/null +++ b/apis/src/anthropic/validate/config.rs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Configuration for the Anthropic request validation filter. + +use praxis_filter::{FilterError, builtins::http::payload_processing::config_validation::validate_max_body_bytes}; +use serde::Deserialize; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Default maximum request body size for validation buffering. +/// +/// Validation only needs the top-level JSON envelope, so the +/// default stays below the shared JSON inspection ceiling. Users +/// can raise it up to `MAX_JSON_BODY_BYTES` (64 MiB) when they need to +/// accept larger Anthropic request bodies. +const DEFAULT_MAX_BODY_BYTES: usize = 1_048_576; // 1 MiB + +// ----------------------------------------------------------------------------- +// AnthropicValidateConfig +// ----------------------------------------------------------------------------- + +/// YAML configuration for the [`AnthropicValidateFilter`]. +/// +/// [`AnthropicValidateFilter`]: super::AnthropicValidateFilter +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AnthropicValidateConfig { + /// Maximum body size in bytes for `StreamBuffer` mode. + #[serde(default = "default_max_body_bytes")] + pub max_body_bytes: usize, +} + +/// Default max body bytes. +fn default_max_body_bytes() -> usize { + DEFAULT_MAX_BODY_BYTES +} + +// ----------------------------------------------------------------------------- +// Config Validation +// ----------------------------------------------------------------------------- + +/// Validate the parsed configuration. +pub(crate) fn build_config(cfg: AnthropicValidateConfig) -> Result { + validate_max_body_bytes("anthropic_validate", cfg.max_body_bytes)?; + Ok(cfg) +} diff --git a/apis/src/anthropic/validate/mod.rs b/apis/src/anthropic/validate/mod.rs new file mode 100644 index 0000000000..04762b32ca --- /dev/null +++ b/apis/src/anthropic/validate/mod.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Anthropic Messages request validation filter. +//! +//! Validates the JSON request envelope before forwarding. +//! Backend-owned Anthropic API semantics remain the +//! inference backend's responsibility. + +mod config; + +#[cfg(test)] +#[expect( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::needless_raw_strings, + reason = "tests" +)] +mod tests; + +use async_trait::async_trait; +use bytes::Bytes; +use praxis_filter::{ + BodyAccess, BodyMode, FilterAction, FilterError, HttpFilter, HttpFilterContext, Rejection, parse_filter_config, +}; +use tracing::debug; + +use self::config::{AnthropicValidateConfig, build_config}; +use crate::anthropic::wire; + +// ----------------------------------------------------------------------------- +// AnthropicValidateFilter +// ----------------------------------------------------------------------------- + +/// Validates Anthropic Messages request bodies for proxy-owned +/// JSON envelope requirements. +/// +/// # YAML +/// +/// ```yaml +/// filter: anthropic_validate +/// ``` +pub struct AnthropicValidateFilter { + /// Parsed and validated configuration. + config: AnthropicValidateConfig, +} + +impl AnthropicValidateFilter { + /// Create a filter from parsed YAML config. + /// + /// # Errors + /// + /// Returns [`FilterError`] if the YAML config is invalid. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: AnthropicValidateConfig = parse_filter_config("anthropic_validate", config)?; + let validated = build_config(cfg)?; + Ok(Box::new(Self { config: validated })) + } +} + +#[async_trait] +impl HttpFilter for AnthropicValidateFilter { + fn name(&self) -> &'static str { + "anthropic_validate" + } + + fn request_body_access(&self) -> BodyAccess { + BodyAccess::ReadOnly + } + + fn request_body_mode(&self) -> BodyMode { + BodyMode::StreamBuffer { + max_bytes: Some(self.config.max_body_bytes), + } + } + + async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result { + Ok(FilterAction::Continue) + } + + async fn on_request_body( + &self, + _ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + if !end_of_stream { + return Ok(FilterAction::Continue); + } + + let Some(bytes) = body.as_deref().filter(|b| !b.is_empty()) else { + return Ok(FilterAction::Reject(reject("request body is required"))); + }; + + if let Some(rejection) = validate_request(bytes) { + return Ok(FilterAction::Reject(rejection)); + } + + debug!("anthropic request validation passed"); + Ok(FilterAction::Continue) + } +} + +// ----------------------------------------------------------------------------- +// Validation +// ----------------------------------------------------------------------------- + +/// Validate the JSON envelope in the request body. +fn validate_request(body: &[u8]) -> Option { + let value: serde_json::Value = match serde_json::from_slice(body) { + Ok(v) => v, + Err(_) => return Some(reject("request body is not valid JSON")), + }; + + if !value.is_object() { + return Some(reject("request body is not a JSON object")); + } + + None +} + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +/// Build a 400 rejection with a JSON error body. +fn reject(message: &str) -> Rejection { + wire::invalid_request_rejection(message) +} diff --git a/apis/src/anthropic/validate/tests.rs b/apis/src/anthropic/validate/tests.rs new file mode 100644 index 0000000000..93f72d6094 --- /dev/null +++ b/apis/src/anthropic/validate/tests.rs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Unit tests for the `anthropic_validate` filter. + +use super::*; + +// ----------------------------------------------------------------------------- +// Validation Logic +// ----------------------------------------------------------------------------- + +#[test] +fn valid_request_passes() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":"Hi"}]}"#; + assert!(validate_request(body).is_none(), "valid request should pass"); +} + +#[test] +fn backend_owned_semantics_pass() { + let body = br#"{"model":"","max_tokens":0,"messages":[]}"#; + assert!( + validate_request(body).is_none(), + "backend-owned Anthropic semantics should be deferred" + ); +} + +#[test] +fn missing_backend_owned_fields_pass() { + let body = br#"{"metadata":{"tenant":"blue"}}"#; + assert!( + validate_request(body).is_none(), + "required Anthropic fields should be validated by the backend" + ); +} + +#[test] +fn invalid_json_rejected() { + let body = b"not json {{{"; + let rejection = validate_request(body).expect("invalid JSON should be rejected"); + let parsed: serde_json::Value = serde_json::from_slice(rejection.body.as_deref().unwrap()).unwrap(); + + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "invalid_request_error"); + assert!(parsed.get("request_id").is_some()); + assert!(parsed["request_id"].is_null()); +} + +#[test] +fn non_object_json_rejected() { + let body = br#"[]"#; + let rejection = validate_request(body); + assert!(rejection.is_some(), "non-object JSON should be rejected"); +} + +#[tokio::test] +async fn empty_body_rejected_by_filter() { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let filter = AnthropicValidateFilter::from_config(&yaml).unwrap(); + let req = Box::leak(Box::new(crate::test_utils::make_request( + http::Method::POST, + "/v1/messages", + ))); + let mut ctx = crate::test_utils::make_filter_context(req); + let mut body = Some(Bytes::new()); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + assert!( + matches!(action, FilterAction::Reject(_)), + "empty body should be rejected" + ); +} + +// ----------------------------------------------------------------------------- +// Config +// ----------------------------------------------------------------------------- + +#[test] +fn default_config_parses() { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let filter = AnthropicValidateFilter::from_config(&yaml).unwrap(); + assert_eq!( + filter.name(), + "anthropic_validate", + "filter name should be anthropic_validate" + ); +} + +#[test] +fn zero_max_body_bytes_rejected() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_body_bytes: 0").unwrap(); + let result = AnthropicValidateFilter::from_config(&yaml); + assert!(result.is_err(), "zero max_body_bytes should be rejected"); +} + +#[test] +fn rejects_max_body_bytes_above_ceiling() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_body_bytes: 67108865").unwrap(); + let result = AnthropicValidateFilter::from_config(&yaml); + + assert!( + result.is_err(), + "max_body_bytes above 64 MiB ceiling should be rejected" + ); +} diff --git a/apis/src/anthropic/web_search/mod.rs b/apis/src/anthropic/web_search/mod.rs new file mode 100644 index 0000000000..be4f7fe930 --- /dev/null +++ b/apis/src/anthropic/web_search/mod.rs @@ -0,0 +1,585 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Anthropic Messages web-search loop support. + +use std::borrow::Cow; + +use async_trait::async_trait; +use bytes::Bytes; +use http::header::{CONTENT_TYPE, HeaderValue}; +use praxis_filter::{ + BodyAccess, BodyMode, FilterAction, FilterError, HttpFilter, HttpFilterContext, IterationState, NextIterationBody, + Rejection, parse_filter_config, +}; +use serde::{Deserialize, de::IgnoredAny}; +use serde_json::{Value, json}; + +use crate::web_search::{ + SearchClient, SearchContextSize, SearchOutcome, SearchResult, WebSearchFilterConfig, build_config, + format_search_results, +}; + +/// Registry name and filter-results namespace. +const FILTER_NAME: &str = "anthropic_web_search"; +/// IRR action that re-enters the inference step. +const ACTION_LOOP: &str = "loop"; +/// IRR action that returns the current response to the client. +const ACTION_DONE: &str = "done"; +/// IRR accumulator entry holding the latest serialized Messages request. +const REQUEST_ACCUMULATOR_KEY: &str = "anthropic_web_search.request"; +/// Maximum UTF-8 size accepted for a server-managed search query. +const MAX_SEARCH_QUERY_BYTES: usize = 8 * 1024; + +/// Server-owned search call classified from the accounted previous response. +#[derive(Debug)] +struct PendingSearch { + /// Anthropic tool-use identifier matched by the result block. + id: String, + /// Search query supplied by the model. + query: String, +} + +/// Classification of a buffered Messages response. +enum ResponseDecision { + /// Return the response to the client unchanged. + Done, + /// Execute this server-owned search and re-enter inference. + Managed(PendingSearch), + /// Reject a malformed server-owned search call. + InvalidManagedCall, + /// Reject a server-owned search call whose query is too large. + QueryTooLong, +} + +/// Initial request fields inspected without materializing the full payload. +#[derive(Deserialize)] +struct RequestEnvelope { + /// Whether the client requested streaming. + stream: Option, +} + +/// A borrowed JSON string or an ignored value of another type. +#[derive(Deserialize)] +#[serde(untagged)] +enum TextField<'a> { + /// Borrowed string field, allocating only when JSON escaping requires it. + Text(#[serde(borrow)] Cow<'a, str>), + /// Field with a non-string value. + Other(IgnoredAny), +} + +impl TextField<'_> { + /// Return the string value when this field is a JSON string. + fn as_str(&self) -> Option<&str> { + match self { + Self::Text(value) => Some(value.as_ref()), + Self::Other(_) => None, + } + } +} + +/// Borrowed input object for a candidate managed search. +#[derive(Deserialize)] +struct SearchInput<'a> { + /// Candidate search query. + #[serde(borrow)] + query: Option>, +} + +/// A search input object or an ignored value of another type. +#[derive(Deserialize)] +#[serde(untagged)] +enum InputField<'a> { + /// Parsed input object. + Input(#[serde(borrow)] SearchInput<'a>), + /// Input with a non-object value. + Other(IgnoredAny), +} + +/// Borrowed fields from one response content block. +#[derive(Deserialize)] +struct ResponseBlock<'a> { + /// Content block type. + #[serde(rename = "type", borrow)] + kind: Option>, + /// Tool name. + #[serde(borrow)] + name: Option>, + /// Tool-use identifier. + #[serde(borrow)] + id: Option>, + /// Tool input. + #[serde(borrow)] + input: Option>, +} + +/// A response content object or an ignored value of another type. +#[derive(Deserialize)] +#[serde(untagged)] +enum ContentField<'a> { + /// Parsed content block. + Block(#[serde(borrow)] ResponseBlock<'a>), + /// Non-object content value. + Other(IgnoredAny), +} + +/// Response fields inspected before deciding whether IRR should loop. +#[derive(Deserialize)] +struct ResponseEnvelope<'a> { + /// Anthropic object type. + #[serde(rename = "type", borrow)] + kind: Option>, + /// Message role. + #[serde(borrow)] + role: Option>, + /// Stop reason. + #[serde(borrow)] + stop_reason: Option>, + /// Message content blocks. + #[serde(borrow)] + content: Option>>, +} + +/// Executes server-owned `WebSearch` tool calls in an Anthropic Messages loop. +/// +/// # YAML +/// +/// ```yaml +/// filter: anthropic_web_search +/// provider: you +/// api_key: ${WEB_SEARCH_API_KEY} +/// ``` +/// +/// # Full YAML +/// +/// ```yaml +/// filter: anthropic_web_search +/// provider: you +/// api_key: ${WEB_SEARCH_API_KEY} +/// default_context_size: medium +/// timeout_ms: 10000 +/// provider_failure_mode: closed +/// status_on_error: 502 +/// max_body_bytes: 67108864 +/// ``` +/// +/// # Live demo YAML +/// +/// ```yaml +/// # cargo run -p praxis-test-utils --example anthropic_messages_web_search_mock +/// # WEB_SEARCH_API_KEY="$WEB_SEARCH_API_KEY" cargo run -p praxis-ai-proxy -- \ +/// # -c examples/configs/anthropic/messages-web-search.yaml +/// # curl http://127.0.0.1:8080/v1/messages \ +/// # -H 'content-type: application/json' \ +/// # -d '{"model":"openai/gpt-oss-20b","max_tokens":1024,"stream":false,"messages":[{"role":"user","content":"Use web search to look up potato, then summarize in one sentence."}],"tools":[{"name":"WebSearch","description":"Search the web","input_schema":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}]}' +/// ``` +pub struct AnthropicWebSearchFilter { + /// Result-count hint passed to the provider. + default_context_size: SearchContextSize, + /// Maximum request and response body size buffered by the loop. + max_body_bytes: usize, + /// Shared provider client used for You.com callouts. + search_client: SearchClient, +} + +impl AnthropicWebSearchFilter { + /// Create a filter with an isolated subrequest client. + /// + /// # Errors + /// + /// Returns [`FilterError`] when the filter configuration is invalid. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let client = + crate::subrequest::SubRequestClient::new(praxis_core::subrequest::SubRequestConnector::new(4, None)); + Self::build(config, client) + } + + /// Create a filter with the server's shared subrequest client. + /// + /// # Errors + /// + /// Returns [`FilterError`] when the filter configuration is invalid. + pub fn from_config_with_client( + config: &serde_yaml::Value, + client: crate::subrequest::SubRequestClient, + ) -> Result, FilterError> { + Self::build(config, client) + } + + /// Build the filter around the supplied subrequest client. + fn build( + config: &serde_yaml::Value, + client: crate::subrequest::SubRequestClient, + ) -> Result, FilterError> { + let config: WebSearchFilterConfig = parse_filter_config(FILTER_NAME, config)?; + let validated = build_config(FILTER_NAME, &config)?; + let search_client = SearchClient::from_config(FILTER_NAME, &validated, client)?; + Ok(Box::new(Self { + default_context_size: validated.default_context_size, + max_body_bytes: validated.max_body_bytes, + search_client, + })) + } + + /// Execute one pending call and map provider failure policy to Messages semantics. + async fn execute_pending_search(&self, pending: &PendingSearch) -> Result, Rejection> { + let outcome = self + .search_client + .search(&pending.query, Some(self.default_context_size)) + .await; + match outcome { + SearchOutcome::Results(results) => Ok(results), + SearchOutcome::Skipped => Ok(Vec::new()), + SearchOutcome::Rejected { status } => Err(anthropic_rejection( + status, + "api_error", + "web search provider unavailable", + )), + } + } + + /// Execute a retained search and replace the IRR request body. + #[expect( + clippy::too_many_lines, + reason = "keeps accounted state access and bounded body replacement adjacent" + )] + async fn handle_reentry( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + ) -> Result { + let Some(iteration_state) = ctx.extensions.get::() else { + return Err(FilterError::from(format!( + "{FILTER_NAME}: IRR iteration state unavailable during re-entry" + ))); + }; + let request_bytes = iteration_state + .accumulator + .get(REQUEST_ACCUMULATOR_KEY) + .unwrap_or(&iteration_state.original_request.body); + let Some(previous_response) = iteration_state.previous_response.as_ref() else { + return Err(FilterError::from(format!( + "{FILTER_NAME}: previous IRR response unavailable during re-entry" + ))); + }; + + let (pending, assistant_content) = managed_search_from_response(&previous_response.body)?; + + let mut request: Value = match serde_json::from_slice(request_bytes) { + Ok(value) => value, + Err(error) => { + return Err(FilterError::from(format!( + "{FILTER_NAME}: retained request parsing failed: {error}" + ))); + }, + }; + if request.get("messages").and_then(Value::as_array).is_none() { + return Ok(FilterAction::Reject(anthropic_rejection( + 400, + "invalid_request_error", + "messages must be an array for web search re-entry", + ))); + } + let results = match self.execute_pending_search(&pending).await { + Ok(results) => results, + Err(rejection) => { + return Ok(FilterAction::Reject(rejection)); + }, + }; + if let Err(rejection) = append_search_turns(&mut request, assistant_content, pending, &results) { + return Ok(FilterAction::Reject(rejection)); + } + let rebuilt = serde_json::to_vec(&request) + .map_err(|error| FilterError::from(format!("{FILTER_NAME}: request serialization failed: {error}")))?; + if rebuilt.len() > self.max_body_bytes { + return Ok(FilterAction::Reject(anthropic_rejection( + 413, + "invalid_request_error", + "web search request exceeds configured max_body_bytes", + ))); + } + let rebuilt = Bytes::from(rebuilt); + let iteration_state = ctx.extensions.get_mut::().ok_or_else(|| { + FilterError::from(format!( + "{FILTER_NAME}: IRR iteration state unavailable while retaining request" + )) + })?; + // These `Bytes` clones share one allocation across the accounted state, + // the next iteration, and the active step body. + iteration_state + .accumulator + .insert(REQUEST_ACCUMULATOR_KEY.to_owned(), rebuilt.clone()); + ctx.extensions.insert(NextIterationBody(rebuilt.clone())); + ctx.request_headers_to_set + .push((CONTENT_TYPE, HeaderValue::from_static("application/json"))); + *body = Some(rebuilt); + Ok(FilterAction::Continue) + } +} + +#[async_trait] +impl HttpFilter for AnthropicWebSearchFilter { + fn name(&self) -> &'static str { + "anthropic_web_search" + } + + fn request_body_access(&self) -> BodyAccess { + BodyAccess::ReadWrite + } + + fn request_body_mode(&self) -> BodyMode { + BodyMode::StreamBuffer { + max_bytes: Some(self.max_body_bytes), + } + } + + fn response_body_access(&self) -> BodyAccess { + BodyAccess::ReadOnly + } + + fn response_body_mode(&self) -> BodyMode { + BodyMode::StreamBuffer { + max_bytes: Some(self.max_body_bytes), + } + } + + async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result { + Ok(FilterAction::Continue) + } + + async fn on_request_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + if !end_of_stream { + return Ok(FilterAction::Continue); + } + + if ctx + .extensions + .get::() + .and_then(|state| state.previous_response.as_ref()) + .is_some() + { + return self.handle_reentry(ctx, body).await; + } + + let Some(bytes) = body.as_deref() else { + return Ok(FilterAction::Continue); + }; + let request: RequestEnvelope = match serde_json::from_slice(bytes) { + Ok(value) => value, + Err(_) => return Ok(FilterAction::Continue), + }; + if request.stream == Some(true) { + return Ok(FilterAction::Reject(anthropic_rejection( + 400, + "invalid_request_error", + "streaming is not supported with anthropic_web_search", + ))); + } + + Ok(FilterAction::Continue) + } + + fn on_response_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + if !end_of_stream { + return Ok(FilterAction::Continue); + } + + if !is_success_response(ctx) { + set_action(ctx, ACTION_DONE)?; + return Ok(FilterAction::Continue); + } + + let decision = body.as_deref().map_or(ResponseDecision::Done, classify_response); + + match decision { + ResponseDecision::Done => set_action(ctx, ACTION_DONE)?, + ResponseDecision::Managed(_) => set_action(ctx, ACTION_LOOP)?, + ResponseDecision::InvalidManagedCall => { + return Ok(FilterAction::Reject(anthropic_rejection( + 400, + "invalid_request_error", + "WebSearch tool use requires a non-empty id and input.query", + ))); + }, + ResponseDecision::QueryTooLong => { + return Ok(FilterAction::Reject(anthropic_rejection( + 400, + "invalid_request_error", + "WebSearch input.query must not exceed 8192 bytes", + ))); + }, + } + Ok(FilterAction::Continue) + } +} + +/// Whether the current upstream response may contain a managed call. +fn is_success_response(ctx: &HttpFilterContext<'_>) -> bool { + ctx.response_header + .as_ref() + .is_none_or(|response| response.status.is_success()) +} + +/// Select a sole, well-formed server-owned search call. +#[expect( + clippy::too_many_lines, + reason = "validates one small external JSON envelope linearly" +)] +fn classify_response(response_bytes: &[u8]) -> ResponseDecision { + let Ok(response) = serde_json::from_slice::>(response_bytes) else { + return ResponseDecision::Done; + }; + let stop_reason = response.stop_reason.as_ref().and_then(TextField::as_str); + if response.kind.as_ref().and_then(TextField::as_str) != Some("message") + || response.role.as_ref().and_then(TextField::as_str) != Some("assistant") + // vLLM's Messages-compatible endpoint currently labels otherwise + // valid tool-use responses as `end_turn`. + || !matches!(stop_reason, Some("tool_use" | "end_turn")) + { + return ResponseDecision::Done; + } + let Some(content) = response.content.as_deref() else { + return ResponseDecision::Done; + }; + let mut tools = content.iter().filter_map(|field| match field { + ContentField::Block(block) if block.kind.as_ref().and_then(TextField::as_str) == Some("tool_use") => { + Some(block) + }, + ContentField::Block(_) | ContentField::Other(_) => None, + }); + let Some(tool) = tools.next() else { + return ResponseDecision::Done; + }; + if tools.next().is_some() { + return ResponseDecision::Done; + } + if tool.name.as_ref().and_then(TextField::as_str) != Some("WebSearch") { + return ResponseDecision::Done; + } + let Some(id) = tool + .id + .as_ref() + .and_then(TextField::as_str) + .filter(|value| !value.is_empty()) + else { + return ResponseDecision::InvalidManagedCall; + }; + let Some(query) = tool + .input + .as_ref() + .and_then(|input| match input { + InputField::Input(input) => input.query.as_ref().and_then(TextField::as_str), + InputField::Other(_) => None, + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return ResponseDecision::InvalidManagedCall; + }; + if query.len() > MAX_SEARCH_QUERY_BYTES { + return ResponseDecision::QueryTooLong; + } + let id = id.to_owned(); + let query = query.to_owned(); + ResponseDecision::Managed(PendingSearch { id, query }) +} + +/// Recover the managed call and complete content from the accounted response. +fn managed_search_from_response(response_bytes: &[u8]) -> Result<(PendingSearch, Vec), FilterError> { + let ResponseDecision::Managed(pending) = classify_response(response_bytes) else { + return Err(FilterError::from(format!( + "{FILTER_NAME}: previous response no longer contains a managed WebSearch call" + ))); + }; + let mut response: Value = serde_json::from_slice(response_bytes).map_err(|error| { + FilterError::from(format!( + "{FILTER_NAME}: previous response parsing failed during re-entry: {error}" + )) + })?; + let assistant_content = response + .get_mut("content") + .and_then(Value::as_array_mut) + .map(std::mem::take) + .ok_or_else(|| { + FilterError::from(format!( + "{FILTER_NAME}: previous response content unavailable during re-entry" + )) + })?; + Ok((pending, assistant_content)) +} + +/// Append the assistant tool call and matching user result block. +fn append_search_turns( + request: &mut Value, + assistant_content: Vec, + pending: PendingSearch, + results: &[SearchResult], +) -> Result<(), Rejection> { + let Some(messages) = request.get_mut("messages").and_then(Value::as_array_mut) else { + return Err(anthropic_rejection( + 400, + "invalid_request_error", + "messages must be an array for web search re-entry", + )); + }; + let content = if results.is_empty() { + "No search results found.".to_owned() + } else { + format_search_results(results) + }; + let PendingSearch { id, query: _ } = pending; + let mut assistant_turn = serde_json::Map::new(); + assistant_turn.insert("role".to_owned(), Value::String("assistant".to_owned())); + assistant_turn.insert("content".to_owned(), Value::Array(assistant_content)); + messages.push(Value::Object(assistant_turn)); + messages.push(json!({"role":"user","content":[{ + "type":"tool_result","tool_use_id":id,"content":content + }]})); + if request.get("tool_choice").is_some() + && let Some(object) = request.as_object_mut() + { + object.insert("tool_choice".to_owned(), json!({"type":"auto"})); + } + Ok(()) +} + +/// Publish the loop decision for the IRR transition table. +fn set_action(ctx: &mut HttpFilterContext<'_>, action: &'static str) -> Result<(), FilterError> { + ctx.filter_results + .entry(FILTER_NAME) + .or_default() + .set("action", action)?; + Ok(()) +} + +/// Build an Anthropic JSON error response. +fn anthropic_rejection(status: u16, error_type: &str, message: &str) -> Rejection { + Rejection::status(status) + .with_header("content-type", "application/json") + .with_body(Bytes::from(super::wire::error_body(error_type, message, None))) +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + clippy::needless_pass_by_value, + clippy::needless_raw_strings, + clippy::too_many_lines, + reason = "tests" +)] +mod tests; diff --git a/apis/src/anthropic/web_search/tests.rs b/apis/src/anthropic/web_search/tests.rs new file mode 100644 index 0000000000..7a7fba65b1 --- /dev/null +++ b/apis/src/anthropic/web_search/tests.rs @@ -0,0 +1,588 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +use std::{ + io::{Read as _, Write as _}, + net::{TcpListener, TcpStream}, + sync::{Arc, Mutex}, +}; + +use bytes::Bytes; +use http::Method; +use praxis_filter::{FilterAction, HttpFilter, HttpFilterContext, Request}; +use serde_json::{Value, json}; + +use super::*; +use crate::test_utils::{make_filter_context, make_request, make_response}; + +fn test_filter() -> Box { + let config = serde_yaml::from_str( + r" +provider: you +api_key: test-key +default_context_size: medium +", + ) + .unwrap(); + AnthropicWebSearchFilter::from_config(&config).unwrap() +} + +fn test_filter_impl_with_base_url(base_url: &str, provider_failure_mode: &str) -> AnthropicWebSearchFilter { + let config = serde_yaml::from_str(&format!( + r#" +provider: you +api_key: test-key +default_context_size: medium +provider_failure_mode: {provider_failure_mode} +base_url: "{base_url}" +"#, + )) + .unwrap(); + let config: WebSearchFilterConfig = parse_filter_config(FILTER_NAME, &config).unwrap(); + let validated = build_config(FILTER_NAME, &config).unwrap(); + let client = crate::subrequest::SubRequestClient::new(praxis_core::subrequest::SubRequestConnector::new(4, None)); + let search_client = SearchClient::from_config(FILTER_NAME, &validated, client).unwrap(); + AnthropicWebSearchFilter { + default_context_size: validated.default_context_size, + max_body_bytes: validated.max_body_bytes, + search_client, + } +} + +struct SearchStub { + base_url: String, + requests: Arc>>, +} + +impl SearchStub { + fn base_url(&self) -> &str { + &self.base_url + } + + fn last_request(&self) -> String { + self.requests.lock().unwrap().last().unwrap().clone() + } + + fn last_json(&self) -> Value { + let request = self.last_request(); + let (_, body) = request.split_once("\r\n\r\n").unwrap(); + serde_json::from_str(body).unwrap() + } +} + +fn start_you_search_stub(status: u16, body: String) -> SearchStub { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + captured.lock().unwrap().push(read_http_request(&mut stream)); + let response = format!( + "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + SearchStub { + base_url: format!("http://{address}"), + requests, + } +} + +fn read_http_request(stream: &mut TcpStream) -> String { + let mut request = Vec::new(); + loop { + let mut buffer = [0_u8; 4096]; + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "search request closed before its body arrived"); + request.extend_from_slice(buffer.get(..count).unwrap()); + + let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") else { + continue; + }; + let body_start = header_end + 4; + let headers = String::from_utf8_lossy(request.get(..header_end).unwrap()).to_ascii_lowercase(); + let content_length = headers + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .map(str::trim) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + if request.len() >= body_start + content_length { + return String::from_utf8(request).unwrap(); + } + } +} + +fn valid_you_body() -> String { + json!({ + "results": { + "web": [{ + "title": "Potato - Wikipedia", + "url": "https://en.wikipedia.org/wiki/Potato", + "description": "Potato is a starchy tuber native to the Americas." + }], + "news": [] + } + }) + .to_string() +} + +#[test] +fn search_stub_reads_full_content_length_body() { + let search = start_you_search_stub(200, valid_you_body()); + let query = "q".repeat(20 * 1024); + let body = json!({"query": query, "count": 5}).to_string(); + let address = search.base_url().strip_prefix("http://").unwrap(); + let mut stream = TcpStream::connect(address).unwrap(); + let request = format!( + "POST /v1/search HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + + stream.write_all(request.as_bytes()).unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + + assert!(String::from_utf8_lossy(&response).starts_with("HTTP/1.1 200")); + assert_eq!(search.last_json()["query"], query); +} + +fn base_request() -> Value { + json!({ + "model":"openai/gpt-oss-20b", + "max_tokens":256, + "system":"Answer with sources.", + "metadata":{"user_id":"demo"}, + "tools":[{"name":"WebSearch","description":"Search the web","input_schema":{"type":"object"}}], + "tool_choice":{"type":"tool","name":"WebSearch"}, + "messages":[{"role":"user","content":"Find potato facts"}] + }) +} + +fn pending_search(query: &str) -> PendingSearch { + PendingSearch { + id: "toolu_search_1".to_owned(), + query: query.to_owned(), + } +} + +fn assistant_content(query: &str) -> Vec { + vec![json!({ + "type":"tool_use","id":"toolu_search_1","name":"WebSearch","input":{"query":query} + })] +} + +fn message_response(content: Value, stop_reason: &str) -> Bytes { + Bytes::from( + json!({ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "test", + "content": content, + "stop_reason": stop_reason, + "stop_sequence": null, + "usage": {"input_tokens": 10, "output_tokens": 5} + }) + .to_string(), + ) +} + +async fn initialized_context<'a>(request: &'a Request) -> HttpFilterContext<'a> { + let filter = test_filter(); + let mut ctx = make_filter_context(request); + let mut body = Some(Bytes::from_static( + br#"{"model":"test","max_tokens":32,"messages":[{"role":"user","content":"search"}]}"#, + )); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); + ctx +} + +fn result_action(ctx: &HttpFilterContext<'_>) -> Option { + ctx.filter_results.get(FILTER_NAME)?.get("action").map(str::to_owned) +} + +#[tokio::test] +async fn streaming_request_is_rejected_before_reentry() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + let mut body = Some(Bytes::from_static( + br#"{"model":"test","max_tokens":32,"stream":true,"messages":[]}"#, + )); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected rejection"); + }; + assert_eq!(rejection.status, 400); + let body: Value = serde_json::from_slice(rejection.body.as_ref().unwrap()).unwrap(); + assert_eq!(body["type"], "error"); + assert_eq!(body["error"]["type"], "invalid_request_error"); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("streaming is not supported")) + ); +} + +#[tokio::test] +async fn sole_web_search_tool_use_signals_loop() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = initialized_context(&request).await; + let mut body = Some(message_response( + json!([{ + "type":"tool_use","id":"toolu_search_1","name":"WebSearch", + "input":{"query":"potato"} + }]), + "tool_use", + )); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + assert_eq!(result_action(&ctx).as_deref(), Some("loop")); + let ResponseDecision::Managed(pending) = classify_response(body.as_ref().unwrap()) else { + panic!("expected managed search"); + }; + assert_eq!(pending.query, "potato"); +} + +#[tokio::test] +async fn vllm_end_turn_web_search_signals_loop() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = initialized_context(&request).await; + let mut body = Some(Bytes::from_static( + br#"{"id":"chatcmpl-8594675bd3b17d40","type":"message","role":"assistant","content":[{"type":"tool_use","id":"chatcmpl-tool-8cb8901f3f024ffe","name":"WebSearch","input":{"query":"potato"}}],"model":"RedHatAI/Qwen3-Coder-Next-NVFP4","stop_reason":"end_turn","usage":{"input_tokens":293,"output_tokens":23}}"#, + )); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + assert_eq!(result_action(&ctx).as_deref(), Some("loop")); + let ResponseDecision::Managed(pending) = classify_response(body.as_ref().unwrap()) else { + panic!("expected vLLM WebSearch response to be managed"); + }; + assert_eq!(pending.query, "potato"); +} + +#[tokio::test] +async fn non_success_web_search_message_signals_done_unchanged() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = initialized_context(&request).await; + let mut response = make_response(); + response.status = http::StatusCode::TOO_MANY_REQUESTS; + ctx.response_header = Some(&mut response); + let original = message_response( + json!([{ + "type":"tool_use","id":"toolu_search_1","name":"WebSearch", + "input":{"query":"potato"} + }]), + "tool_use", + ); + let mut body = Some(original.clone()); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + assert_eq!(result_action(&ctx).as_deref(), Some("done")); + assert_eq!(body, Some(original)); +} + +#[tokio::test] +async fn managed_query_at_utf8_byte_limit_signals_loop() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = initialized_context(&request).await; + let query = "é".repeat(4096); + let mut body = Some(message_response( + json!([{ + "type":"tool_use","id":"toolu_search_1","name":"WebSearch", + "input":{"query":query} + }]), + "tool_use", + )); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + assert_eq!(result_action(&ctx).as_deref(), Some("loop")); +} + +#[tokio::test] +async fn escaped_managed_query_signals_loop_with_decoded_text() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = initialized_context(&request).await; + let mut body = Some(message_response( + json!([{ + "type":"tool_use","id":"toolu_search_1","name":"WebSearch", + "input":{"query":"potato\ncultivation"} + }]), + "tool_use", + )); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + assert_eq!(result_action(&ctx).as_deref(), Some("loop")); + let ResponseDecision::Managed(pending) = classify_response(body.as_ref().unwrap()) else { + panic!("expected managed search"); + }; + assert_eq!(pending.query, "potato\ncultivation"); +} + +#[tokio::test] +async fn managed_query_over_utf8_byte_limit_is_rejected() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = initialized_context(&request).await; + let query = format!("{}x", "é".repeat(4096)); + let mut body = Some(message_response( + json!([{ + "type":"tool_use","id":"toolu_search_1","name":"WebSearch", + "input":{"query":query} + }]), + "tool_use", + )); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected rejection"); + }; + assert_eq!(rejection.status, 400); + assert!(String::from_utf8_lossy(rejection.body.as_ref().unwrap()).contains("8192 bytes")); + assert_ne!(result_action(&ctx).as_deref(), Some("loop")); +} + +#[tokio::test] +async fn client_owned_and_mixed_tools_signal_done() { + for content in [ + json!([{"type":"tool_use","id":"toolu_bash","name":"Bash","input":{}}]), + json!([ + {"type":"tool_use","id":"toolu_search","name":"WebSearch","input":{"query":"potato"}}, + {"type":"tool_use","id":"toolu_bash","name":"Bash","input":{}} + ]), + ] { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = initialized_context(&request).await; + let original = message_response(content, "tool_use"); + let mut body = Some(original.clone()); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + assert_eq!(result_action(&ctx).as_deref(), Some("done")); + assert_eq!(body, Some(original)); + } +} + +#[tokio::test] +async fn managed_call_without_query_is_rejected() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = initialized_context(&request).await; + let mut body = Some(message_response( + json!([{"type":"tool_use","id":"toolu_search","name":"WebSearch","input":{}}]), + "tool_use", + )); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected rejection"); + }; + assert_eq!(rejection.status, 400); + assert!(String::from_utf8_lossy(rejection.body.as_ref().unwrap()).contains("query")); +} + +#[tokio::test] +async fn managed_call_with_empty_id_is_rejected() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = initialized_context(&request).await; + let mut body = Some(message_response( + json!([{"type":"tool_use","id":"","name":"WebSearch","input":{"query":"potato"}}]), + "tool_use", + )); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected rejection"); + }; + assert_eq!(rejection.status, 400); +} + +#[tokio::test] +async fn managed_call_with_whitespace_only_query_is_rejected() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = initialized_context(&request).await; + let mut body = Some(message_response( + json!([{"type":"tool_use","id":"toolu_search","name":"WebSearch","input":{"query":" "}}]), + "tool_use", + )); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected rejection"); + }; + assert_eq!(rejection.status, 400); +} + +#[tokio::test] +async fn final_text_signals_done_without_mutating_body() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = initialized_context(&request).await; + let original = message_response(json!([{"type":"text","text":"Potatoes grow underground."}]), "end_turn"); + let mut body = Some(original.clone()); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + assert_eq!(result_action(&ctx).as_deref(), Some("done")); + assert_eq!(body, Some(original)); +} + +#[tokio::test] +async fn non_message_error_body_signals_done() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = initialized_context(&request).await; + let original = Bytes::from_static(br#"{"type":"error","error":{"type":"overloaded_error","message":"busy"}}"#); + let mut body = Some(original.clone()); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + assert_eq!(result_action(&ctx).as_deref(), Some("done")); + assert_eq!(body, Some(original)); +} + +#[tokio::test] +async fn non_end_of_stream_is_noop() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = initialized_context(&request).await; + let mut body = Some(message_response(json!([]), "end_turn")); + + let action = filter.on_response_body(&mut ctx, &mut body, false).unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + assert!(ctx.filter_results.is_empty()); +} + +#[tokio::test] +async fn initial_request_body_is_not_mutated() { + let filter = test_filter(); + let request = make_request(Method::POST, "/v1/messages"); + let mut ctx = make_filter_context(&request); + let original = json!({ + "model":"test", + "max_tokens":32, + "system":"Be concise", + "metadata":{"user_id":"demo"}, + "messages":[{"role":"user","content":"search"}] + }); + let original = Bytes::from(original.to_string()); + let mut body = Some(original.clone()); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + assert_eq!(body, Some(original)); +} + +#[tokio::test] +async fn pending_search_executes_and_appends_tool_result() { + let search = start_you_search_stub(200, valid_you_body()); + let filter = test_filter_impl_with_base_url(search.base_url(), "closed"); + let pending = pending_search("potato"); + + let results = filter.execute_pending_search(&pending).await.unwrap(); + let mut rebuilt = base_request(); + append_search_turns(&mut rebuilt, assistant_content("potato"), pending, &results).unwrap(); + + assert_eq!(rebuilt["model"], "openai/gpt-oss-20b"); + assert_eq!(rebuilt["system"], "Answer with sources."); + assert_eq!(rebuilt["metadata"]["user_id"], "demo"); + assert_eq!(rebuilt["tools"][0]["name"], "WebSearch"); + assert_eq!(rebuilt["tool_choice"], json!({"type":"auto"})); + let messages = rebuilt["messages"].as_array().unwrap(); + assert_eq!(messages[messages.len() - 2]["role"], "assistant"); + assert_eq!(messages[messages.len() - 1]["content"][0]["type"], "tool_result"); + assert_eq!( + messages[messages.len() - 1]["content"][0]["tool_use_id"], + "toolu_search_1" + ); + assert!( + messages[messages.len() - 1]["content"][0]["content"] + .as_str() + .unwrap() + .contains("Potato - Wikipedia") + ); + assert_eq!(search.last_json()["query"], "potato"); + assert!( + search + .last_request() + .to_ascii_lowercase() + .contains("x-api-key: test-key") + ); +} + +#[tokio::test] +async fn closed_provider_failure_returns_anthropic_error() { + let search = start_you_search_stub(503, "unavailable".to_owned()); + let filter = test_filter_impl_with_base_url(search.base_url(), "closed"); + let pending = pending_search("potato"); + + let result = filter.execute_pending_search(&pending).await; + + let Err(rejection) = result else { + panic!("expected rejection"); + }; + assert_eq!(rejection.status, 502); + assert!(String::from_utf8_lossy(rejection.body.as_ref().unwrap()).contains("api_error")); +} + +#[tokio::test] +async fn open_provider_failure_appends_no_results_tool_result() { + let search = start_you_search_stub(503, "unavailable".to_owned()); + let filter = test_filter_impl_with_base_url(search.base_url(), "open"); + let pending = pending_search("potato"); + + let results = filter.execute_pending_search(&pending).await.unwrap(); + let mut rebuilt = base_request(); + append_search_turns(&mut rebuilt, assistant_content("potato"), pending, &results).unwrap(); + + let content = rebuilt["messages"].as_array().unwrap().last().unwrap()["content"][0]["content"] + .as_str() + .unwrap(); + assert_eq!(content, "No search results found."); +} + +#[test] +fn accounted_previous_response_recovers_complete_assistant_content() { + let content = json!([ + {"type":"text","text":"I will search first."}, + {"type":"tool_use","id":"toolu_search_1","name":"WebSearch","input":{"query":"potato"}} + ]); + let response = message_response(content.clone(), "tool_use"); + + let (pending, recovered) = managed_search_from_response(&response).unwrap(); + + assert_eq!(pending.id, "toolu_search_1"); + assert_eq!(pending.query, "potato"); + assert_eq!(recovered, content.as_array().unwrap().clone()); +} diff --git a/apis/src/anthropic/wire.rs b/apis/src/anthropic/wire.rs new file mode 100644 index 0000000000..b431017cac --- /dev/null +++ b/apis/src/anthropic/wire.rs @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Shared Anthropic wire response types. + +use bytes::Bytes; +use praxis_filter::Rejection; +use serde::Serialize; +use serde_json::{Map, Value}; + +/// Fallback error body used only if Serde serialization fails. +const ERROR_SERIALIZATION_FALLBACK: &[u8] = br#"{"type":"error","error":{"type":"api_error","message":"failed to serialize error response"},"request_id":null}"#; + +/// Complete Anthropic Messages response. +#[derive(Serialize)] +pub(crate) struct MessageResponse<'a> { + /// Ordered response content blocks. + pub content: Vec>, + /// Container information, when available. + pub container: Option, + /// Message identifier. + pub id: String, + /// Model that generated the response. + pub model: &'a str, + /// Message author role. + pub role: &'static str, + /// Extended stop information, when available. + pub stop_details: Option, + /// Reason generation stopped. + pub stop_reason: String, + /// Matched stop sequence, when available. + pub stop_sequence: Option<&'static str>, + /// Response discriminator. + pub r#type: &'static str, + /// Token and service usage. + pub usage: MessageUsage, +} + +/// Anthropic response content block. +#[derive(Serialize)] +#[serde(rename_all = "snake_case", tag = "type")] +pub(crate) enum ContentBlock<'a> { + /// Assistant-generated text. + Text { + /// Source citations, when available. + citations: Option, + /// Generated text. + text: &'a str, + }, + /// Assistant-requested tool invocation. + ToolUse { + /// Execution context that produced the tool call. + caller: DirectCaller, + /// Tool call identifier. + id: &'a str, + /// Parsed tool arguments. + input: Map, + /// Tool name. + name: &'a str, + }, +} + +impl<'a> ContentBlock<'a> { + /// Create an assistant-generated text block. + pub(crate) fn text(text: &'a str) -> Self { + Self::Text { citations: None, text } + } + + /// Create a directly requested tool-use block. + pub(crate) fn tool_use(id: &'a str, input: Map, name: &'a str) -> Self { + Self::ToolUse { + caller: DirectCaller::new(), + id, + input, + name, + } + } +} + +/// Direct tool invocation caller. +#[derive(Serialize)] +pub(crate) struct DirectCaller { + /// Caller discriminator. + r#type: &'static str, +} + +impl DirectCaller { + /// Create a direct invocation caller. + pub(crate) fn new() -> Self { + Self { r#type: "direct" } + } +} + +/// Complete Anthropic Messages usage object. +#[derive(Serialize)] +pub(crate) struct MessageUsage { + /// Cache creation details, when available. + pub cache_creation: Option, + /// Tokens used to create cache entries. + pub cache_creation_input_tokens: Option, + /// Tokens read from cache. + pub cache_read_input_tokens: Option, + /// Inference geography, when reported. + pub inference_geo: Option, + /// Non-cached input tokens. + pub input_tokens: u64, + /// Generated output tokens. + pub output_tokens: u64, + /// Server tool usage, when reported. + pub server_tool_use: Option, + /// Service tier, when reported. + pub service_tier: Option, +} + +impl MessageUsage { + /// Create usage from token values available in Chat Completions. + pub(crate) fn new(input_tokens: u64, output_tokens: u64, cache_read_input_tokens: Option) -> Self { + Self { + cache_creation: None, + cache_creation_input_tokens: None, + cache_read_input_tokens, + inference_geo: None, + input_tokens, + output_tokens, + server_tool_use: None, + service_tier: None, + } + } +} + +/// Anthropic terminal streaming delta usage object. +#[derive(Serialize)] +pub(crate) struct MessageDeltaUsage { + /// Tokens used to create cache entries. + pub cache_creation_input_tokens: Option, + /// Tokens read from cache. + pub cache_read_input_tokens: Option, + /// Input tokens, when reported in the terminal delta. + pub input_tokens: Option, + /// Cumulative generated output tokens. + pub output_tokens: u64, + /// Server tool usage, when reported. + pub server_tool_use: Option, +} + +impl MessageDeltaUsage { + /// Create terminal delta usage from the cumulative output token count. + pub(crate) fn new(output_tokens: u64) -> Self { + Self { + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + input_tokens: None, + output_tokens, + server_tool_use: None, + } + } +} + +/// Anthropic error response envelope. +#[derive(Serialize)] +struct ErrorResponse<'a> { + /// Structured error details. + error: ErrorDetail<'a>, + /// Anthropic request identifier, when supplied upstream. + request_id: Option<&'a str>, + /// Top-level response discriminator. + r#type: &'static str, +} + +/// Anthropic structured error details. +#[derive(Serialize)] +struct ErrorDetail<'a> { + /// Human-readable diagnostic. + message: &'a str, + /// Anthropic error category. + r#type: &'a str, +} + +/// Serialize a schema-complete Anthropic error response. +pub(crate) fn error_body(error_type: &str, message: &str, request_id: Option<&str>) -> Vec { + serde_json::to_vec(&ErrorResponse { + error: ErrorDetail { + message, + r#type: error_type, + }, + request_id, + r#type: "error", + }) + .unwrap_or_else(|_| ERROR_SERIALIZATION_FALLBACK.to_vec()) +} + +/// Build a schema-complete Anthropic invalid-request rejection. +pub(crate) fn invalid_request_rejection(message: &str) -> Rejection { + Rejection::status(400) + .with_header("content-type", "application/json") + .with_body(Bytes::from(error_body("invalid_request_error", message, None))) +} + +#[cfg(test)] +#[expect(clippy::unwrap_used, clippy::indexing_slicing, reason = "tests")] +mod tests { + use serde_json::Value; + + use super::*; + + #[test] + fn error_body_is_schema_complete_and_json_safe() { + let body = error_body("invalid_request_error", "bad \"model\"\nvalue", None); + let parsed: Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "invalid_request_error"); + assert_eq!(parsed["error"]["message"], "bad \"model\"\nvalue"); + assert!(parsed.get("request_id").is_some()); + assert!(parsed["request_id"].is_null()); + } + + #[test] + fn error_body_preserves_request_id() { + let body = error_body("rate_limit_error", "rate limited", Some("req_01")); + let parsed: Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(parsed["request_id"], "req_01"); + } + + #[test] + fn invalid_request_rejection_uses_error_envelope() { + let rejection = invalid_request_rejection("bad request"); + let parsed: Value = serde_json::from_slice(rejection.body.as_deref().unwrap()).unwrap(); + + assert_eq!(rejection.status, 400); + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "invalid_request_error"); + assert_eq!(parsed["error"]["message"], "bad request"); + assert!(parsed["request_id"].is_null()); + } +} diff --git a/apis/src/classifier/mod.rs b/apis/src/classifier/mod.rs new file mode 100644 index 0000000000..c3ce0f0f98 --- /dev/null +++ b/apis/src/classifier/mod.rs @@ -0,0 +1,1193 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Pure request body classifier for AI API format detection. +//! +//! Disambiguates Responses API, Anthropic Messages, and Chat +//! Completions from a single JSON body parse. + +// ----------------------------------------------------------------------------- +// AiRequestFormat +// ----------------------------------------------------------------------------- + +/// Classified request body format. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AiRequestFormat { + /// `OpenAI` Responses API (has `input` field). + Responses, + /// Anthropic Messages API (`messages` + required `max_tokens`). + AnthropicMessages, + /// Chat Completions API (has `messages` without required `max_tokens`). + ChatCompletions, + /// Valid JSON but neither recognized format. + UnknownJson, + /// Body is not valid JSON. + InvalidJson, + /// Body is empty or absent. + NonJson, +} + +impl AiRequestFormat { + /// Stable string representation for headers, metadata, and filter results. + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Responses => "openai_responses", + Self::AnthropicMessages => "anthropic_messages", + Self::ChatCompletions => "openai_chat_completions", + Self::UnknownJson => "unknown", + Self::InvalidJson => "invalid_json", + Self::NonJson => "non_json", + } + } +} + +// ----------------------------------------------------------------------------- +// ClassifiedRequest +// ----------------------------------------------------------------------------- + +/// Extracted facts from a classified request body. +#[derive(Debug)] +#[expect(clippy::struct_excessive_bools, reason = "independent presence flags from JSON body")] +pub(crate) struct ClassifiedRequest { + /// Extracted `background` field value, if present. + pub background: Option, + /// Detected body format. + pub format: AiRequestFormat, + /// Whether `conversation` is present and non-null. + pub has_conversation: bool, + /// Whether `previous_response_id` is present and non-null. + pub has_previous_response_id: bool, + /// Whether `prompt.id` is present and non-null. + pub has_prompt_id: bool, + /// Whether `tools` is a non-empty array (coarse presence check). + /// + /// This does NOT validate individual entries: an array like + /// `[{"unexpected": true}]` will set this to `true` even though + /// no entry carries a recognised `type` discriminator. + /// [`openai_tool_parse`] applies stricter per-entry classification and + /// may disagree on malformed arrays. + /// + /// [`openai_tool_parse`]: crate::openai::responses::openai_tool_parse + pub has_tools: bool, + /// Extracted `max_output_tokens` field value (Responses API), if present. + pub max_output_tokens: Option, + /// Extracted `max_tokens` field value, if present. + pub max_tokens: Option, + /// Extracted `model` field value, if present. + pub model: Option, + /// Extracted `store` field value, if present. + pub store: Option, + /// Extracted `stream` field value, if present. + pub stream: Option, +} + +// ----------------------------------------------------------------------------- +// Path Classification +// ----------------------------------------------------------------------------- + +/// Check whether a method + path pair matches a known Responses API endpoint. +/// +/// Returns `true` for: +/// - `GET /v1/responses/{id}` +/// - `GET /v1/responses/{id}/input_items` +/// - `POST /v1/responses/{id}/cancel` +/// - `POST /v1/responses/input_tokens` +/// - `POST /v1/responses/compact` +/// - `DELETE /v1/responses/{id}` +pub(crate) fn is_responses_path(method: &http::Method, path: &str) -> bool { + let path = normalize_trailing_slash(path); + let rest = match path.strip_prefix("/v1/responses/") { + Some(r) if !r.is_empty() => r, + _ => return false, + }; + + match *method { + http::Method::POST => { + matches!(rest, "input_tokens" | "compact") + || rest + .strip_suffix("/cancel") + .is_some_and(|id| !id.is_empty() && !id.contains('/')) + }, + http::Method::GET => { + !rest.contains('/') + || rest + .strip_suffix("/input_items") + .is_some_and(|id| !id.is_empty() && !id.contains('/')) + }, + http::Method::DELETE => !rest.contains('/'), + _ => false, + } +} + +/// Check whether a method + path pair is the Responses API create endpoint. +/// +/// Returns `true` only for `POST /v1/responses` (with optional trailing slash). +/// Sub-resource POSTs like `/v1/responses/{id}/cancel` return `false`. +pub(crate) fn is_responses_create(method: &http::Method, path: &str) -> bool { + method == http::Method::POST && normalize_trailing_slash(path) == "/v1/responses" +} + +/// Check whether a request is a Responses API `WebSocket` handshake. +/// +/// The handshake uses `GET /v1/responses` and the opening handshake from +/// [RFC 6455 Section 4.1]. `Connection` options follow the token-list +/// semantics in [RFC 9110 Section 7.6.1], including comma-separated and +/// repeated field lines. +/// +/// [RFC 6455 Section 4.1]: https://datatracker.ietf.org/doc/html/rfc6455#section-4.1 +/// [RFC 9110 Section 7.6.1]: https://datatracker.ietf.org/doc/html/rfc9110#section-7.6.1 +pub(crate) fn is_responses_websocket_handshake(method: &http::Method, path: &str, headers: &http::HeaderMap) -> bool { + if method != http::Method::GET || normalize_trailing_slash(path) != "/v1/responses" { + return false; + } + + let connection_upgrades = headers + .get_all(http::header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|token| token.trim().eq_ignore_ascii_case("upgrade")); + let mut upgrade_values = headers.get_all(http::header::UPGRADE).iter(); + let upgrades_to_websocket = upgrade_values + .next() + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.trim().eq_ignore_ascii_case("websocket")) + && upgrade_values.next().is_none(); + + connection_upgrades && upgrades_to_websocket +} + +// ----------------------------------------------------------------------------- +// Body Classification +// ----------------------------------------------------------------------------- + +/// Classify a request body and extract routing facts. +/// +/// This function is pure: no I/O, no side effects, no mutation of +/// the input bytes. +pub(crate) fn classify_request_body(body: &[u8]) -> ClassifiedRequest { + if body.is_empty() { + return empty_result(AiRequestFormat::NonJson); + } + + let Ok(value) = serde_json::from_slice::(body) else { + return empty_result(AiRequestFormat::InvalidJson); + }; + + let Some(obj) = value.as_object() else { + return empty_result(AiRequestFormat::InvalidJson); + }; + + let format = classify_format(obj); + + ClassifiedRequest { + background: obj.get("background").and_then(serde_json::Value::as_bool), + format, + has_conversation: obj.get("conversation").is_some_and(|v| !v.is_null()), + has_previous_response_id: obj.get("previous_response_id").is_some_and(|v| !v.is_null()), + has_prompt_id: obj + .get("prompt") + .and_then(serde_json::Value::as_object) + .and_then(|prompt| prompt.get("id")) + .is_some_and(|v| !v.is_null()), + has_tools: obj + .get("tools") + .is_some_and(|v| v.as_array().is_some_and(|a| !a.is_empty())), + max_output_tokens: obj.get("max_output_tokens").and_then(serde_json::Value::as_u64), + max_tokens: obj.get("max_tokens").and_then(serde_json::Value::as_u64), + model: extract_string(obj, "model"), + store: obj.get("store").and_then(serde_json::Value::as_bool), + stream: obj.get("stream").and_then(serde_json::Value::as_bool), + } +} + +/// Determine format from top-level keys. +/// +/// Precedence: `input`, `prompt` object, `previous_response_id`, +/// or `conversation` → Responses, then `messages` with Anthropic +/// signals → Anthropic Messages, then `messages` alone → Chat +/// Completions. +/// +/// Anthropic signals: `max_tokens` is required AND at least one of +/// top-level `system` field or typed content blocks (arrays of +/// objects with a `type` key in `messages`). This prevents false +/// positives when `OpenAI` Chat Completions requests include the +/// optional `max_tokens` field. +fn classify_format(obj: &serde_json::Map) -> AiRequestFormat { + if obj.contains_key("input") + || obj.get("prompt").is_some_and(serde_json::Value::is_object) + || obj.contains_key("previous_response_id") + || obj.contains_key("conversation") + { + return AiRequestFormat::Responses; + } + + if obj.contains_key("messages") { + if obj.contains_key("max_tokens") && has_anthropic_signals(obj) { + return AiRequestFormat::AnthropicMessages; + } + return AiRequestFormat::ChatCompletions; + } + + AiRequestFormat::UnknownJson +} + +/// Check for Anthropic-specific structural signals beyond `max_tokens`. +/// +/// Returns true if any of: +/// - Top-level `system` field is present as a string or array (Anthropic separates system from messages; `OpenAI` puts +/// it in the messages array) +/// - Any message in `messages` has typed content blocks (array of objects with a `type` key, e.g. `[{"type": "text", +/// ...}]`) +fn has_anthropic_signals(obj: &serde_json::Map) -> bool { + if obj.contains_key("system") { + return true; + } + + if let Some(serde_json::Value::Array(messages)) = obj.get("messages") { + for msg in messages { + if let Some(serde_json::Value::Array(blocks)) = msg.get("content") + && blocks.iter().any(|b| b.get("type").is_some()) + { + return true; + } + } + } + + false +} + +// ----------------------------------------------------------------------------- +// Private Utilities +// ----------------------------------------------------------------------------- + +/// Strip a single trailing slash unless the path is the root `/`. +fn normalize_trailing_slash(path: &str) -> &str { + path.strip_suffix('/').filter(|p| !p.is_empty()).unwrap_or(path) +} + +/// Build a result with no extracted facts. +pub(crate) fn empty_result(format: AiRequestFormat) -> ClassifiedRequest { + ClassifiedRequest { + background: None, + format, + has_conversation: false, + has_previous_response_id: false, + has_prompt_id: false, + has_tools: false, + max_output_tokens: None, + max_tokens: None, + model: None, + store: None, + stream: None, + } +} + +/// Extract a string field from a JSON object, converting numbers/booleans +/// to their string representation. +fn extract_string(obj: &serde_json::Map, key: &str) -> Option { + obj.get(key).and_then(|v| match v { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + serde_json::Value::Bool(b) => Some(b.to_string()), + _ => None, + }) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + clippy::needless_raw_strings, + clippy::needless_raw_string_hashes, + reason = "tests" +)] +mod tests { + use super::*; + + #[test] + fn responses_string_input() { + let body = br#"{"model":"gpt-4.1-mini","input":"Hello, world!"}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::Responses, + "string input should classify as responses" + ); + assert_eq!( + result.model.as_deref(), + Some("gpt-4.1-mini"), + "model should be extracted" + ); + } + + #[test] + fn responses_array_input() { + let body = br#"{"model":"gpt-4.1","input":[{"type":"message","role":"user","content":"Hi"}]}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::Responses, + "array input should classify as responses" + ); + assert_eq!(result.model.as_deref(), Some("gpt-4.1"), "model should be extracted"); + } + + #[test] + fn responses_null_input_classifies_as_responses() { + let body = br#"{"model":"gpt-4.1","input":null}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::Responses, + "input key should classify as responses even when input is null" + ); + assert_eq!(result.model.as_deref(), Some("gpt-4.1"), "model should be extracted"); + } + + #[test] + fn responses_with_stream_store_previous_response_id() { + let body = + br#"{"model":"gpt-4.1","input":"test","stream":true,"store":false,"background":true,"previous_response_id":"resp_abc"}"#; + let result = classify_request_body(body); + + assert_eq!(result.format, AiRequestFormat::Responses, "should be responses"); + assert_eq!(result.stream, Some(true), "stream should be extracted"); + assert_eq!(result.store, Some(false), "store should be extracted"); + assert_eq!(result.background, Some(true), "background should be extracted"); + assert!( + result.has_previous_response_id, + "previous_response_id should be detected" + ); + } + + #[test] + fn responses_max_output_tokens_extracted() { + let body = br#"{"model":"gpt-4.1","input":"test","max_output_tokens":2048}"#; + let result = classify_request_body(body); + + assert_eq!(result.format, AiRequestFormat::Responses, "should be responses"); + assert_eq!( + result.max_output_tokens, + Some(2048), + "max_output_tokens should be extracted" + ); + assert!(result.max_tokens.is_none(), "max_tokens should be None"); + } + + #[test] + fn responses_absent_max_output_tokens_is_none() { + let body = br#"{"model":"gpt-4.1","input":"test"}"#; + let result = classify_request_body(body); + + assert!( + result.max_output_tokens.is_none(), + "absent max_output_tokens should be None" + ); + } + + #[test] + fn responses_with_conversation() { + let body = br#"{"model":"gpt-4.1","input":"test","conversation":{"id":"conv_123"}}"#; + let result = classify_request_body(body); + + assert_eq!(result.format, AiRequestFormat::Responses, "should be responses"); + assert!(result.has_conversation, "conversation should be detected"); + assert!(!result.has_previous_response_id, "no previous_response_id"); + } + + #[test] + fn chat_completions_messages_without_max_tokens() { + let body = br#"{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}]}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::ChatCompletions, + "messages without max_tokens should classify as chat_completions" + ); + assert_eq!(result.model.as_deref(), Some("gpt-4"), "model should be extracted"); + } + + #[test] + fn chat_completions_with_stream() { + let body = br#"{"model":"gpt-4","messages":[],"stream":true}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::ChatCompletions, + "should be chat_completions" + ); + assert_eq!(result.stream, Some(true), "stream should be extracted"); + } + + #[test] + fn anthropic_messages_with_system() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"system":"Be helpful.","messages":[{"role":"user","content":"Hi"}]}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::AnthropicMessages, + "messages + max_tokens + system should classify as anthropic_messages" + ); + assert_eq!( + result.model.as_deref(), + Some("claude-opus-4-8"), + "model should be extracted" + ); + assert_eq!(result.max_tokens, Some(1024), "max_tokens should be extracted"); + } + + #[test] + fn anthropic_messages_with_typed_content_blocks() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":512,"messages":[{"role":"user","content":[{"type":"text","text":"Hi"}]}],"stream":true}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::AnthropicMessages, + "typed content blocks should classify as anthropic_messages" + ); + assert_eq!(result.stream, Some(true), "stream should be extracted"); + } + + #[test] + fn chat_completions_with_max_tokens_not_misclassified() { + let body = br#"{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}],"max_tokens":100}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::ChatCompletions, + "messages + max_tokens without Anthropic signals should be chat_completions" + ); + } + + #[test] + fn anthropic_messages_max_tokens_without_signals_is_chat() { + let body = br#"{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":"Hi"}]}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::ChatCompletions, + "max_tokens + string content + no system should be chat_completions — header override disambiguates in the filter layer" + ); + } + + #[test] + fn unknown_json_no_input_no_messages() { + let body = br#"{"model":"gpt-4","prompt":"hello"}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::UnknownJson, + "JSON without input or messages should be unknown" + ); + assert_eq!( + result.model.as_deref(), + Some("gpt-4"), + "model should still be extracted" + ); + } + + #[test] + fn invalid_json() { + let body = b"not json at all {{{"; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::InvalidJson, + "garbage should be invalid_json" + ); + assert!(result.model.is_none(), "no model from invalid JSON"); + } + + #[test] + fn empty_body() { + let result = classify_request_body(b""); + + assert_eq!(result.format, AiRequestFormat::NonJson, "empty body should be non_json"); + } + + #[test] + fn json_array_is_invalid() { + let body = b"[1, 2, 3]"; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::InvalidJson, + "JSON array should be invalid (not an object)" + ); + } + + #[test] + fn null_previous_response_id_not_detected() { + let body = br#"{"model":"gpt-4.1","input":"test","previous_response_id":null}"#; + let result = classify_request_body(body); + + assert!( + !result.has_previous_response_id, + "null previous_response_id should not be detected as present" + ); + } + + #[test] + fn null_conversation_not_detected() { + let body = br#"{"model":"gpt-4.1","input":"test","conversation":null}"#; + let result = classify_request_body(body); + + assert!( + !result.has_conversation, + "null conversation should not be detected as present" + ); + } + + #[test] + fn missing_model_returns_none() { + let body = br#"{"input":"test"}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::Responses, + "should still classify as responses" + ); + assert!(result.model.is_none(), "missing model should return None"); + } + + #[test] + fn stream_and_store_absent_returns_none() { + let body = br#"{"model":"gpt-4.1","input":"test"}"#; + let result = classify_request_body(body); + + assert!(result.stream.is_none(), "absent stream should be None"); + assert!(result.store.is_none(), "absent store should be None"); + assert!(result.background.is_none(), "absent background should be None"); + } + + #[test] + fn background_false_extracted() { + let body = br#"{"model":"gpt-4.1","input":"test","background":false}"#; + let result = classify_request_body(body); + + assert_eq!( + result.background, + Some(false), + "top-level boolean background:false should be extracted" + ); + } + + #[test] + fn null_background_not_detected() { + let body = br#"{"model":"gpt-4.1","input":"test","background":null}"#; + let result = classify_request_body(body); + + assert!( + result.background.is_none(), + "null background should not be detected as present" + ); + } + + #[test] + fn tools_non_empty_array_detected() { + let body = br#"{"model":"gpt-4.1","input":"test","tools":[{"type":"function"}]}"#; + let result = classify_request_body(body); + + assert!(result.has_tools, "non-empty tools array should be detected"); + } + + #[test] + fn tools_empty_array_not_detected() { + let body = br#"{"model":"gpt-4.1","input":"test","tools":[]}"#; + let result = classify_request_body(body); + + assert!(!result.has_tools, "empty tools array should not be detected"); + } + + #[test] + fn tools_absent_not_detected() { + let body = br#"{"model":"gpt-4.1","input":"test"}"#; + let result = classify_request_body(body); + + assert!(!result.has_tools, "absent tools should not be detected"); + } + + #[test] + fn tools_null_not_detected() { + let body = br#"{"model":"gpt-4.1","input":"test","tools":null}"#; + let result = classify_request_body(body); + + assert!(!result.has_tools, "null tools should not be detected"); + } + + #[test] + fn prompt_id_nested_detected() { + let body = br#"{"model":"gpt-4.1","input":"test","prompt":{"id":"pmpt_123"}}"#; + let result = classify_request_body(body); + + assert!(result.has_prompt_id, "nested prompt.id should be detected"); + } + + #[test] + fn prompt_id_absent_not_detected() { + let body = br#"{"model":"gpt-4.1","input":"test"}"#; + let result = classify_request_body(body); + + assert!(!result.has_prompt_id, "absent prompt should not be detected"); + } + + #[test] + fn prompt_id_null_not_detected() { + let body = br#"{"model":"gpt-4.1","input":"test","prompt":{"id":null}}"#; + let result = classify_request_body(body); + + assert!(!result.has_prompt_id, "null prompt.id should not be detected"); + } + + #[test] + fn prompt_object_without_prompt_id_not_detected() { + let body = br#"{"model":"gpt-4.1","input":"test","prompt":{"variables":{"city":"SF"}}}"#; + let result = classify_request_body(body); + + assert!( + !result.has_prompt_id, + "prompt object without id should not set has_prompt_id" + ); + } + + #[test] + fn prompt_object_prompt_id_field_detected() { + let body = br#"{"model":"gpt-4.1","input":"test","prompt":{"id":"pmpt_123"}}"#; + let result = classify_request_body(body); + + assert!( + result.has_prompt_id, + "prompt.id should be detected as the prompt identifier" + ); + } + + #[test] + fn prompt_string_not_detected_as_prompt_id() { + let body = br#"{"model":"gpt-4.1","input":"test","prompt":"some string"}"#; + let result = classify_request_body(body); + + assert!( + !result.has_prompt_id, + "string prompt should not be treated as prompt object" + ); + } + + #[test] + fn prompt_object_classifies_as_responses() { + let body = br#"{"model":"gpt-4.1","prompt":{"id":"pmpt_123","variables":{"city":"SF"}}}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::Responses, + "prompt object should classify as responses even without input" + ); + assert!(result.has_prompt_id, "prompt.id should be detected"); + } + + #[test] + fn top_level_prompt_id_not_detected() { + let body = br#"{"model":"gpt-4.1","input":"test","prompt_id":"pmpt_123"}"#; + let result = classify_request_body(body); + + assert!( + !result.has_prompt_id, + "top-level prompt_id should not be detected (must be nested in prompt object)" + ); + } + + #[test] + fn non_boolean_background_not_detected() { + let body = br#"{"model":"gpt-4.1","input":"test","background":"true"}"#; + let result = classify_request_body(body); + + assert!( + result.background.is_none(), + "non-boolean background should not be detected as present" + ); + } + + #[test] + fn nested_background_not_detected() { + let body = br#"{"model":"gpt-4.1","input":[{"type":"input_image","background":true}]}"#; + let result = classify_request_body(body); + + assert!( + result.background.is_none(), + "nested background fields should not be detected as top-level background" + ); + } + + #[test] + fn oversized_model_extracted() { + let long_model = "x".repeat(1024); + let body = format!(r#"{{"model":"{long_model}","input":"test"}}"#); + let result = classify_request_body(body.as_bytes()); + + assert_eq!( + result.model.as_deref(), + Some(long_model.as_str()), + "oversized model should still be extracted by classifier" + ); + } + + #[test] + fn both_input_and_messages_classifies_as_responses() { + let body = br#"{"model":"gpt-4.1","input":"test","messages":[{"role":"user","content":"Hi"}]}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::Responses, + "input takes precedence when both input and messages are present" + ); + } + + #[test] + fn previous_response_id_with_messages_classifies_as_responses() { + let body = + br#"{"model":"gpt-4.1","previous_response_id":"resp_abc","messages":[{"role":"user","content":"Hi"}]}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::Responses, + "previous_response_id should take precedence over messages" + ); + } + + #[test] + fn conversation_with_messages_classifies_as_responses() { + let body = br#"{"model":"gpt-4.1","conversation":{"id":"conv_123"},"messages":[{"role":"user","content":"Hi"}],"max_tokens":1024,"system":"Be helpful."}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::Responses, + "conversation should take precedence over Anthropic signals" + ); + } + + // ------------------------------------------------------------------------- + // Path Classification + // ------------------------------------------------------------------------- + + #[test] + fn get_v1_responses_list_does_not_match() { + assert!( + !is_responses_path(&http::Method::GET, "/v1/responses"), + "GET /v1/responses is not a public API endpoint" + ); + } + + #[test] + fn get_v1_responses_with_id_matches() { + assert!( + is_responses_path(&http::Method::GET, "/v1/responses/resp_abc123"), + "GET /v1/responses/{{id}} should match" + ); + } + + #[test] + fn get_v1_responses_input_items_matches() { + assert!( + is_responses_path(&http::Method::GET, "/v1/responses/resp_abc123/input_items"), + "GET /v1/responses/{{id}}/input_items should match" + ); + } + + #[test] + fn delete_v1_responses_with_id_matches() { + assert!( + is_responses_path(&http::Method::DELETE, "/v1/responses/resp_abc123"), + "DELETE /v1/responses/{{id}} should match" + ); + } + + #[test] + fn post_v1_responses_cancel_matches() { + assert!( + is_responses_path(&http::Method::POST, "/v1/responses/resp_abc123/cancel"), + "POST /v1/responses/{{id}}/cancel should match" + ); + } + + #[test] + fn post_v1_responses_input_tokens_matches() { + assert!( + is_responses_path(&http::Method::POST, "/v1/responses/input_tokens"), + "POST /v1/responses/input_tokens should match" + ); + } + + #[test] + fn post_v1_responses_compact_matches() { + assert!( + is_responses_path(&http::Method::POST, "/v1/responses/compact"), + "POST /v1/responses/compact should match" + ); + } + + #[test] + fn post_v1_responses_does_not_match() { + assert!( + !is_responses_path(&http::Method::POST, "/v1/responses"), + "POST /v1/responses (create) should not match path classification" + ); + } + + #[test] + fn get_v1_responses_cancel_does_not_match() { + assert!( + !is_responses_path(&http::Method::GET, "/v1/responses/resp_abc/cancel"), + "GET /v1/responses/{{id}}/cancel should not match" + ); + } + + #[test] + fn delete_v1_responses_list_does_not_match() { + assert!( + !is_responses_path(&http::Method::DELETE, "/v1/responses"), + "DELETE /v1/responses (no id) should not match" + ); + } + + #[test] + fn get_v1_responses_unknown_sub_resource_does_not_match() { + assert!( + !is_responses_path(&http::Method::GET, "/v1/responses/resp_abc/other"), + "GET /v1/responses/{{id}}/other should not match" + ); + } + + #[test] + fn get_unrelated_path_does_not_match() { + assert!( + !is_responses_path(&http::Method::GET, "/v1/chat/completions"), + "GET /v1/chat/completions should not match" + ); + } + + #[test] + fn get_v1_responses_trailing_slash_does_not_match() { + assert!( + !is_responses_path(&http::Method::GET, "/v1/responses/"), + "GET /v1/responses/ is not a public API endpoint" + ); + } + + // ------------------------------------------------------------------------- + // Responses WebSocket Handshake Classification + // ------------------------------------------------------------------------- + + /// Accept the canonical Responses opening handshake. + #[test] + fn responses_websocket_handshake_matches_standard_upgrade() { + let headers = websocket_headers("Upgrade", "websocket"); + + assert!( + is_responses_websocket_handshake(&http::Method::GET, "/v1/responses", &headers), + "the canonical Responses WebSocket handshake should match" + ); + } + + /// Treat protocol tokens case-insensitively and normalize a trailing slash. + #[test] + fn responses_websocket_handshake_is_case_insensitive_and_allows_trailing_slash() { + let headers = websocket_headers("keep-alive, UpGrAdE", "WebSocket"); + + assert!( + is_responses_websocket_handshake(&http::Method::GET, "/v1/responses/", &headers), + "field tokens should ignore case and the endpoint should allow a trailing slash" + ); + } + + /// Find an upgrade token across repeated `Connection` field lines. + #[test] + fn responses_websocket_handshake_finds_token_across_repeated_connection_headers() { + let mut headers = websocket_headers("keep-alive", "websocket"); + headers.append(http::header::CONNECTION, "upgrade".parse().unwrap()); + + assert!( + is_responses_websocket_handshake(&http::Method::GET, "/v1/responses", &headers), + "a repeated Connection field should contribute its upgrade token" + ); + } + + /// Limit handshake classification to the exact Responses endpoint and method. + #[test] + fn responses_websocket_handshake_rejects_wrong_method_or_path() { + let headers = websocket_headers("upgrade", "websocket"); + + assert!( + !is_responses_websocket_handshake(&http::Method::POST, "/v1/responses", &headers), + "a POST request is not a WebSocket opening handshake" + ); + assert!( + !is_responses_websocket_handshake(&http::Method::GET, "/v1/responses/resp_123", &headers), + "a response subresource must not match the opening endpoint" + ); + assert!( + !is_responses_websocket_handshake(&http::Method::GET, "/v1/chat/completions", &headers), + "an unrelated API endpoint must not match" + ); + assert!( + !is_responses_websocket_handshake(&http::Method::GET, "/v1/responses-other", &headers), + "a path that merely shares the Responses prefix must not match" + ); + } + + /// Require both HTTP upgrade fields before classifying a handshake. + #[test] + fn responses_websocket_handshake_requires_both_upgrade_headers() { + let mut connection_only = http::HeaderMap::new(); + connection_only.insert(http::header::CONNECTION, "upgrade".parse().unwrap()); + let mut upgrade_only = http::HeaderMap::new(); + upgrade_only.insert(http::header::UPGRADE, "websocket".parse().unwrap()); + + assert!( + !is_responses_websocket_handshake(&http::Method::GET, "/v1/responses", &connection_only), + "Connection alone must not classify a WebSocket handshake" + ); + assert!( + !is_responses_websocket_handshake(&http::Method::GET, "/v1/responses", &upgrade_only), + "Upgrade alone must not classify a WebSocket handshake" + ); + } + + /// Reject lookalike tokens, other protocols, and ambiguous upgrade fields. + #[test] + fn responses_websocket_handshake_rejects_non_websocket_upgrade_and_substring_token() { + let wrong_upgrade = websocket_headers("upgrade", "h2c"); + let substring_connection = websocket_headers("upgrader", "websocket"); + let mut repeated_upgrade = websocket_headers("upgrade", "websocket"); + repeated_upgrade.append(http::header::UPGRADE, "h2c".parse().unwrap()); + + assert!( + !is_responses_websocket_handshake(&http::Method::GET, "/v1/responses", &wrong_upgrade), + "an h2c upgrade must not classify as WebSocket" + ); + assert!( + !is_responses_websocket_handshake(&http::Method::GET, "/v1/responses", &substring_connection), + "an upgrade substring must not match the Connection token" + ); + assert!( + !is_responses_websocket_handshake(&http::Method::GET, "/v1/responses", &repeated_upgrade), + "multiple Upgrade field lines are ambiguous and must not match" + ); + } + + /// Reject field values that cannot contain valid UTF-8 protocol tokens. + #[test] + fn responses_websocket_handshake_rejects_non_utf8_upgrade_headers() { + let mut invalid_connection = websocket_headers("upgrade", "websocket"); + invalid_connection.insert( + http::header::CONNECTION, + http::HeaderValue::from_bytes(&[0xFF]).unwrap(), + ); + let mut invalid_upgrade = websocket_headers("upgrade", "websocket"); + invalid_upgrade.insert(http::header::UPGRADE, http::HeaderValue::from_bytes(&[0xFF]).unwrap()); + + assert!( + !is_responses_websocket_handshake(&http::Method::GET, "/v1/responses", &invalid_connection), + "a non-UTF-8 Connection value cannot contain a valid upgrade token" + ); + assert!( + !is_responses_websocket_handshake(&http::Method::GET, "/v1/responses", &invalid_upgrade), + "a non-UTF-8 Upgrade value cannot identify the WebSocket protocol" + ); + } + + #[test] + fn delete_v1_responses_input_items_does_not_match() { + assert!( + !is_responses_path(&http::Method::DELETE, "/v1/responses/resp_abc/input_items"), + "DELETE /v1/responses/{{id}}/input_items should not match" + ); + } + + #[test] + fn get_v1_responses_double_slash_input_items_does_not_match() { + assert!( + !is_responses_path(&http::Method::GET, "/v1/responses//input_items"), + "GET /v1/responses//input_items should not collapse empty id segment" + ); + } + + // ------------------------------------------------------------------------- + // Create-Endpoint Classification + // ------------------------------------------------------------------------- + + #[test] + fn create_matches_post_v1_responses() { + assert!( + is_responses_create(&http::Method::POST, "/v1/responses"), + "POST /v1/responses should match create" + ); + } + + #[test] + fn create_matches_post_v1_responses_trailing_slash() { + assert!( + is_responses_create(&http::Method::POST, "/v1/responses/"), + "POST /v1/responses/ should match create" + ); + } + + #[test] + fn create_rejects_get() { + assert!( + !is_responses_create(&http::Method::GET, "/v1/responses"), + "GET /v1/responses should not match create" + ); + } + + #[test] + fn create_rejects_cancel_subresource() { + assert!( + !is_responses_create(&http::Method::POST, "/v1/responses/resp_abc/cancel"), + "POST /v1/responses/{{id}}/cancel should not match create" + ); + } + + #[test] + fn create_rejects_input_tokens() { + assert!( + !is_responses_create(&http::Method::POST, "/v1/responses/input_tokens"), + "POST /v1/responses/input_tokens should not match create" + ); + } + + #[test] + fn create_rejects_compact() { + assert!( + !is_responses_create(&http::Method::POST, "/v1/responses/compact"), + "POST /v1/responses/compact should not match create" + ); + } + + #[test] + fn create_rejects_chat_completions() { + assert!( + !is_responses_create(&http::Method::POST, "/v1/chat/completions"), + "POST /v1/chat/completions should not match create" + ); + } + + #[test] + fn previous_response_id_only_classifies_as_responses() { + let body = br#"{"model":"gpt-4.1","previous_response_id":"resp_abc"}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::Responses, + "previous_response_id without input should classify as responses" + ); + assert!( + result.has_previous_response_id, + "previous_response_id should be detected" + ); + } + + #[test] + fn previous_response_id_with_input_classifies_as_responses() { + let body = br#"{"model":"gpt-4.1","previous_response_id":"resp_abc","input":"hello"}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::Responses, + "previous_response_id with input should still classify as responses" + ); + } + + #[test] + fn conversation_only_classifies_as_responses() { + let body = br#"{"model":"gpt-4.1","conversation":{"id":"conv_123"}}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::Responses, + "conversation without input should classify as responses" + ); + assert!(result.has_conversation, "conversation should be detected"); + } + + #[test] + fn null_previous_response_id_still_classifies_as_responses() { + let body = br#"{"model":"gpt-4.1","previous_response_id":null}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::Responses, + "previous_response_id key present (even null) should classify as responses" + ); + assert!( + !result.has_previous_response_id, + "null value should not set the has_previous_response_id flag" + ); + } + + #[test] + fn null_conversation_still_classifies_as_responses() { + let body = br#"{"model":"gpt-4.1","conversation":null}"#; + let result = classify_request_body(body); + + assert_eq!( + result.format, + AiRequestFormat::Responses, + "conversation key present (even null) should classify as responses" + ); + assert!( + !result.has_conversation, + "null value should not set the has_conversation flag" + ); + } + + #[test] + fn control_char_model_extracted() { + let body = b"{\"model\":\"bad\\nmodel\",\"input\":\"test\"}"; + let result = classify_request_body(body); + + assert_eq!( + result.model.as_deref(), + Some("bad\nmodel"), + "model with control chars should still be extracted by classifier" + ); + } + + // ------------------------------------------------------------------------- + // Test Utilities + // ------------------------------------------------------------------------- + + fn websocket_headers(connection: &'static str, upgrade: &'static str) -> http::HeaderMap { + let mut headers = http::HeaderMap::new(); + headers.insert(http::header::CONNECTION, connection.parse().unwrap()); + headers.insert(http::header::UPGRADE, upgrade.parse().unwrap()); + headers + } +} diff --git a/apis/src/json_body.rs b/apis/src/json_body.rs new file mode 100644 index 0000000000..cd7e810352 --- /dev/null +++ b/apis/src/json_body.rs @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Shared JSON request-body mutation. +//! +//! Several filters buffer the request body (`BodyMode::StreamBuffer`), mutate a parsed JSON value, and then +//! re-serialize it back into the body, each re-implementing the same serialize / replace dance. This module +//! provides that shared machinery so individual filters only own their mutation logic: +//! +//! ```text +//! let mut value: serde_json::Value = serde_json::from_slice(raw)?; +//! value["model"] = "qwen-2.5-72b".into(); +//! let mutation = replace_json_body(body, &value, "model_rewrite", "model")?; +//! ``` +//! +//! [`serialize_json_body`] and [`SerializedJson::commit`] split the two halves for callers that must inspect the +//! serialized length before committing (for example, rejecting a rewritten body that exceeds a configured cap). +//! +//! Every commit emits one consistent `tracing` event carrying the filter name, the field changed, and the size +//! delta, and returns a [`BodyMutation`] report with the same data for callers that need it programmatically. +//! +//! Request-side only: core repairs upstream `Content-Length` framing for mutated request bodies via +//! `mutated_request_body_len`, so filters must not set `Content-Length` themselves. + +use bytes::Bytes; +use serde_json::Value; +use tracing::debug; + +/// Serialize `value` for a later body replacement. +/// +/// Returns the serialized form without touching the buffered body, so callers can inspect the length (or +/// otherwise validate) before committing via [`SerializedJson::commit`]. +/// +/// # Errors +/// +/// Returns [`serde_json::Error`] if `value` fails to serialize. +pub fn serialize_json_body(value: &Value) -> Result { + Ok(SerializedJson { + bytes: Bytes::from(serde_json::to_vec(value)?), + }) +} + +/// Serialize `value`, replace the buffered request `body`, and emit the mutation event. +/// +/// Convenience wrapper around [`serialize_json_body`] and [`SerializedJson::commit`] for callers with no +/// pre-commit checks. +/// +/// # Errors +/// +/// Returns [`serde_json::Error`] if `value` fails to serialize. +pub fn replace_json_body( + body: &mut Option, + value: &Value, + filter: &'static str, + field: &'static str, +) -> Result { + Ok(serialize_json_body(value)?.commit(body, filter, field)) +} + +/// A serialized JSON body ready to be committed. +#[derive(Debug, Clone)] +pub struct SerializedJson { + /// The serialized body bytes. + bytes: Bytes, +} + +impl SerializedJson { + /// Wrap already-serialized JSON bytes (produced by a state-driven serializer rather than a + /// `serde_json::Value`) for a later body replacement. + #[must_use] + pub fn from_bytes(bytes: impl Into) -> Self { + Self { bytes: bytes.into() } + } + + /// Length of the serialized body in bytes. + #[must_use] + pub fn len(&self) -> usize { + self.bytes.len() + } + + /// Whether the serialized body is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.bytes.is_empty() + } + + /// The serialized bytes. + #[must_use] + pub fn as_bytes(&self) -> &Bytes { + &self.bytes + } + + /// Replace the buffered request body with the serialized value and emit the mutation event. + /// Returns a [`BodyMutation`] report of what changed. + /// + /// Does not set `Content-Length`; core handles upstream framing via `mutated_request_body_len`. + pub fn commit(self, body: &mut Option, filter: &'static str, field: &'static str) -> BodyMutation { + let mutation = BodyMutation { + filter, + field, + original_len: body.as_ref().map_or(0, Bytes::len), + new_len: self.bytes.len(), + }; + + *body = Some(self.bytes); + + debug!( + filter = mutation.filter(), + field = mutation.field(), + original_len = mutation.original_len(), + new_len = mutation.new_len(), + size_delta = mutation.size_delta(), + "request body mutated" + ); + + mutation + } +} + +/// Report of a committed JSON request-body mutation. +/// +/// The same fields are emitted as a structured `tracing` event at commit time; this report lets callers act +/// on them (length caps, filter-specific logging, metadata promotion). +#[derive(Debug, Clone)] +pub struct BodyMutation { + /// Name of the filter that performed the mutation. + filter: &'static str, + /// Top-level JSON field the mutation targeted. + field: &'static str, + /// Buffered body length before the mutation, in bytes. + original_len: usize, + /// Body length after the mutation, in bytes. + new_len: usize, +} + +impl BodyMutation { + /// Name of the filter that performed the mutation. + #[must_use] + pub fn filter(&self) -> &'static str { + self.filter + } + + /// Top-level JSON field the mutation targeted. + #[must_use] + pub fn field(&self) -> &'static str { + self.field + } + + /// Buffered body length before the mutation, in bytes. + #[must_use] + pub fn original_len(&self) -> usize { + self.original_len + } + + /// Body length after the mutation, in bytes. + #[must_use] + pub fn new_len(&self) -> usize { + self.new_len + } + + /// `new_len - original_len`; negative when the body shrank. + #[must_use] + pub fn size_delta(&self) -> i64 { + let new = i64::try_from(self.new_len).unwrap_or(i64::MAX); + let old = i64::try_from(self.original_len).unwrap_or(i64::MAX); + new - old + } +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + reason = "tests" +)] +mod tests { + use serde_json::json; + + use super::*; + + fn serialized_len(value: &Value) -> usize { + serde_json::to_vec(value).unwrap().len() + } + + #[test] + fn replace_grows_body() { + let original = json!({"model": "a"}); + let mut body = Some(Bytes::from(serde_json::to_vec(&original).unwrap())); + let mutated = json!({"model": "qwen-2.5-72b-instruct"}); + + let mutation = replace_json_body(&mut body, &mutated, "model_rewrite", "model").unwrap(); + + assert_eq!(mutation.filter(), "model_rewrite"); + assert_eq!(mutation.field(), "model"); + assert_eq!(mutation.original_len(), serialized_len(&original)); + assert_eq!(mutation.new_len(), serialized_len(&mutated)); + assert!(mutation.size_delta() > 0, "growth should report a positive delta"); + assert_eq!( + body.as_ref().unwrap(), + &Bytes::from(serde_json::to_vec(&mutated).unwrap()), + "buffered body should hold the mutated value" + ); + } + + #[test] + fn replace_shrinks_body_reports_negative_delta() { + let original = json!({"model": "qwen-2.5-72b-instruct"}); + let mut body = Some(Bytes::from(serde_json::to_vec(&original).unwrap())); + let mutated = json!({"model": "a"}); + + let mutation = replace_json_body(&mut body, &mutated, "model_rewrite", "model").unwrap(); + + assert!(mutation.size_delta() < 0, "shrinkage should report a negative delta"); + assert_eq!(mutation.size_delta(), { + let new = i64::try_from(serialized_len(&mutated)).unwrap(); + let old = i64::try_from(serialized_len(&original)).unwrap(); + new - old + }); + assert_eq!(body.as_ref().unwrap().len(), serialized_len(&mutated)); + } + + #[test] + fn replace_with_absent_body_reports_zero_original_len() { + let mut body = None; + let mutated = json!({"model": "qwen-2.5-72b"}); + + let mutation = replace_json_body(&mut body, &mutated, "model_rewrite", "model").unwrap(); + + assert_eq!(mutation.original_len(), 0); + assert_eq!(mutation.new_len(), serialized_len(&mutated)); + assert!(body.is_some(), "body should be populated after commit"); + } + + #[test] + fn replace_counts_multibyte_content_in_bytes() { + let mut body = Some(Bytes::from_static(b"{}")); + let mutated = json!({"input": "caf\u{00e9} \u{2615} \u{65e5}\u{672c}\u{8a9e}"}); + + let mutation = replace_json_body(&mut body, &mutated, "prompt_enrich", "input").unwrap(); + + assert_eq!(mutation.new_len(), serialized_len(&mutated)); + assert!( + mutation.new_len() > "caf\u{00e9} \u{2615} \u{65e5}\u{672c}\u{8a9e}".len(), + "JSON serialization of multibyte content should produce more bytes than the rust str len" + ); + } + + #[test] + fn two_step_serialize_then_commit_supports_pre_commit_checks() { + let original = json!({"input": [{"type": "input_image", "image_url": "https://example.com/a.png"}]}); + let mut body = Some(Bytes::from(serde_json::to_vec(&original).unwrap())); + let mutated = json!({"input": [{"type": "input_text", "text": "resolved"}]}); + + let serialized = serialize_json_body(&mutated).unwrap(); + assert_eq!(serialized.len(), serialized_len(&mutated)); + assert!(!serialized.is_empty()); + assert_eq!( + body.as_ref().unwrap().len(), + serialized_len(&original), + "body must be untouched before commit" + ); + + let max_body_bytes = 1024; + assert!(serialized.len() <= max_body_bytes); + + let mutation = serialized.commit(&mut body, "openai_file_resolve", "input"); + assert_eq!(mutation.field(), "input"); + assert_eq!(mutation.original_len(), serialized_len(&original)); + assert_eq!(body.as_ref().unwrap().len(), serialized_len(&mutated)); + } + + #[test] + fn commit_with_identical_value_reports_zero_delta() { + let value = json!({"model": "a"}); + let mut body = Some(Bytes::from(serde_json::to_vec(&value).unwrap())); + + let mutation = replace_json_body(&mut body, &value, "model_rewrite", "model").unwrap(); + + assert_eq!(mutation.size_delta(), 0); + } + + #[test] + fn as_bytes_exposes_the_serialized_form() { + let value = json!({"a": 1}); + let serialized = serialize_json_body(&value).unwrap(); + assert_eq!(serialized.as_bytes(), &Bytes::from(serde_json::to_vec(&value).unwrap())); + } + + #[test] + fn from_bytes_wraps_preserialized_json() { + let mut body = Some(Bytes::from_static(b"{}")); + let bytes = Bytes::from(serde_json::to_vec(&json!({"a": 1})).unwrap()); + + let mutation = SerializedJson::from_bytes(bytes.clone()).commit(&mut body, "openai_responses_proxy", "body"); + + assert_eq!(mutation.new_len(), bytes.len()); + assert_eq!(body.as_ref().unwrap(), &bytes); + } +} diff --git a/apis/src/lib.rs b/apis/src/lib.rs new file mode 100644 index 0000000000..851fdcd43d --- /dev/null +++ b/apis/src/lib.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +#![allow(unreachable_pub, reason = "migration: visibility will be tightened")] + +//! AI provider API types and persistence for Praxis. +//! +//! Contains provider-specific protocol types (OpenAI, Anthropic), +//! request classification, shared JSON body-mutation helpers, and +//! response storage backends. + +pub mod anthropic; +pub mod classifier; +pub mod json_body; +pub(crate) mod mcp_client; +pub mod openai; +pub mod promotion; +#[cfg(feature = "store")] +pub mod store; +pub(crate) mod subrequest; +pub(crate) mod web_search; + +/// Whether a `Content-Type` header value indicates `text/event-stream`, +/// ignoring parameters (e.g. `; charset=utf-8`) and ASCII case. +pub fn is_event_stream_content_type(content_type: &str) -> bool { + content_type + .split(';') + .next() + .unwrap_or_default() + .trim() + .eq_ignore_ascii_case("text/event-stream") +} + +// ----------------------------------------------------------------------------- +// Test Utilities +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow(clippy::expect_used, reason = "test utilities")] +pub(crate) mod test_utils { + use std::sync::LazyLock; + + use http::{HeaderMap, Method, Uri}; + use praxis_core::id::IdGenerator; + use praxis_filter::{HttpFilterContext, Request, RequestExtensions, Response}; + + /// Deterministic ID generator for tests (seed=0). + static TEST_ID_GENERATOR: LazyLock = LazyLock::new(|| IdGenerator::with_seed(0)); + + /// Build a minimal request for filter unit tests. + pub(crate) fn make_request(method: Method, path: &str) -> Request { + Request { + method, + uri: path.parse::().expect("invalid URI in test"), + headers: HeaderMap::new(), + } + } + + /// Build a minimal filter context for unit tests. + #[expect(clippy::allow_attributes, reason = "blanket test suppressions")] + #[allow( + clippy::too_many_lines, + reason = "test context constructor mirrors all context fields" + )] + pub(crate) fn make_filter_context(req: &Request) -> HttpFilterContext<'_> { + HttpFilterContext { + buffered_request_body: None, + body_done_indices: Vec::new(), + branch_iterations: std::collections::HashMap::new(), + client_addr: None, + cluster: None, + current_filter_id: None, + downstream_tls: false, + extensions: RequestExtensions::default(), + executed_filter_indices: Vec::new(), + extra_request_headers: Vec::new(), + request_headers_to_remove: Vec::new(), + request_headers_to_set: Vec::new(), + filter_metadata: std::collections::HashMap::new(), + pre_read_mutations: Vec::new(), + structured_metadata: std::collections::HashMap::new(), + filter_results: std::collections::HashMap::new(), + filter_state: std::collections::HashMap::new(), + health_registry: None, + id_generator: &TEST_ID_GENERATOR, + kv_stores: None, + metrics_route: None, + peer_identity: None, + request: req, + request_body_bytes: 0, + request_body_mode: praxis_filter::BodyMode::Stream, + request_start: std::time::Instant::now(), + response_body_bytes: 0, + response_body_mode: praxis_filter::BodyMode::Stream, + response_header: None, + response_headers_modified: false, + subrequest_client: None, + subrequest_response_mode: praxis_filter::SubRequestResponseMode::Buffered, + #[cfg(feature = "praxis-main")] + attempted_endpoints: Vec::new(), + #[cfg(feature = "praxis-main")] + retry_policy: None, + #[cfg(feature = "praxis-main")] + route_retry_policy: None, + #[cfg(feature = "praxis-main")] + cluster_retry_state: None, + #[cfg(feature = "praxis-main")] + cluster_retry_state_released: false, + #[cfg(feature = "praxis-main")] + endpoint_reselector: None, + rewritten_path: None, + selected_endpoint_index: None, + time_source: &praxis_core::time::SystemTimeSource, + upstream: None, + } + } + + /// Build a minimal OK response for filter unit tests. + pub(crate) fn make_response() -> Response { + Response { + headers: HeaderMap::new(), + status: http::StatusCode::OK, + } + } + + /// Build a [`FilterRegistry`] with core builtins plus AI API filters + /// needed by pipeline integration tests. + /// + /// [`FilterRegistry`]: praxis_filter::FilterRegistry + pub(crate) fn make_ai_registry() -> praxis_filter::FilterRegistry { + let mut registry = praxis_filter::FilterRegistry::with_builtins(); + praxis_filter::register_filters!( + @register registry, + http "openai_responses_format" => crate::openai::ResponsesFormatFilter::from_config + ); + praxis_filter::register_filters!( + @register registry, + http "openai_response_store" => crate::openai::ResponseStoreFilter::from_config + ); + praxis_filter::register_filters!( + @register registry, + http "openai_responses_rehydrate" => crate::openai::RehydrateFilter::from_config + ); + praxis_filter::register_filters!( + @register registry, + http "openai_stream_events" => crate::openai::OpenaiStreamEventsFilter::from_config + ); + registry + } +} diff --git a/apis/src/mcp_client/mod.rs b/apis/src/mcp_client/mod.rs new file mode 100644 index 0000000000..2bf254345a --- /dev/null +++ b/apis/src/mcp_client/mod.rs @@ -0,0 +1,579 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! MCP client wrapper for calling upstream MCP servers. +//! +//! Thin layer over `rmcp` that exposes [`list_tools`] for resolving +//! MCP tool declarations. Designed for reuse by `mcp_tool` (#27) +//! when `call_tool` support is added. + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + clippy::needless_pass_by_value, + clippy::unused_self, + missing_docs, + reason = "tests" +)] +mod tests; + +use std::{ + collections::HashMap, + fmt, + net::{IpAddr, Ipv4Addr, SocketAddr}, + time::Duration, +}; + +use rmcp::{ + Peer, RoleClient, ServiceExt as _, + model::{CallToolRequestParams, PaginatedRequestParams}, + transport::{StreamableHttpClientTransport, streamable_http_client::StreamableHttpClientTransportConfig}, +}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Alibaba Cloud instance metadata service IPv4 endpoint. +const ALIBABA_CLOUD_METADATA_V4: Ipv4Addr = Ipv4Addr::new(100, 100, 100, 200); + +// ----------------------------------------------------------------------------- +// McpDisplayUrl +// ----------------------------------------------------------------------------- + +/// Sanitized MCP URL retained for errors and diagnostics. +/// +/// User information, query strings, and fragments are deliberately omitted so +/// credentials cannot escape through formatted errors. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct McpDisplayUrl(String); + +impl McpDisplayUrl { + /// Build a safe display URL from an already-parsed URI. + pub(crate) fn from_uri(uri: &http::Uri) -> Self { + let mut value = String::new(); + + if let Some(scheme) = uri.scheme_str() { + value.push_str(scheme); + value.push_str("://"); + } + + if let Some(authority) = uri.authority() { + let authority = authority.as_str(); + let safe_authority = authority.rsplit_once('@').map_or(authority, |(_userinfo, host)| host); + value.push_str(safe_authority); + } + + value.push_str(uri.path()); + + if value.is_empty() { Self::invalid() } else { Self(value) } + } + + /// Opaque replacement used when URI parsing fails. + fn invalid() -> Self { + Self("".to_owned()) + } +} + +impl fmt::Display for McpDisplayUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// Build an SSRF error from a sanitized URL and safe reason. +fn ssrf_blocked(url: McpDisplayUrl, reason: &'static str) -> McpClientError { + McpClientError::SsrfBlocked { url, reason } +} + +// ----------------------------------------------------------------------------- +// McpClientError +// ----------------------------------------------------------------------------- + +/// Errors from MCP server communication. +#[derive(Debug, thiserror::Error)] +pub(crate) enum McpClientError { + /// Failed to connect to the MCP server or complete the + /// handshake. + /// + /// The third-party source error is intentionally discarded because it may + /// retain and format the original credential-bearing URL. + #[error("mcp connection failed for {url}")] + Connection { + /// URL of the MCP server. + url: McpDisplayUrl, + }, + + /// The `tools/list` call failed or returned an invalid + /// response. + /// + /// The third-party source error is intentionally discarded because it may + /// retain and format the original credential-bearing URL. + #[error("mcp tools/list failed for {url}")] + ListTools { + /// URL of the MCP server. + url: McpDisplayUrl, + }, + + /// The `tools/call` request failed. + /// + /// The third-party source error is intentionally discarded because it may + /// retain and format the original credential-bearing URL. + #[error("mcp tools/call failed for {url} tool {tool_name}")] + CallTool { + /// URL of the MCP server. + url: McpDisplayUrl, + + /// Name of the tool that was called. + tool_name: String, + }, + + /// Timed out waiting for the MCP server. + #[error("mcp request timed out for {url} after {timeout:?}")] + Timeout { + /// URL of the MCP server. + url: McpDisplayUrl, + + /// Configured timeout duration. + timeout: Duration, + }, + + /// Failed to serialize tool definitions to JSON. + #[error("failed to serialize tool definitions: {0}")] + Serialization( + /// Serialization error. + #[from] + serde_json::Error, + ), + + /// An MCP server returned more tools than the configured cap. + #[error("mcp server {url} returned too many tools: {count} exceeds limit of {max}")] + TooManyTools { + /// Server URL. + url: McpDisplayUrl, + + /// Actual tool count. + count: usize, + + /// Configured maximum. + max: usize, + }, + + /// MCP server URL is invalid or resolves to a blocked address. + #[error("mcp server URL blocked (SSRF): {url}: {reason}")] + SsrfBlocked { + /// The blocked URL. + url: McpDisplayUrl, + + /// Safe explanation of why the URL was blocked. + reason: &'static str, + }, + + /// Authorization token contains invalid header characters. + #[error("authorization token contains invalid HTTP header characters")] + InvalidAuthorization, +} + +// ----------------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------------- + +/// Call `tools/list` on an MCP server and return tool definitions +/// as opaque JSON values. +/// +/// Creates a fresh Streamable HTTP transport per call. The +/// `previous_tools` cache in `ResponsesState` prevents redundant +/// calls across request continuations. +/// +/// # Errors +/// +/// Returns [`McpClientError`] on connection failure, timeout, or +/// invalid server response. +#[expect(clippy::too_many_arguments, reason = "allow_loopback extends the existing param set")] +pub(crate) async fn list_tools( + server_url: &str, + headers: Option<&serde_json::Value>, + authorization: Option<&str>, + timeout: Duration, + max_tools: usize, + allow_loopback: bool, +) -> Result, McpClientError> { + let resolved = resolve_and_validate(server_url, timeout, allow_loopback).await?; + let transport = StreamableHttpClientTransport::with_client( + build_pinned_client(&resolved)?, + build_transport_config(server_url, headers, authorization)?, + ); + let display_url = resolved.display_url; + let client = tokio::time::timeout(timeout, Box::pin(().serve(transport))) + .await + .map_err(|_elapsed| McpClientError::Timeout { + url: display_url.clone(), + timeout, + })? + .map_err(|_source| McpClientError::Connection { + url: display_url.clone(), + })?; + let tools = paginate_tools(&client, timeout, max_tools, &display_url).await?; + tools_to_json(tools) +} + +/// Call `tools/call` on an MCP server and return the result. +/// +/// Creates a fresh Streamable HTTP transport per call, same +/// pattern as [`list_tools`]. Session reuse deferred to MCP +/// Foundation PR 5. +/// +/// # Errors +/// +/// Returns [`McpClientError`] on connection failure, timeout, or +/// tool execution failure. +#[expect(clippy::too_many_arguments, reason = "allow_loopback extends the existing param set")] +#[expect(clippy::too_many_lines, reason = "transport setup + call follows list_tools pattern")] +#[expect(clippy::large_stack_frames, reason = "rmcp call_tool future is inherently large")] +pub(crate) async fn call_tool( + server_url: &str, + headers: Option<&serde_json::Value>, + authorization: Option<&str>, + tool_name: &str, + arguments: serde_json::Value, + timeout: Duration, + allow_loopback: bool, +) -> Result { + let resolved = resolve_and_validate(server_url, timeout, allow_loopback).await?; + let transport = StreamableHttpClientTransport::with_client( + build_pinned_client(&resolved)?, + build_transport_config(server_url, headers, authorization)?, + ); + let display_url = resolved.display_url; + + let client = tokio::time::timeout(timeout, Box::pin(().serve(transport))) + .await + .map_err(|_elapsed| McpClientError::Timeout { + url: display_url.clone(), + timeout, + })? + .map_err(|_source| McpClientError::Connection { + url: display_url.clone(), + })?; + + let parsed_args = match &arguments { + serde_json::Value::Object(obj) => Some(obj.clone()), + serde_json::Value::String(s) => serde_json::from_str::>(s).ok(), + _ => None, + }; + let mut params = CallToolRequestParams::new(tool_name.to_owned()); + if let Some(args_obj) = parsed_args { + params = params.with_arguments(args_obj); + } + + tokio::time::timeout(timeout, Box::pin(client.call_tool(params))) + .await + .map_err(|_elapsed| McpClientError::Timeout { + url: display_url.clone(), + timeout, + })? + .map_err(|_source| McpClientError::CallTool { + url: display_url.clone(), + tool_name: tool_name.to_owned(), + }) +} + +/// Cap on pagination rounds to prevent infinite loops from +/// servers returning empty pages with valid cursors. +const MAX_PAGES: usize = 100; + +/// Paginate `tools/list`, bounded by both `max_tools` and +/// [`MAX_PAGES`]. +#[expect(clippy::too_many_lines, reason = "pagination loop with error branches")] +async fn paginate_tools( + client: &Peer, + timeout: Duration, + max_tools: usize, + url: &McpDisplayUrl, +) -> Result, McpClientError> { + let mut all_tools = Vec::new(); + let mut cursor = None; + for _ in 0..MAX_PAGES { + let params = PaginatedRequestParams::default().with_cursor(cursor); + let page = tokio::time::timeout(timeout, Box::pin(client.list_tools(Some(params)))) + .await + .map_err(|_elapsed| McpClientError::Timeout { + url: url.clone(), + timeout, + })? + .map_err(|_source| McpClientError::ListTools { url: url.clone() })?; + all_tools.extend(page.tools); + if all_tools.len() > max_tools { + return Err(McpClientError::TooManyTools { + url: url.clone(), + count: all_tools.len(), + max: max_tools, + }); + } + match page.next_cursor { + Some(next) => cursor = Some(next), + None => return Ok(all_tools), + } + } + Err(McpClientError::TooManyTools { + url: url.clone(), + count: all_tools.len(), + max: max_tools, + }) +} + +// ----------------------------------------------------------------------------- +// Private Helpers +// ----------------------------------------------------------------------------- + +/// Build transport config from server URL, optional headers, and +/// optional `OAuth` authorization token. +/// +/// # Errors +/// +/// Returns [`McpClientError::InvalidAuthorization`] if the token +/// contains characters invalid in HTTP header values. +fn build_transport_config( + server_url: &str, + headers: Option<&serde_json::Value>, + authorization: Option<&str>, +) -> Result { + let mut config = StreamableHttpClientTransportConfig::with_uri(server_url); + let mut header_map = HashMap::new(); + + if let Some(headers_obj) = headers.and_then(serde_json::Value::as_object) { + for (key, value) in headers_obj { + if let Some(value_str) = value.as_str() + && let Ok(name) = key.parse::() + && !is_blocked_mcp_header(&name) + && let Ok(val) = http::HeaderValue::from_str(value_str) + { + header_map.insert(name, val); + } + } + } + + inject_authorization(&mut header_map, authorization)?; + + if !header_map.is_empty() { + config = config.custom_headers(header_map); + } + + Ok(config) +} + +/// Inject `authorization` as a Bearer token. +/// +/// `Authorization` headers in the `headers` field are stripped +/// upstream so the dedicated `authorization` field is the only +/// auth source. +/// +/// # Errors +/// +/// Returns [`McpClientError::InvalidAuthorization`] if the token +/// contains characters invalid in HTTP header values. +fn inject_authorization( + header_map: &mut HashMap, + authorization: Option<&str>, +) -> Result<(), McpClientError> { + let Some(token) = authorization else { + return Ok(()); + }; + let bearer = format!("Bearer {token}"); + let val = http::HeaderValue::from_str(&bearer).map_err(|_invalid| McpClientError::InvalidAuthorization)?; + header_map.insert(http::header::AUTHORIZATION, val); + Ok(()) +} + +/// Reject MCP server URLs that point at SSRF-sensitive addresses. +/// +/// Lightweight validation for use on the cache-hit path where no +/// connection is made. For the connect path, `resolve_and_validate` +/// also pins resolved addresses. +/// +/// # Errors +/// +/// Returns [`McpClientError::SsrfBlocked`] if the URL resolves to +/// a loopback, link-local, or metadata address. +pub(crate) async fn validate_mcp_url(url: &str, timeout: Duration, allow_loopback: bool) -> Result<(), McpClientError> { + resolve_and_validate(url, timeout, allow_loopback) + .await + .map(|_resolved| ()) +} + +/// Resolved MCP URL with validated addresses pinned for +/// connect-time use, eliminating DNS rebinding between +/// validation and the actual connection. +struct ResolvedMcpUrl { + /// Sanitized URL retained for diagnostics. + display_url: McpDisplayUrl, + + /// Hostname to pin (present for DNS-resolved hosts, absent + /// for literal IPs). + hostname: Option, + + /// Validated socket addresses from DNS resolution. + addrs: Vec, +} + +/// Validate an MCP server URL and resolve its addresses. +/// +/// Returns the validated resolved addresses so the caller can +/// pin them on the HTTP client, closing the DNS-rebinding +/// TOCTOU window between validation and connect. +async fn resolve_and_validate( + url: &str, + timeout: Duration, + allow_loopback: bool, +) -> Result { + let uri: http::Uri = url + .parse() + .map_err(|_parse_err| ssrf_blocked(McpDisplayUrl::invalid(), "invalid URL"))?; + let scheme = uri.scheme_str().unwrap_or_default(); + if scheme != "http" && scheme != "https" { + return Err(ssrf_blocked(McpDisplayUrl::invalid(), "scheme must be http or https")); + } + let display_url = McpDisplayUrl::from_uri(&uri); + if uri.authority().is_some_and(|a| a.as_str().contains('@')) { + return Err(ssrf_blocked(display_url, "embedded credentials are not allowed")); + } + let Some(host) = uri.host() else { + return Err(ssrf_blocked(display_url, "URL must include a host")); + }; + let host = host.trim_matches(|c| c == '[' || c == ']'); + if !allow_loopback && is_blocked_hostname(host) { + return Err(ssrf_blocked(display_url, "localhost hostnames are not allowed")); + } + if let Ok(ip) = host.parse::() { + check_ip(ip, &display_url, allow_loopback)?; + return Ok(ResolvedMcpUrl { + display_url, + hostname: None, + addrs: Vec::new(), + }); + } + let port = uri.port_u16().unwrap_or(if scheme == "https" { 443 } else { 80 }); + resolve_hostname_ssrf(host, port, display_url, timeout, allow_loopback).await +} + +/// Check a literal IP address against the SSRF block list. +fn check_ip(ip: IpAddr, url: &McpDisplayUrl, allow_loopback: bool) -> Result<(), McpClientError> { + let ip = praxis_core::connectivity::normalize_mapped_ipv4(ip); + if allow_loopback && ip.is_loopback() { + return Ok(()); + } + if is_ssrf_sensitive(&ip) { + return Err(ssrf_blocked( + url.clone(), + "address is loopback, link-local, unique-local, unspecified, or cloud metadata", + )); + } + Ok(()) +} + +/// Resolve a hostname and check all resolved addresses. Fails +/// closed: DNS resolution failure or timeout blocks the request. +/// Returns validated addresses for connect-time pinning. +async fn resolve_hostname_ssrf( + host: &str, + port: u16, + url: McpDisplayUrl, + timeout: Duration, + allow_loopback: bool, +) -> Result { + let addrs: Vec = tokio::time::timeout(timeout, tokio::net::lookup_host((host, port))) + .await + .map_err(|_elapsed| McpClientError::Timeout { + url: url.clone(), + timeout, + })? + .map_err(|_dns_err| ssrf_blocked(url.clone(), "DNS resolution failed"))? + .collect(); + check_resolved_addrs(&addrs, &url, allow_loopback)?; + Ok(ResolvedMcpUrl { + display_url: url, + hostname: Some(host.to_owned()), + addrs, + }) +} + +/// Check DNS-resolved addresses against the SSRF block list. +fn check_resolved_addrs(addrs: &[SocketAddr], url: &McpDisplayUrl, allow_loopback: bool) -> Result<(), McpClientError> { + for addr in addrs { + check_ip(addr.ip(), url, allow_loopback)?; + } + Ok(()) +} + +/// Build a reqwest client with resolved addresses pinned, so +/// the connection uses the same IPs that passed SSRF validation. +fn build_pinned_client(resolved: &ResolvedMcpUrl) -> Result { + let mut builder = reqwest::Client::builder() + .pool_max_idle_per_host(0) + .no_proxy() + .redirect(reqwest::redirect::Policy::none()); + + if let Some(hostname) = &resolved.hostname { + builder = builder.resolve_to_addrs(hostname, &resolved.addrs); + } + + builder.build().map_err(|_source| McpClientError::Connection { + url: resolved.display_url.clone(), + }) +} + +/// Headers that must not pass through from client-supplied MCP +/// tool config into the proxy's outbound MCP transport. +fn is_blocked_mcp_header(name: &http::HeaderName) -> bool { + if matches!( + *name, + http::header::AUTHORIZATION + | http::header::CONNECTION + | http::header::CONTENT_LENGTH + | http::header::COOKIE + | http::header::FORWARDED + | http::header::HOST + | http::header::PROXY_AUTHORIZATION + | http::header::SET_COOKIE + | http::header::TE + | http::header::TRAILER + | http::header::TRANSFER_ENCODING + | http::header::UPGRADE + ) { + return true; + } + let s = name.as_str(); + s.starts_with("x-forwarded-") || s.starts_with("x-praxis-") || s.starts_with("x-mcp-") || s.starts_with("x-a2a-") +} + +/// Hostnames that resolve to loopback. +fn is_blocked_hostname(host: &str) -> bool { + let lower = host.to_ascii_lowercase(); + lower == "localhost" || lower.ends_with(".localhost") +} + +/// Loopback, link-local, unique-local, unspecified, and +/// known cloud metadata addresses are SSRF-sensitive. +fn is_ssrf_sensitive(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() || v4.is_link_local() || v4.is_unspecified() || *v4 == ALIBABA_CLOUD_METADATA_V4 + }, + IpAddr::V6(v6) => { + let [a, b, ..] = v6.octets(); + v6.is_loopback() || v6.is_unspecified() || (a == 0xFE && (b & 0xC0) == 0x80) || (a & 0xFE) == 0xFC + }, + } +} + +/// Convert `rmcp::model::Tool` values to opaque JSON. +fn tools_to_json(tools: Vec) -> Result, McpClientError> { + tools + .into_iter() + .map(|tool| serde_json::to_value(tool).map_err(McpClientError::Serialization)) + .collect() +} diff --git a/apis/src/mcp_client/tests.rs b/apis/src/mcp_client/tests.rs new file mode 100644 index 0000000000..cc44e7b724 --- /dev/null +++ b/apis/src/mcp_client/tests.rs @@ -0,0 +1,977 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Unit tests for the MCP client wrapper. + +use std::time::Duration; + +use super::*; + +const TEST_TIMEOUT: Duration = Duration::from_secs(5); + +async fn validate_url(url: &str) -> Result<(), McpClientError> { + validate_mcp_url(url, TEST_TIMEOUT, false).await +} + +fn display_url(url: &str) -> McpDisplayUrl { + McpDisplayUrl::from_uri(&url.parse().unwrap()) +} + +fn assert_error_uses_sanitized_url(error: &McpClientError) { + let message = error.to_string(); + assert!( + message.contains("https://example.com:8443/mcp/tools"), + "error should retain the sanitized endpoint: {message}" + ); + assert!(!message.contains("user"), "error must redact URL userinfo: {message}"); + assert!(!message.contains("pass"), "error must redact URL passwords: {message}"); + assert!(!message.contains("api_key"), "error must redact query names: {message}"); + assert!( + !message.contains("TOPSECRET"), + "error must redact query values: {message}" + ); +} + +// ========================================================================= +// Transport Config +// ========================================================================= + +#[test] +fn build_config_with_no_headers() { + let config = build_transport_config("http://localhost:8001/mcp", None, None).unwrap(); + assert_eq!(&*config.uri, "http://localhost:8001/mcp", "URI should match"); + assert!(config.custom_headers.is_empty(), "no custom headers expected"); +} + +#[test] +fn build_config_with_headers() { + let headers = serde_json::json!({"x-custom": "value", "x-other": "val2"}); + let config = build_transport_config("http://localhost:8001/mcp", Some(&headers), None).unwrap(); + + assert_eq!(config.custom_headers.len(), 2, "should have 2 custom headers"); +} + +#[test] +fn build_config_ignores_non_string_header_values() { + let headers = serde_json::json!({"x-good": "ok", "x-bad": 123}); + let config = build_transport_config("http://localhost:8001/mcp", Some(&headers), None).unwrap(); + + assert_eq!( + config.custom_headers.len(), + 1, + "should only include string-valued headers" + ); +} + +#[test] +fn build_config_ignores_non_object_headers() { + let headers = serde_json::json!("not-an-object"); + let config = build_transport_config("http://localhost:8001/mcp", Some(&headers), None).unwrap(); + + assert!(config.custom_headers.is_empty(), "non-object headers should be ignored"); +} + +// ========================================================================= +// Hop-by-hop / framing header blocking +// ========================================================================= + +#[test] +fn hop_by_hop_headers_stripped_from_mcp_headers() { + let headers = serde_json::json!({ + "host": "evil.example.com", + "content-length": "999", + "transfer-encoding": "chunked", + "connection": "keep-alive", + "te": "trailers", + "trailer": "Foo", + "upgrade": "websocket", + "proxy-authorization": "Basic creds", + "x-custom": "safe" + }); + let config = build_transport_config("http://api.example.com/mcp", Some(&headers), None).unwrap(); + + assert_eq!(config.custom_headers.len(), 1, "only safe header should remain"); + assert!( + config + .custom_headers + .contains_key(&http::HeaderName::from_static("x-custom")), + "x-custom should pass through" + ); +} + +#[test] +fn reserved_internal_headers_stripped_from_mcp_headers() { + let headers = serde_json::json!({ + "x-praxis-ai-format": "openai", + "x-mcp-servername": "backend-1", + "x-a2a-method": "task/send", + "x-custom": "safe" + }); + let config = build_transport_config("http://api.example.com/mcp", Some(&headers), None).unwrap(); + + assert_eq!(config.custom_headers.len(), 1, "only safe header should remain"); + assert!( + config + .custom_headers + .contains_key(&http::HeaderName::from_static("x-custom")), + "x-custom should pass through" + ); +} + +// ========================================================================= +// Cookie and forwarded header blocking +// ========================================================================= + +#[test] +fn cookie_and_forwarded_headers_stripped_from_mcp_headers() { + let headers = serde_json::json!({ + "cookie": "session=abc123", + "set-cookie": "id=xyz; Path=/", + "forwarded": "for=192.0.2.60;proto=http", + "x-forwarded-for": "203.0.113.50", + "x-forwarded-host": "original.example.com", + "x-forwarded-proto": "https", + "x-custom": "safe" + }); + let config = build_transport_config("http://api.example.com/mcp", Some(&headers), None).unwrap(); + + assert_eq!(config.custom_headers.len(), 1, "only safe header should remain"); + assert!( + config + .custom_headers + .contains_key(&http::HeaderName::from_static("x-custom")), + "x-custom should pass through" + ); +} + +// ========================================================================= +// Authorization +// ========================================================================= + +#[test] +fn authorization_injects_bearer_header() { + let config = build_transport_config("http://api.example.com/mcp", None, Some("tok_abc")).unwrap(); + let auth = config.custom_headers.get(&http::header::AUTHORIZATION).unwrap(); + assert_eq!(auth, "Bearer tok_abc", "should inject Bearer token"); +} + +#[test] +fn authorization_with_custom_headers() { + let headers = serde_json::json!({"x-custom": "val"}); + let config = build_transport_config("http://api.example.com/mcp", Some(&headers), Some("tok_xyz")).unwrap(); + + assert_eq!(config.custom_headers.len(), 2, "should have both headers"); + assert_eq!( + config.custom_headers.get(&http::header::AUTHORIZATION).unwrap(), + "Bearer tok_xyz", + "should have authorization" + ); +} + +#[test] +fn authorization_field_overrides_headers_authorization() { + let headers = serde_json::json!({"authorization": "Basic creds"}); + let config = build_transport_config("http://api.example.com/mcp", Some(&headers), Some("tok_real")).unwrap(); + + let auth = config.custom_headers.get(&http::header::AUTHORIZATION).unwrap(); + assert_eq!( + auth, "Bearer tok_real", + "authorization field should win over headers.Authorization" + ); +} + +#[test] +fn authorization_in_headers_stripped_when_no_field() { + let headers = serde_json::json!({"authorization": "Basic creds", "x-custom": "val"}); + let config = build_transport_config("http://api.example.com/mcp", Some(&headers), None).unwrap(); + + assert!( + !config.custom_headers.contains_key(&http::header::AUTHORIZATION), + "Authorization from headers should be stripped" + ); + assert_eq!(config.custom_headers.len(), 1, "only x-custom should remain"); +} + +#[test] +fn no_authorization_no_header() { + let config = build_transport_config("http://api.example.com/mcp", None, None).unwrap(); + assert!(config.custom_headers.is_empty(), "no headers expected"); +} + +#[test] +fn authorization_with_invalid_chars_returns_error() { + let result = build_transport_config("http://api.example.com/mcp", None, Some("tok\x00bad")); + assert!(result.is_err(), "invalid header chars should return error"); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("invalid HTTP header"), + "error should describe invalid header: {msg}" + ); +} + +// ========================================================================= +// Error Display +// ========================================================================= + +#[test] +fn connection_error_display() { + let err = McpClientError::Connection { + url: display_url("http://example.com/mcp"), + }; + let msg = err.to_string(); + assert!(msg.contains("example.com"), "should include URL"); + assert!(msg.contains("connection failed"), "should describe failure"); +} + +#[test] +fn timeout_error_display() { + let err = McpClientError::Timeout { + url: display_url("http://example.com/mcp"), + timeout: Duration::from_secs(5), + }; + let msg = err.to_string(); + assert!(msg.contains("timed out"), "should describe timeout"); + assert!(msg.contains("5s"), "should include duration"); +} + +#[test] +fn too_many_tools_error_display() { + let err = McpClientError::TooManyTools { + url: display_url("http://example.com/mcp"), + count: 200, + max: 128, + }; + let msg = err.to_string(); + assert!(msg.contains("200"), "should include actual count"); + assert!(msg.contains("128"), "should include max limit"); +} + +#[test] +fn list_tools_error_display() { + let err = McpClientError::ListTools { + url: display_url("http://example.com/mcp"), + }; + let msg = err.to_string(); + assert!(msg.contains("tools/list failed"), "should describe failure"); + assert!(msg.contains("example.com"), "should include URL"); +} + +#[test] +fn invalid_authorization_error_display() { + let err = McpClientError::InvalidAuthorization; + let msg = err.to_string(); + assert!( + msg.contains("invalid HTTP header"), + "should describe invalid header: {msg}" + ); +} + +#[test] +fn display_url_retains_only_routable_components() { + let display = display_url("https://user:pass@example.com:8443/mcp/tools?api_key=TOPSECRET"); + assert_eq!(display.to_string(), "https://example.com:8443/mcp/tools"); +} + +#[test] +fn display_url_preserves_ipv6_authority() { + let display = display_url("https://user:pass@[2001:db8::1]:8443/mcp?api_key=TOPSECRET"); + assert_eq!(display.to_string(), "https://[2001:db8::1]:8443/mcp"); +} + +#[test] +fn url_bearing_errors_only_format_sanitized_urls() { + let url = display_url("https://user:pass@example.com:8443/mcp/tools?api_key=TOPSECRET"); + let errors = [ + McpClientError::Connection { url: url.clone() }, + McpClientError::ListTools { url: url.clone() }, + McpClientError::CallTool { + url: url.clone(), + tool_name: "test_tool".to_owned(), + }, + McpClientError::Timeout { + url: url.clone(), + timeout: Duration::from_secs(5), + }, + McpClientError::TooManyTools { + url: url.clone(), + count: 200, + max: 128, + }, + McpClientError::SsrfBlocked { + url, + reason: "test reason", + }, + ]; + + for error in &errors { + assert_error_uses_sanitized_url(error); + } +} + +// ========================================================================= +// SSRF Validation +// ========================================================================= + +#[tokio::test] +async fn ssrf_blocks_ipv4_loopback() { + assert!(validate_url("http://127.0.0.1/mcp").await.is_err()); + assert!(validate_url("http://127.0.0.99:8080/mcp").await.is_err()); +} + +#[tokio::test] +async fn ssrf_blocks_ipv6_loopback() { + assert!(validate_url("http://[::1]/mcp").await.is_err()); +} + +#[tokio::test] +async fn ssrf_blocks_ipv6_link_local() { + assert!(validate_url("http://[fe80::1]/mcp").await.is_err()); + assert!(validate_url("http://[fe80::1%25eth0]:8080/mcp").await.is_err()); +} + +#[tokio::test] +async fn ssrf_blocks_localhost_hostname() { + assert!(validate_url("http://localhost/mcp").await.is_err()); + assert!(validate_url("http://LOCALHOST/mcp").await.is_err()); + assert!(validate_url("http://sub.localhost/mcp").await.is_err()); +} + +#[tokio::test] +async fn ssrf_blocks_link_local() { + assert!(validate_url("http://169.254.169.254/latest/meta-data/").await.is_err()); + assert!(validate_url("http://169.254.0.1/mcp").await.is_err()); +} + +#[tokio::test] +async fn ssrf_blocks_alibaba_cloud_metadata() { + assert!( + validate_url("http://100.100.100.200/latest/meta-data/instance-id") + .await + .is_err() + ); +} + +#[tokio::test] +async fn ssrf_blocks_mapped_ipv4_loopback() { + assert!(validate_url("http://[::ffff:127.0.0.1]/mcp").await.is_err()); +} + +#[tokio::test] +async fn ssrf_blocks_mapped_metadata() { + assert!(validate_url("http://[::ffff:169.254.169.254]/mcp").await.is_err()); +} + +#[tokio::test] +async fn ssrf_blocks_mapped_alibaba_cloud_metadata() { + assert!( + validate_url("http://[::ffff:100.100.100.200]/latest/meta-data/instance-id") + .await + .is_err() + ); +} + +#[test] +fn ssrf_blocks_dns_resolved_alibaba_cloud_metadata() { + let addrs = ["100.100.100.200:80".parse::().unwrap()]; + let url = display_url("http://metadata.example/mcp"); + assert!( + check_resolved_addrs(&addrs, &url, false).is_err(), + "DNS-resolved Alibaba Cloud metadata address should be blocked" + ); +} + +#[tokio::test] +async fn ssrf_blocks_invalid_url() { + assert!(validate_url("not-a-url").await.is_err()); +} + +#[tokio::test] +async fn ssrf_blocks_unresolvable_hostname() { + assert!(validate_url("http://unresolvable.invalid/mcp").await.is_err()); +} + +#[tokio::test] +async fn ssrf_errors_redact_sensitive_url_components() { + let urls = [ + "http://unresolvable.invalid/mcp?api_key=TOPSECRET", + "http://127.0.0.1/mcp?api_key=TOPSECRET", + "http://127.0.0.1/mcp?api_key=TOPSECRET#FRAGMENTSECRET", + ]; + + for url in urls { + let message = validate_url(url).await.unwrap_err().to_string(); + assert!( + !message.contains("TOPSECRET"), + "error must redact URL query credentials: {message}" + ); + assert!( + !message.contains("FRAGMENTSECRET"), + "error must redact URL fragments: {message}" + ); + } +} + +#[tokio::test] +async fn invalid_url_errors_use_opaque_display_value() { + let urls = [ + "http://exa mple.com/mcp?api_key=TOPSECRET#FRAGMENTSECRET", + "//user:pass@example.com/mcp?api_key=TOPSECRET#FRAGMENTSECRET", + "ftp://user:pass@example.com/mcp?api_key=TOPSECRET#FRAGMENTSECRET", + ]; + + for url in urls { + let message = validate_url(url).await.unwrap_err().to_string(); + assert!( + message.contains(""), + "invalid URL should be opaque: {message}" + ); + assert!(!message.contains("user"), "invalid URL must redact userinfo: {message}"); + assert!( + !message.contains("pass"), + "invalid URL must redact passwords: {message}" + ); + assert!( + !message.contains("TOPSECRET"), + "invalid URL must redact query values: {message}" + ); + assert!( + !message.contains("FRAGMENTSECRET"), + "invalid URL must redact fragments: {message}" + ); + } +} + +#[tokio::test] +async fn ssrf_errors_include_actionable_reason() { + let cases = [ + ("ftp://example.com/mcp", "scheme must be http or https"), + ( + "http://user:pass@example.com/mcp", + "embedded credentials are not allowed", + ), + ("http://localhost/mcp", "localhost hostnames are not allowed"), + ( + "http://127.0.0.1/mcp", + "address is loopback, link-local, unique-local, unspecified, or cloud metadata", + ), + ]; + + for (url, expected_reason) in cases { + let message = validate_url(url).await.unwrap_err().to_string(); + assert!( + message.contains(expected_reason), + "error should explain how to fix the blocked URL: {message}" + ); + } +} + +#[tokio::test] +async fn ssrf_allows_public_ips() { + assert!(validate_url("http://8.8.8.8/mcp").await.is_ok()); + assert!(validate_url("https://1.1.1.1:443/v1").await.is_ok()); +} + +#[tokio::test] +async fn ssrf_allows_private_rfc1918() { + assert!(validate_url("http://10.0.0.5/mcp").await.is_ok()); + assert!(validate_url("http://192.168.1.100/mcp").await.is_ok()); +} + +#[test] +fn ssrf_error_display() { + let err = McpClientError::SsrfBlocked { + url: display_url("http://127.0.0.1/mcp"), + reason: "loopback address is not allowed", + }; + let msg = err.to_string(); + assert!(msg.contains("SSRF"), "should mention SSRF"); + assert!(msg.contains("127.0.0.1"), "should include the URL"); + assert!(msg.contains("loopback address"), "should include the reason"); +} + +#[tokio::test] +async fn ssrf_blocks_unspecified_ipv4() { + assert!(validate_url("http://0.0.0.0/mcp").await.is_err()); +} + +#[tokio::test] +async fn ssrf_blocks_unspecified_ipv6() { + assert!(validate_url("http://[::]/mcp").await.is_err()); +} + +#[tokio::test] +async fn ssrf_blocks_mapped_unspecified() { + assert!(validate_url("http://[::ffff:0.0.0.0]/mcp").await.is_err()); +} + +#[tokio::test] +async fn ssrf_blocks_url_with_userinfo() { + let err = validate_url("http://user:pass@example.com/mcp").await.unwrap_err(); + let msg = err.to_string(); + assert!(!msg.contains("pass"), "error must not leak credentials"); + assert!(validate_url("https://user@example.com/mcp").await.is_err()); + + let ipv6_message = validate_url("http://user:pass@[::1]:8080/mcp?api_key=TOPSECRET") + .await + .unwrap_err() + .to_string(); + assert!(!ipv6_message.contains("user"), "IPv6 error must redact URL userinfo"); + assert!(!ipv6_message.contains("pass"), "IPv6 error must redact URL passwords"); + assert!( + !ipv6_message.contains("TOPSECRET"), + "IPv6 error must redact URL queries" + ); +} + +#[tokio::test] +async fn ssrf_blocks_aws_imds_ipv6() { + assert!(validate_url("http://[fd00:ec2::254]/latest/meta-data/").await.is_err()); +} + +#[test] +fn aws_imds_v6_detected_by_is_ssrf_sensitive() { + let ip = "fd00:ec2::254".parse::().unwrap(); + assert!(is_ssrf_sensitive(&ip), "fd00:ec2::254 should be SSRF-sensitive"); +} + +#[test] +fn unspecified_ip_detected_by_is_ssrf_sensitive() { + let v4 = "0.0.0.0".parse::().unwrap(); + assert!(is_ssrf_sensitive(&v4), "0.0.0.0 should be SSRF-sensitive"); + let v6 = "::".parse::().unwrap(); + assert!(is_ssrf_sensitive(&v6), ":: should be SSRF-sensitive"); +} + +#[test] +fn no_authorization_field_injects_no_auth_header() { + let headers = serde_json::json!({"x-custom": "val"}); + let config = build_transport_config("http://api.example.com/mcp", Some(&headers), None).unwrap(); + assert!( + !config.custom_headers.contains_key(&http::header::AUTHORIZATION), + "should not inject Authorization when authorization field is absent" + ); +} + +#[test] +fn ipv6_link_local_detected_by_is_ssrf_sensitive() { + let fe80 = "fe80::1".parse::().unwrap(); + assert!(is_ssrf_sensitive(&fe80), "fe80::1 should be SSRF-sensitive"); + let febf = "febf::1".parse::().unwrap(); + assert!(is_ssrf_sensitive(&febf), "febf::1 should be SSRF-sensitive"); + let fe00 = "fe00::1".parse::().unwrap(); + assert!(!is_ssrf_sensitive(&fe00), "fe00::1 is not link-local"); +} + +#[tokio::test] +async fn ssrf_blocks_ipv6_unique_local() { + assert!(validate_url("http://[fc00::1]/mcp").await.is_err()); + assert!(validate_url("http://[fd00:ec2::23]/mcp").await.is_err()); +} + +#[test] +fn ipv6_unique_local_detected_by_is_ssrf_sensitive() { + let fc00 = "fc00::1".parse::().unwrap(); + assert!(is_ssrf_sensitive(&fc00), "fc00::1 should be SSRF-sensitive"); + let fd00 = "fd00:ec2::23".parse::().unwrap(); + assert!(is_ssrf_sensitive(&fd00), "fd00:ec2::23 should be SSRF-sensitive"); + let fb00 = "fb00::1".parse::().unwrap(); + assert!(!is_ssrf_sensitive(&fb00), "fb00::1 is not unique-local"); +} + +// ========================================================================= +// allow_loopback +// ========================================================================= + +#[tokio::test] +async fn allow_loopback_permits_ipv4_loopback() { + assert!( + validate_mcp_url("http://127.0.0.1/mcp", TEST_TIMEOUT, true) + .await + .is_ok() + ); +} + +#[tokio::test] +async fn allow_loopback_permits_localhost_hostname() { + assert!( + validate_mcp_url("http://localhost/mcp", TEST_TIMEOUT, true) + .await + .is_ok() + ); +} + +#[tokio::test] +async fn allow_loopback_still_blocks_link_local() { + assert!( + validate_mcp_url("http://169.254.169.254/mcp", TEST_TIMEOUT, true) + .await + .is_err() + ); +} + +#[tokio::test] +async fn allow_loopback_still_blocks_unspecified() { + assert!( + validate_mcp_url("http://0.0.0.0/mcp", TEST_TIMEOUT, true) + .await + .is_err() + ); +} + +// ========================================================================= +// CallTool Error +// ========================================================================= + +#[test] +fn call_tool_error_display() { + let err = McpClientError::CallTool { + url: display_url("http://example.com/mcp?api_key=TOPSECRET"), + tool_name: "get_weather".to_owned(), + }; + let msg = err.to_string(); + assert!(msg.contains("tools/call failed"), "should mention tools/call: {msg}"); + assert!(msg.contains("get_weather"), "should mention tool name: {msg}"); + assert!(msg.contains("example.com"), "should mention URL: {msg}"); + assert!(!msg.contains("TOPSECRET"), "error must redact query credentials: {msg}"); +} + +// ========================================================================= +// Integration tests (real rmcp MCP server) +// ========================================================================= + +use rmcp::{ + ServerHandler, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{ServerCapabilities, ServerInfo}, + tool, tool_handler, tool_router, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use schemars::JsonSchema; +use serde::Deserialize; + +#[derive(Debug, Deserialize, JsonSchema)] +struct EchoRequest { + #[schemars(description = "The message to echo back")] + message: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct AddRequest { + #[schemars(description = "First operand")] + a: i32, + #[schemars(description = "Second operand")] + b: i32, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FailRequest { + #[schemars(description = "The error message to return")] + message: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct SlowRequest { + #[schemars(description = "Milliseconds to sleep before responding")] + sleep_ms: u64, +} + +#[derive(Debug, Clone)] +struct TestMcpServer { + tool_router: ToolRouter, +} + +#[expect(clippy::unused_self, reason = "rmcp macro-generated code")] +#[tool_router] +impl TestMcpServer { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } + + #[tool(description = "Echo the input message back verbatim")] + fn echo(&self, Parameters(req): Parameters) -> String { + req.message + } + + #[tool(description = "Add two integers and return the sum")] + fn add(&self, Parameters(req): Parameters) -> String { + (req.a + req.b).to_string() + } + + #[tool(description = "Always returns an error with the given message")] + fn fail(&self, Parameters(req): Parameters) -> Result { + Err(req.message) + } + + #[tool(description = "Sleep for the specified duration then return")] + async fn slow(&self, Parameters(req): Parameters) -> String { + tokio::time::sleep(Duration::from_millis(req.sleep_ms)).await; + "done".to_owned() + } +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for TestMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions("Test MCP server for integration tests") + } +} + +async fn start_test_mcp_server() -> (String, tokio_util::sync::CancellationToken) { + let ct = tokio_util::sync::CancellationToken::new(); + let config = StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()); + + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(TestMcpServer::new()), std::sync::Arc::default(), config); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let shutdown = ct.clone(); + tokio::spawn(async move { + drop( + axum::serve(listener, router) + .with_graceful_shutdown(async move { shutdown.cancelled_owned().await }) + .await, + ); + }); + + (format!("http://{addr}/mcp"), ct) +} + +const INTEGRATION_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn list_tools_returns_all_tools() { + let (url, ct) = start_test_mcp_server().await; + let tools = list_tools(&url, None, None, INTEGRATION_TIMEOUT, 128, true) + .await + .unwrap(); + ct.cancel(); + + let names: Vec<&str> = tools + .iter() + .filter_map(|t| t.get("name").and_then(serde_json::Value::as_str)) + .collect(); + assert_eq!(names.len(), 4, "expected 4 tools, got: {names:?}"); + assert!(names.contains(&"echo"), "missing echo tool"); + assert!(names.contains(&"add"), "missing add tool"); + assert!(names.contains(&"fail"), "missing fail tool"); + assert!(names.contains(&"slow"), "missing slow tool"); +} + +#[tokio::test] +async fn list_tools_contains_expected_schema() { + let (url, ct) = start_test_mcp_server().await; + let tools = list_tools(&url, None, None, INTEGRATION_TIMEOUT, 128, true) + .await + .unwrap(); + ct.cancel(); + + let add_tool = tools + .iter() + .find(|t| t.get("name").and_then(serde_json::Value::as_str) == Some("add")) + .expect("add tool should be present"); + + assert!( + add_tool.get("description").is_some(), + "add tool should have a description" + ); + + let schema = add_tool.get("inputSchema").expect("add tool should have inputSchema"); + let props = schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("inputSchema should have properties"); + assert!(props.contains_key("a"), "schema should have property 'a'"); + assert!(props.contains_key("b"), "schema should have property 'b'"); +} + +#[tokio::test] +async fn list_tools_enforces_max_tools() { + let (url, ct) = start_test_mcp_server().await; + let result = list_tools(&url, None, None, INTEGRATION_TIMEOUT, 2, true).await; + ct.cancel(); + + let err = result.expect_err("should fail with TooManyTools"); + let msg = err.to_string(); + assert!( + msg.contains("too many tools"), + "error should mention too many tools: {msg}" + ); +} + +#[tokio::test] +async fn list_tools_with_custom_headers() { + let (url, ct) = start_test_mcp_server().await; + let headers = serde_json::json!({"x-custom-header": "test-value"}); + let tools = list_tools(&url, Some(&headers), None, INTEGRATION_TIMEOUT, 128, true) + .await + .unwrap(); + ct.cancel(); + + assert_eq!(tools.len(), 4, "should still return all 4 tools"); +} + +#[tokio::test] +async fn list_tools_with_authorization() { + let (url, ct) = start_test_mcp_server().await; + let tools = list_tools(&url, None, Some("test-token"), INTEGRATION_TIMEOUT, 128, true) + .await + .unwrap(); + ct.cancel(); + + assert_eq!(tools.len(), 4, "should still return all 4 tools"); +} + +#[tokio::test] +async fn call_tool_echo() { + let (url, ct) = start_test_mcp_server().await; + let result = call_tool( + &url, + None, + None, + "echo", + serde_json::json!({"message": "hello world"}), + INTEGRATION_TIMEOUT, + true, + ) + .await + .unwrap(); + ct.cancel(); + + let text = result + .content + .first() + .and_then(|c| c.as_text()) + .map(|t| t.text.as_str()) + .expect("expected text content"); + assert_eq!(text, "hello world", "echo should return the input message"); +} + +#[tokio::test] +async fn call_tool_add_with_arguments() { + let (url, ct) = start_test_mcp_server().await; + let result = call_tool( + &url, + None, + None, + "add", + serde_json::json!({"a": 17, "b": 25}), + INTEGRATION_TIMEOUT, + true, + ) + .await + .unwrap(); + ct.cancel(); + + let text = result + .content + .first() + .and_then(|c| c.as_text()) + .map(|t| t.text.as_str()) + .expect("expected text content"); + assert_eq!(text, "42", "17 + 25 should be 42"); +} + +#[tokio::test] +async fn call_tool_add_with_string_arguments() { + let (url, ct) = start_test_mcp_server().await; + let result = call_tool( + &url, + None, + None, + "add", + serde_json::Value::String(r#"{"a": 3, "b": 7}"#.to_owned()), + INTEGRATION_TIMEOUT, + true, + ) + .await + .unwrap(); + ct.cancel(); + + let text = result + .content + .first() + .and_then(|c| c.as_text()) + .map(|t| t.text.as_str()) + .expect("expected text content"); + assert_eq!(text, "10", "3 + 7 should be 10"); +} + +#[tokio::test] +async fn call_tool_error_returns_is_error() { + let (url, ct) = start_test_mcp_server().await; + let result = call_tool( + &url, + None, + None, + "fail", + serde_json::json!({"message": "something broke"}), + INTEGRATION_TIMEOUT, + true, + ) + .await + .unwrap(); + ct.cancel(); + + assert_eq!(result.is_error, Some(true), "fail tool should set is_error=true"); + let text = result + .content + .first() + .and_then(|c| c.as_text()) + .map(|t| t.text.as_str()) + .expect("expected error text content"); + assert!( + text.contains("something broke"), + "error text should contain the message: {text}" + ); +} + +#[tokio::test] +async fn call_tool_nonexistent_tool() { + let (url, ct) = start_test_mcp_server().await; + let result = call_tool( + &url, + None, + None, + "nonexistent_tool", + serde_json::json!({}), + INTEGRATION_TIMEOUT, + true, + ) + .await; + ct.cancel(); + + assert!(result.is_err(), "calling a nonexistent tool should fail"); +} + +#[tokio::test] +async fn call_tool_timeout() { + let (url, ct) = start_test_mcp_server().await; + let short_timeout = Duration::from_millis(200); + let result = call_tool( + &url, + None, + None, + "slow", + serde_json::json!({"sleep_ms": 5000}), + short_timeout, + true, + ) + .await; + ct.cancel(); + + let err = result.expect_err("should time out"); + let msg = err.to_string(); + assert!(msg.contains("timed out"), "error should mention timeout: {msg}"); +} diff --git a/apis/src/openai/api_client/error.rs b/apis/src/openai/api_client/error.rs new file mode 100644 index 0000000000..5eb0a5ae78 --- /dev/null +++ b/apis/src/openai/api_client/error.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Error types for OpenAI-compatible API client operations. + +/// Errors from OpenAI-compatible API client HTTP operations. +/// +/// Covers transport failures, bounded-read overflows, JSON decode +/// errors, and URL construction problems. Consumers map these to +/// domain-specific error types (e.g. `ResolveError` for the file +/// resolver). +#[derive(Debug, Clone)] +pub(crate) enum ApiClientError { + /// The HTTP callout failed (transport error, timeout, circuit + /// open, or non-2xx status). + CalloutFailed { + /// Human-readable error description. + detail: String, + }, + + /// A resource ID cannot be safely encoded as a URL path segment. + InvalidResourceId { + /// The resource ID that was rejected. + resource_id: String, + /// Human-readable error description. + detail: String, + }, + + /// The response body exceeded the configured size limit during + /// a bounded read. + ResponseTooLarge { + /// Maximum allowed response size in bytes. + limit: usize, + }, + + /// The response body could not be decoded as JSON. + DecodeFailed { + /// Human-readable error description. + detail: String, + }, +} + +impl std::fmt::Display for ApiClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CalloutFailed { detail } => { + write!(f, "API callout failed: {detail}") + }, + Self::InvalidResourceId { resource_id, detail } => { + write!(f, "invalid resource id '{resource_id}': {detail}") + }, + Self::ResponseTooLarge { limit } => { + write!(f, "response exceeds size limit ({limit} bytes)") + }, + Self::DecodeFailed { detail } => { + write!(f, "response decode failed: {detail}") + }, + } + } +} diff --git a/apis/src/openai/api_client/mod.rs b/apis/src/openai/api_client/mod.rs new file mode 100644 index 0000000000..d37cba0ec4 --- /dev/null +++ b/apis/src/openai/api_client/mod.rs @@ -0,0 +1,788 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Shared HTTP client for OpenAI-compatible API callouts. +//! +//! Provides URL construction, SSRF-safe base-URL validation, +//! resource-ID path-segment encoding, header forwarding, bounded +//! JSON and byte reads, and normalized error mapping. Used by +//! [`FilesApiClient`] and vector-store search. +//! +//! All requests route through the [`SubRequestClient`] from +//! praxis-core for connection pooling, TLS, admission control, +//! and response body size limits. +//! +//! Each consuming filter retains its own [`ApiClient`] instance. +//! +//! [`FilesApiClient`]: super::responses::file_resolve +//! [`SubRequestClient`]: praxis_core::subrequest::SubRequestClient + +pub(crate) mod error; +pub(crate) mod url; + +use std::time::Duration; + +use bytes::Bytes; +use http::HeaderMap; + +pub(crate) use self::{ + error::ApiClientError, + url::{resource_url, validate_base_url, validate_forward_headers}, +}; +use crate::subrequest::{self, SubRequest, SubRequestClient, SubRequestError, SubResponse}; + +/// Configuration for constructing an [`ApiClient`]. +/// +/// Assembled programmatically by each consuming filter from its +/// own validated YAML config — no shared YAML schema. +pub(crate) struct ApiClientConfig { + /// Base URL of the API endpoint (trailing slash stripped). + pub api_base_url: String, + /// Sub-request client for bounded execution. + pub client: SubRequestClient, + /// Per-request timeout. + pub timeout: Duration, + /// Maximum response body bytes. + pub max_response_bytes: usize, + /// Header names to forward from the original request. + pub forward_header_names: Vec, +} + +/// Shared HTTP client for OpenAI-compatible API callouts. +/// +/// All requests route through the [`SubRequestClient`] from +/// praxis-core for connection pooling, TLS, admission control, +/// and response body size limits. +/// +/// [`SubRequestClient`]: praxis_core::subrequest::SubRequestClient +pub(crate) struct ApiClient { + /// Base URL of the API endpoint (trailing slash stripped). + api_base_url: String, + /// Sub-request client for bounded execution. + client: SubRequestClient, + /// Per-request timeout. + timeout: Duration, + /// Maximum response body bytes for JSON requests. + max_response_bytes: usize, + /// Header names to forward from the original downstream + /// request. + forward_header_names: Vec, +} + +/// Map a [`SubRequestError`] to an [`ApiClientError`]. +fn map_subrequest_error(err: SubRequestError) -> ApiClientError { + match err { + SubRequestError::ResponseTooLarge { limit, .. } => ApiClientError::ResponseTooLarge { limit }, + other => ApiClientError::CalloutFailed { + detail: other.to_string(), + }, + } +} + +impl ApiClient { + /// Build a new client from validated configuration. + /// + /// The base URL should already be validated with + /// [`validate_base_url`]. + pub(crate) fn new(config: ApiClientConfig) -> Self { + let ApiClientConfig { + api_base_url, + client, + timeout, + max_response_bytes, + forward_header_names, + } = config; + + Self { + api_base_url: api_base_url.trim_end_matches('/').to_owned(), + client, + timeout, + max_response_bytes, + forward_header_names, + } + } + + /// Return the validated base URL. + pub(crate) fn api_base_url(&self) -> &str { + &self.api_base_url + } + + /// Return the configured per-request timeout. + pub(crate) fn timeout(&self) -> Duration { + self.timeout + } + + /// Build a resource URL from the configured base, a path + /// prefix, a resource ID, and an optional suffix. + /// + /// See [`resource_url`] for encoding and validation behavior. + pub(crate) fn resource_url( + &self, + path_prefix: &str, + resource_id: &str, + suffix: Option<&str>, + ) -> Result { + resource_url(&self.api_base_url, path_prefix, resource_id, suffix) + } + + /// Send a GET request and parse the response body as JSON. + pub(crate) async fn get_json( + &self, + url: String, + request_headers: &HeaderMap, + ) -> Result { + let headers = self.build_header_map(request_headers); + let response = self.execute_url(&url, http::Method::GET, headers, Bytes::new()).await?; + serde_json::from_slice(&response.body).map_err(|e| ApiClientError::DecodeFailed { + detail: format!("JSON decode failed: {e}"), + }) + } + + /// Send a POST request with a JSON body and parse the response + /// body as JSON. + pub(crate) async fn post_json( + &self, + url: String, + body: &serde_json::Value, + request_headers: &HeaderMap, + ) -> Result { + let serialized = serde_json::to_vec(body).map_err(|e| ApiClientError::DecodeFailed { + detail: format!("request body serialization failed: {e}"), + })?; + + let response = self.post_json_bytes(url, serialized, request_headers).await?; + + serde_json::from_slice(&response).map_err(|e| ApiClientError::DecodeFailed { + detail: format!("JSON decode failed: {e}"), + }) + } + + /// Send a pre-serialized JSON body and return the bounded raw + /// response. + pub(crate) async fn post_json_bytes( + &self, + url: String, + body: Vec, + request_headers: &HeaderMap, + ) -> Result { + let mut headers = self.build_header_map(request_headers); + headers.remove(http::header::CONTENT_TYPE); + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + + let response = self + .execute_url(&url, http::Method::POST, headers, Bytes::from(body)) + .await?; + Ok(response.body) + } + + /// Send a GET request and return the response body with + /// bounded reads. + /// + /// The Pingora connector does not follow redirects (it + /// connects to a specific peer), matching the redirect- + /// rejection behavior of the previous `reqwest` path. + pub(crate) async fn get_bytes( + &self, + url: &str, + request_headers: &HeaderMap, + max_bytes: usize, + ) -> Result { + let headers = self.build_header_map(request_headers); + let request = SubRequest { + method: http::Method::GET, + uri: http::Uri::default(), + headers, + body: Bytes::new(), + }; + + let response = subrequest::execute_url(&self.client, url, request, max_bytes, self.timeout) + .await + .map_err(map_subrequest_error)?; + + if response.status < 200 || response.status >= 300 { + return Err(ApiClientError::CalloutFailed { + detail: format!("content download failed: {}", response.status), + }); + } + + Ok(response.body) + } + + /// Copy configured headers from the original downstream + /// request into a [`HeaderMap`] for forwarding. + pub(crate) fn forward_headers(&self, request_headers: &HeaderMap) -> Vec<(http::HeaderName, http::HeaderValue)> { + let mut headers = Vec::new(); + for name in &self.forward_header_names { + if let Some(value) = request_headers.get(name) { + headers.push((name.clone(), value.clone())); + } + } + headers + } + + /// Build a [`HeaderMap`] from forwarded headers. + fn build_header_map(&self, request_headers: &HeaderMap) -> HeaderMap { + let mut map = HeaderMap::new(); + for name in &self.forward_header_names { + if let Some(value) = request_headers.get(name) { + map.insert(name.clone(), value.clone()); + } + } + map + } + + /// Parse the URL, build a [`SubRequest`], execute via the + /// client, and check for non-2xx status. + async fn execute_url( + &self, + url: &str, + method: http::Method, + headers: HeaderMap, + body: Bytes, + ) -> Result { + let request = SubRequest { + method, + uri: http::Uri::default(), + headers, + body, + }; + + let response = subrequest::execute_url(&self.client, url, request, self.max_response_bytes, self.timeout) + .await + .map_err(map_subrequest_error)?; + + if response.status < 200 || response.status >= 300 { + return Err(ApiClientError::CalloutFailed { + detail: format!("callout rejected with status {}", response.status), + }); + } + + Ok(response) + } +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + reason = "tests" +)] +mod tests { + use std::{ + io::{Read as _, Write as _}, + net::{SocketAddr, TcpListener, TcpStream}, + thread::JoinHandle, + }; + + use super::*; + + fn bind_test_server() -> (TcpListener, SocketAddr) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + (listener, addr) + } + + fn capture_request(listener: TcpListener, response_body: &str) -> JoinHandle { + let body = response_body.to_owned(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_request(&mut stream); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + String::from_utf8(request).unwrap() + }) + } + + fn read_request(stream: &mut TcpStream) -> Vec { + let mut request = Vec::new(); + let mut buf = [0_u8; 4096]; + + loop { + let n = stream.read(&mut buf).unwrap(); + assert!(n > 0, "connection closed before the complete request arrived"); + request.extend_from_slice(&buf[..n]); + + let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") else { + continue; + }; + let body_start = header_end + 4; + let headers = std::str::from_utf8(&request[..header_end]).unwrap(); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + + if request.len() >= body_start + content_length { + return request; + } + } + } + + use praxis_core::subrequest::SubRequestConnector; + + fn test_client(base_url: &str) -> ApiClient { + ApiClient::new(ApiClientConfig { + api_base_url: base_url.to_owned(), + client: SubRequestClient::new(SubRequestConnector::new(4, None)), + timeout: Duration::from_millis(1_000), + max_response_bytes: 1_048_576, + forward_header_names: Vec::new(), + }) + } + + #[test] + fn new_strips_trailing_slash() { + let client = test_client("http://ogx:8321/"); + assert_eq!(client.api_base_url(), "http://ogx:8321"); + } + + #[test] + fn forward_headers_copies_configured_headers() { + let client = ApiClient::new(ApiClientConfig { + api_base_url: "http://ogx:8321".to_owned(), + client: SubRequestClient::new(SubRequestConnector::new(4, None)), + timeout: Duration::from_millis(1_000), + max_response_bytes: 1_048_576, + forward_header_names: vec![ + http::header::AUTHORIZATION, + http::HeaderName::from_static("x-tenant-id"), + ], + }); + + let mut request_headers = HeaderMap::new(); + request_headers.insert(http::header::AUTHORIZATION, "Bearer token".parse().unwrap()); + request_headers.insert("x-tenant-id", "tenant-1".parse().unwrap()); + request_headers.insert("x-unrelated", "ignored".parse().unwrap()); + + let forwarded = client.forward_headers(&request_headers); + + assert_eq!(forwarded.len(), 2, "only configured headers should be forwarded"); + assert!( + forwarded + .iter() + .any(|(n, v)| n == "authorization" && v == "Bearer token"), + "authorization header should be forwarded" + ); + assert!( + forwarded.iter().any(|(n, v)| n == "x-tenant-id" && v == "tenant-1"), + "x-tenant-id header should be forwarded" + ); + } + + #[test] + fn resource_url_delegates_to_url_module() { + let client = test_client("http://ogx:8321"); + let url = client.resource_url("v1/files", "file-abc", Some("content")).unwrap(); + assert_eq!(url, "http://ogx:8321/v1/files/file-abc/content"); + } + + #[tokio::test] + async fn get_bytes_does_not_follow_redirects() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _read = stream.read(&mut request).unwrap(); + stream + .write_all( + b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:9/secret\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + }); + let client = test_client(&format!("http://{address}")); + + let err = client + .get_bytes( + &format!("http://{address}/v1/files/test/content"), + &HeaderMap::new(), + 1024, + ) + .await + .unwrap_err(); + + assert!( + matches!(err, ApiClientError::CalloutFailed { .. }), + "redirect response should be rejected without contacting its target" + ); + } + + #[tokio::test] + async fn get_bytes_transport_failure_returns_callout_error() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + drop(stream); + }); + let client = test_client(&format!("http://{address}")); + + let err = client + .get_bytes( + &format!("http://{address}/v1/files/test/content"), + &HeaderMap::new(), + 1024, + ) + .await + .unwrap_err(); + + assert!( + matches!(err, ApiClientError::CalloutFailed { .. }), + "transport errors should be mapped to CalloutFailed" + ); + } + + #[tokio::test] + async fn get_bytes_rejects_response_exceeding_per_request_limit() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _read = stream.read(&mut request).unwrap(); + stream + .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n0123456789abcdef") + .unwrap(); + }); + let client = test_client(&format!("http://{address}")); + + let err = client + .get_bytes(&format!("http://{address}/v1/files/test/content"), &HeaderMap::new(), 8) + .await + .unwrap_err(); + + assert!( + matches!(err, ApiClientError::ResponseTooLarge { .. }), + "responses exceeding per-request max_bytes should be rejected as ResponseTooLarge: {err:?}" + ); + } + + #[tokio::test] + async fn get_bytes_oversized_non_2xx_is_response_too_large() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _read = stream.read(&mut request).unwrap(); + let body = vec![b'x'; 64]; + let response = format!( + "HTTP/1.1 404 Not Found\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(&body).unwrap(); + }); + let client = test_client(&format!("http://{address}")); + + let err = client + .get_bytes(&format!("http://{address}/v1/files/test/content"), &HeaderMap::new(), 8) + .await + .unwrap_err(); + + assert!( + matches!(err, ApiClientError::ResponseTooLarge { .. }), + "oversized response body should be ResponseTooLarge regardless of status: {err:?}" + ); + } + + #[tokio::test] + async fn get_json_parses_valid_json() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _read = stream.read(&mut request).unwrap(); + let body = r#"{"id":"file-abc","content_type":"text/plain"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + let client = test_client(&format!("http://{address}")); + + let json = client + .get_json(format!("http://{address}/v1/files/file-abc"), &HeaderMap::new()) + .await + .unwrap(); + + assert_eq!(json["id"].as_str().unwrap(), "file-abc"); + assert_eq!(json["content_type"].as_str().unwrap(), "text/plain"); + } + + #[tokio::test] + async fn get_json_returns_decode_error_on_invalid_json() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _read = stream.read(&mut request).unwrap(); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 11\r\nConnection: close\r\n\r\nnot-json!!!") + .unwrap(); + }); + let client = test_client(&format!("http://{address}")); + + let err = client + .get_json(format!("http://{address}/v1/files/file-abc"), &HeaderMap::new()) + .await + .unwrap_err(); + + assert!( + matches!(err, ApiClientError::DecodeFailed { .. }), + "invalid JSON should return a decode error" + ); + } + + #[tokio::test] + async fn post_json_sends_body_and_parses_response() { + let (listener, address) = bind_test_server(); + let captured = capture_request(listener, r#"{"results":[]}"#); + let client = test_client(&format!("http://{address}")); + + let request_body = serde_json::json!({"query": "test"}); + let json = client + .post_json( + format!("http://{address}/v1/vector_stores/vs-123/search"), + &request_body, + &HeaderMap::new(), + ) + .await + .unwrap(); + + assert!(json["results"].as_array().unwrap().is_empty()); + + let request = captured.join().unwrap(); + let request_lower = request.to_lowercase(); + assert!(request.starts_with("POST"), "should be a POST request"); + assert!( + request_lower.contains("content-type: application/json"), + "should have JSON content-type: {request}" + ); + let (_, body) = request.split_once("\r\n\r\n").unwrap(); + assert_eq!(body, r#"{"query":"test"}"#, "serialized JSON body should be sent"); + } + + #[tokio::test] + async fn post_json_returns_decode_error_on_invalid_json() { + let (listener, address) = bind_test_server(); + let captured = capture_request(listener, "not-json!!!"); + let client = test_client(&format!("http://{address}")); + + let err = client + .post_json( + format!("http://{address}/v1/vector_stores/vs-123/search"), + &serde_json::json!({"query": "test"}), + &HeaderMap::new(), + ) + .await + .unwrap_err(); + + captured.join().unwrap(); + assert!( + matches!(err, ApiClientError::DecodeFailed { .. }), + "invalid JSON should return a decode error" + ); + } + + #[tokio::test] + async fn post_json_strips_forwarded_content_type() { + let (listener, address) = bind_test_server(); + let captured = capture_request(listener, r#"{"ok":true}"#); + + let client = ApiClient::new(ApiClientConfig { + api_base_url: format!("http://{address}"), + client: SubRequestClient::new(SubRequestConnector::new(4, None)), + timeout: Duration::from_millis(1_000), + max_response_bytes: 1_048_576, + forward_header_names: vec![http::header::CONTENT_TYPE], + }); + + let mut headers = HeaderMap::new(); + headers.insert(http::header::CONTENT_TYPE, "text/plain".parse().unwrap()); + + client + .post_json(format!("http://{address}/v1/search"), &serde_json::json!({}), &headers) + .await + .unwrap(); + + let req = captured.join().unwrap(); + let req_lower = req.to_lowercase(); + let ct_count = req_lower.matches("content-type:").count(); + assert_eq!(ct_count, 1, "exactly one content-type header, got {ct_count}"); + assert!( + req_lower.contains("content-type: application/json"), + "should be application/json: {req}" + ); + } + + #[tokio::test] + async fn get_json_non_2xx_returns_callout_failed() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _read = stream.read(&mut request).unwrap(); + let body = r#"{"error":"not found"}"#; + let response = format!( + "HTTP/1.1 404 Not Found\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + let client = test_client(&format!("http://{address}")); + + let err = client + .get_json(format!("http://{address}/v1/files/missing"), &HeaderMap::new()) + .await + .unwrap_err(); + + assert!( + matches!(err, ApiClientError::CalloutFailed { .. }), + "non-2xx JSON response should map to CalloutFailed" + ); + } + + #[tokio::test] + async fn get_bytes_non_2xx_returns_callout_failed() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _read = stream.read(&mut request).unwrap(); + stream + .write_all(b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + }); + let client = test_client(&format!("http://{address}")); + + let err = client + .get_bytes( + &format!("http://{address}/v1/files/test/content"), + &HeaderMap::new(), + 1024, + ) + .await + .unwrap_err(); + + assert!( + matches!(err, ApiClientError::CalloutFailed { .. }), + "non-2xx byte download should map to CalloutFailed via callout client" + ); + } + + #[test] + fn display_callout_failed() { + let err = ApiClientError::CalloutFailed { + detail: "connection refused".to_owned(), + }; + assert_eq!(err.to_string(), "API callout failed: connection refused"); + } + + #[test] + fn display_invalid_resource_id() { + let err = ApiClientError::InvalidResourceId { + resource_id: "../etc/passwd".to_owned(), + detail: "path traversal".to_owned(), + }; + assert_eq!(err.to_string(), "invalid resource id '../etc/passwd': path traversal"); + } + + #[test] + fn display_response_too_large() { + let err = ApiClientError::ResponseTooLarge { limit: 1024 }; + assert_eq!(err.to_string(), "response exceeds size limit (1024 bytes)"); + } + + #[test] + fn display_decode_failed() { + let err = ApiClientError::DecodeFailed { + detail: "expected value at line 1".to_owned(), + }; + assert_eq!(err.to_string(), "response decode failed: expected value at line 1"); + } + + #[tokio::test] + async fn get_bytes_above_one_mib_succeeds() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let payload_size: usize = 1_200_000; + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _read = stream.read(&mut request).unwrap(); + let body = vec![0x42_u8; payload_size]; + let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {payload_size}\r\nConnection: close\r\n\r\n"); + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(&body).unwrap(); + }); + let client = test_client(&format!("http://{address}")); + + let bytes = client + .get_bytes( + &format!("http://{address}/v1/files/big/content"), + &HeaderMap::new(), + 2_000_000, + ) + .await + .unwrap(); + + assert_eq!(bytes.len(), payload_size, "should receive full >1 MiB payload"); + } + + fn slow_body_server(listener: TcpListener) { + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0_u8; 4096]; + let _n = stream.read(&mut buf).unwrap(); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\na") + .unwrap(); + stream.flush().unwrap(); + std::thread::park_timeout(Duration::from_millis(250)); + let _result = stream.write_all(b"bcde"); + }); + } + + #[tokio::test] + async fn get_bytes_timeout_covers_response_body() { + let (listener, addr) = bind_test_server(); + slow_body_server(listener); + + let client = ApiClient::new(ApiClientConfig { + api_base_url: format!("http://{addr}"), + client: SubRequestClient::new(SubRequestConnector::new(4, None)), + timeout: Duration::from_millis(50), + max_response_bytes: 1_048_576, + forward_header_names: Vec::new(), + }); + + let err = client + .get_bytes(&format!("http://{addr}/v1/files/slow/content"), &HeaderMap::new(), 1024) + .await + .unwrap_err(); + + assert!( + matches!(&err, ApiClientError::CalloutFailed { .. }), + "slow body should fail before completing: {err}" + ); + } +} diff --git a/apis/src/openai/api_client/url.rs b/apis/src/openai/api_client/url.rs new file mode 100644 index 0000000000..5cc0967eb5 --- /dev/null +++ b/apis/src/openai/api_client/url.rs @@ -0,0 +1,562 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! URL construction, resource-ID encoding, SSRF validation, and +//! forward-header validation for OpenAI-compatible API clients. +//! +//! These functions are shared by every filter that makes callouts +//! to an OpenAI-compatible API (Files API, vector-store search). +//! Each filter calls the validation helpers during its own config +//! validation phase, passing its filter name for error messages. + +use std::{ + collections::HashSet, + net::{IpAddr, Ipv4Addr}, +}; + +use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; +use praxis_core::connectivity::normalize_mapped_ipv4; +use praxis_filter::FilterError; + +use super::error::ApiClientError; +use crate::openai::url_security::is_non_public_ip; + +/// Characters that could let a client-supplied resource ID escape +/// its single URL path segment. +/// +/// Encoding path separators, query/fragment delimiters, `%`, and +/// URL parser special characters keeps the ID opaque when it is +/// appended to a path prefix like `/v1/files/` or +/// `/v1/vector_stores/`. Dots are encoded as an additional defense +/// against path normalization; exact `.` and `..` IDs are rejected +/// by [`resource_url`]. +pub(crate) const RESOURCE_ID_ENCODE_SET: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'%') + .add(b'.') + .add(b'/') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'[') + .add(b'\\') + .add(b']') + .add(b'^') + .add(b'`') + .add(b'{') + .add(b'}'); + +/// Build a resource URL with the ID encoded as a single path +/// segment. +/// +/// Produces `{api_base_url}/{path_prefix}/{encoded_id}` when +/// `suffix` is `None`, or +/// `{api_base_url}/{path_prefix}/{encoded_id}/{suffix}` when +/// provided. +/// +/// Rejects exact `.` and `..` resource IDs before encoding to +/// prevent path normalization attacks regardless of server +/// behavior. +pub(crate) fn resource_url( + api_base_url: &str, + path_prefix: &str, + resource_id: &str, + suffix: Option<&str>, +) -> Result { + if matches!(resource_id, "." | "..") { + return Err(ApiClientError::InvalidResourceId { + resource_id: resource_id.to_owned(), + detail: "dot path segments are not valid resource IDs".to_owned(), + }); + } + + let encoded_id = utf8_percent_encode(resource_id, RESOURCE_ID_ENCODE_SET); + match suffix { + Some(s) => Ok(format!("{api_base_url}/{path_prefix}/{encoded_id}/{s}")), + None => Ok(format!("{api_base_url}/{path_prefix}/{encoded_id}")), + } +} + +// ----------------------------------------------------------------------------- +// Base URL validation (SSRF) +// ----------------------------------------------------------------------------- + +/// Validate a base URL against SSRF-sensitive targets. +/// +/// Checks scheme, embedded credentials, query strings, fragments, +/// and host address. When `allow_private` is `false`, private, +/// loopback, link-local, CGNAT, and DNS-name hosts are rejected. +/// +/// `filter_name` is used as a prefix in error messages so each +/// consuming filter reports its own name. +pub(crate) fn validate_base_url(filter_name: &str, url: &str, allow_private: bool) -> Result<(), FilterError> { + if url.contains('#') { + return Err(format!("{filter_name}: base URL must not contain a fragment").into()); + } + + let uri: http::Uri = url.parse().map_err(|e: http::uri::InvalidUri| -> FilterError { + format!("{filter_name}: base URL is not valid: {e}").into() + })?; + + match uri.scheme_str() { + Some("http" | "https") => {}, + _ => { + return Err(format!("{filter_name}: base URL must use http or https scheme").into()); + }, + } + + if uri + .authority() + .is_some_and(|authority| authority.as_str().contains('@')) + { + return Err(format!("{filter_name}: base URL must not contain embedded credentials").into()); + } + + if uri.query().is_some() { + return Err(format!("{filter_name}: base URL must not contain a query string").into()); + } + + let host = uri + .host() + .ok_or_else(|| -> FilterError { format!("{filter_name}: base URL must include a host").into() })?; + + validate_host(filter_name, host, allow_private) +} + +/// Validate a host value against SSRF-sensitive targets. +fn validate_host(filter_name: &str, host: &str, allow_private: bool) -> Result<(), FilterError> { + if !allow_private && is_localhost_name(host) { + return Err(format!( + "{filter_name}: base URL targets localhost; \ + set the allow-private option to true to allow" + ) + .into()); + } + + let ip_host = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + if let Ok(ip) = ip_host.parse::() { + validate_ip(filter_name, ip, allow_private)?; + } else if let Some(ip) = parse_legacy_ipv4_host(host) { + validate_ip(filter_name, IpAddr::V4(ip), allow_private)?; + } else { + validate_dns(filter_name, host, allow_private)?; + } + + Ok(()) +} + +/// Validate an IP target against SSRF-sensitive ranges. +fn validate_ip(filter_name: &str, ip: IpAddr, allow_private: bool) -> Result<(), FilterError> { + let ip = normalize_mapped_ipv4(ip); + if !allow_private && is_non_public_ip(&ip) { + return Err(format!( + "{filter_name}: base URL targets a local-sensitive address; \ + set the allow-private option to true to allow" + ) + .into()); + } + Ok(()) +} + +/// Reject DNS hostnames unless private targets are opted in. +fn validate_dns(filter_name: &str, host: &str, allow_private: bool) -> Result<(), FilterError> { + if allow_private { + return Ok(()); + } + Err(format!( + "{filter_name}: base URL host '{host}' is a DNS name; \ + use a literal IP address or set the allow-private option to true to allow DNS targets" + ) + .into()) +} + +/// Return whether a host name is a localhost alias. +fn is_localhost_name(host: &str) -> bool { + host.trim_end_matches('.').eq_ignore_ascii_case("localhost") +} + +/// Parse legacy IPv4 literals accepted by common libc resolvers. +fn parse_legacy_ipv4_host(host: &str) -> Option { + let host = host.trim_end_matches('.'); + let parts: Vec<_> = host.split('.').collect(); + if parts.is_empty() || parts.len() > 4 || parts.iter().any(|part| part.is_empty()) { + return None; + } + + let mut numbers = Vec::with_capacity(parts.len()); + for part in parts { + numbers.push(parse_legacy_ipv4_number(part)?); + } + + let addr = match numbers.as_slice() { + [a] => *a, + [a, b] if *a <= 0xFF && *b <= 0x00FF_FFFF => (*a << 24) | *b, + [a, b, c] if *a <= 0xFF && *b <= 0xFF && *c <= 0xFFFF => (*a << 24) | (*b << 16) | *c, + [a, b, c, d] if numbers.iter().all(|part| *part <= 0xFF) => (*a << 24) | (*b << 16) | (*c << 8) | *d, + _ => return None, + }; + + Some(Ipv4Addr::from(addr)) +} + +/// Parse a decimal, octal, or hexadecimal legacy IPv4 component. +fn parse_legacy_ipv4_number(part: &str) -> Option { + let (digits, radix) = part.strip_prefix("0x").or_else(|| part.strip_prefix("0X")).map_or_else( + || { + if part.len() > 1 && part.starts_with('0') { + (part.get(1..).unwrap_or_default(), 8) + } else { + (part, 10) + } + }, + |digits| (digits, 16), + ); + + if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) { + return None; + } + + u32::from_str_radix(digits, radix).ok() +} + +// ----------------------------------------------------------------------------- +// Forward-header validation +// ----------------------------------------------------------------------------- + +/// Validate and normalize headers forwarded across an external API +/// security boundary. +/// +/// Normalizes header names to lowercase, rejects transport and +/// internal headers, and rejects duplicates. +pub(crate) fn validate_forward_headers(filter_name: &str, headers: &mut [String]) -> Result<(), FilterError> { + let mut seen = HashSet::with_capacity(headers.len()); + + for configured in headers { + let name = http::HeaderName::from_bytes(configured.as_bytes()).map_err(|e| -> FilterError { + format!("{filter_name}: invalid 'forward_headers' entry '{configured}': {e}").into() + })?; + let normalized = name.as_str(); + + if is_blocked_forward_header(normalized) { + return Err(format!( + "{filter_name}: 'forward_headers' must not include transport or internal header '{normalized}'" + ) + .into()); + } + + if !seen.insert(normalized.to_owned()) { + return Err(format!("{filter_name}: duplicate 'forward_headers' entry '{normalized}'").into()); + } + + normalized.clone_into(configured); + } + + Ok(()) +} + +/// Return whether a header is unsafe to copy from the client +/// request to a newly constructed external API request. +fn is_blocked_forward_header(name: &str) -> bool { + name.starts_with("x-praxis-") + || name.starts_with("x-ext-protocol-") + || name.starts_with("x-ext-agent-") + || name.starts_with("x-mcp-") + || name.starts_with("x-a2a-") + || matches!( + name, + "connection" + | "content-length" + | "host" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-connection" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + reason = "tests" +)] +mod tests { + use super::*; + + // -- resource_url -------------------------------------------------------- + + #[test] + fn resource_url_encodes_id_as_single_path_segment() { + let url = resource_url("http://ogx:8321", "v1/files", "../admin?x#y", None).unwrap(); + + assert!( + url.starts_with("http://ogx:8321/v1/files/"), + "URL should use the resource path prefix: {url}" + ); + assert!( + !url.contains("../admin"), + "raw path traversal should not appear in URL: {url}" + ); + assert!(!url.contains("?x"), "query delimiter should be encoded: {url}"); + assert!(!url.contains("#y"), "fragment delimiter should be encoded: {url}"); + assert!(url.contains("%2F"), "slash should be encoded: {url}"); + assert!(url.contains("%3F"), "question mark should be encoded: {url}"); + assert!(url.contains("%23"), "fragment marker should be encoded: {url}"); + } + + #[test] + fn resource_url_rejects_exact_dot_dot_segment() { + let err = resource_url("http://ogx:8321", "v1/files", "..", None).unwrap_err(); + + assert!( + matches!(err, ApiClientError::InvalidResourceId { .. }), + "exact dot-dot resource id should be rejected before URL construction" + ); + } + + #[test] + fn resource_url_rejects_exact_dot_segment() { + let err = resource_url("http://ogx:8321", "v1/files", ".", None).unwrap_err(); + + assert!( + matches!(err, ApiClientError::InvalidResourceId { .. }), + "exact dot resource id should be rejected" + ); + } + + #[test] + fn resource_url_preserves_base_path_and_adds_suffix() { + let url = resource_url("http://ogx:8321/files-api", "v1/files", "file-abc", Some("content")).unwrap(); + + assert_eq!( + url, "http://ogx:8321/files-api/v1/files/file-abc/content", + "URL should preserve configured base path and append suffix" + ); + } + + #[test] + fn resource_url_without_suffix() { + let url = resource_url("http://ogx:8321", "v1/files", "file-abc", None).unwrap(); + + assert_eq!(url, "http://ogx:8321/v1/files/file-abc"); + } + + // -- SSRF validation ----------------------------------------------------- + + #[test] + fn ssrf_rejects_loopback_ipv4() { + assert!( + validate_base_url("test", "http://127.0.0.1:8321", false).is_err(), + "loopback IPv4 should be rejected without allow_private" + ); + } + + #[test] + fn ssrf_rejects_loopback_ipv6() { + assert!( + validate_base_url("test", "http://[::1]:8321", false).is_err(), + "loopback IPv6 should be rejected without allow_private" + ); + } + + #[test] + fn ssrf_rejects_private_ipv4() { + assert!( + validate_base_url("test", "http://10.0.0.1:8321", false).is_err(), + "private IPv4 should be rejected without allow_private" + ); + } + + #[test] + fn ssrf_rejects_link_local_ipv4() { + assert!( + validate_base_url("test", "http://169.254.169.254", false).is_err(), + "link-local IPv4 (metadata endpoint) should be rejected" + ); + } + + #[test] + fn ssrf_rejects_cgnat_ipv4() { + assert!( + validate_base_url("test", "http://100.64.0.1:8321", false).is_err(), + "CGNAT IPv4 should be rejected without allow_private" + ); + } + + #[test] + fn ssrf_rejects_localhost_name() { + assert!( + validate_base_url("test", "http://localhost:8321", false).is_err(), + "localhost name should be rejected without allow_private" + ); + } + + #[test] + fn ssrf_rejects_dns_name() { + assert!( + validate_base_url("test", "http://ogx:8321", false).is_err(), + "DNS name should be rejected without allow_private" + ); + } + + #[test] + fn ssrf_rejects_legacy_octal_loopback() { + assert!( + validate_base_url("test", "http://0177.0.0.1:8321", false).is_err(), + "octal-encoded loopback should be rejected" + ); + } + + #[test] + fn ssrf_rejects_shared_special_use_range() { + assert!( + validate_base_url("test", "http://192.0.2.1:8321", false).is_err(), + "documentation ranges should be rejected by shared IP classification" + ); + } + + #[test] + fn ssrf_rejects_shared_cloud_metadata_endpoint() { + assert!( + validate_base_url("test", "http://100.100.100.200:8321", false).is_err(), + "cloud metadata endpoints should be rejected by shared IP classification" + ); + } + + #[test] + fn ssrf_rejects_ipv4_mapped_ipv6_loopback() { + assert!( + validate_base_url("test", "http://[::ffff:127.0.0.1]:8321", false).is_err(), + "IPv4-mapped IPv6 loopback should be rejected" + ); + } + + #[test] + fn ssrf_allows_public_ipv4() { + assert!( + validate_base_url("test", "http://8.8.8.8:8321", false).is_ok(), + "public IPv4 should be allowed" + ); + } + + #[test] + fn ssrf_allows_public_ipv6() { + assert!( + validate_base_url("test", "https://[2606:4700:4700::1111]:8321", false).is_ok(), + "bracketed public IPv6 should be recognized as an IP literal" + ); + } + + #[test] + fn ssrf_allows_private_with_override() { + assert!( + validate_base_url("test", "http://127.0.0.1:8321", true).is_ok(), + "loopback should be allowed with allow_private" + ); + } + + #[test] + fn ssrf_allows_dns_with_override() { + assert!( + validate_base_url("test", "http://ogx:8321", true).is_ok(), + "DNS name should be allowed with allow_private" + ); + } + + #[test] + fn ssrf_rejects_non_http_scheme() { + assert!( + validate_base_url("test", "ftp://ogx:8321", false).is_err(), + "non-http scheme should be rejected" + ); + } + + #[test] + fn ssrf_rejects_embedded_credentials() { + assert!( + validate_base_url("test", "http://user:password@ogx:8321", true).is_err(), + "embedded URL credentials should be rejected" + ); + } + + #[test] + fn ssrf_rejects_query_string() { + assert!( + validate_base_url("test", "http://ogx:8321/base?tenant=abc", true).is_err(), + "query strings should be rejected" + ); + } + + #[test] + fn ssrf_rejects_fragment() { + assert!( + validate_base_url("test", "http://ogx:8321/base#v2", true).is_err(), + "fragments should be rejected" + ); + } + + // -- forward-header validation ------------------------------------------- + + #[test] + fn forward_headers_are_normalized() { + let mut headers = vec!["Authorization".to_owned(), "X-Tenant-ID".to_owned()]; + validate_forward_headers("test", &mut headers).unwrap(); + + assert_eq!( + headers, + vec!["authorization", "x-tenant-id"], + "forwarded header names should be normalized" + ); + } + + #[test] + fn invalid_forward_header_rejected() { + let mut headers = vec!["bad header".to_owned()]; + assert!( + validate_forward_headers("test", &mut headers).is_err(), + "syntactically invalid forwarded header names should be rejected" + ); + } + + #[test] + fn unsafe_forward_headers_rejected() { + for name in [ + "host", + "content-length", + "transfer-encoding", + "proxy-authorization", + "x-praxis-route", + ] { + let mut headers = vec![name.to_owned()]; + assert!( + validate_forward_headers("test", &mut headers).is_err(), + "unsafe forwarded header '{name}' should be rejected" + ); + } + } + + #[test] + fn duplicate_forward_headers_rejected_case_insensitively() { + let mut headers = vec!["Authorization".to_owned(), "authorization".to_owned()]; + assert!( + validate_forward_headers("test", &mut headers).is_err(), + "duplicate forwarded header names should be rejected after normalization" + ); + } +} diff --git a/apis/src/openai/conversations/config.rs b/apis/src/openai/conversations/config.rs new file mode 100644 index 0000000000..bcaa49d958 --- /dev/null +++ b/apis/src/openai/conversations/config.rs @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Configuration types for the conversations filter. + +use percent_encoding::percent_decode_str; +use praxis_filter::{FilterError, has_dot_dot_traversal}; +use secrecy::{ExposeSecret as _, SecretString}; +use serde::Deserialize; + +use crate::store::{ + PoolConfig, SslMode, + postgres_url::{ + self, has_postgres_url_ssl_root_cert, is_verified_postgres_sslmode, postgres_url_sslmode, + validate_postgres_url_tls_file_params, + }, + validate_postgres_table_set_identifiers, validate_table_identifier, +}; + +/// Filter name used in SSRF validation error messages. +const FILTER_NAME: &str = "openai_conversations"; + +// ----------------------------------------------------------------------------- +// StorageBackend +// ----------------------------------------------------------------------------- + +/// Supported storage backends. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum StorageBackend { + /// SQLite backend (file-backed or in-memory). + Sqlite, + + /// `PostgreSQL` backend. + Postgres, +} + +// ----------------------------------------------------------------------------- +// ConversationsConfig +// ----------------------------------------------------------------------------- + +/// YAML configuration for the [`OpenaiConversationsFilter`]. +/// +/// [`OpenaiConversationsFilter`]: super::OpenaiConversationsFilter +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ConversationsConfig { + /// Storage backend to use. + pub backend: StorageBackend, + + /// Database connection URL. Wrapped in [`SecretString`] to + /// prevent accidental logging of credentials. + pub database_url: SecretString, + + /// Table name for conversation records. + #[serde(default = "default_conversations_table")] + pub conversations_table: String, + + /// Table name for conversation item records. + #[serde(default = "default_items_table")] + pub items_table: String, + + /// TLS mode for `PostgreSQL` connections. + /// + /// Only valid when `backend` is `postgres`. Overrides any + /// `sslmode` parameter in the connection URL. + #[serde(default)] + pub ssl_mode: Option, + + /// Path to a PEM-encoded root CA certificate for `PostgreSQL` + /// TLS verification. + /// + /// Only valid when `backend` is `postgres` and the effective + /// SSL mode is `verify-ca` or `verify-full`. + #[serde(default)] + pub ssl_root_cert: Option, + + /// Allow `PostgreSQL` URLs that target local-sensitive addresses. + /// + /// By default, DNS names, localhost, loopback, private, + /// link-local, cloud metadata, unspecified, and Unix socket + /// targets are rejected. This opt-in is intended for local + /// development and tests. + #[serde(default)] + pub allow_private_database_url: bool, + + /// Connection pool tuning options. + /// + /// When omitted, sqlx defaults apply (`max_connections = 10`, + /// `idle_timeout = 600s`, `acquire_timeout = 30s`). + #[serde(default)] + pub pool: Option, +} + +/// Serde default for [`ConversationsConfig::conversations_table`]. +fn default_conversations_table() -> String { + "openai_conversations".to_owned() +} + +/// Serde default for [`ConversationsConfig::items_table`]. +fn default_items_table() -> String { + "openai_conversation_items".to_owned() +} + +impl ConversationsConfig { + /// Return the generated internal responses table name. + /// + /// The store constructors require a responses table, but the + /// conversations filter doesn't use it. This generates a + /// deterministic name so the DDL runs cleanly; the table + /// exists but remains empty. + pub fn responses_table(&self) -> String { + format!("{}_unused_responses", self.conversations_table) + } +} + +// ----------------------------------------------------------------------------- +// Config Validation +// ----------------------------------------------------------------------------- + +/// Validate the parsed configuration. +pub(crate) fn validate_config(cfg: &ConversationsConfig) -> Result<(), FilterError> { + let database_url = cfg.database_url.expose_secret(); + if database_url.is_empty() { + return Err(format!("{FILTER_NAME}: 'database_url' must not be empty").into()); + } + if let Some(pool) = &cfg.pool { + pool.validate().map_err(|e| format!("{FILTER_NAME}: {e}"))?; + } + let responses_table = validate_table_names(cfg)?; + match cfg.backend { + StorageBackend::Sqlite => { + validate_sqlite_database_url(database_url)?; + reject_postgres_fields(cfg)?; + }, + StorageBackend::Postgres => { + postgres_url::validate_postgres_database_url(FILTER_NAME, database_url, cfg.allow_private_database_url)?; + validate_postgres_table_set_identifiers(&responses_table, &cfg.conversations_table, Some(&cfg.items_table)) + .map_err(|e| format!("{FILTER_NAME}: invalid postgres table identifier: {e}"))?; + validate_postgres_ssl_config(cfg, database_url)?; + }, + } + Ok(()) +} + +/// Validate all table name identifiers and uniqueness constraints. +fn validate_table_names(cfg: &ConversationsConfig) -> Result { + validate_table_identifier(&cfg.conversations_table) + .map_err(|e| format!("{FILTER_NAME}: invalid conversations_table: {e}"))?; + validate_table_identifier(&cfg.items_table).map_err(|e| format!("{FILTER_NAME}: invalid items_table: {e}"))?; + if cfg.conversations_table.eq_ignore_ascii_case(&cfg.items_table) { + return Err(format!("{FILTER_NAME}: conversations and items table names must be distinct").into()); + } + let responses_table = cfg.responses_table(); + validate_table_identifier(&responses_table) + .map_err(|e| format!("{FILTER_NAME}: invalid generated responses_table: {e}"))?; + if responses_table.eq_ignore_ascii_case(&cfg.items_table) { + return Err(format!("{FILTER_NAME}: generated responses and items table names must be distinct").into()); + } + Ok(responses_table) +} + +/// Reject `..` segments in the SQLite file path. +fn validate_sqlite_database_url(database_url: &str) -> Result<(), FilterError> { + if is_memory_database_url(database_url) { + return Ok(()); + } + + let path = sqlite_file_path(database_url).unwrap_or(database_url); + let path = percent_decode_str(path) + .decode_utf8() + .map_err(|e| format!("{FILTER_NAME}: database_url path must be valid UTF-8: {e}"))?; + if has_dot_dot_traversal(&path) { + return Err(format!("{FILTER_NAME}: database_url must not contain '..' path traversal").into()); + } + Ok(()) +} + +/// Re-validate only the `PostgreSQL` host/IP portions of the +/// connection URL immediately before `SQLx` resolves and connects. +pub(crate) fn revalidate_postgres_host(cfg: &ConversationsConfig) -> Result<(), FilterError> { + let database_url = cfg.database_url.expose_secret(); + postgres_url::revalidate_postgres_host(FILTER_NAME, database_url, cfg.allow_private_database_url) +} + +/// Validate `PostgreSQL` TLS options. +fn validate_postgres_ssl_config(cfg: &ConversationsConfig, database_url: &str) -> Result<(), FilterError> { + validate_postgres_url_tls_file_params(FILTER_NAME, database_url)?; + + if let Some(root_cert) = &cfg.ssl_root_cert { + let root_cert = root_cert.expose_secret(); + if has_dot_dot_traversal(root_cert) { + return Err(format!("{FILTER_NAME}: ssl_root_cert must not contain '..' path traversal").into()); + } + } + + if has_postgres_ssl_root_cert(cfg, database_url) && !has_verified_postgres_ssl_mode(cfg, database_url) { + return Err(format!("{FILTER_NAME}: 'ssl_root_cert' requires ssl_mode 'verify-ca' or 'verify-full'").into()); + } + Ok(()) +} + +/// Return whether any configured `PostgreSQL` root CA path is present. +fn has_postgres_ssl_root_cert(cfg: &ConversationsConfig, database_url: &str) -> bool { + cfg.ssl_root_cert.is_some() || has_postgres_url_ssl_root_cert(database_url) +} + +/// Return whether the effective `PostgreSQL` SSL mode verifies certificates. +/// +/// When no explicit `ssl_mode` is set, the runtime default is +/// [`SslMode::VerifyFull`], so the `None` case is considered verified +/// unless the URL carries a non-verifying `sslmode`. +fn has_verified_postgres_ssl_mode(cfg: &ConversationsConfig, database_url: &str) -> bool { + match cfg.ssl_mode { + Some(SslMode::VerifyCa | SslMode::VerifyFull) => true, + Some(SslMode::Disable | SslMode::Prefer | SslMode::Require) => false, + None => postgres_url_sslmode(database_url) + .as_deref() + .is_none_or(is_verified_postgres_sslmode), + } +} + +/// Reject `PostgreSQL`-specific fields when backend is SQLite. +fn reject_postgres_fields(cfg: &ConversationsConfig) -> Result<(), FilterError> { + if cfg.ssl_mode.is_some() { + return Err(format!("{FILTER_NAME}: 'ssl_mode' is only valid with the 'postgres' backend").into()); + } + if cfg.ssl_root_cert.is_some() { + return Err(format!("{FILTER_NAME}: 'ssl_root_cert' is only valid with the 'postgres' backend").into()); + } + if cfg.allow_private_database_url { + return Err( + format!("{FILTER_NAME}: 'allow_private_database_url' is only valid with the 'postgres' backend").into(), + ); + } + Ok(()) +} + +/// Return whether a SQLite URL targets an in-memory database. +fn is_memory_database_url(database_url: &str) -> bool { + let url = database_url.trim(); + if url == "sqlite::memory:" || url == "sqlite://:memory:" { + return true; + } + url.split_once('?') + .map_or("", |(_, query)| query) + .split('&') + .any(|param| param == "mode=memory") +} + +/// Extract the file path component from a SQLite URL. +fn sqlite_file_path(database_url: &str) -> Option<&str> { + database_url + .strip_prefix("sqlite://") + .or_else(|| database_url.strip_prefix("sqlite:")) + .map(|rest| rest.split_once('?').map_or(rest, |(path, _query)| path)) +} diff --git a/apis/src/openai/conversations/contracts.rs b/apis/src/openai/conversations/contracts.rs new file mode 100644 index 0000000000..dad22299a4 --- /dev/null +++ b/apis/src/openai/conversations/contracts.rs @@ -0,0 +1,416 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Runtime JSON contracts for locally handled Conversations operations. + +#![expect( + clippy::large_stack_frames, + reason = "utoipa macro-generated schema builders allocate large temporary values" +)] + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use utoipa::{ + PartialSchema, ToSchema, + openapi::schema::{AnyOfBuilder, ArrayBuilder, Object, ObjectBuilder, Schema, Type}, +}; + +/// Maximum number of items accepted by create operations. +pub(super) const MAX_ITEMS_PER_REQUEST: usize = 20; + +/// Optional response fields supported by Conversation item endpoints. +/// +/// The spellings match OpenAI's `IncludeEnum` exactly. Runtime query parsing +/// and generated `OpenAPI` both consume this enum. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)] +pub(super) enum IncludeField { + /// Include file search result payloads. + #[serde(rename = "file_search_call.results")] + #[schema(rename = "file_search_call.results")] + FileSearchCallResults, + /// Include web search result payloads. + #[serde(rename = "web_search_call.results")] + #[schema(rename = "web_search_call.results")] + WebSearchCallResults, + /// Include the sources used by web search actions. + #[serde(rename = "web_search_call.action.sources")] + #[schema(rename = "web_search_call.action.sources")] + WebSearchCallActionSources, + /// Include image URLs in message input-image parts. + #[serde(rename = "message.input_image.image_url")] + #[schema(rename = "message.input_image.image_url")] + MessageInputImageImageUrl, + /// Include image URLs in computer-call outputs. + #[serde(rename = "computer_call_output.output.image_url")] + #[schema(rename = "computer_call_output.output.image_url")] + ComputerCallOutputImageUrl, + /// Include code-interpreter output payloads. + #[serde(rename = "code_interpreter_call.outputs")] + #[schema(rename = "code_interpreter_call.outputs")] + CodeInterpreterCallOutputs, + /// Include encrypted reasoning content. + #[serde(rename = "reasoning.encrypted_content")] + #[schema(rename = "reasoning.encrypted_content")] + ReasoningEncryptedContent, + /// Include token log probabilities in message output-text parts. + #[serde(rename = "message.output_text.logprobs")] + #[schema(rename = "message.output_text.logprobs")] + MessageOutputTextLogprobs, +} + +impl IncludeField { + /// Parse one decoded query value using the official enum spelling. + pub(super) fn parse(value: &str) -> Option { + match value { + "file_search_call.results" => Some(Self::FileSearchCallResults), + "web_search_call.results" => Some(Self::WebSearchCallResults), + "web_search_call.action.sources" => Some(Self::WebSearchCallActionSources), + "message.input_image.image_url" => Some(Self::MessageInputImageImageUrl), + "computer_call_output.output.image_url" => Some(Self::ComputerCallOutputImageUrl), + "code_interpreter_call.outputs" => Some(Self::CodeInterpreterCallOutputs), + "reasoning.encrypted_content" => Some(Self::ReasoningEncryptedContent), + "message.output_text.logprobs" => Some(Self::MessageOutputTextLogprobs), + _ => None, + } + } + + /// Return this field's bit in the compact runtime include set. + const fn bit(self) -> u8 { + match self { + Self::FileSearchCallResults => 1 << 0, + Self::WebSearchCallResults => 1 << 1, + Self::WebSearchCallActionSources => 1 << 2, + Self::MessageInputImageImageUrl => 1 << 3, + Self::ComputerCallOutputImageUrl => 1 << 4, + Self::CodeInterpreterCallOutputs => 1 << 5, + Self::ReasoningEncryptedContent => 1 << 6, + Self::MessageOutputTextLogprobs => 1 << 7, + } + } +} + +/// Set of requested optional item fields. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct IncludeFields(u8); + +impl IncludeFields { + /// Add one requested field. + pub(super) fn insert(&mut self, field: IncludeField) { + self.0 |= field.bit(); + } + + /// Return whether a field was requested. + pub(super) const fn contains(self, field: IncludeField) -> bool { + self.0 & field.bit() != 0 + } +} + +/// Request body accepted by `POST /conversations`. +#[derive(Debug, Default, Deserialize, ToSchema)] +pub(super) struct CreateConversationRequest { + /// Optional metadata map. Missing and null both produce empty metadata. + #[schema(schema_with = nullable_metadata_schema)] + pub(super) metadata: Option, + + /// Optional nullable initial items to add to the conversation. + #[schema(schema_with = nullable_initial_items_schema)] + pub(super) items: Option>, +} + +/// Request body accepted by `POST /conversations/{conversation_id}`. +#[derive(Debug, Deserialize, ToSchema)] +pub(super) struct UpdateConversationRequest { + /// Required metadata replacement. + #[serde(deserialize_with = "deserialize_metadata_object")] + pub(super) metadata: Metadata, +} + +/// Request body accepted by `POST /conversations/{conversation_id}/items`. +#[derive(Debug, Deserialize, ToSchema)] +pub(super) struct CreateConversationItemsRequest { + /// Items to create. + #[serde(default)] + #[schema(value_type = Vec, required = true, max_items = 20)] + pub(super) items: Option>, +} + +/// Metadata supplied with a conversation. +/// +/// The runtime keeps the original JSON object ordering. Validation enforces +/// string values before the value crosses into storage. +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[serde(transparent)] +#[schema(value_type = std::collections::BTreeMap)] +pub(super) struct Metadata(Value); + +impl Metadata { + /// Borrow the underlying JSON value for validation. + pub(super) const fn as_value(&self) -> &Value { + &self.0 + } + + /// Move the underlying JSON value into storage. + pub(super) fn into_value(self) -> Value { + self.0 + } + + /// Wrap metadata read from storage for response serialization. + pub(super) const fn from_value(value: Value) -> Self { + Self(value) + } +} + +/// Preserve both nullable layers emitted for create metadata upstream. +fn nullable_metadata_schema() -> Schema { + let metadata = AnyOfBuilder::new() + .item(Schema::Object( + ObjectBuilder::new() + .schema_type(Type::Object) + .additional_properties(Some(ObjectBuilder::new().schema_type(Type::String))) + .build(), + )) + .item(Schema::Object(ObjectBuilder::new().schema_type(Type::Null).build())) + .build(); + Schema::AnyOf( + AnyOfBuilder::new() + .item(Schema::AnyOf(metadata)) + .item(Schema::Object(ObjectBuilder::new().schema_type(Type::Null).build())) + .build(), + ) +} + +/// Generate the official nullable, bounded initial-items composition. +fn nullable_initial_items_schema() -> Schema { + Schema::AnyOf( + AnyOfBuilder::new() + .item( + ArrayBuilder::new() + .items(::schema()) + .max_items(Some(MAX_ITEMS_PER_REQUEST)), + ) + .item(Schema::Object(ObjectBuilder::new().schema_type(Type::Null).build())) + .build(), + ) +} + +/// Deserialize update metadata only from a JSON object. +fn deserialize_metadata_object<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + if !value.is_object() { + return Err(serde::de::Error::custom("metadata must be an object")); + } + Ok(Metadata(value)) +} + +/// Polymorphic conversation item stored and returned as an opaque JSON object. +/// +/// Message-specific normalization happens in the handler. Other item kinds are +/// deliberately preserved so new provider variants do not require proxy code +/// changes. +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[serde(transparent)] +#[schema(value_type = Object)] +pub(super) struct ConversationItem(Value); + +impl ConversationItem { + /// Move an input item into runtime normalization. + pub(super) fn into_value(self) -> Value { + self.0 + } + + /// Wrap a stored item for response serialization. + pub(super) const fn from_value(value: Value) -> Self { + Self(value) + } +} + +/// Local conversation response object. +#[derive(Debug, Serialize, ToSchema)] +pub(super) struct ConversationResource { + /// Conversation ID. + id: String, + /// Object discriminator. + #[schema(schema_with = conversation_object_schema)] + object: ConversationObject, + /// Creation timestamp measured in seconds since the Unix epoch. + #[schema(format = "unixtime")] + created_at: i64, + /// Conversation metadata. + metadata: Metadata, +} + +impl ConversationResource { + /// Construct a conversation response from runtime-owned fields. + pub(super) const fn new(id: String, created_at: i64, metadata: Metadata) -> Self { + Self { + id, + object: ConversationObject::Conversation, + created_at, + metadata, + } + } +} + +/// Delete conversation response object. +#[derive(Debug, Serialize, ToSchema)] +pub(super) struct DeletedConversationResource { + /// Conversation ID. + id: String, + /// Object discriminator. + #[schema(schema_with = deleted_conversation_object_schema)] + object: DeletedConversationObject, + /// Whether the object was deleted. + deleted: bool, +} + +impl DeletedConversationResource { + /// Construct a successful delete response. + pub(super) fn deleted(id: impl Into) -> Self { + Self { + id: id.into(), + object: DeletedConversationObject::ConversationDeleted, + deleted: true, + } + } +} + +/// Conversation item list response object. +#[derive(Debug, Serialize, ToSchema)] +pub(super) struct ConversationItemList { + /// Object discriminator. + #[schema(schema_with = list_object_schema)] + object: ListObject, + /// Conversation items. + data: Vec, + /// Whether more items are available. + has_more: bool, + /// First item ID in this page. + first_id: String, + /// Last item ID in this page. + last_id: String, +} + +impl ConversationItemList { + /// Construct one page of conversation items. + pub(super) const fn new(data: Vec, has_more: bool, first_id: String, last_id: String) -> Self { + Self { + object: ListObject::List, + data, + has_more, + first_id, + last_id, + } + } +} + +/// Conversation object discriminator. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub(super) enum ConversationObject { + /// Conversation resource. + Conversation, +} + +/// Deleted conversation object discriminator. +#[derive(Debug, Serialize, ToSchema)] +pub(super) enum DeletedConversationObject { + /// Deleted conversation resource. + #[serde(rename = "conversation.deleted")] + #[schema(rename = "conversation.deleted")] + ConversationDeleted, +} + +/// List object discriminator. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub(super) enum ListObject { + /// List resource. + List, +} + +/// Supported item list ordering. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub(super) enum ItemOrder { + /// Oldest item first. + Asc, + /// Newest item first. + #[default] + Desc, +} + +impl ItemOrder { + /// Whether records should be returned oldest-first. + pub(super) const fn is_ascending(self) -> bool { + matches!(self, Self::Asc) + } +} + +/// Generate the fixed conversation discriminator schema. +fn conversation_object_schema() -> Object { + fixed_string_schema("conversation", true) +} + +/// Generate the fixed deleted-conversation discriminator schema. +fn deleted_conversation_object_schema() -> Object { + fixed_string_schema("conversation.deleted", true) +} + +/// Generate the fixed list discriminator schema. +fn list_object_schema() -> Object { + fixed_string_schema("list", false) +} + +/// Build an inline string schema for a single discriminator value. +fn fixed_string_schema(value: &str, include_default: bool) -> Object { + let mut schema = ObjectBuilder::new() + .schema_type(Type::String) + .enum_values(Some([value])); + if include_default { + schema = schema.default(Some(Value::String(value.to_owned()))); + } + schema.build() +} + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "tests")] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn create_request_distinguishes_missing_and_null_items() { + let missing: CreateConversationRequest = serde_json::from_value(json!({})).unwrap(); + assert!(missing.items.is_none(), "missing items should use the default"); + + let null: CreateConversationRequest = serde_json::from_value(json!({"items": null})).unwrap(); + assert!(null.items.is_none(), "null items should use the default"); + } + + #[test] + fn update_request_requires_non_null_metadata() { + let missing = serde_json::from_value::(json!({})); + assert!(missing.is_err(), "metadata must be present on update"); + + let null = serde_json::from_value::(json!({"metadata": null})); + assert!(null.is_err(), "metadata must be an object on update"); + + let array = serde_json::from_value::(json!({"metadata": ["a", "b"]})); + assert!(array.is_err(), "metadata must reject arrays"); + + let replacement: UpdateConversationRequest = + serde_json::from_value(json!({"metadata": {"project": "praxis"}})).unwrap(); + assert_eq!(replacement.metadata.as_value(), &json!({"project": "praxis"})); + } + + #[test] + fn conversation_item_preserves_unknown_object_variants() { + let value = json!({"type": "future_provider_item", "provider_data": {"enabled": true}}); + let item: ConversationItem = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(item).unwrap(), value); + } +} diff --git a/apis/src/openai/conversations/filter.rs b/apis/src/openai/conversations/filter.rs new file mode 100644 index 0000000000..35e28566f5 --- /dev/null +++ b/apis/src/openai/conversations/filter.rs @@ -0,0 +1,658 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! [`OpenaiConversationsFilter`] handles all `/v1/conversations` +//! endpoints locally via `FilterAction::Reject`, backed by the +//! `ConversationItemStore` trait. + +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use praxis_filter::{ + FilterAction, FilterError, HttpFilter, HttpFilterContext, Rejection, + body::{BodyAccess, BodyMode, MAX_JSON_BODY_BYTES}, + parse_filter_config, +}; +use secrecy::ExposeSecret as _; +use serde_json::Value; +use tokio::sync::OnceCell; +use tracing::{debug, trace, warn}; + +use super::{ + config::{ConversationsConfig, StorageBackend, revalidate_postgres_host, validate_config}, + handlers, + routes::{self, ConversationOperation, MatchedConversationRoute}, +}; +use crate::{ + openai::responses::{DEFAULT_TENANT_ID, TENANT_METADATA_KEY, state::ResponsesState}, + store::{ConversationItemStore, ConversationRecord, PostgresResponseStore, SqliteResponseStore, StoreError}, +}; + +// ----------------------------------------------------------------------------- +// OpenaiConversationsFilter +// ----------------------------------------------------------------------------- + +/// Handles all `/v1/conversations` endpoints locally. +/// +/// All matched requests are served from the local store and never +/// forwarded upstream. Unmatched paths pass through as `Continue`. +/// +/// # YAML +/// +/// ```yaml +/// filter: openai_conversations +/// backend: sqlite +/// database_url: sqlite://conversations.db?mode=rwc +/// conversations_table: conversations +/// items_table: conversation_items +/// ``` +pub struct OpenaiConversationsFilter { + /// Filter configuration (backend, database URL, table names). + config: ConversationsConfig, + /// Lazily-initialized store; `None` on permanent init failure (SQLite). + store: OnceCell>>, +} + +/// Per-request state used when another filter forces request-body pre-read +/// before this filter's header hook has run. +#[derive(Default)] +struct ConversationRequestState { + /// Whether this filter's `on_request` hook has run for the request. + request_filters_ran: bool, + + /// Full body captured by an early pre-read pass. + deferred_body: Option, +} + +/// Per-request response-phase state that controls whether append-back +/// should run during `on_response_body`. +struct ConversationResponseState { + /// Whether response body buffering is armed for append-back. + armed: bool, +} + +impl OpenaiConversationsFilter { + /// Create a filter from parsed YAML config. + /// + /// # Errors + /// + /// Returns [`FilterError`] if the YAML config is invalid. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", config)?; + validate_config(&cfg)?; + Ok(Box::new(Self::new(cfg))) + } + + /// Wrap a validated config into a new filter instance. + fn new(config: ConversationsConfig) -> Self { + Self { + config, + store: OnceCell::new(), + } + } + + /// Build the configured store backend. + async fn build_store(&self) -> Result, StoreError> { + let responses_table = self.config.responses_table(); + match self.config.backend { + StorageBackend::Sqlite => self.build_sqlite_store(&responses_table).await, + StorageBackend::Postgres => Box::pin(self.build_postgres_store(&responses_table)).await, + } + } + + /// Construct a SQLite-backed store. + async fn build_sqlite_store(&self, responses_table: &str) -> Result, StoreError> { + SqliteResponseStore::new( + self.config.database_url.expose_secret(), + responses_table, + &self.config.conversations_table, + Some(&self.config.items_table), + self.config.pool.as_ref(), + ) + .await + .map(|s| { + let arc: Arc = Arc::new(s); + arc + }) + } + + /// Construct a Postgres-backed store. + async fn build_postgres_store(&self, responses_table: &str) -> Result, StoreError> { + revalidate_postgres_host(&self.config) + .map_err(|e| StoreError::Unavailable(format!("postgres host validation failed before connect: {e}")))?; + let ssl_root_cert = self.config.ssl_root_cert.as_ref().map(|s| { + let secret: &str = s.expose_secret(); + secret + }); + PostgresResponseStore::new( + self.config.database_url.expose_secret(), + responses_table, + &self.config.conversations_table, + Some(&self.config.items_table), + self.config.ssl_mode, + ssl_root_cert, + self.config.pool.as_ref(), + ) + .await + .map(|s| { + let arc: Arc = Arc::new(s); + arc + }) + } + + /// Build the store and log the outcome. + async fn build_logged_store(&self) -> Result, StoreError> { + let store = Box::pin(self.build_store()).await?; + debug!( + backend = ?self.config.backend, + conversations_table = %self.config.conversations_table, + items_table = %self.config.items_table, + "conversations store initialized" + ); + Ok(store) + } + + /// Build and cache the store permanently (SQLite path — no retry on failure). + async fn init_permanent_store(&self) -> Option> { + match Box::pin(self.build_logged_store()).await { + Ok(store) => Some(store), + Err(e) => { + warn!( + backend = ?self.config.backend, + error = %e, + "conversations store initialization failed (permanent)" + ); + None + }, + } + } + + /// Return the cached store, initializing on first call. + async fn get_or_init_store(&self) -> Option> { + if matches!(self.config.backend, StorageBackend::Postgres) { + match self + .store + .get_or_try_init(|| async { Box::pin(self.build_logged_store()).await.map(Some) }) + .await + { + Ok(store) => store.as_ref().map(Arc::clone), + Err(e) => { + warn!( + backend = ?self.config.backend, + error = %e, + "conversations store initialization failed (will retry)" + ); + None + }, + } + } else { + self.store + .get_or_init(|| async { Box::pin(self.init_permanent_store()).await }) + .await + .as_ref() + .map(Arc::clone) + } + } + + /// Return the store or a 500 rejection if unavailable. + async fn require_store(&self) -> Result, FilterError> { + self.get_or_init_store() + .await + .ok_or_else(|| FilterError::from("openai_conversations: store unavailable")) + } + + /// Mark the request phase complete and return any body captured earlier. + fn mark_request_filters_ran(ctx: &mut HttpFilterContext<'_>) -> Option { + ctx.current_filter_id?; + let mut state = ctx + .remove_filter_state::() + .unwrap_or_default(); + state.request_filters_ran = true; + let deferred_body = state.deferred_body.take(); + ctx.insert_filter_state(state); + deferred_body + } + + /// Whether it is safe for the body hook to mutate the local store. + fn request_filters_ran(ctx: &HttpFilterContext<'_>) -> bool { + ctx.current_filter_id.is_none() + || ctx + .get_filter_state::() + .is_some_and(|state| state.request_filters_ran) + } + + /// Store a complete request body for handling once `on_request` runs. + fn defer_body_until_request_filters(ctx: &mut HttpFilterContext<'_>, body: Option<&Bytes>) -> FilterAction { + let mut state = ctx + .remove_filter_state::() + .unwrap_or_default(); + state.deferred_body = Some(body.cloned().unwrap_or_default()); + ctx.insert_filter_state(state); + FilterAction::Release + } + + /// Dispatch a matched POST body to the appropriate local handler. + async fn handle_post_route( + ctx: &HttpFilterContext<'_>, + store: &dyn ConversationItemStore, + route: &MatchedConversationRoute<'_>, + body: &[u8], + ) -> Result { + match route.spec.operation { + ConversationOperation::CreateConversation => handlers::handle_create_conversation(ctx, store, body).await, + ConversationOperation::UpdateConversation => { + let id = route + .conversation_id() + .ok_or_else(|| FilterError::from("openai_conversations: matched update route missing id"))?; + handlers::handle_update_conversation(ctx, store, id, body).await + }, + ConversationOperation::CreateConversationItems => { + let id = route + .conversation_id() + .ok_or_else(|| FilterError::from("openai_conversations: matched item create route missing id"))?; + handlers::handle_create_items(ctx, store, id, body).await + }, + ConversationOperation::GetConversation + | ConversationOperation::DeleteConversation + | ConversationOperation::ListConversationItems + | ConversationOperation::GetConversationItem + | ConversationOperation::DeleteConversationItem => Err(FilterError::from(format!( + "openai_conversations: handle_post_route called for non-body operation {:?}", + route.spec.operation + ))), + } + } + + /// Persist conversation items synchronously using `block_in_place`. + fn append_items_blocking( + &self, + tenant_id: &str, + conversation_id: &str, + ctx: &HttpFilterContext<'_>, + items: Vec, + ) -> Result<(), FilterError> { + let store = self + .store + .get() + .and_then(Option::as_ref) + .ok_or_else(|| FilterError::from("openai_conversations: store unavailable for append-back"))?; + + let handle = tokio::runtime::Handle::current(); + tokio::task::block_in_place(|| { + handle.block_on(persist_items(store.as_ref(), tenant_id, conversation_id, ctx, items)) + }) + } +} + +// ----------------------------------------------------------------------------- +// HttpFilter Implementation +// ----------------------------------------------------------------------------- + +#[async_trait] +impl HttpFilter for OpenaiConversationsFilter { + fn name(&self) -> &'static str { + "openai_conversations" + } + + fn request_body_access(&self) -> BodyAccess { + BodyAccess::ReadOnly + } + + fn response_body_access(&self) -> BodyAccess { + BodyAccess::ReadOnly + } + + fn request_body_mode(&self) -> BodyMode { + BodyMode::StreamBuffer { + max_bytes: Some(MAX_JSON_BODY_BYTES), + } + } + + fn response_body_mode(&self) -> BodyMode { + BodyMode::Stream + } + + fn needs_request_context(&self) -> bool { + true + } + + #[expect(clippy::too_many_lines, reason = "dispatcher with one arm per endpoint")] + async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + let Some(route) = routes::match_route(ctx.request.method.as_str(), ctx.request.uri.path()) else { + if should_append_back(ctx) { + drop(self.get_or_init_store().await); + } + return Ok(FilterAction::Continue); + }; + + match route.spec.operation { + ConversationOperation::GetConversation => { + let id = route + .conversation_id() + .ok_or_else(|| FilterError::from("openai_conversations: matched get route missing id"))?; + let store = self.require_store().await?; + handlers::handle_get_conversation(ctx, store.as_ref(), id).await + }, + ConversationOperation::ListConversationItems => { + let id = route + .conversation_id() + .ok_or_else(|| FilterError::from("openai_conversations: matched list route missing id"))?; + let store = self.require_store().await?; + handlers::handle_list_items(ctx, store.as_ref(), id).await + }, + ConversationOperation::GetConversationItem => { + let id = route + .conversation_id() + .ok_or_else(|| FilterError::from("openai_conversations: matched get item route missing id"))?; + let item_id = route + .item_id() + .ok_or_else(|| FilterError::from("openai_conversations: matched get item route missing item id"))?; + let store = self.require_store().await?; + handlers::handle_get_item(ctx, store.as_ref(), id, item_id).await + }, + ConversationOperation::DeleteConversation => { + let id = route + .conversation_id() + .ok_or_else(|| FilterError::from("openai_conversations: matched delete route missing id"))?; + let store = self.require_store().await?; + handlers::handle_delete_conversation(ctx, store.as_ref(), id).await + }, + ConversationOperation::DeleteConversationItem => { + let id = route + .conversation_id() + .ok_or_else(|| FilterError::from("openai_conversations: matched delete item route missing id"))?; + let item_id = route.item_id().ok_or_else(|| { + FilterError::from("openai_conversations: matched delete item route missing item id") + })?; + let store = self.require_store().await?; + handlers::handle_delete_item(ctx, store.as_ref(), id, item_id).await + }, + ConversationOperation::CreateConversation + | ConversationOperation::UpdateConversation + | ConversationOperation::CreateConversationItems => { + ctx.set_request_body_mode(BodyMode::StreamBuffer { + max_bytes: Some(MAX_JSON_BODY_BYTES), + }); + let deferred_body = Self::mark_request_filters_ran(ctx); + let Some(body) = deferred_body else { + return Ok(FilterAction::Continue); + }; + let Some(store) = self.get_or_init_store().await else { + return Ok(FilterAction::Reject(reject_store_unavailable())); + }; + Box::pin(Self::handle_post_route(ctx, store.as_ref(), &route, &body)).await + }, + } + } + + async fn on_request_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + if !end_of_stream || ctx.request.method != http::Method::POST { + return Ok(FilterAction::Continue); + } + + let empty: &[u8] = &[]; + let bytes = body.as_ref().map_or(empty, |b| b.as_ref()); + + let Some(route) = routes::match_route(ctx.request.method.as_str(), ctx.request.uri.path()) else { + return Ok(FilterAction::Continue); + }; + if !route.spec.has_request_body() { + return Ok(FilterAction::Continue); + } + + if !Self::request_filters_ran(ctx) { + return Ok(Self::defer_body_until_request_filters(ctx, body.as_ref())); + } + + let Some(store) = self.get_or_init_store().await else { + return Ok(FilterAction::Reject(reject_store_unavailable())); + }; + Box::pin(Self::handle_post_route(ctx, store.as_ref(), &route, bytes)).await + } + + async fn on_response(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + if !should_append_back(ctx) { + ctx.insert_filter_state(ConversationResponseState { armed: false }); + return Ok(FilterAction::Continue); + } + + let resp = ctx.response_header.as_ref(); + let is_success = resp.is_none_or(|r| r.status.is_success()); + let is_json = resp + .and_then(|r| r.headers.get(http::header::CONTENT_TYPE)) + .and_then(|v| v.to_str().ok()) + .is_some_and(|ct| { + ct.split(';') + .next() + .unwrap_or_default() + .trim() + .eq_ignore_ascii_case("application/json") + }); + + let armed = is_success && is_json; + if !armed { + trace!("conversation append-back skipped (non-2xx or non-JSON response)"); + } + ctx.insert_filter_state(ConversationResponseState { armed }); + + if armed { + ctx.set_response_body_mode(BodyMode::StreamBuffer { + max_bytes: Some(MAX_JSON_BODY_BYTES), + }); + drop(self.get_or_init_store().await); + } + + Ok(FilterAction::Continue) + } + + fn on_response_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + let armed = ctx + .get_filter_state::() + .is_some_and(|s| s.armed); + + if !armed { + return Ok(FilterAction::Release); + } + + if !end_of_stream { + return Ok(FilterAction::Continue); + } + + let Some(items) = extract_append_back_items(ctx, body) else { + return Ok(FilterAction::Continue); + }; + + let conv_id = items.conversation_id; + if let Err(e) = self.append_items_blocking(&items.tenant_id, &conv_id, ctx, items.all_items) { + warn!(error = %e, conversation_id = %conv_id, "conversation append-back failed"); + } + + Ok(FilterAction::Continue) + } +} + +/// Whether this request should trigger conversation append-back on +/// the response path. +fn should_append_back(ctx: &HttpFilterContext<'_>) -> bool { + ctx.get_metadata("openai_responses_format.has_conversation") == Some("true") + && ctx.get_metadata("responses.conversation_id").is_some() + && ctx.get_metadata("openai_responses_format.stream") != Some("true") + && ctx.get_metadata("openai_responses_format.background") != Some("true") +} + +// ----------------------------------------------------------------------------- +// Append-Back +// ----------------------------------------------------------------------------- + +/// Collected items for append-back persistence. +struct AppendBackItems { + /// Target conversation ID. + conversation_id: String, + /// Tenant scope for the conversation. + tenant_id: String, + /// Input + output items to persist. + all_items: Vec, +} + +/// Extract and merge input+output items from the response body for +/// append-back. Returns `None` when there is nothing to persist. +fn extract_append_back_items(ctx: &HttpFilterContext<'_>, body: &Option) -> Option { + let bytes = body.as_ref().filter(|b| !b.is_empty())?; + let conv_id = ctx.get_metadata("responses.conversation_id")?.to_owned(); + let tenant_id = ctx + .get_metadata(TENANT_METADATA_KEY) + .unwrap_or(DEFAULT_TENANT_ID) + .to_owned(); + + let all_items = merge_input_output_items(ctx, bytes)?; + + Some(AppendBackItems { + conversation_id: conv_id, + tenant_id, + all_items, + }) +} + +/// Parse the response body and combine request input items with +/// response output items. Returns `None` when both are empty. +fn merge_input_output_items(ctx: &HttpFilterContext<'_>, bytes: &[u8]) -> Option> { + let response_json: Value = match serde_json::from_slice(bytes) { + Ok(v) => v, + Err(e) => { + warn!(error = %e, "conversation append-back: invalid response JSON"); + return None; + }, + }; + + let status = response_json.get("status").and_then(Value::as_str).unwrap_or_default(); + if status != "completed" { + trace!(status, "conversation append-back skipped (response not completed)"); + return None; + } + + let output_items = response_json + .get("output") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + let input_items = ctx + .extensions + .get::() + .map(|state| state.input.clone()) + .unwrap_or_default(); + + if input_items.is_empty() && output_items.is_empty() { + return None; + } + + let mut all_items = input_items; + all_items.extend(output_items); + Some(all_items) +} + +/// Persist items and refresh the denormalized message cache. +async fn persist_items( + store: &dyn ConversationItemStore, + tenant_id: &str, + conversation_id: &str, + ctx: &HttpFilterContext<'_>, + items: Vec, +) -> Result<(), FilterError> { + let max_pos = store + .max_item_position(tenant_id, conversation_id) + .await + .map_err(|e| -> FilterError { Box::new(e) })?; + let start_position = max_pos.saturating_add(1); + let created_at = handlers::current_timestamp(ctx); + + let records = handlers::build_item_records(ctx, tenant_id, conversation_id, created_at, start_position, items) + .map_err(|e| -> FilterError { e.into() })?; + + if records.is_empty() { + return Ok(()); + } + + let count = records.len(); + store + .create_conversation_items(&records) + .await + .map_err(|e| -> FilterError { Box::new(e) })?; + + refresh_message_cache(store, tenant_id, conversation_id).await; + debug!( + conversation_id, + tenant_id, count, "conversation items appended from response" + ); + + Ok(()) +} + +/// Refresh the denormalized conversation message cache after item mutation. +async fn refresh_message_cache(store: &dyn ConversationItemStore, tenant_id: &str, conversation_id: &str) { + let record = ConversationRecord { + conversation_id: conversation_id.to_owned(), + tenant_id: tenant_id.to_owned(), + created_at: 0, + metadata: Value::Object(serde_json::Map::default()), + messages: Value::Null, + }; + if let Err(e) = handlers::sync_conversation_messages(store, record).await { + warn!(error = %e, conversation_id, "conversation message sync failed after append-back"); + } +} + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +/// Build a 500 rejection when the store is unavailable. +fn reject_store_unavailable() -> Rejection { + let body = serde_json::json!({ + "error": { + "message": "Internal server error.", + "type": "server_error", + } + }); + Rejection::status(500) + .with_header("content-type", "application/json") + .with_body(serde_json::to_vec(&body).unwrap_or_default()) +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow(clippy::unwrap_used, clippy::indexing_slicing, reason = "tests")] +mod tests { + use super::*; + + #[test] + fn reject_store_unavailable_returns_500_server_error() { + let rejection = reject_store_unavailable(); + assert_eq!(rejection.status, 500); + let body: Value = serde_json::from_slice(rejection.body.as_deref().unwrap()).unwrap(); + assert_eq!(body["error"]["type"], "server_error"); + assert_eq!(body["error"]["message"], "Internal server error."); + } + + #[test] + fn reject_store_unavailable_sets_json_content_type() { + let rejection = reject_store_unavailable(); + let ct = rejection + .headers + .iter() + .find(|(k, _)| k == "content-type") + .map(|(_, v)| v.as_str()); + assert_eq!(ct, Some("application/json"), "should set application/json content-type"); + } +} diff --git a/apis/src/openai/conversations/handlers.rs b/apis/src/openai/conversations/handlers.rs new file mode 100644 index 0000000000..552b353406 --- /dev/null +++ b/apis/src/openai/conversations/handlers.rs @@ -0,0 +1,1515 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Request handlers for the `/v1/conversations` endpoints. + +use std::{borrow::Cow, collections::HashSet, fmt, marker::PhantomData}; + +use percent_encoding::percent_decode_str; +use praxis_filter::{FilterAction, FilterError, HttpFilterContext, Rejection}; +use serde::{ + Deserializer as _, Serialize, + de::{DeserializeOwned, MapAccess, Visitor, value::MapAccessDeserializer}, +}; +use serde_json::{Map, Value}; +use tracing::debug; + +use super::{ + contracts::{ + ConversationItem, ConversationItemList, ConversationResource, CreateConversationItemsRequest, + CreateConversationRequest, DeletedConversationResource, IncludeField, IncludeFields, ItemOrder, + MAX_ITEMS_PER_REQUEST, Metadata, UpdateConversationRequest, + }, + validate::{MetadataError, validate_metadata}, +}; +use crate::{ + openai::responses::{ + DEFAULT_TENANT_ID, TENANT_METADATA_KEY, + store::{DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT}, + }, + store::{ConversationItemRecord, ConversationItemStore, ConversationRecord, StoreError}, +}; + +// ----------------------------------------------------------------------------- +// ItemListParams +// ----------------------------------------------------------------------------- + +/// Cursor pagination parameters for conversation item listing. +#[derive(Debug)] +struct ItemListParams { + /// Item ID to page after. + after_item_id: Option, + + /// Maximum number of items to return. + limit: u32, + + /// Result ordering. + order: ItemOrder, +} + +impl Default for ItemListParams { + fn default() -> Self { + Self { + after_item_id: None, + limit: DEFAULT_PAGE_LIMIT, + order: ItemOrder::default(), + } + } +} + + +// ----------------------------------------------------------------------------- +// Conversation Lifecycle +// ----------------------------------------------------------------------------- + +/// Handle `POST /v1/conversations` — create a new conversation. +#[expect(clippy::too_many_lines, reason = "sequential guard-clause pipeline")] +pub(super) async fn handle_create_conversation( + ctx: &HttpFilterContext<'_>, + store: &dyn ConversationItemStore, + body: &[u8], +) -> Result { + let tenant_id = ctx.get_metadata(TENANT_METADATA_KEY).unwrap_or(DEFAULT_TENANT_ID); + let input = if body.is_empty() { + CreateConversationRequest::default() + } else { + match parse_json_body(body) { + Ok(v) => v, + Err(msg) => return Ok(FilterAction::Reject(invalid_input_response(&msg)?)), + } + }; + let metadata = match input.metadata { + Some(metadata) => { + if let Err(e) = validate_metadata(metadata.as_value()) { + return Ok(FilterAction::Reject(invalid_input_response(&e.to_string())?)); + } + metadata.into_value() + }, + None => Value::Object(Map::new()), + }; + + let raw_id = ctx.id_generator.generate(ctx.time_source); + let conversation_id = format!("conv_{raw_id}"); + let created_at = current_timestamp(ctx); + let items = input.items.unwrap_or_default(); + if let Err(msg) = validate_item_count(items.len()) { + return Ok(FilterAction::Reject(invalid_input_response(&msg)?)); + } + let item_values = items.into_iter().map(ConversationItem::into_value); + let item_records = match build_item_records(ctx, tenant_id, &conversation_id, created_at, 1, item_values) { + Ok(records) => records, + Err(msg) => return Ok(FilterAction::Reject(invalid_input_response(&msg)?)), + }; + if let Some(item_id) = duplicate_item_id(&item_records) { + return Ok(FilterAction::Reject(invalid_input_response( + &duplicate_item_id_message(item_id), + )?)); + } + let messages = Value::Array(item_records.iter().map(|item| item.item_data.clone()).collect()); + + let record = ConversationRecord { + conversation_id: conversation_id.clone(), + tenant_id: tenant_id.to_owned(), + created_at, + metadata, + messages, + }; + + if let Err(e) = store.upsert_conversation(&record).await { + return Ok(FilterAction::Reject(store_error_response(&e)?)); + } + if !item_records.is_empty() + && let Err(e) = store.create_conversation_items(&item_records).await + { + return Ok(FilterAction::Reject(store_error_response(&e)?)); + } + debug!(conversation_id, tenant_id, "conversation created"); + + let body = conversation_response(record); + Ok(FilterAction::Reject(json_response(200, &body)?)) +} + +/// Handle `GET /v1/conversations/{id}` — retrieve a conversation. +pub(super) async fn handle_get_conversation( + ctx: &HttpFilterContext<'_>, + store: &dyn ConversationItemStore, + conversation_id: &str, +) -> Result { + let tenant_id = ctx.get_metadata(TENANT_METADATA_KEY).unwrap_or(DEFAULT_TENANT_ID); + + match store.get_conversation(tenant_id, conversation_id).await { + Ok(Some(record)) => { + let body = conversation_response(record); + Ok(FilterAction::Reject(json_response(200, &body)?)) + }, + Ok(None) => { + debug!(conversation_id, "conversation not found"); + Ok(FilterAction::Reject(not_found_response(&format!( + "No conversation found with id: '{conversation_id}'." + ))?)) + }, + Err(e) => Ok(FilterAction::Reject(store_error_response(&e)?)), + } +} + +/// Handle `POST /v1/conversations/{id}` — update a conversation. +#[expect(clippy::too_many_lines, reason = "sequential guard-clause pipeline")] +pub(super) async fn handle_update_conversation( + ctx: &HttpFilterContext<'_>, + store: &dyn ConversationItemStore, + conversation_id: &str, + body: &[u8], +) -> Result { + let tenant_id = ctx.get_metadata(TENANT_METADATA_KEY).unwrap_or(DEFAULT_TENANT_ID); + if body.is_empty() { + return Ok(FilterAction::Reject(invalid_input_response_with( + "Missing required parameter: 'metadata'.", + Some("missing_required_parameter"), + Some("metadata"), + )?)); + } + let input: UpdateConversationRequest = match parse_json_body(body) { + Ok(v) => v, + Err(msg) => { + return Ok(FilterAction::Reject(classify_update_error(&msg)?)); + }, + }; + if let Err(e) = validate_metadata(input.metadata.as_value()) { + return Ok(FilterAction::Reject(match e { + MetadataError::InvalidType(_) => { + invalid_input_response_with(&e.to_string(), Some("invalid_type"), Some("metadata"))? + }, + MetadataError::ConstraintViolation(_) => invalid_input_response(&e.to_string())?, + })); + } + + let existing = match store.get_conversation(tenant_id, conversation_id).await { + Ok(record) => record, + Err(e) => return Ok(FilterAction::Reject(store_error_response(&e)?)), + }; + let Some(existing) = existing else { + debug!(conversation_id, "conversation not found for update"); + return Ok(FilterAction::Reject(not_found_response(&format!( + "No conversation found with id: '{conversation_id}'." + ))?)); + }; + + let metadata = input.metadata.into_value(); + + let record = ConversationRecord { + conversation_id: conversation_id.to_owned(), + tenant_id: tenant_id.to_owned(), + created_at: existing.created_at, + metadata, + messages: existing.messages, + }; + + if let Err(e) = store.upsert_conversation(&record).await { + return Ok(FilterAction::Reject(store_error_response(&e)?)); + } + debug!(conversation_id, tenant_id, "conversation updated"); + + let body = conversation_response(record); + Ok(FilterAction::Reject(json_response(200, &body)?)) +} + +/// Handle `DELETE /v1/conversations/{id}` — delete a conversation. +/// +/// This intentionally deletes only the conversation record. The OpenAI +/// Conversations API specifies that deleting a conversation does not delete +/// its items; item cleanup belongs to item deletion or a separate retention +/// policy, not this endpoint. +pub(super) async fn handle_delete_conversation( + ctx: &HttpFilterContext<'_>, + store: &dyn ConversationItemStore, + conversation_id: &str, +) -> Result { + let tenant_id = ctx.get_metadata(TENANT_METADATA_KEY).unwrap_or(DEFAULT_TENANT_ID); + + match store.delete_conversation(tenant_id, conversation_id).await { + Ok(true) => { + debug!(conversation_id, tenant_id, "conversation deleted"); + let body = DeletedConversationResource::deleted(conversation_id); + Ok(FilterAction::Reject(json_response(200, &body)?)) + }, + Ok(false) => { + debug!(conversation_id, "conversation not found for delete"); + Ok(FilterAction::Reject(not_found_response(&format!( + "No conversation found with id: '{conversation_id}'." + ))?)) + }, + Err(e) => Ok(FilterAction::Reject(store_error_response(&e)?)), + } +} + +// ----------------------------------------------------------------------------- +// Conversation Items +// ----------------------------------------------------------------------------- + +/// Handle `POST /v1/conversations/{id}/items` — create items. +#[expect(clippy::too_many_lines, reason = "sequential guard-clause pipeline")] +#[expect(clippy::large_stack_frames, reason = "Pingora context types are large")] +pub(super) async fn handle_create_items( + ctx: &HttpFilterContext<'_>, + store: &dyn ConversationItemStore, + conversation_id: &str, + body: &[u8], +) -> Result { + let tenant_id = ctx.get_metadata(TENANT_METADATA_KEY).unwrap_or(DEFAULT_TENANT_ID); + let input: CreateConversationItemsRequest = match parse_json_body(body) { + Ok(v) => v, + Err(msg) => return Ok(FilterAction::Reject(invalid_input_response(&msg)?)), + }; + let includes = match parse_include_fields(ctx.request.uri.query()) { + Ok(includes) => includes, + Err(msg) => return Ok(FilterAction::Reject(invalid_input_response(&msg)?)), + }; + let existing = match store.get_conversation(tenant_id, conversation_id).await { + Ok(record) => record, + Err(e) => return Ok(FilterAction::Reject(store_error_response(&e)?)), + }; + let Some(existing) = existing else { + debug!(conversation_id, "conversation not found for item create"); + return Ok(FilterAction::Reject(not_found_response( + &conversation_not_found_message(conversation_id), + )?)); + }; + + let Some(items) = input.items else { + return Ok(FilterAction::Reject(invalid_input_response("'items' is required")?)); + }; + if let Err(msg) = validate_item_count(items.len()) { + return Ok(FilterAction::Reject(invalid_input_response(&msg)?)); + } + let item_values = items.into_iter().map(ConversationItem::into_value); + let start_position = match store.max_item_position(tenant_id, conversation_id).await { + Ok(pos) => pos.saturating_add(1), + Err(e) => return Ok(FilterAction::Reject(store_error_response(&e)?)), + }; + let created_at = current_timestamp(ctx); + let item_records = + match build_item_records(ctx, tenant_id, conversation_id, created_at, start_position, item_values) { + Ok(records) => records, + Err(msg) => return Ok(FilterAction::Reject(invalid_input_response(&msg)?)), + }; + if let Some(item_id) = duplicate_item_id(&item_records) { + return Ok(FilterAction::Reject(invalid_input_response( + &duplicate_item_id_message(item_id), + )?)); + } + let requested_ids: Vec<&str> = item_records.iter().map(|r| r.item_id.as_str()).collect(); + let already_present = match store + .get_existing_conversation_item_ids(tenant_id, conversation_id, &requested_ids) + .await + { + Ok(ids) => ids, + Err(e) => return Ok(FilterAction::Reject(store_error_response(&e)?)), + }; + if let Some(item_id) = already_present.first() { + return Ok(FilterAction::Reject(invalid_input_response( + &existing_item_id_message(item_id), + )?)); + } + + if let Err(e) = store.create_conversation_items(&item_records).await { + return Ok(FilterAction::Reject(store_error_response(&e)?)); + } + if let Err(e) = sync_conversation_messages(store, existing).await { + return Ok(FilterAction::Reject(store_error_response(&e)?)); + } + debug!( + conversation_id, + tenant_id, + count = item_records.len(), + "conversation items created" + ); + + let body = conversation_items_response(item_records, false, includes); + Ok(FilterAction::Reject(json_response(200, &body)?)) +} + +/// Handle `GET /v1/conversations/{id}/items` — list items. +#[expect(clippy::too_many_lines, reason = "sequential guard-clause pipeline")] +pub(super) async fn handle_list_items( + ctx: &HttpFilterContext<'_>, + store: &dyn ConversationItemStore, + conversation_id: &str, +) -> Result { + let tenant_id = ctx.get_metadata(TENANT_METADATA_KEY).unwrap_or(DEFAULT_TENANT_ID); + let includes = match parse_include_fields(ctx.request.uri.query()) { + Ok(includes) => includes, + Err(msg) => return Ok(FilterAction::Reject(invalid_input_response(&msg)?)), + }; + let params = match parse_item_list_params(ctx.request.uri.query()) { + Ok(params) => params, + Err(msg) => return Ok(FilterAction::Reject(invalid_input_response(&msg)?)), + }; + match store.get_conversation(tenant_id, conversation_id).await { + Ok(Some(_)) => {}, + Ok(None) => { + debug!(conversation_id, "conversation not found for item list"); + return Ok(FilterAction::Reject(not_found_response( + &conversation_not_found_message(conversation_id), + )?)); + }, + Err(e) => return Ok(FilterAction::Reject(store_error_response(&e)?)), + } + + let limit = params.limit; + let rows = match store + .list_conversation_items( + tenant_id, + conversation_id, + params.after_item_id.as_deref(), + limit.saturating_add(1), + params.order.is_ascending(), + ) + .await + { + Ok(rows) => rows, + Err(e) => return Ok(FilterAction::Reject(store_error_response(&e)?)), + }; + let take_limit = usize::try_from(limit).unwrap_or(usize::MAX); + let has_more = rows.len() > take_limit; + let data: Vec<_> = rows.into_iter().take(take_limit).collect(); + + let body = conversation_items_response(data, has_more, includes); + Ok(FilterAction::Reject(json_response(200, &body)?)) +} + +/// Handle `GET /v1/conversations/{id}/items/{item_id}` — retrieve one item. +pub(super) async fn handle_get_item( + ctx: &HttpFilterContext<'_>, + store: &dyn ConversationItemStore, + conversation_id: &str, + item_id: &str, +) -> Result { + let tenant_id = ctx.get_metadata(TENANT_METADATA_KEY).unwrap_or(DEFAULT_TENANT_ID); + let includes = match parse_include_fields(ctx.request.uri.query()) { + Ok(includes) => includes, + Err(msg) => return Ok(FilterAction::Reject(invalid_input_response(&msg)?)), + }; + let item_id = match decode_item_id_path_segment(item_id) { + Ok(id) => id, + Err(msg) => return Ok(FilterAction::Reject(invalid_input_response(&msg)?)), + }; + let item_id = item_id.as_ref(); + match store.get_conversation_item(tenant_id, conversation_id, item_id).await { + Ok(Some(record)) => { + let mut item_data = record.item_data; + project_conversation_item(&mut item_data, includes); + let item = ConversationItem::from_value(item_data); + Ok(FilterAction::Reject(json_response(200, &item)?)) + }, + Ok(None) => { + debug!(conversation_id, item_id, "conversation item not found"); + Ok(FilterAction::Reject(not_found_response(&item_not_found_message( + item_id, + ))?)) + }, + Err(e) => Ok(FilterAction::Reject(store_error_response(&e)?)), + } +} + +/// Handle `DELETE /v1/conversations/{id}/items/{item_id}` — delete one item. +#[expect(clippy::too_many_lines, reason = "sequential guard-clause pipeline")] +#[expect(clippy::cognitive_complexity, reason = "tracing macros inflate complexity")] +pub(super) async fn handle_delete_item( + ctx: &HttpFilterContext<'_>, + store: &dyn ConversationItemStore, + conversation_id: &str, + item_id: &str, +) -> Result { + let tenant_id = ctx.get_metadata(TENANT_METADATA_KEY).unwrap_or(DEFAULT_TENANT_ID); + let item_id = match decode_item_id_path_segment(item_id) { + Ok(id) => id, + Err(msg) => return Ok(FilterAction::Reject(invalid_input_response(&msg)?)), + }; + let item_id = item_id.as_ref(); + let existing = match store.get_conversation(tenant_id, conversation_id).await { + Ok(Some(record)) => record, + Ok(None) => { + debug!(conversation_id, item_id, "conversation not found for item delete"); + return Ok(FilterAction::Reject(not_found_response( + &conversation_not_found_message(conversation_id), + )?)); + }, + Err(e) => return Ok(FilterAction::Reject(store_error_response(&e)?)), + }; + + match store + .delete_conversation_item(tenant_id, conversation_id, item_id) + .await + { + Ok(true) => { + if let Err(e) = sync_conversation_messages(store, existing).await { + return Ok(FilterAction::Reject(store_error_response(&e)?)); + } + debug!(conversation_id, item_id, tenant_id, "conversation item deleted"); + match store.get_conversation(tenant_id, conversation_id).await { + Ok(Some(record)) => { + let body = conversation_response(record); + Ok(FilterAction::Reject(json_response(200, &body)?)) + }, + Ok(None) => Ok(FilterAction::Reject(not_found_response( + &conversation_not_found_message(conversation_id), + )?)), + Err(e) => Ok(FilterAction::Reject(store_error_response(&e)?)), + } + }, + Ok(false) => { + debug!(conversation_id, item_id, "conversation item not found for delete"); + Ok(FilterAction::Reject(not_found_response(&item_not_found_message( + item_id, + ))?)) + }, + Err(e) => Ok(FilterAction::Reject(store_error_response(&e)?)), + } +} + +// ----------------------------------------------------------------------------- +// JSON Helpers +// ----------------------------------------------------------------------------- + +/// Parse a request body into its runtime contract. +fn parse_json_body(body: &[u8]) -> Result { + let mut deserializer = serde_json::Deserializer::from_slice(body); + let value = deserializer + .deserialize_map(JsonObjectVisitor(PhantomData)) + .map_err(|e| format!("invalid JSON body: {e}"))?; + deserializer.end().map_err(|e| format!("invalid JSON body: {e}"))?; + Ok(value) +} + +/// Deserialize a typed contract only from a top-level JSON object. +struct JsonObjectVisitor(PhantomData); + +impl<'de, T: DeserializeOwned> Visitor<'de> for JsonObjectVisitor { + type Value = T; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON object") + } + + fn visit_map>(self, map: A) -> Result { + T::deserialize(MapAccessDeserializer::new(map)) + } +} + +/// Validate the shared item-count bound after deserialization. +fn validate_item_count(item_count: usize) -> Result<(), String> { + if item_count > MAX_ITEMS_PER_REQUEST { + return Err(format!("items may contain at most {MAX_ITEMS_PER_REQUEST} entries")); + } + Ok(()) +} + +/// Return the first duplicate item ID in a create request. +fn duplicate_item_id(items: &[ConversationItemRecord]) -> Option<&str> { + let mut seen = HashSet::new(); + for item in items { + if !seen.insert(item.item_id.as_str()) { + return Some(item.item_id.as_str()); + } + } + None +} + +/// Build store records for normalized conversation item JSON values. +#[expect(clippy::too_many_arguments, reason = "factoring into struct would add indirection")] +pub(super) fn build_item_records( + ctx: &HttpFilterContext<'_>, + tenant_id: &str, + conversation_id: &str, + created_at: i64, + start_position: i64, + items: impl IntoIterator, +) -> Result, String> { + items + .into_iter() + .enumerate() + .map(|(index, item)| { + let (item_id, item_data) = normalize_item(ctx, item)?; + let offset = i64::try_from(index).unwrap_or(i64::MAX); + Ok(ConversationItemRecord { + item_id, + tenant_id: tenant_id.to_owned(), + conversation_id: conversation_id.to_owned(), + item_data, + created_at, + position: start_position.saturating_add(offset), + }) + }) + .collect() +} + +/// Ensure an item is an object and has a usable ID. +pub(super) fn normalize_item(ctx: &HttpFilterContext<'_>, item: Value) -> Result<(String, Value), String> { + let Value::Object(mut map) = item else { + return Err("each item must be a JSON object".to_owned()); + }; + let item_id = match map.get("id") { + Some(Value::String(id)) if !id.is_empty() => id.clone(), + Some(Value::String(_)) => return Err("item id must not be empty".to_owned()), + Some(Value::Null) | None => generated_item_id(ctx), + Some(_) => return Err("item id must be a string".to_owned()), + }; + map.insert("id".to_owned(), Value::String(item_id.clone())); + normalize_message_item(&mut map)?; + Ok((item_id, Value::Object(map))) +} + +/// Normalize easy SDK message inputs into conversation message response objects. +fn normalize_message_item(map: &mut Map) -> Result<(), String> { + if map.get("type").and_then(Value::as_str) != Some("message") { + return Ok(()); + } + + let role = match map.get("role") { + Some(Value::String(role)) if !role.is_empty() => role.clone(), + Some(Value::String(_)) => return Err("message role must not be empty".to_owned()), + Some(_) => return Err("message role must be a string".to_owned()), + None => return Err("message role is required".to_owned()), + }; + + let content = map + .remove("content") + .ok_or_else(|| "message content is required".to_owned())?; + map.insert("content".to_owned(), normalize_message_content(&role, content)?); + map.entry("status".to_owned()) + .or_insert_with(|| Value::String("completed".to_owned())); + + Ok(()) +} + +/// Convert string message content to the list-form content returned by the API. +fn normalize_message_content(role: &str, content: Value) -> Result { + match content { + Value::String(text) => { + let content_item = if role == "assistant" { + serde_json::json!({ + "type": "output_text", + "text": text, + "annotations": [], + }) + } else { + serde_json::json!({ + "type": "input_text", + "text": text, + }) + }; + Ok(Value::Array(vec![content_item])) + }, + Value::Array(_) => Ok(content), + _ => Err("message content must be a string or array".to_owned()), + } +} + +/// Generate a conversation item ID. +pub(super) fn generated_item_id(ctx: &HttpFilterContext<'_>) -> String { + let raw_id = ctx.id_generator.generate(ctx.time_source); + format!("item_{raw_id}") +} + +/// Decode an item ID path segment the same way clients encode path parameters. +fn decode_item_id_path_segment(item_id: &str) -> Result, String> { + percent_decode_str(item_id) + .decode_utf8() + .map_err(|e| format!("item id path segment must be valid UTF-8: {e}")) +} + +/// Move a stored conversation into its public response contract. +fn conversation_response(record: ConversationRecord) -> ConversationResource { + ConversationResource::new( + record.conversation_id, + record.created_at, + Metadata::from_value(record.metadata), + ) +} + +/// Move item records into an `OpenAI` list response without copying item JSON. +fn conversation_items_response( + records: Vec, + has_more: bool, + includes: IncludeFields, +) -> ConversationItemList { + let record_count = records.len(); + let mut first_id = String::new(); + let mut last_id = String::new(); + let mut data = Vec::with_capacity(record_count); + + for (index, record) in records.into_iter().enumerate() { + if record_count == 1 { + first_id.clone_from(&record.item_id); + last_id = record.item_id; + } else if index == 0 { + first_id = record.item_id; + } else if index + 1 == record_count { + last_id = record.item_id; + } + let mut item_data = record.item_data; + project_conversation_item(&mut item_data, includes); + data.push(ConversationItem::from_value(item_data)); + } + + ConversationItemList::new(data, has_more, first_id, last_id) +} + +/// Remove optional fields that were not requested through `include`. +/// +/// Projection changes only the response-owned value after the complete item +/// representation has crossed the storage boundary. +fn project_conversation_item(item: &mut Value, includes: IncludeFields) { + let Some(object) = item.as_object_mut() else { + return; + }; + match projection_kind(object) { + ProjectionKind::Reasoning => remove_unless_included( + object, + "encrypted_content", + includes.contains(IncludeField::ReasoningEncryptedContent), + ), + ProjectionKind::FileSearch => remove_unless_included( + object, + "results", + includes.contains(IncludeField::FileSearchCallResults), + ), + ProjectionKind::WebSearch => project_web_search_fields(object, includes), + ProjectionKind::CodeInterpreter => remove_unless_included( + object, + "outputs", + includes.contains(IncludeField::CodeInterpreterCallOutputs), + ), + ProjectionKind::ComputerOutput => project_computer_output_fields(object, includes), + ProjectionKind::Message => project_message_fields(object, includes), + ProjectionKind::Other => {}, + } +} + +/// Item variants with fields controlled by `include`. +#[derive(Clone, Copy)] +enum ProjectionKind { + /// Reasoning item with optional encrypted content. + Reasoning, + /// File-search call with optional results. + FileSearch, + /// Web-search call with optional results and sources. + WebSearch, + /// Code-interpreter call with optional outputs. + CodeInterpreter, + /// Computer-call output with an optional image URL. + ComputerOutput, + /// Message with optional fields in typed content parts. + Message, + /// Item without any fields controlled by `include`. + Other, +} + +/// Classify an item without retaining a borrow into the mutable object. +fn projection_kind(object: &Map) -> ProjectionKind { + match object.get("type").and_then(Value::as_str) { + Some("reasoning") => ProjectionKind::Reasoning, + Some("file_search_call") => ProjectionKind::FileSearch, + Some("web_search_call") => ProjectionKind::WebSearch, + Some("code_interpreter_call") => ProjectionKind::CodeInterpreter, + Some("computer_call_output") => ProjectionKind::ComputerOutput, + Some("message") => ProjectionKind::Message, + _ => ProjectionKind::Other, + } +} + +/// Remove one top-level field unless it was explicitly requested. +fn remove_unless_included(object: &mut Map, field: &str, included: bool) { + if !included { + object.remove(field); + } +} + +/// Project web-search fields controlled by independent include values. +fn project_web_search_fields(object: &mut Map, includes: IncludeFields) { + remove_unless_included(object, "results", includes.contains(IncludeField::WebSearchCallResults)); + if !includes.contains(IncludeField::WebSearchCallActionSources) + && let Some(action) = object.get_mut("action").and_then(Value::as_object_mut) + { + action.remove("sources"); + } +} + +/// Project the nested image URL from a computer-call output. +fn project_computer_output_fields(object: &mut Map, includes: IncludeFields) { + if !includes.contains(IncludeField::ComputerCallOutputImageUrl) + && let Some(output) = object.get_mut("output").and_then(Value::as_object_mut) + { + output.remove("image_url"); + } +} + +/// Project optional fields from typed message content parts. +fn project_message_fields(object: &mut Map, includes: IncludeFields) { + let Some(content) = object.get_mut("content").and_then(Value::as_array_mut) else { + return; + }; + for part in content { + let Some(part) = part.as_object_mut() else { + continue; + }; + if part.get("type").and_then(Value::as_str) == Some("input_image") + && !includes.contains(IncludeField::MessageInputImageImageUrl) + { + part.remove("image_url"); + } else if part.get("type").and_then(Value::as_str) == Some("output_text") + && !includes.contains(IncludeField::MessageOutputTextLogprobs) + { + part.remove("logprobs"); + } + } +} + +/// Parse both official SDK encodings for the array-valued `include` query: +/// repeated `include=value` pairs and bracketed `include[]=value` pairs. +fn parse_include_fields(query: Option<&str>) -> Result { + let Some(query) = query else { + return Ok(IncludeFields::default()); + }; + + let mut includes = IncludeFields::default(); + for pair in query.split('&') { + let Some((raw_key, raw_value)) = pair.split_once('=') else { + let key = decode_query_component_strict(pair)?; + if matches!(key.as_ref(), "include" | "include[]") { + return Err("'include' query parameter requires a value".to_owned()); + } + continue; + }; + let key = decode_query_component_strict(raw_key)?; + if !matches!(key.as_ref(), "include" | "include[]") { + continue; + } + let value = decode_query_component_strict(raw_value)?; + let field = IncludeField::parse(&value).ok_or_else(|| format!("unsupported include value: '{value}'"))?; + includes.insert(field); + } + Ok(includes) +} + +/// Strictly decode one query component, including form-style `+` spaces. +fn decode_query_component_strict(value: &str) -> Result, String> { + if value.contains('+') { + let normalized = value.replace('+', " "); + return percent_decode_str(&normalized) + .decode_utf8() + .map(|decoded| Cow::Owned(decoded.into_owned())) + .map_err(|e| format!("query parameter must be valid UTF-8: {e}")); + } + percent_decode_str(value) + .decode_utf8() + .map_err(|e| format!("query parameter must be valid UTF-8: {e}")) +} + +/// Parse and validate cursor-based pagination parameters from a query string. +#[expect( + clippy::too_many_lines, + reason = "query parser benefits from single-function locality" +)] +fn parse_item_list_params(query: Option<&str>) -> Result { + let Some(qs) = query else { + return Ok(ItemListParams::default()); + }; + + let mut params = ItemListParams::default(); + let mut seen_limit = false; + let mut seen_order = false; + let mut seen_after = false; + + for pair in qs.split('&') { + if pair.is_empty() { + continue; + } + let Some((raw_key, raw_value)) = pair.split_once('=') else { + let key = decode_query_component_strict(pair)?; + if matches!(key.as_ref(), "include" | "include[]") { + continue; + } + if matches!(key.as_ref(), "limit" | "order" | "after") { + return Err(format!("Missing value for query parameter '{key}'.")); + } + return Err(format!("Unknown query parameter: '{key}'.")); + }; + let key = decode_query_component_strict(raw_key)?; + match key.as_ref() { + "after" => { + if seen_after { + return Err("Duplicate query parameter: 'after'.".to_owned()); + } + seen_after = true; + let value = decode_query_component_strict(raw_value)?; + if value.is_empty() { + return Err("Invalid value for 'after': cursor must not be empty.".to_owned()); + } + params.after_item_id = Some(value.into_owned()); + }, + "limit" => { + if seen_limit { + return Err("Duplicate query parameter: 'limit'.".to_owned()); + } + seen_limit = true; + let value = decode_query_component_strict(raw_value)?; + params.limit = parse_limit(&value)?; + }, + "order" => { + if seen_order { + return Err("Duplicate query parameter: 'order'.".to_owned()); + } + seen_order = true; + let value = decode_query_component_strict(raw_value)?; + params.order = parse_order(&value)?; + }, + "include" | "include[]" => {}, + _ => return Err(format!("Unknown query parameter: '{key}'.")), + } + } + Ok(params) +} + +/// Parse and validate a `limit` query-string value. +fn parse_limit(value: &str) -> Result { + let n: u32 = value + .parse() + .map_err(|_e| format!("Invalid value for 'limit': '{value}' is not a valid integer."))?; + if n > MAX_PAGE_LIMIT { + return Err(format!( + "Invalid value for 'limit': must be between 0 and {MAX_PAGE_LIMIT}, got {n}." + )); + } + Ok(n) +} + +/// Parse and validate an `order` query-string value. +fn parse_order(value: &str) -> Result { + match value { + "asc" => Ok(ItemOrder::Asc), + "desc" => Ok(ItemOrder::Desc), + _ => Err(format!( + "Invalid value for 'order': must be 'asc' or 'desc', got '{value}'." + )), + } +} + +/// Return the current Unix timestamp as an `i64`. +pub(super) fn current_timestamp(ctx: &HttpFilterContext<'_>) -> i64 { + i64::try_from(ctx.time_source.now().as_secs()).unwrap_or(i64::MAX) +} + +/// Build a JSON response with the given status code. +fn json_response(status: u16, body: &T) -> Result { + let bytes = serde_json::to_vec(body) + .map_err(|e| FilterError::from(format!("openai_conversations: serialize failed: {e}")))?; + Ok(Rejection::status(status) + .with_header("content-type", "application/json") + .with_body(bytes)) +} + +/// Build a 400 JSON response for invalid input. +fn invalid_input_response(message: &str) -> Result { + json_response( + 400, + &serde_json::json!({ + "error": { + "message": message, + "type": "invalid_request_error", + } + }), + ) +} + +/// Build a 400 JSON response with optional OpenAI error code and parameter. +fn invalid_input_response_with( + message: &str, + code: Option<&str>, + param: Option<&str>, +) -> Result { + json_response( + 400, + &serde_json::json!({ + "error": { + "message": message, + "type": "invalid_request_error", + "code": code, + "param": param, + } + }), + ) +} + +/// Map update deserialization errors to OpenAI-style error codes. +fn classify_update_error(msg: &str) -> Result { + if msg.contains("missing field") && msg.contains("metadata") { + return invalid_input_response_with( + "Missing required parameter: 'metadata'.", + Some("missing_required_parameter"), + Some("metadata"), + ); + } + if msg.contains("metadata must be an object") { + return invalid_input_response_with( + "Invalid type for 'metadata': expected an object.", + Some("invalid_type"), + Some("metadata"), + ); + } + invalid_input_response(msg) +} + +/// Build a 404 JSON response. +fn not_found_response(message: &str) -> Result { + json_response( + 404, + &serde_json::json!({ + "error": { + "message": message, + "type": "invalid_request_error", + } + }), + ) +} + +/// Build the standard conversation not-found message. +fn conversation_not_found_message(conversation_id: &str) -> String { + format!("No conversation found with id: '{conversation_id}'.") +} + +/// Build the standard item not-found message. +fn item_not_found_message(item_id: &str) -> String { + format!("No conversation item found with id: '{item_id}'.") +} + +/// Build a duplicate-item client error message. +fn duplicate_item_id_message(item_id: &str) -> String { + format!("duplicate item id in request: '{item_id}'") +} + +/// Build an existing-item client error message. +fn existing_item_id_message(item_id: &str) -> String { + format!("item id already exists in conversation: '{item_id}'") +} + +/// Build a 500 JSON response from a store error. +fn store_error_response(error: &StoreError) -> Result { + let message = match error { + StoreError::InvalidInput(msg) => { + return json_response( + 400, + &serde_json::json!({ + "error": { + "message": msg, + "type": "invalid_request_error", + } + }), + ); + }, + _ => "Internal server error.", + }; + json_response( + 500, + &serde_json::json!({ + "error": { + "message": message, + "type": "server_error", + } + }), + ) +} + +/// Refresh the denormalized conversation message cache from item rows. +/// +/// This currently re-reads all items on every mutation. Conversations are not +/// assumed to be small: the OpenAI contract has no cumulative item or byte +/// ceiling. Replace this full-history rebuild with incremental processing; do +/// not add a non-spec conversation limit as a workaround. Tracked in #532. +pub(super) async fn sync_conversation_messages( + store: &dyn ConversationItemStore, + record: ConversationRecord, +) -> Result<(), StoreError> { + let messages = + Value::Array(collect_conversation_messages(store, &record.tenant_id, &record.conversation_id).await?); + let updated = store + .update_conversation_messages(&record.tenant_id, &record.conversation_id, &messages) + .await?; + if updated { + Ok(()) + } else { + Err(StoreError::Database(format!( + "conversation disappeared during message sync: {}", + record.conversation_id + ))) + } +} + +/// Collect all item JSON values for a conversation in ascending order. +async fn collect_conversation_messages( + store: &dyn ConversationItemStore, + tenant_id: &str, + conversation_id: &str, +) -> Result, StoreError> { + let mut after = None; + let mut messages = Vec::new(); + loop { + let rows = store + .list_conversation_items(tenant_id, conversation_id, after.as_deref(), MAX_PAGE_LIMIT, true) + .await?; + if rows.is_empty() { + break; + } + after = rows.last().map(|record| record.item_id.clone()); + let row_count = rows.len(); + messages.extend(rows.into_iter().map(|record| record.item_data)); + if row_count < usize::try_from(MAX_PAGE_LIMIT).unwrap_or(usize::MAX) { + break; + } + } + Ok(messages) +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, reason = "tests")] +mod tests { + use super::*; + + // ------------------------------------------------------------------------- + // store_error_response + // ------------------------------------------------------------------------- + + #[test] + fn store_error_invalid_input_returns_400() { + let error = StoreError::InvalidInput("bad cursor".to_owned()); + let rejection = store_error_response(&error).unwrap(); + assert_eq!(rejection.status, 400); + let body: Value = serde_json::from_slice(rejection.body.as_deref().unwrap()).unwrap(); + assert_eq!(body["error"]["type"], "invalid_request_error"); + assert_eq!(body["error"]["message"], "bad cursor"); + } + + #[test] + fn store_error_database_returns_500() { + let error = StoreError::Database("connection lost".to_owned()); + let rejection = store_error_response(&error).unwrap(); + assert_eq!(rejection.status, 500); + let body: Value = serde_json::from_slice(rejection.body.as_deref().unwrap()).unwrap(); + assert_eq!(body["error"]["type"], "server_error"); + assert_eq!(body["error"]["message"], "Internal server error."); + } + + // ------------------------------------------------------------------------- + // parse_item_list_params + // ------------------------------------------------------------------------- + + #[test] + fn parse_params_unknown_key_only_rejected() { + let err = parse_item_list_params(Some("noseparator&limit=5")).unwrap_err(); + assert!( + err.contains("Unknown query parameter"), + "unknown key-only component should be rejected: {err}" + ); + } + + #[test] + fn parse_params_unknown_order_rejected() { + let err = parse_item_list_params(Some("order=random")).unwrap_err(); + assert!( + err.contains("must be 'asc' or 'desc'"), + "unknown order should be rejected: {err}" + ); + } + + #[test] + fn parse_params_non_numeric_limit_rejected() { + let err = parse_item_list_params(Some("limit=abc")).unwrap_err(); + assert!( + err.contains("not a valid integer"), + "non-numeric limit should be rejected: {err}" + ); + } + + // ------------------------------------------------------------------------- + // decode_item_id_path_segment + // ------------------------------------------------------------------------- + + #[test] + fn decode_item_id_path_segment_invalid_utf8_returns_error() { + let result = decode_item_id_path_segment("%FF%FE"); + assert!(result.is_err(), "invalid UTF-8 should return error"); + assert!( + result.unwrap_err().contains("valid UTF-8"), + "error should mention UTF-8 requirement" + ); + } + + // ------------------------------------------------------------------------- + // store_error_response — catch-all variants + // ------------------------------------------------------------------------- + + #[test] + fn store_error_serialization_returns_500() { + let error = StoreError::Serialization("corrupt data".to_owned()); + let rejection = store_error_response(&error).unwrap(); + assert_eq!(rejection.status, 500); + let body: Value = serde_json::from_slice(rejection.body.as_deref().unwrap()).unwrap(); + assert_eq!(body["error"]["type"], "server_error"); + assert_eq!(body["error"]["message"], "Internal server error."); + } + + #[test] + fn store_error_unavailable_returns_500() { + let error = StoreError::Unavailable("not connected".to_owned()); + let rejection = store_error_response(&error).unwrap(); + assert_eq!(rejection.status, 500); + let body: Value = serde_json::from_slice(rejection.body.as_deref().unwrap()).unwrap(); + assert_eq!(body["error"]["type"], "server_error"); + assert_eq!(body["error"]["message"], "Internal server error."); + } + + // ------------------------------------------------------------------------- + // parse_item_list_params — additional edges + // ------------------------------------------------------------------------- + + #[test] + fn parse_params_none_query_returns_defaults() { + let params = parse_item_list_params(None).unwrap(); + assert_eq!(params.limit, DEFAULT_PAGE_LIMIT); + assert!(!params.order.is_ascending()); + assert!(params.after_item_id.is_none()); + } + + #[test] + fn parse_params_valid_after_parameter() { + let params = parse_item_list_params(Some("after=item_abc123&limit=10")).unwrap(); + assert_eq!(params.after_item_id.as_deref(), Some("item_abc123")); + assert_eq!(params.limit, 10); + } + + #[test] + fn parse_params_asc_order() { + let params = parse_item_list_params(Some("order=asc")).unwrap(); + assert!(params.order.is_ascending(), "order=asc should set ascending"); + } + + #[test] + fn parse_params_desc_order() { + let params = parse_item_list_params(Some("order=desc")).unwrap(); + assert!(!params.order.is_ascending(), "order=desc should set descending"); + } + + #[test] + fn parse_params_negative_limit_rejected() { + let err = parse_item_list_params(Some("limit=-5")).unwrap_err(); + assert!( + err.contains("not a valid integer"), + "negative limit should be rejected: {err}" + ); + } + + #[test] + fn parse_params_percent_encoded_after() { + let params = parse_item_list_params(Some("after=item%20with+space")).unwrap(); + assert_eq!( + params.after_item_id.as_deref(), + Some("item with space"), + "percent-encoded and plus-encoded values should decode" + ); + } + + #[test] + fn parse_params_limit_zero_accepted() { + let params = parse_item_list_params(Some("limit=0")).unwrap(); + assert_eq!(params.limit, 0, "limit=0 should be accepted"); + } + + #[test] + fn parse_params_limit_above_max_rejected() { + let err = parse_item_list_params(Some(&format!("limit={}", MAX_PAGE_LIMIT + 1))).unwrap_err(); + assert!( + err.contains("must be between 0 and"), + "limit above max should be rejected: {err}" + ); + } + + #[test] + fn parse_params_limit_at_max_accepted() { + let params = parse_item_list_params(Some(&format!("limit={MAX_PAGE_LIMIT}"))).unwrap(); + assert_eq!( + params.limit, MAX_PAGE_LIMIT, + "limit at MAX_PAGE_LIMIT should be accepted" + ); + } + + #[test] + fn parse_params_duplicate_limit_rejected() { + let err = parse_item_list_params(Some("limit=5&limit=10")).unwrap_err(); + assert!( + err.contains("Duplicate query parameter: 'limit'"), + "duplicate limit should be rejected: {err}" + ); + } + + #[test] + fn parse_params_duplicate_order_rejected() { + let err = parse_item_list_params(Some("order=asc&order=desc")).unwrap_err(); + assert!( + err.contains("Duplicate query parameter: 'order'"), + "duplicate order should be rejected: {err}" + ); + } + + #[test] + fn parse_params_duplicate_after_rejected() { + let err = parse_item_list_params(Some("after=a&after=b")).unwrap_err(); + assert!( + err.contains("Duplicate query parameter: 'after'"), + "duplicate after should be rejected: {err}" + ); + } + + #[test] + fn parse_params_unknown_param_rejected() { + let err = parse_item_list_params(Some("foo=bar")).unwrap_err(); + assert!( + err.contains("Unknown query parameter: 'foo'"), + "unknown parameter should be rejected: {err}" + ); + } + + #[test] + fn parse_params_known_key_only_rejected() { + let err = parse_item_list_params(Some("limit")).unwrap_err(); + assert!( + err.contains("Missing value for query parameter 'limit'"), + "key-only known param should be rejected: {err}" + ); + } + + #[test] + fn parse_params_empty_after_rejected() { + let err = parse_item_list_params(Some("after=")).unwrap_err(); + assert!( + err.contains("cursor must not be empty"), + "empty after should be rejected: {err}" + ); + } + + #[test] + fn parse_params_invalid_utf8_key_rejected() { + let err = parse_item_list_params(Some("%FF=1")).unwrap_err(); + assert!( + err.contains("valid UTF-8"), + "invalid UTF-8 key should be rejected: {err}" + ); + } + + #[test] + fn parse_params_invalid_utf8_value_rejected() { + let err = parse_item_list_params(Some("limit=%FF")).unwrap_err(); + assert!( + err.contains("valid UTF-8"), + "invalid UTF-8 value should be rejected: {err}" + ); + } + + #[test] + fn parse_params_repeated_include_allowed() { + let params = parse_item_list_params(Some( + "include=reasoning.encrypted_content&include=message.output_text.logprobs", + )) + .unwrap(); + assert_eq!( + params.limit, DEFAULT_PAGE_LIMIT, + "repeated include should not affect other defaults" + ); + } + + #[test] + fn parse_params_empty_components_ignored() { + let params = parse_item_list_params(Some("&&limit=5&")).unwrap(); + assert_eq!(params.limit, 5, "empty components should be silently ignored"); + } + + #[test] + fn parse_params_encoded_duplicate_key_rejected() { + let err = parse_item_list_params(Some("limit=5&%6Cimit=10")).unwrap_err(); + assert!( + err.contains("Duplicate query parameter: 'limit'"), + "encoded duplicate key should be rejected: {err}" + ); + } + + // ------------------------------------------------------------------------- + // include parsing and projection + // ------------------------------------------------------------------------- + + #[test] + fn parse_include_fields_supports_python_and_node_sdk_encodings() { + let includes = parse_include_fields(Some( + "include=reasoning.encrypted_content&include%5B%5D=message.output_text.logprobs", + )) + .unwrap(); + + assert!( + includes.contains(IncludeField::ReasoningEncryptedContent), + "repeated-key encoding should parse reasoning encrypted content" + ); + assert!( + includes.contains(IncludeField::MessageOutputTextLogprobs), + "bracket encoding should parse output-text log probabilities" + ); + assert!( + !includes.contains(IncludeField::FileSearchCallResults), + "unrequested include values must remain absent" + ); + } + + #[test] + fn parse_include_fields_rejects_unknown_or_malformed_values() { + let unknown = parse_include_fields(Some("include=future.secret_field")).unwrap_err(); + assert!( + unknown.contains("unsupported include value"), + "unknown values should produce an unsupported-value diagnostic: {unknown}" + ); + + let missing = parse_include_fields(Some("include")).unwrap_err(); + assert!( + missing.contains("requires a value"), + "missing include values should identify the required value: {missing}" + ); + + let invalid_utf8 = parse_include_fields(Some("include=%FF")).unwrap_err(); + assert!( + invalid_utf8.contains("valid UTF-8"), + "invalid encoding should identify the UTF-8 requirement: {invalid_utf8}" + ); + } + + #[test] + #[expect(clippy::too_many_lines, reason = "one fixture covers every include projection path")] + fn projection_removes_every_unrequested_include_gated_field() { + let mut items = vec![ + serde_json::json!({ + "type": "reasoning", + "encrypted_content": "secret", + "summary": [] + }), + serde_json::json!({ + "type": "file_search_call", + "results": [{"file_id": "file_1"}], + "status": "completed" + }), + serde_json::json!({ + "type": "web_search_call", + "results": [{"url": "https://example.com"}], + "action": { + "type": "search", + "sources": [{"type": "url", "url": "https://example.com"}] + } + }), + serde_json::json!({ + "type": "code_interpreter_call", + "outputs": [{"type": "logs", "logs": "done"}], + "status": "completed" + }), + serde_json::json!({ + "type": "computer_call_output", + "output": {"type": "computer_screenshot", "image_url": "data:image/png;base64,AA=="} + }), + serde_json::json!({ + "type": "message", + "content": [ + {"type": "input_image", "image_url": "https://example.com/image.png", "detail": "auto"}, + {"type": "output_text", "text": "answer", "annotations": [], "logprobs": []}, + {"type": "input_text", "text": "keep me"} + ] + }), + ]; + + for item in &mut items { + project_conversation_item(item, IncludeFields::default()); + } + + assert!( + items[0].get("encrypted_content").is_none(), + "reasoning encrypted content should be omitted" + ); + assert!( + items[1].get("results").is_none(), + "file-search results should be omitted" + ); + assert!( + items[2].get("results").is_none(), + "web-search results should be omitted" + ); + assert!( + items[2]["action"].get("sources").is_none(), + "web-search action sources should be omitted" + ); + assert!( + items[3].get("outputs").is_none(), + "code-interpreter outputs should be omitted" + ); + assert!( + items[4]["output"].get("image_url").is_none(), + "computer-output image URLs should be omitted" + ); + assert!( + items[5]["content"][0].get("image_url").is_none(), + "message input-image URLs should be omitted" + ); + assert!( + items[5]["content"][1].get("logprobs").is_none(), + "message output-text log probabilities should be omitted" + ); + assert_eq!(items[5]["content"][2]["text"], "keep me"); + } + + #[test] + fn projection_preserves_every_requested_include_gated_field() { + let mut includes = IncludeFields::default(); + for field in [ + IncludeField::FileSearchCallResults, + IncludeField::WebSearchCallResults, + IncludeField::WebSearchCallActionSources, + IncludeField::MessageInputImageImageUrl, + IncludeField::ComputerCallOutputImageUrl, + IncludeField::CodeInterpreterCallOutputs, + IncludeField::ReasoningEncryptedContent, + IncludeField::MessageOutputTextLogprobs, + ] { + includes.insert(field); + } + let original = serde_json::json!({ + "type": "message", + "content": [ + {"type": "input_image", "image_url": "https://example.com/image.png"}, + {"type": "output_text", "logprobs": [{"token": "x"}]} + ] + }); + let mut projected = original.clone(); + + project_conversation_item(&mut projected, includes); + + assert_eq!(projected, original); + } + + // ------------------------------------------------------------------------- + // decode_item_id_path_segment — additional cases + // ------------------------------------------------------------------------- + + #[test] + fn decode_item_id_plain_ascii_passes_through() { + let result = decode_item_id_path_segment("item_abc123").unwrap(); + assert_eq!(result.as_ref(), "item_abc123"); + } + + #[test] + fn decode_item_id_percent_encoded_ascii() { + let result = decode_item_id_path_segment("item%5Fabc").unwrap(); + assert_eq!(result.as_ref(), "item_abc", "percent-encoded underscore should decode"); + } +} diff --git a/apis/src/openai/conversations/mod.rs b/apis/src/openai/conversations/mod.rs new file mode 100644 index 0000000000..0fae7a25ca --- /dev/null +++ b/apis/src/openai/conversations/mod.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Conversations filter: local `/v1/conversations` endpoints. +//! +//! Handles all 8 conversation and item CRUD operations locally +//! via `FilterAction::Reject`, backed by the `ConversationItemStore` +//! trait. Requests never reach upstream. + +mod config; +mod contracts; +mod filter; +mod handlers; +pub mod openapi; +mod routes; +mod validate; + +pub use filter::OpenaiConversationsFilter; +pub use openapi::implementation_openapi_json; +pub use routes::{ConversationOperation, ConversationOperationSpec, operation_specs}; + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + clippy::print_stdout, + clippy::too_many_lines, + reason = "tests" +)] +mod tests; diff --git a/apis/src/openai/conversations/openapi.rs b/apis/src/openai/conversations/openapi.rs new file mode 100644 index 0000000000..bfab2075cb --- /dev/null +++ b/apis/src/openai/conversations/openapi.rs @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Generated `OpenAPI` description for locally owned Conversations operations. + +use utoipa::openapi::OpenApi; + +use super::routes::operation_specs; +use crate::openai::operation; + +/// Generate the local Conversations implementation `OpenAPI` document as +/// pretty JSON. +/// +/// # Errors +/// +/// Returns an error if the generated `OpenAPI` document cannot be serialized +/// as JSON. +pub fn implementation_openapi_json() -> Result { + serde_json::to_string_pretty(&implementation_openapi()) +} + +/// Build the local Conversations implementation document from the operation +/// registry and its bound runtime contract types. +fn implementation_openapi() -> OpenApi { + operation::implementation_openapi( + "Praxis AI OpenAI Conversations implementation", + "0.1.0", + "Conversations", + operation_specs().iter().map(|spec| &spec.definition), + ) +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow(clippy::unwrap_used, reason = "tests")] +mod tests { + use std::collections::BTreeSet; + + use serde_json::Value; + + use super::*; + + #[test] + fn generated_operations_come_from_owned_registry_entries() { + let openapi = implementation_openapi(); + let generated = openapi + .paths + .paths + .iter() + .flat_map(|(path, item)| { + [ + ("GET", item.get.as_ref()), + ("POST", item.post.as_ref()), + ("DELETE", item.delete.as_ref()), + ] + .into_iter() + .filter_map(move |(method, operation)| operation.map(|_| (method, path.as_str()))) + }) + .collect::>(); + let expected = operation_specs() + .iter() + .filter(|spec| spec.owned_contract().is_some() && spec.mode.owns_contract()) + .map(|spec| (spec.method.as_str(), spec.spec_path)) + .collect::>(); + + assert_eq!(generated, expected); + } + + #[test] + fn every_generated_component_reference_resolves() { + let document = serde_json::to_value(implementation_openapi()).unwrap(); + let mut references = Vec::new(); + collect_component_references(&document, &mut references); + assert!(!references.is_empty()); + + for reference in references { + let pointer = reference.strip_prefix('#').unwrap(); + assert!( + document.pointer(pointer).is_some(), + "generated OpenAPI reference does not resolve: {reference}" + ); + } + } + + #[test] + fn generated_components_preserve_runtime_contract_constraints() { + let document = serde_json::to_value(implementation_openapi()).unwrap(); + + assert_eq!( + document.pointer("/components/schemas/CreateConversationItemsRequest/properties/items/type"), + Some(&Value::String("array".to_owned())) + ); + assert_eq!( + document.pointer("/components/schemas/ConversationItem/type"), + Some(&Value::String("object".to_owned())) + ); + assert_eq!( + document.pointer("/components/schemas/ConversationResource/properties/created_at/format"), + Some(&Value::String("unixtime".to_owned())) + ); + assert!( + document + .pointer("/components/schemas/ConversationItemList/properties/object/default") + .is_none(), + "the list discriminator has no runtime defaulting behavior" + ); + assert!( + document.pointer("/paths/~1conversations/post/parameters").is_none(), + "parameterless operations should omit the OpenAPI parameters field" + ); + } + + #[test] + #[expect(clippy::too_many_lines, reason = "checks all three operations and eight enum values")] + fn generated_item_operations_share_the_official_include_enum() { + let document = serde_json::to_value(implementation_openapi()).unwrap(); + let expected = [ + "file_search_call.results", + "web_search_call.results", + "web_search_call.action.sources", + "message.input_image.image_url", + "computer_call_output.output.image_url", + "code_interpreter_call.outputs", + "reasoning.encrypted_content", + "message.output_text.logprobs", + ] + .map(Value::from) + .to_vec(); + for pointer in [ + "/paths/~1conversations~1{conversation_id}~1items/post/parameters", + "/paths/~1conversations~1{conversation_id}~1items/get/parameters", + "/paths/~1conversations~1{conversation_id}~1items~1{item_id}/get/parameters", + ] { + let parameters = document.pointer(pointer).and_then(Value::as_array).unwrap(); + let include = parameters + .iter() + .find(|parameter| parameter.get("name") == Some(&Value::String("include".to_owned()))) + .unwrap(); + assert_eq!(include["in"], "query"); + assert_eq!(include["required"], false); + assert_eq!( + include.pointer("/schema/type"), + Some(&Value::String("array".to_owned())) + ); + assert_eq!( + include.pointer("/schema/items/enum"), + Some(&Value::Array(expected.clone())) + ); + } + } + + #[test] + fn generated_create_request_contract_matches_runtime() { + let document = serde_json::to_value(implementation_openapi()).unwrap(); + + assert_eq!( + document.pointer("/components/schemas/CreateConversationRequest/properties/items/anyOf/0/type"), + Some(&Value::String("array".to_owned())) + ); + assert_eq!( + document.pointer("/components/schemas/CreateConversationRequest/properties/items/anyOf/1/type"), + Some(&Value::String("null".to_owned())) + ); + assert_eq!( + document.pointer("/components/schemas/CreateConversationRequest/properties/items/anyOf/0/maxItems"), + Some(&Value::Number(serde_json::Number::from(20_u64))), + "items array should preserve the 20-item bound" + ); + assert!( + document + .pointer("/paths/~1conversations/post/requestBody/required") + .is_none_or(|required| required == &Value::Bool(false)), + "create request body should be optional" + ); + } + + #[test] + fn generated_create_metadata_has_two_layer_nullable_anyof() { + let document = serde_json::to_value(implementation_openapi()).unwrap(); + let base = "/components/schemas/CreateConversationRequest/properties/metadata"; + + assert_eq!( + document.pointer(&format!("{base}/anyOf/1/type")), + Some(&Value::String("null".to_owned())) + ); + assert_eq!( + document.pointer(&format!("{base}/anyOf/0/anyOf/0/type")), + Some(&Value::String("object".to_owned())), + "inner anyOf should contain the string-map object type" + ); + assert_eq!( + document.pointer(&format!("{base}/anyOf/0/anyOf/0/additionalProperties/type")), + Some(&Value::String("string".to_owned())), + "metadata values should be strings" + ); + assert_eq!( + document.pointer(&format!("{base}/anyOf/0/anyOf/1/type")), + Some(&Value::String("null".to_owned())), + "inner anyOf should include null" + ); + } + + #[test] + fn generated_update_request_contract_matches_runtime() { + let document = serde_json::to_value(implementation_openapi()).unwrap(); + + assert_eq!( + document.pointer("/components/schemas/UpdateConversationRequest/required/0"), + Some(&Value::String("metadata".to_owned())) + ); + assert_eq!( + document.pointer("/components/schemas/UpdateConversationRequest/properties/metadata/$ref"), + Some(&Value::String("#/components/schemas/Metadata".to_owned())) + ); + assert_eq!( + document.pointer("/paths/~1conversations~1{conversation_id}/post/requestBody/required"), + Some(&Value::Bool(true)), + "live OpenAI behavior requires an update request body" + ); + } + + fn collect_component_references<'a>(value: &'a Value, references: &mut Vec<&'a str>) { + match value { + Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(Value::as_str) + && reference.starts_with("#/components/schemas/") + { + references.push(reference); + } + for value in object.values() { + collect_component_references(value, references); + } + }, + Value::Array(values) => { + for value in values { + collect_component_references(value, references); + } + }, + _ => {}, + } + } +} diff --git a/apis/src/openai/conversations/routes.rs b/apis/src/openai/conversations/routes.rs new file mode 100644 index 0000000000..8e931afef2 --- /dev/null +++ b/apis/src/openai/conversations/routes.rs @@ -0,0 +1,448 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Conversations operation registry and zero-allocation runtime matcher. + +use std::ops::Deref; + +use utoipa::PartialSchema; + +use super::contracts::{ + ConversationItem, ConversationItemList, ConversationResource, CreateConversationItemsRequest, + CreateConversationRequest, DeletedConversationResource, IncludeField, ItemOrder, UpdateConversationRequest, +}; +use crate::openai::operation::{ + MediaTypeSpec, OpenAiHandlingMode, OpenAiHttpMethod, OpenAiOperationSpec, OwnedOperationContract, + ParameterLocation, ParameterSpec, RequestBodySpec, ResponseSpec, schema_binding, +}; + +/// JSON media type used by all Conversations bodies. +const JSON_CONTENT_TYPE: &str = "application/json"; + +/// Static metadata for one Conversations operation. +#[derive(Clone, Copy)] +pub struct ConversationOperationSpec { + /// Runtime operation. + pub operation: ConversationOperation, + /// Shared operation and owned-contract metadata. + pub definition: OpenAiOperationSpec, +} + +impl Deref for ConversationOperationSpec { + type Target = OpenAiOperationSpec; + + fn deref(&self) -> &Self::Target { + &self.definition + } +} + +/// Convert a registry request declaration into an optional schema binding. +macro_rules! request_binding { + ([none]) => { + None + }; + ([required $schema:ty]) => { + Some(RequestBodySpec { + required: true, + content: &[MediaTypeSpec::new(JSON_CONTENT_TYPE, schema_binding!($schema))], + }) + }; + ([optional $schema:ty]) => { + Some(RequestBodySpec { + required: false, + content: &[MediaTypeSpec::new(JSON_CONTENT_TYPE, schema_binding!($schema))], + }) + }; +} + +/// Convert a registry contract declaration into optional owned metadata. +#[expect( + unused_macro_rules, + reason = "non-owning form is part of the registry API but current Conversations operations are all local" +)] +macro_rules! operation_contract { + (none {}) => { + None + }; + ( + owned { + parameters: [$($parameter:expr),* $(,)?], + request: $request:tt, + response: $response:ty $(,)? + } + ) => { + Some(OwnedOperationContract { + parameters: &[$($parameter),*], + request: request_binding!($request), + responses: &[ResponseSpec { + status: "200", + description: "OK", + content: &[MediaTypeSpec::new(JSON_CONTENT_TYPE, schema_binding!($response))], + }], + }) + }; +} + +/// Declare a required string path parameter. +macro_rules! path_parameter { + ($name:literal, $description:literal) => { + ParameterSpec::new( + $name, + ParameterLocation::Path, + true, + $description, + ::schema, + ) + }; +} + +/// Declare an optional typed query parameter. +macro_rules! query_parameter { + ($name:literal, $schema:ty, $description:literal) => { + ParameterSpec::new( + $name, + ParameterLocation::Query, + false, + $description, + <$schema as PartialSchema>::schema, + ) + }; +} + +/// Declare each operation once and derive both runtime and `OpenAPI` metadata. +macro_rules! conversation_operations { + ( + $( + $operation:ident { + operation_id: $operation_id:literal, + method: $method:ident, + path: $path:literal, + mode: $mode:ident, + contract: $contract_kind:ident $contract:tt $(,)? + } + ),+ $(,)? + ) => { + /// One Conversations operation recognized by the local filter. + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub enum ConversationOperation { + $( + #[doc = concat!(stringify!($method), " /v1", $path)] + $operation, + )+ + } + + /// All Conversations operations recognized by the local filter. + pub const OPERATION_SPECS: &[ConversationOperationSpec] = &[ + $( + ConversationOperationSpec { + operation: ConversationOperation::$operation, + definition: OpenAiOperationSpec { + operation_id: $operation_id, + method: OpenAiHttpMethod::$method, + spec_path: $path, + runtime_path: concat!("/v1", $path), + mode: OpenAiHandlingMode::$mode, + owned_contract: operation_contract!($contract_kind $contract), + }, + }, + )+ + ]; + }; +} + +conversation_operations! { + CreateConversation { + operation_id: "createConversation", + method: Post, + path: "/conversations", + mode: Local, + contract: owned { + parameters: [], + request: [optional CreateConversationRequest], + response: ConversationResource, + }, + }, + GetConversation { + operation_id: "getConversation", + method: Get, + path: "/conversations/{conversation_id}", + mode: Local, + contract: owned { + parameters: [path_parameter!( + "conversation_id", + "The ID of the conversation to retrieve." + )], + request: [none], + response: ConversationResource, + }, + }, + UpdateConversation { + operation_id: "updateConversation", + method: Post, + path: "/conversations/{conversation_id}", + mode: Local, + contract: owned { + parameters: [path_parameter!( + "conversation_id", + "The ID of the conversation to update." + )], + request: [required UpdateConversationRequest], + response: ConversationResource, + }, + }, + DeleteConversation { + operation_id: "deleteConversation", + method: Delete, + path: "/conversations/{conversation_id}", + mode: Local, + contract: owned { + parameters: [path_parameter!( + "conversation_id", + "The ID of the conversation to delete." + )], + request: [none], + response: DeletedConversationResource, + }, + }, + CreateConversationItems { + operation_id: "createConversationItems", + method: Post, + path: "/conversations/{conversation_id}/items", + mode: Local, + contract: owned { + parameters: [ + path_parameter!( + "conversation_id", + "The ID of the conversation to add the items to." + ), + query_parameter!( + "include", + Vec, + "Additional fields to include in the response." + ), + ], + request: [required CreateConversationItemsRequest], + response: ConversationItemList, + }, + }, + ListConversationItems { + operation_id: "listConversationItems", + method: Get, + path: "/conversations/{conversation_id}/items", + mode: Local, + contract: owned { + parameters: [ + path_parameter!( + "conversation_id", + "The ID of the conversation to list items for." + ), + query_parameter!("limit", u32, "Maximum number of items to return."), + query_parameter!("order", ItemOrder, "Sort order for returned items."), + query_parameter!("after", String, "Item ID to list after."), + query_parameter!( + "include", + Vec, + "Additional fields to include in the response." + ), + ], + request: [none], + response: ConversationItemList, + }, + }, + GetConversationItem { + operation_id: "getConversationItem", + method: Get, + path: "/conversations/{conversation_id}/items/{item_id}", + mode: Local, + contract: owned { + parameters: [ + path_parameter!( + "conversation_id", + "The ID of the conversation that contains the item." + ), + path_parameter!("item_id", "The ID of the item to retrieve."), + query_parameter!( + "include", + Vec, + "Additional fields to include in the response." + ), + ], + request: [none], + response: ConversationItem, + }, + }, + DeleteConversationItem { + operation_id: "deleteConversationItem", + method: Delete, + path: "/conversations/{conversation_id}/items/{item_id}", + mode: Local, + contract: owned { + parameters: [ + path_parameter!( + "conversation_id", + "The ID of the conversation that contains the item." + ), + path_parameter!("item_id", "The ID of the item to delete."), + ], + request: [none], + response: ConversationResource, + }, + }, +} + +/// Path parameters borrowed directly from the request URI. +#[derive(Clone, Copy, Debug, Default)] +struct RouteParams<'a> { + /// Conversation identifier path segment. + conversation_id: Option<&'a str>, + /// Conversation item identifier path segment. + item_id: Option<&'a str>, +} + +impl<'a> RouteParams<'a> { + /// Record a parameter recognized by the Conversations registry. + fn insert(&mut self, name: &str, value: &'a str) -> Option<()> { + let slot = match name { + "conversation_id" => &mut self.conversation_id, + "item_id" => &mut self.item_id, + _ => return None, + }; + if slot.replace(value).is_some() { + return None; + } + Some(()) + } +} + +/// One matched runtime route. +#[derive(Clone, Copy)] +pub(crate) struct MatchedConversationRoute<'a> { + /// Matched operation metadata. + pub spec: &'static ConversationOperationSpec, + /// Borrowed path parameters. + params: RouteParams<'a>, +} + +impl<'a> MatchedConversationRoute<'a> { + /// Return the borrowed conversation ID path segment. + pub(crate) const fn conversation_id(&self) -> Option<&'a str> { + self.params.conversation_id + } + + /// Return the borrowed item ID path segment. + pub(crate) const fn item_id(&self) -> Option<&'a str> { + self.params.item_id + } +} + +/// Return all Conversations operation specs. +#[must_use] +pub const fn operation_specs() -> &'static [ConversationOperationSpec] { + OPERATION_SPECS +} + +/// Match an HTTP method and runtime path to a Conversations operation. +pub(crate) fn match_route<'a>(method: &str, path: &'a str) -> Option> { + let path = path.strip_suffix('/').filter(|path| !path.is_empty()).unwrap_or(path); + OPERATION_SPECS + .iter() + .filter(|spec| spec.method.as_str() == method) + .find_map(|spec| { + match_path_template(spec.runtime_path, path).map(|params| MatchedConversationRoute { spec, params }) + }) +} + +/// Match one path against a template with `{param}` placeholders. +fn match_path_template<'a>(template: &'static str, path: &'a str) -> Option> { + let mut template_segments = template.split('/'); + let mut path_segments = path.split('/'); + let mut params = RouteParams::default(); + + loop { + match (template_segments.next(), path_segments.next()) { + (None, None) => return Some(params), + (Some(template_segment), Some(path_segment)) => { + if let Some(name) = template_segment + .strip_prefix('{') + .and_then(|segment| segment.strip_suffix('}')) + { + if path_segment.is_empty() { + return None; + } + params.insert(name, path_segment)?; + } else if template_segment != path_segment { + return None; + } + }, + _ => return None, + } + } +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow(clippy::unwrap_used, reason = "tests")] +mod tests { + use std::collections::BTreeSet; + + use super::*; + + #[test] + fn registry_has_unique_local_conversations_operations() { + assert_eq!(OPERATION_SPECS.len(), 8); + + let operation_keys = OPERATION_SPECS + .iter() + .map(|spec| (spec.method, spec.spec_path)) + .collect::>(); + assert_eq!(operation_keys.len(), OPERATION_SPECS.len()); + let operation_ids = OPERATION_SPECS + .iter() + .map(|spec| spec.operation_id) + .collect::>(); + assert_eq!(operation_ids.len(), OPERATION_SPECS.len()); + assert!(OPERATION_SPECS.iter().all(|spec| spec.mode == OpenAiHandlingMode::Local + && spec.mode.owns_contract() + && spec.owned_contract().is_some())); + } + + #[test] + fn handling_modes_classify_contract_ownership() { + assert!(!OpenAiHandlingMode::Passthrough.owns_contract()); + assert!(!OpenAiHandlingMode::Inspect.owns_contract()); + assert!(OpenAiHandlingMode::Transform.owns_contract()); + assert!(OpenAiHandlingMode::Local.owns_contract()); + } + + #[test] + fn matches_static_runtime_path() { + let route = match_route("POST", "/v1/conversations").unwrap(); + assert_eq!(route.spec.operation, ConversationOperation::CreateConversation); + assert!(route.conversation_id().is_none()); + } + + #[test] + fn matches_parameterized_runtime_path_without_allocating_params() { + let route = match_route("GET", "/v1/conversations/conv_123/items/item_456").unwrap(); + assert_eq!(route.spec.operation, ConversationOperation::GetConversationItem); + assert_eq!(route.conversation_id(), Some("conv_123")); + assert_eq!(route.item_id(), Some("item_456")); + } + + #[test] + fn every_registry_runtime_template_matches_its_operation() { + for spec in OPERATION_SPECS { + let path = spec + .runtime_path + .replace("{conversation_id}", "conv_test") + .replace("{item_id}", "item_test"); + let route = match_route(spec.method.as_str(), &path).unwrap(); + assert_eq!(route.spec.operation, spec.operation); + } + } + + #[test] + fn rejects_empty_parameter() { + assert!(match_route("GET", "/v1/conversations/").is_none()); + } +} diff --git a/apis/src/openai/conversations/tests.rs b/apis/src/openai/conversations/tests.rs new file mode 100644 index 0000000000..bfb606cde5 --- /dev/null +++ b/apis/src/openai/conversations/tests.rs @@ -0,0 +1,3875 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +use std::collections::BTreeMap; + +use bytes::Bytes; +use http::Method; +use praxis_filter::{BodyAccess, BodyMode, FilterAction, HttpFilter, parse_filter_config}; +use serde_json::Value; + +use super::{ + config::{ConversationsConfig, revalidate_postgres_host, validate_config}, + filter::OpenaiConversationsFilter, + routes::{self, ConversationOperation, ConversationOperationSpec, operation_specs}, + validate::validate_metadata, +}; +use crate::{ + openai::responses::state::ResponsesState, + test_utils::{make_filter_context, make_request, make_response}, +}; + +fn rejection_body(rejection: &praxis_filter::Rejection) -> Value { + serde_json::from_slice(rejection.body.as_deref().unwrap()).unwrap() +} + +// ----------------------------------------------------------------------------- +// Config Tests +// ----------------------------------------------------------------------------- + +#[test] +fn parse_valid_sqlite_config() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); +} + +#[test] +fn parse_valid_postgres_config() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/conversations" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); +} + +#[test] +fn reject_empty_database_url() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("must not be empty"), + "expected empty URL error: {err}" + ); +} + +#[test] +fn reject_duplicate_table_names() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: same_name + items_table: same_name + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("distinct"), + "expected distinct table names error: {err}" + ); +} + +#[test] +fn reject_items_table_matching_generated_responses_table() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: conversations + items_table: conversations_unused_responses + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("generated responses and items table names"), + "expected generated response table collision error: {err}" + ); +} + +#[test] +fn reject_invalid_table_name() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: "1invalid" + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("invalid conversations_table"), + "expected invalid table name error: {err}" + ); +} + +#[test] +fn reject_postgres_items_table_above_index_safe_length() { + let items_table = "i".repeat(64); + let yaml: serde_yaml::Value = serde_yaml::from_str(&format!( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/conversations" + conversations_table: conversations + items_table: {items_table} + "# + )) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("items table name"), + "expected postgres items table length error: {err}" + ); +} + +#[test] +fn reject_sqlite_path_traversal() { + for database_url in [ + "sqlite://../../etc/data.db", + "sqlite://..%2F..%2Fetc%2Fdata.db?mode=rwc", + ] { + let yaml: serde_yaml::Value = serde_yaml::from_str(&format!( + r#" + backend: sqlite + database_url: "{database_url}" + conversations_table: conversations + items_table: conversation_items + "# + )) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("path traversal"), + "expected path traversal error for {database_url}: {err}" + ); + } +} + +#[test] +fn reject_ssl_mode_on_sqlite() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: conversations + items_table: conversation_items + ssl_mode: require + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("only valid with the 'postgres' backend"), + "expected postgres-only error: {err}" + ); +} + +#[test] +fn reject_unknown_fields() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: conversations + items_table: conversation_items + unknown_field: true + "#, + ) + .unwrap(); + let result = parse_filter_config::("openai_conversations", &yaml); + assert!(result.is_err(), "should reject unknown fields"); +} + +#[test] +fn config_accepts_pool_options() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: conversations + items_table: conversation_items + pool: + max_connections: 20 + min_connections: 2 + idle_timeout_secs: 300 + acquire_timeout_secs: 15 + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); + + let pool = cfg.pool.expect("pool config should be present"); + assert_eq!(pool.max_connections, Some(20)); + assert_eq!(pool.min_connections, Some(2)); + assert_eq!(pool.idle_timeout_secs, Some(300)); + assert_eq!(pool.acquire_timeout_secs, Some(15)); +} + +#[test] +fn config_accepts_partial_pool_options() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: conversations + items_table: conversation_items + pool: + max_connections: 50 + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); + + let pool = cfg.pool.expect("pool config should be present"); + assert_eq!(pool.max_connections, Some(50)); + assert!(pool.min_connections.is_none()); + assert!(pool.idle_timeout_secs.is_none()); + assert!(pool.acquire_timeout_secs.is_none()); +} + +#[test] +fn config_omitted_pool_yields_none() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); + assert!(cfg.pool.is_none(), "omitted pool should be None"); +} + +#[test] +fn reject_postgres_without_scheme() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "1.2.3.4:5432/conversations" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("must start with"), + "expected scheme error: {err}" + ); +} + +// ----------------------------------------------------------------------------- +// Config Tests — Postgres URL Validation +// ----------------------------------------------------------------------------- + +#[test] +fn reject_postgres_loopback_ip() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://127.0.0.1:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "loopback IP should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_private_ip() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://192.168.1.1:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "private IP should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_link_local_ip() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://169.254.1.1:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "link-local IP should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_unspecified_ip() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://0.0.0.0:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "unspecified IP should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_ipv6_loopback() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://[::1]:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "IPv6 loopback should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_ipv6_unique_local() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://[fd00::1]:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "IPv6 unique-local should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_ipv6_link_local() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://[fe80::1]:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "IPv6 link-local should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_ipv6_unspecified() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://[::]:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "IPv6 unspecified should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_dns_name() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://db.example.com:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("DNS name"), + "DNS name should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_localhost() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://localhost:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("localhost"), + "localhost should be rejected: {err}" + ); +} + +#[test] +fn allow_private_database_url_bypasses_ip_checks() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://127.0.0.1:5432/db" + conversations_table: conversations + items_table: conversation_items + allow_private_database_url: true + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); +} + +#[test] +fn allow_private_database_url_bypasses_dns_checks() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://db.example.com:5432/db" + conversations_table: conversations + items_table: conversation_items + allow_private_database_url: true + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); +} + +#[test] +fn reject_postgres_unix_socket() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres:///db?host=/var/run/postgresql" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("Unix socket"), + "Unix socket should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_no_explicit_host() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres:///db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("explicit host"), + "missing host should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_hostaddr_private() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/db?hostaddr=127.0.0.1" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "private hostaddr should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_host_query_param_localhost() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres:///db?host=localhost" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("localhost"), + "localhost host param should be rejected: {err}" + ); +} + +#[test] +fn accept_postgresql_scheme() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgresql://1.2.3.4:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); +} + +#[test] +fn postgres_url_with_credentials_validates_host() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://user:pass@1.2.3.4:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); +} + +#[test] +fn reject_postgres_mapped_ipv4_loopback() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://[::ffff:127.0.0.1]:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "IPv4-mapped IPv6 loopback should be rejected: {err}" + ); +} + +// ----------------------------------------------------------------------------- +// Config Tests — Postgres TLS +// ----------------------------------------------------------------------------- + +#[test] +fn reject_ssl_root_cert_path_traversal() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/db" + conversations_table: conversations + items_table: conversation_items + ssl_mode: verify-ca + ssl_root_cert: "../../etc/ca.pem" + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("path traversal"), + "ssl_root_cert path traversal should be rejected: {err}" + ); +} + +#[test] +fn reject_ssl_root_cert_without_verify_mode() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/db" + conversations_table: conversations + items_table: conversation_items + ssl_mode: require + ssl_root_cert: "/path/to/ca.pem" + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("verify-ca"), + "ssl_root_cert without verify mode should be rejected: {err}" + ); +} + +#[test] +fn accept_ssl_root_cert_without_explicit_ssl_mode() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/db" + conversations_table: conversations + items_table: conversation_items + ssl_root_cert: "/path/to/ca.pem" + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); +} + +#[test] +fn accept_ssl_root_cert_with_verify_ca() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/db" + conversations_table: conversations + items_table: conversation_items + ssl_mode: verify-ca + ssl_root_cert: "/path/to/ca.pem" + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); +} + +#[test] +fn accept_ssl_root_cert_with_verify_full() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/db" + conversations_table: conversations + items_table: conversation_items + ssl_mode: verify-full + ssl_root_cert: "/path/to/ca.pem" + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); +} + +#[test] +fn reject_postgres_url_tls_file_path_traversal() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/db?sslrootcert=../../etc/ca.pem" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("path traversal"), + "sslrootcert path traversal should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_url_sslkey_path_traversal() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/db?sslkey=../../etc/key.pem" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("path traversal"), + "sslkey path traversal should be rejected: {err}" + ); +} + +#[test] +fn url_sslmode_verify_ca_with_sslrootcert_is_valid() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/db?sslmode=verify-ca&sslrootcert=/ca.pem" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); +} + +// ----------------------------------------------------------------------------- +// Config Tests — SQLite Extras +// ----------------------------------------------------------------------------- + +#[test] +fn reject_ssl_root_cert_on_sqlite() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: conversations + items_table: conversation_items + ssl_root_cert: "/path/to/ca.pem" + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("only valid with the 'postgres' backend"), + "ssl_root_cert on sqlite should be rejected: {err}" + ); +} + +#[test] +fn reject_allow_private_on_sqlite() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: conversations + items_table: conversation_items + allow_private_database_url: true + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("only valid with the 'postgres' backend"), + "allow_private_database_url on sqlite should be rejected: {err}" + ); +} + +#[test] +fn accept_sqlite_memory_mode_query_param() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite://file?mode=memory" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); +} + +#[test] +fn accept_sqlite_colon_memory_variant() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite://:memory:" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); +} + +#[test] +fn accept_sqlite_file_path_without_traversal() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite://data/conversations.db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); +} + +#[test] +fn default_table_names_are_valid() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + validate_config(&cfg).unwrap(); + assert_eq!(cfg.conversations_table, "openai_conversations"); + assert_eq!(cfg.items_table, "openai_conversation_items"); +} + +#[test] +fn reject_postgres_conversations_table_above_index_safe_length() { + let table = "c".repeat(64); + let yaml: serde_yaml::Value = serde_yaml::from_str(&format!( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/conversations" + conversations_table: {table} + items_table: conversation_items + "# + )) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("conversations_table") || err.to_string().contains("table"), + "expected postgres table length error: {err}" + ); +} + +// ----------------------------------------------------------------------------- +// Config Tests — Legacy IPv4 Parsing +// ----------------------------------------------------------------------------- + +#[test] +fn reject_postgres_octal_loopback() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://0177.0.0.01:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "octal 127.0.0.1 should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_hex_loopback() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://0x7f000001:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "hex 127.0.0.1 should be rejected: {err}" + ); +} + +#[test] +fn reject_postgres_decimal_collapsed_loopback() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://2130706433:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = validate_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "decimal 127.0.0.1 (2130706433) should be rejected: {err}" + ); +} + +// ----------------------------------------------------------------------------- +// Config Tests — revalidate_postgres_host +// ----------------------------------------------------------------------------- + +#[test] +fn revalidate_postgres_host_rejects_private_ip() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://10.0.0.1:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = revalidate_postgres_host(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "revalidation should reject private IP: {err}" + ); +} + +#[test] +fn revalidate_postgres_host_rejects_hostaddr_param() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/db?hostaddr=192.168.0.1" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + let err = revalidate_postgres_host(&cfg).unwrap_err(); + assert!( + err.to_string().contains("local-sensitive"), + "revalidation should reject private hostaddr: {err}" + ); +} + +#[test] +fn revalidate_postgres_host_accepts_public_ip() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: postgres + database_url: "postgres://1.2.3.4:5432/db" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + revalidate_postgres_host(&cfg).unwrap(); +} + +#[test] +fn revalidate_skips_sqlite_backend() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let cfg: ConversationsConfig = parse_filter_config("openai_conversations", &yaml).unwrap(); + revalidate_postgres_host(&cfg).unwrap(); +} + +// ----------------------------------------------------------------------------- +// Metadata Validation Tests +// ----------------------------------------------------------------------------- + +#[test] +fn valid_metadata() { + let metadata = serde_json::json!({"key1": "value1", "key2": "value2"}); + validate_metadata(&metadata).unwrap(); +} + +#[test] +fn null_metadata_is_valid() { + validate_metadata(&Value::Null).unwrap(); +} + +#[test] +fn reject_non_object_metadata() { + let metadata = serde_json::json!("string"); + let err = validate_metadata(&metadata).unwrap_err(); + assert!(err.to_string().contains("must be a JSON object"), "got: {err}"); +} + +#[test] +fn reject_too_many_keys() { + let mut map = serde_json::Map::new(); + for i in 0..17 { + map.insert(format!("key{i}"), Value::String("val".to_owned())); + } + let err = validate_metadata(&Value::Object(map)).unwrap_err(); + assert!(err.to_string().contains("at most 16 keys"), "got: {err}"); +} + +#[test] +fn reject_long_key() { + let long_key = "k".repeat(65); + let metadata = serde_json::json!({long_key: "value"}); + let err = validate_metadata(&metadata).unwrap_err(); + assert!(err.to_string().contains("exceeds 64 bytes"), "got: {err}"); +} + +#[test] +fn reject_long_value() { + let long_value = "v".repeat(513); + let metadata = serde_json::json!({"key": long_value}); + let err = validate_metadata(&metadata).unwrap_err(); + assert!(err.to_string().contains("exceeds 512 bytes"), "got: {err}"); +} + +#[test] +fn reject_non_string_value() { + let metadata = serde_json::json!({"key": 42}); + let err = validate_metadata(&metadata).unwrap_err(); + assert!(err.to_string().contains("must be a string"), "got: {err}"); +} + +// ----------------------------------------------------------------------------- +// Filter Factory Tests +// ----------------------------------------------------------------------------- + +#[test] +fn from_config_creates_filter() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: conversations + items_table: conversation_items + "#, + ) + .unwrap(); + let filter = OpenaiConversationsFilter::from_config(&yaml).unwrap(); + assert_eq!(filter.name(), "openai_conversations"); +} + +// ----------------------------------------------------------------------------- +// Handler Tests +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn create_and_get_conversation() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"metadata": {"env": "test"}}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 200); + let resp = rejection_body(&rejection); + assert_eq!(resp["object"], "conversation"); + let conv_id = resp["id"].as_str().unwrap(); + assert!(conv_id.starts_with("conv_")); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 200); + let resp = rejection_body(&rejection); + assert_eq!(resp["id"], conv_id); + assert_eq!(resp["metadata"]["env"], "test"); +} + +#[tokio::test] +async fn get_nonexistent_conversation_returns_404() { + let filter = build_test_filter(); + + let req = make_request(Method::GET, "/v1/conversations/conv_nonexistent"); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 404); +} + +#[tokio::test] +async fn update_conversation() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({"v": "1"})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"metadata": {"v": "2"}}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 200); + let resp = rejection_body(&rejection); + assert_eq!(resp["metadata"]["v"], "2"); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from get after update"); + }; + let resp = rejection_body(&rejection); + assert_eq!(resp["metadata"]["v"], "2", "updated metadata should be persisted"); +} + +#[tokio::test] +async fn update_conversation_body_requires_metadata() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({"v": "1"})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(b"{}")); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 400); + let error = &rejection_body(&rejection)["error"]; + assert_eq!(error["type"], "invalid_request_error"); + assert_eq!(error["code"], "missing_required_parameter"); + assert_eq!(error["param"], "metadata"); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from get after update"); + }; + let resp = rejection_body(&rejection); + assert_eq!(resp["metadata"]["v"], "1", "invalid update must not change metadata"); +} + +#[tokio::test] +async fn update_conversation_without_body_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({"v": "1"})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = None; + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 400); + let error = &rejection_body(&rejection)["error"]; + assert_eq!(error["type"], "invalid_request_error"); + assert_eq!(error["code"], "missing_required_parameter"); + assert_eq!(error["param"], "metadata"); +} + +#[tokio::test] +async fn delete_conversation() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::DELETE, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 200); + let resp = rejection_body(&rejection); + assert!(resp["deleted"].as_bool().unwrap()); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject"); + }; + assert_eq!(rejection.status, 404); +} + +#[tokio::test] +async fn delete_conversation_preserves_item_rows() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "metadata": {}, + "items": [ + {"id": "item_keep", "type": "message", "role": "user", "content": "keep me"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create conversation"); + }; + assert_eq!(rejection.status, 200, "create should return 200"); + let resp = rejection_body(&rejection); + let conv_id = resp["id"].as_str().unwrap(); + + let req = make_request(Method::DELETE, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from delete conversation"); + }; + assert_eq!(rejection.status, 200, "delete conversation should return 200"); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}/items/item_keep")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from get retained item"); + }; + assert_eq!(rejection.status, 200, "conversation delete should not delete item row"); + let resp = rejection_body(&rejection); + assert_eq!(resp["id"], "item_keep"); + assert_eq!(resp["content"][0]["text"], "keep me"); +} + +#[tokio::test] +async fn unmatched_path_continues() { + let filter = build_test_filter(); + + let req = make_request(Method::GET, "/v1/chat/completions"); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); +} + +#[tokio::test] +async fn post_routes_use_stream_buffer_request_body_mode() { + let filter = build_test_filter(); + assert!( + matches!( + filter.request_body_mode(), + BodyMode::StreamBuffer { max_bytes: Some(_) } + ), + "request body mode must be StreamBuffer so the pipeline pre-reads the body for local handling" + ); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + ctx.request_body_mode = filter.request_body_mode(); + let action = filter.on_request(&mut ctx).await.unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + assert!( + matches!(ctx.request_body_mode, BodyMode::StreamBuffer { max_bytes: Some(_) }), + "matched POST should keep buffering enabled for request-body handling" + ); +} + +#[tokio::test] +async fn response_body_mode_defaults_to_stream() { + let filter = build_test_filter(); + assert!( + matches!(filter.response_body_mode(), BodyMode::Stream), + "response body mode should default to Stream to avoid buffering unrelated responses" + ); +} + +#[tokio::test] +async fn unmatched_post_path_continues() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/chat/completions"); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + assert!(matches!(action, FilterAction::Continue)); +} + +#[tokio::test] +async fn early_body_pre_read_defers_store_write_until_request_filters_run() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(7); + + let body_json = serde_json::json!({"metadata": {"phase": "deferred"}}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!( + matches!(action, FilterAction::Release), + "early body hook should not write the store before request filters run" + ); + + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected deferred body to be handled during on_request, got {action:?}"); + }; + assert_eq!(rejection.status, 200); + let resp = rejection_body(&rejection); + assert_eq!(resp["metadata"]["phase"], "deferred"); +} + +#[tokio::test] +async fn create_conversation_with_invalid_metadata() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"metadata": "not-an-object"}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for invalid metadata, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "invalid metadata should return 400"); +} + +#[tokio::test] +async fn create_conversation_with_invalid_json_returns_400() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(b"{not-json")); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for invalid JSON, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "invalid JSON should return 400"); + let resp = rejection_body(&rejection); + assert_eq!( + resp["error"]["type"], "invalid_request_error", + "invalid JSON should be a client error" + ); +} + +#[tokio::test] +async fn create_conversation_with_non_object_json_returns_400() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(b"[]")); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for non-object JSON, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "non-object JSON should return 400"); + let resp = rejection_body(&rejection); + assert_eq!( + resp["error"]["type"], "invalid_request_error", + "non-object JSON should be a client error" + ); +} + +#[tokio::test] +async fn update_conversation_with_non_object_json_preserves_metadata() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({"v": "1"})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(b"[]")); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for non-object JSON, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "non-object JSON should return 400"); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from get after invalid update"); + }; + let resp = rejection_body(&rejection); + assert_eq!(resp["metadata"]["v"], "1", "invalid update should not reset metadata"); +} + +#[tokio::test] +async fn initial_items_can_be_listed_and_retrieved() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "metadata": {}, + "items": [ + {"id": "item_explicit", "type": "message", "role": "user", "content": "hello"}, + {"type": "message", "role": "assistant", "content": "hi"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create conversation"); + }; + assert_eq!(rejection.status, 200, "create should return 200"); + let resp = rejection_body(&rejection); + let conv_id = resp["id"].as_str().unwrap(); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}/items?order=asc")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from list items"); + }; + assert_eq!(rejection.status, 200, "list items should return 200"); + let resp = rejection_body(&rejection); + assert_eq!(resp["data"][0]["id"], "item_explicit"); + assert_eq!(resp["data"][0]["status"], "completed"); + assert_eq!(resp["data"][0]["content"][0]["type"], "input_text"); + assert_eq!(resp["data"][0]["content"][0]["text"], "hello"); + let generated_id = resp["data"][1]["id"].as_str().unwrap(); + assert!(generated_id.starts_with("item_"), "missing item ID should be generated"); + assert_eq!(resp["data"][1]["status"], "completed"); + assert_eq!(resp["data"][1]["content"][0]["type"], "output_text"); + assert_eq!(resp["data"][1]["content"][0]["text"], "hi"); + assert_eq!(resp["data"][1]["content"][0]["annotations"], serde_json::json!([])); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}/items/item_explicit")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from get item"); + }; + assert_eq!(rejection.status, 200, "get item should return 200"); + let resp = rejection_body(&rejection); + assert_eq!(resp["status"], "completed"); + assert_eq!(resp["content"][0]["type"], "input_text"); + assert_eq!(resp["content"][0]["text"], "hello"); +} + +#[tokio::test] +async fn empty_item_list_returns_string_pagination_ids() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from list items"); + }; + assert_eq!(rejection.status, 200, "list empty items should return 200"); + let resp = rejection_body(&rejection); + assert_eq!(resp["data"], serde_json::json!([])); + assert_eq!(resp["first_id"], ""); + assert_eq!(resp["last_id"], ""); + assert_eq!(resp["has_more"], false); +} + +#[tokio::test] +async fn create_conversation_rejects_duplicate_initial_item_ids() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "metadata": {}, + "items": [ + {"id": "item_dup", "type": "message", "role": "user", "content": "first"}, + {"id": "item_dup", "type": "message", "role": "assistant", "content": "second"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for duplicate item id"); + }; + assert_eq!(rejection.status, 400, "duplicate initial item IDs should return 400"); + let resp = rejection_body(&rejection); + assert!( + resp["error"]["message"].as_str().unwrap().contains("duplicate item id"), + "duplicate error should mention item id" + ); +} + +#[tokio::test] +async fn create_and_delete_item_endpoints_are_local() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [ + {"id": "item_new", "type": "message", "role": "user", "content": "new"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create items"); + }; + assert_eq!(rejection.status, 200, "create items should return 200"); + let resp = rejection_body(&rejection); + assert_eq!(resp["data"][0]["id"], "item_new"); + assert_eq!(resp["data"][0]["status"], "completed"); + assert_eq!(resp["data"][0]["content"][0]["type"], "input_text"); + assert_eq!(resp["data"][0]["content"][0]["text"], "new"); + + let req = make_request(Method::DELETE, &format!("/v1/conversations/{conv_id}/items/item_new")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from delete item"); + }; + assert_eq!(rejection.status, 200, "delete item should return 200"); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}/items/item_new")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from get deleted item"); + }; + assert_eq!(rejection.status, 404, "deleted item should return 404"); +} + +#[tokio::test] +async fn item_endpoints_apply_include_projection_without_changing_stored_items() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + let body_json = serde_json::json!({ + "items": [{ + "id": "reasoning_1", + "type": "reasoning", + "summary": [], + "encrypted_content": "stored-secret" + }] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create items"); + }; + assert_eq!(rejection.status, 200); + let response = rejection_body(&rejection); + assert!( + response["data"][0].get("encrypted_content").is_none(), + "create response must omit unrequested encrypted reasoning" + ); + + let req = make_request( + Method::GET, + &format!("/v1/conversations/{conv_id}/items/reasoning_1?include%5B%5D=reasoning.encrypted_content"), + ); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from get item"); + }; + assert_eq!(rejection.status, 200); + let response = rejection_body(&rejection); + assert_eq!( + response["encrypted_content"], "stored-secret", + "Node SDK bracket encoding should reveal the requested stored field" + ); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from list items"); + }; + let response = rejection_body(&rejection); + assert!( + response["data"][0].get("encrypted_content").is_none(), + "list response must omit unrequested encrypted reasoning" + ); + + let req = make_request( + Method::GET, + &format!("/v1/conversations/{conv_id}/items?include=reasoning.encrypted_content"), + ); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from list items with include"); + }; + let response = rejection_body(&rejection); + assert_eq!( + response["data"][0]["encrypted_content"], "stored-secret", + "Python SDK repeated-key encoding should reveal the requested stored field" + ); +} + +#[tokio::test] +async fn item_endpoints_reject_unknown_include_values() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request( + Method::POST, + &format!("/v1/conversations/{conv_id}/items?include=future.secret_field"), + ); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + let mut body = Some(Bytes::from_static( + br#"{"items":[{"id":"reasoning_2","type":"reasoning","summary":[]}]}"#, + )); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create items with unknown include"); + }; + assert_eq!(rejection.status, 400); + assert_eq!(rejection_body(&rejection)["error"]["type"], "invalid_request_error"); + + for path in [ + format!("/v1/conversations/{conv_id}/items?include=future.secret_field"), + format!("/v1/conversations/{conv_id}/items/missing?include=future.secret_field"), + ] { + let req = make_request(Method::GET, &path); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from {path}"); + }; + assert_eq!(rejection.status, 400); + let response = rejection_body(&rejection); + assert_eq!(response["error"]["type"], "invalid_request_error"); + assert!( + response["error"]["message"] + .as_str() + .unwrap() + .contains("unsupported include"), + "unknown include response should explain the unsupported value" + ); + } +} + +#[tokio::test] +async fn item_subresource_routes_do_not_fall_through_upstream() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + assert!( + matches!(action, FilterAction::Continue), + "POST item route should continue only until request-body handling" + ); + assert!( + matches!(ctx.request_body_mode, BodyMode::StreamBuffer { max_bytes: Some(_) }), + "POST item route should keep body buffering so it cannot reach upstream" + ); + + let body_json = serde_json::json!({ + "items": [ + {"id": "item_local", "type": "message", "role": "user", "content": "local"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected local Reject from POST item body"); + }; + assert_eq!(rejection.status, 200, "POST item route should be handled locally"); + + for (method, path) in [ + (Method::GET, format!("/v1/conversations/{conv_id}/items")), + (Method::GET, format!("/v1/conversations/{conv_id}/items/item_local")), + (Method::DELETE, format!("/v1/conversations/{conv_id}/items/item_local")), + ] { + let req = make_request(method.clone(), &path); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("{method} {path} should be handled locally, got {action:?}"); + }; + assert!( + matches!(rejection.status, 200 | 404), + "{method} {path} should return a local item response, got {}", + rejection.status + ); + } +} + +#[tokio::test] +async fn encoded_item_id_path_segments_are_decoded() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [ + {"id": "item with space", "type": "message", "role": "user", "content": "encoded"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create items"); + }; + assert_eq!(rejection.status, 200, "create items should return 200"); + + let req = make_request( + Method::GET, + &format!("/v1/conversations/{conv_id}/items/item%20with%20space"), + ); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from get encoded item"); + }; + assert_eq!(rejection.status, 200, "encoded item ID should be retrievable"); + let resp = rejection_body(&rejection); + assert_eq!(resp["id"], "item with space"); + assert_eq!(resp["content"][0]["text"], "encoded"); + + let req = make_request( + Method::DELETE, + &format!("/v1/conversations/{conv_id}/items/item%20with%20space"), + ); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from delete encoded item"); + }; + assert_eq!(rejection.status, 200, "encoded item ID should be deletable"); +} + +#[tokio::test] +async fn item_list_after_cursor_decodes_query_plus_as_space() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [ + {"id": "item with space", "type": "message", "role": "user", "content": "first"}, + {"id": "item_next", "type": "message", "role": "assistant", "content": "second"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create conversation"); + }; + assert_eq!(rejection.status, 200, "create should return 200"); + let resp = rejection_body(&rejection); + let conv_id = resp["id"].as_str().unwrap(); + + let req = make_request( + Method::GET, + &format!("/v1/conversations/{conv_id}/items?order=asc&after=item+with+space"), + ); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from list after cursor"); + }; + assert_eq!(rejection.status, 200, "list items should return 200"); + let resp = rejection_body(&rejection); + assert_eq!(resp["data"].as_array().unwrap().len(), 1); + assert_eq!(resp["data"][0]["id"], "item_next"); + assert_eq!(resp["data"][0]["content"][0]["text"], "second"); +} + +#[tokio::test] +async fn create_items_rejects_duplicate_ids_in_request() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [ + {"id": "item_dup", "type": "message", "role": "user", "content": "first"}, + {"id": "item_dup", "type": "message", "role": "assistant", "content": "second"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for duplicate item id"); + }; + assert_eq!(rejection.status, 400, "duplicate request item IDs should return 400"); +} + +#[tokio::test] +async fn create_items_rejects_existing_id_without_overwrite() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + let body_json = serde_json::json!({ + "items": [ + {"id": "item_existing", "type": "message", "role": "user", "content": "original"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from initial item create"); + }; + assert_eq!(rejection.status, 200, "initial item create should succeed"); + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + let body_json = serde_json::json!({ + "items": [ + {"id": "item_existing", "type": "message", "role": "assistant", "content": "overwrite"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for existing item id"); + }; + assert_eq!(rejection.status, 400, "existing item ID should return 400"); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}/items/item_existing")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from get existing item"); + }; + assert_eq!(rejection.status, 200, "original item should still exist"); + let resp = rejection_body(&rejection); + assert_eq!( + resp["content"][0]["text"], "original", + "duplicate create must not overwrite item data" + ); +} + +// ----------------------------------------------------------------------------- +// Handler Tests — Delete Non-existent +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn delete_nonexistent_conversation_returns_404() { + let filter = build_test_filter(); + + let req = make_request(Method::DELETE, "/v1/conversations/conv_nonexistent"); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 404); +} + +#[tokio::test] +async fn update_nonexistent_conversation_returns_404() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations/conv_nonexistent"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"metadata": {"v": "1"}}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 404); +} + +// ----------------------------------------------------------------------------- +// Handler Tests — Item Create Edge Cases +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn create_items_missing_items_field_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(b"{}")); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for missing items, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "missing items should return 400"); + let resp = rejection_body(&rejection); + assert!( + resp["error"]["message"].as_str().unwrap().contains("required"), + "should mention items is required" + ); +} + +#[tokio::test] +async fn create_items_non_array_items_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"items": "not-an-array"}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for non-array items, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "non-array items should return 400"); +} + +#[tokio::test] +async fn create_items_null_items_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(br#"{"items":null}"#)); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for null items, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "null items should return 400"); +} + +#[tokio::test] +async fn create_items_too_many_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let items: Vec = (0..21) + .map(|i| serde_json::json!({"id": format!("item_{i}"), "type": "message", "role": "user", "content": "hi"})) + .collect(); + let body_json = serde_json::json!({"items": items}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for too many items, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "too many items should return 400"); + let resp = rejection_body(&rejection); + assert!( + resp["error"]["message"].as_str().unwrap().contains("at most"), + "should mention items limit" + ); +} + +#[tokio::test] +async fn create_items_for_nonexistent_conversation_returns_404() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations/conv_nonexistent/items"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [ + {"id": "item_1", "type": "message", "role": "user", "content": "hi"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 404, "non-existent conversation should return 404"); +} + +#[tokio::test] +async fn create_items_with_invalid_json_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(b"{not-json")); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for invalid JSON, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "invalid JSON should return 400"); +} + +#[tokio::test] +async fn create_items_with_non_object_json_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(b"[]")); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for non-object JSON, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "non-object JSON should return 400"); +} + +// ----------------------------------------------------------------------------- +// Handler Tests — Item Normalization Edge Cases +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn create_items_with_non_object_item_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"items": ["not-an-object"]}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for non-object item, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "non-object item should return 400"); + let resp = rejection_body(&rejection); + assert!( + resp["error"]["message"] + .as_str() + .unwrap() + .contains("must be a JSON object"), + "should mention object requirement" + ); +} + +#[tokio::test] +async fn create_items_with_empty_item_id_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [{"id": "", "type": "message", "role": "user", "content": "hi"}] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for empty item id, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "empty item id should return 400"); +} + +#[tokio::test] +async fn create_items_with_numeric_item_id_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [{"id": 42, "type": "message", "role": "user", "content": "hi"}] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject for numeric item id, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "numeric item id should return 400"); + let resp = rejection_body(&rejection); + assert!( + resp["error"]["message"].as_str().unwrap().contains("must be a string"), + "should mention string requirement" + ); +} + +#[tokio::test] +async fn create_items_with_null_item_id_generates_id() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [{"id": null, "type": "message", "role": "user", "content": "hi"}] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 200, "null item id should auto-generate"); + let resp = rejection_body(&rejection); + let generated_id = resp["data"][0]["id"].as_str().unwrap(); + assert!( + generated_id.starts_with("item_"), + "generated id should have item_ prefix" + ); +} + +// ----------------------------------------------------------------------------- +// Handler Tests — Message Role/Content Validation +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn create_items_with_empty_role_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [{"type": "message", "role": "", "content": "hi"}] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "empty role should return 400"); + let resp = rejection_body(&rejection); + assert!( + resp["error"]["message"].as_str().unwrap().contains("role"), + "empty role error should mention role: {resp}" + ); +} + +#[tokio::test] +async fn create_items_with_non_string_role_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [{"type": "message", "role": 42, "content": "hi"}] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "non-string role should return 400"); + let resp = rejection_body(&rejection); + assert!( + resp["error"]["message"] + .as_str() + .unwrap() + .contains("role must be a string") + ); +} + +#[tokio::test] +async fn create_items_with_missing_role_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [{"type": "message", "content": "hi"}] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "missing role should return 400"); + let resp = rejection_body(&rejection); + assert!( + resp["error"]["message"].as_str().unwrap().contains("role is required"), + "missing role error should mention required role: {resp}" + ); +} + +#[tokio::test] +async fn create_items_with_missing_content_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [{"type": "message", "role": "user"}] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "missing content should return 400"); + let resp = rejection_body(&rejection); + assert!( + resp["error"]["message"] + .as_str() + .unwrap() + .contains("content is required") + ); +} + +#[tokio::test] +async fn create_items_with_non_string_non_array_content_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [{"type": "message", "role": "user", "content": 42}] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "numeric content should return 400"); + let resp = rejection_body(&rejection); + assert!( + resp["error"]["message"] + .as_str() + .unwrap() + .contains("must be a string or array") + ); +} + +#[tokio::test] +async fn create_items_with_array_content_passthrough() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let content = serde_json::json!([{"type": "input_text", "text": "array content"}]); + let body_json = serde_json::json!({ + "items": [{"type": "message", "role": "user", "content": content}] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 200, "array content should be accepted"); + let resp = rejection_body(&rejection); + assert_eq!( + resp["data"][0]["content"][0]["text"], "array content", + "array content should pass through unchanged" + ); +} + +#[tokio::test] +async fn non_message_item_type_skips_normalization() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [{"type": "function_call", "name": "test", "arguments": "{}"}] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 200, "non-message type should be accepted"); + let resp = rejection_body(&rejection); + assert_eq!(resp["data"][0]["type"], "function_call"); +} + +// ----------------------------------------------------------------------------- +// Handler Tests — Item Delete Edge Cases +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn delete_item_from_nonexistent_conversation_returns_404() { + let filter = build_test_filter(); + + let req = make_request(Method::DELETE, "/v1/conversations/conv_nonexistent/items/item_1"); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!( + rejection.status, 404, + "delete item from non-existent conversation should return 404" + ); +} + +#[tokio::test] +async fn delete_nonexistent_item_returns_404() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request( + Method::DELETE, + &format!("/v1/conversations/{conv_id}/items/item_nonexistent"), + ); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 404, "delete non-existent item should return 404"); +} + +#[tokio::test] +async fn get_item_from_nonexistent_conversation_returns_404() { + let filter = build_test_filter(); + + let req = make_request(Method::GET, "/v1/conversations/conv_nonexistent/items/item_1"); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!( + rejection.status, 404, + "get item from non-existent conversation should return 404" + ); +} + +// ----------------------------------------------------------------------------- +// Handler Tests — List Items Edge Cases +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn list_items_for_nonexistent_conversation_returns_404() { + let filter = build_test_filter(); + + let req = make_request(Method::GET, "/v1/conversations/conv_nonexistent/items"); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!( + rejection.status, 404, + "list items for non-existent conversation should return 404" + ); +} + +#[tokio::test] +async fn list_items_with_limit_parameter() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [ + {"id": "item_a", "type": "message", "role": "user", "content": "first"}, + {"id": "item_b", "type": "message", "role": "assistant", "content": "second"}, + {"id": "item_c", "type": "message", "role": "user", "content": "third"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create"); + }; + let resp = rejection_body(&rejection); + let conv_id = resp["id"].as_str().unwrap(); + + let req = make_request( + Method::GET, + &format!("/v1/conversations/{conv_id}/items?limit=2&order=asc"), + ); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from list items"); + }; + assert_eq!(rejection.status, 200); + let resp = rejection_body(&rejection); + assert_eq!(resp["data"].as_array().unwrap().len(), 2, "should respect limit"); + assert_eq!(resp["has_more"], true, "should indicate more items"); + assert_eq!(resp["data"][0]["id"], "item_a"); + assert_eq!(resp["data"][1]["id"], "item_b"); +} + +#[tokio::test] +async fn list_items_desc_order() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [ + {"id": "item_a", "type": "message", "role": "user", "content": "first"}, + {"id": "item_b", "type": "message", "role": "assistant", "content": "second"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create"); + }; + let resp = rejection_body(&rejection); + let conv_id = resp["id"].as_str().unwrap(); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}/items?order=desc")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from list items"); + }; + assert_eq!(rejection.status, 200); + let resp = rejection_body(&rejection); + assert_eq!(resp["data"][0]["id"], "item_b", "desc order should list newest first"); + assert_eq!(resp["data"][1]["id"], "item_a"); +} + +// ----------------------------------------------------------------------------- +// Handler Tests — Conversation Create with Initial Items Edge Cases +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn create_conversation_with_non_array_items_returns_400() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"items": "not-an-array"}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "non-array items should return 400"); +} + +#[tokio::test] +async fn create_conversation_with_null_items_defaults_to_empty() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(br#"{"items":null}"#)); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject response for null items, got {action:?}"); + }; + assert_eq!(rejection.status, 200, "null items should behave like omitted items"); + assert_eq!(rejection_body(&rejection)["metadata"], serde_json::json!({})); +} + +#[tokio::test] +async fn create_conversation_with_too_many_initial_items_returns_400() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let items: Vec = (0..21) + .map(|i| serde_json::json!({"id": format!("item_{i}"), "type": "message", "role": "user", "content": "hi"})) + .collect(); + let body_json = serde_json::json!({"items": items}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "too many initial items should return 400"); +} + +#[tokio::test] +async fn create_conversation_with_null_metadata_defaults_to_empty() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"metadata": null}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 200); + let resp = rejection_body(&rejection); + assert_eq!( + resp["metadata"], + serde_json::json!({}), + "null metadata should default to empty object" + ); +} + +#[tokio::test] +async fn create_conversation_with_empty_object_defaults_to_empty() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(b"{}")); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 200); + let resp = rejection_body(&rejection); + assert_eq!( + resp["metadata"], + serde_json::json!({}), + "missing metadata should default to empty object" + ); +} + +#[tokio::test] +async fn create_conversation_without_body_defaults_to_empty() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = None; + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 200); + assert_eq!(rejection_body(&rejection)["metadata"], serde_json::json!({})); +} + +// ----------------------------------------------------------------------------- +// Handler Tests — Update Conversation Edge Cases +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn update_conversation_with_invalid_metadata_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({"v": "1"})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"metadata": "not-an-object"}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "invalid metadata should return 400"); + let error = &rejection_body(&rejection)["error"]; + assert_eq!(error["type"], "invalid_request_error"); + assert_eq!(error["code"], "invalid_type"); + assert_eq!(error["param"], "metadata"); +} + +#[tokio::test] +async fn update_conversation_with_null_metadata_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({"v": "1"})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"metadata": null}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 400); + let error = &rejection_body(&rejection)["error"]; + assert_eq!(error["type"], "invalid_request_error"); + assert_eq!(error["code"], "invalid_type"); + assert_eq!(error["param"], "metadata"); +} + +#[tokio::test] +async fn update_conversation_with_invalid_json_returns_400() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({"v": "1"})).await; + + let req = make_request(Method::POST, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(b"{bad-json")); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 400, "invalid JSON in update should return 400"); +} + +// ----------------------------------------------------------------------------- +// Handler Tests — Tenant Isolation +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn cross_tenant_get_conversation_returns_404() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + ctx.set_metadata("responses.tenant_id", "tenant-a"); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"metadata": {"owner": "a"}}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create"); + }; + let resp = rejection_body(&rejection); + let conv_id = resp["id"].as_str().unwrap(); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + ctx.set_metadata("responses.tenant_id", "tenant-b"); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from cross-tenant GET"); + }; + assert_eq!( + rejection.status, 404, + "cross-tenant GET should return 404, not leak data" + ); +} + +#[tokio::test] +async fn cross_tenant_delete_conversation_returns_404() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + ctx.set_metadata("responses.tenant_id", "tenant-a"); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"metadata": {"owner": "a"}}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create"); + }; + let resp = rejection_body(&rejection); + let conv_id = resp["id"].as_str().unwrap(); + + let req = make_request(Method::DELETE, &format!("/v1/conversations/{conv_id}")); + let mut ctx = make_filter_context(&req); + ctx.set_metadata("responses.tenant_id", "tenant-b"); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from cross-tenant DELETE"); + }; + assert_eq!( + rejection.status, 404, + "cross-tenant DELETE should return 404, not delete another tenant's data" + ); +} + +#[tokio::test] +async fn cross_tenant_delete_item_returns_404() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + ctx.set_metadata("responses.tenant_id", "tenant-a"); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [{"id": "item_secret", "type": "message", "role": "user", "content": "private"}] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create"); + }; + let resp = rejection_body(&rejection); + let conv_id = resp["id"].as_str().unwrap(); + + let req = make_request( + Method::DELETE, + &format!("/v1/conversations/{conv_id}/items/item_secret"), + ); + let mut ctx = make_filter_context(&req); + ctx.set_metadata("responses.tenant_id", "tenant-b"); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from cross-tenant item DELETE"); + }; + assert_eq!(rejection.status, 404, "cross-tenant item DELETE should return 404"); +} + +// ----------------------------------------------------------------------------- +// Handler Tests — Delete Item Syncs Conversation Messages +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn delete_item_returns_updated_conversation() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({ + "items": [ + {"id": "item_stay", "type": "message", "role": "user", "content": "keep"}, + {"id": "item_gone", "type": "message", "role": "assistant", "content": "remove"} + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create"); + }; + let resp = rejection_body(&rejection); + let conv_id = resp["id"].as_str().unwrap(); + + let req = make_request(Method::DELETE, &format!("/v1/conversations/{conv_id}/items/item_gone")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from delete item"); + }; + assert_eq!(rejection.status, 200); + let resp = rejection_body(&rejection); + assert_eq!( + resp["object"], "conversation", + "delete item should return updated conversation" + ); + assert_eq!(resp["id"], conv_id); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}/items")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from list items"); + }; + assert_eq!(rejection.status, 200); + let items_resp = rejection_body(&rejection); + let items = items_resp["data"].as_array().unwrap(); + assert_eq!(items.len(), 1, "only the kept item should remain"); + assert_eq!(items[0]["id"], "item_stay"); +} + +// ----------------------------------------------------------------------------- +// Filter Tests — Body Modes and Access +// ----------------------------------------------------------------------------- + +#[test] +fn filter_request_body_access_is_read_only() { + let filter = build_test_filter(); + assert_eq!(filter.request_body_access(), BodyAccess::ReadOnly); +} + +// ----------------------------------------------------------------------------- +// Filter Tests — Trailing Slash Normalization +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn trailing_slash_on_conversation_path_is_normalized() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({"k": "v"})).await; + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}/")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 200, "trailing slash should be normalized"); + let resp = rejection_body(&rejection); + assert_eq!(resp["id"], conv_id); +} + +#[tokio::test] +async fn trailing_slash_on_items_path_is_normalized() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}/items/")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!( + rejection.status, 200, + "trailing slash on items path should be normalized" + ); +} + +// ----------------------------------------------------------------------------- +// Filter Tests — Non-POST Body Passthrough +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn body_hook_with_end_of_stream_false_continues() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(b"partial")); + let action = filter.on_request_body(&mut ctx, &mut body, false).await.unwrap(); + assert!( + matches!(action, FilterAction::Continue), + "non-final body chunk should continue" + ); +} + +#[tokio::test] +async fn body_hook_for_get_request_continues() { + let filter = build_test_filter(); + + let req = make_request(Method::GET, "/v1/conversations/conv_1"); + let mut ctx = make_filter_context(&req); + + let mut body = Some(Bytes::from_static(b"ignored")); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!( + matches!(action, FilterAction::Continue), + "body hook for GET should continue" + ); +} + +// ----------------------------------------------------------------------------- +// Filter Tests — Unmatched Methods +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn put_on_conversation_path_continues() { + let filter = build_test_filter(); + + let req = make_request(Method::PUT, "/v1/conversations/conv_1"); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue), "PUT should not be handled"); +} + +#[tokio::test] +async fn patch_on_conversation_path_continues() { + let filter = build_test_filter(); + + let req = make_request(Method::PATCH, "/v1/conversations/conv_1"); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue), "PATCH should not be handled"); +} + +// ----------------------------------------------------------------------------- +// Append-Back: on_response +// ----------------------------------------------------------------------------- + +fn set_append_back_metadata(ctx: &mut praxis_filter::HttpFilterContext<'_>) { + ctx.set_metadata("openai_responses_format.has_conversation", "true"); + ctx.set_metadata("responses.conversation_id", "conv_test_123"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_not_armed_without_conversation_metadata() { + let filter = build_test_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + + let action = filter.on_response(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_not_armed_when_streaming() { + let filter = build_test_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + set_append_back_metadata(&mut ctx); + ctx.set_metadata("openai_responses_format.stream", "true"); + + let action = filter.on_response(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_not_armed_when_background() { + let filter = build_test_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + set_append_back_metadata(&mut ctx); + ctx.set_metadata("openai_responses_format.background", "true"); + + let action = filter.on_response(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_not_armed_for_non_2xx() { + let filter = build_test_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + set_append_back_metadata(&mut ctx); + + let mut resp = make_response(); + resp.status = http::StatusCode::INTERNAL_SERVER_ERROR; + ctx.response_header = Some(&mut resp); + + let action = filter.on_response(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_not_armed_for_non_json_content_type() { + let filter = build_test_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + set_append_back_metadata(&mut ctx); + + let mut resp = make_response(); + resp.headers + .insert(http::header::CONTENT_TYPE, "text/plain".parse().unwrap()); + ctx.response_header = Some(&mut resp); + + let action = filter.on_response(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_armed_for_json_200() { + let filter = build_test_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + ctx.response_body_mode = filter.response_body_mode(); + set_append_back_metadata(&mut ctx); + + let mut resp = make_response(); + resp.headers + .insert(http::header::CONTENT_TYPE, "application/json".parse().unwrap()); + ctx.response_header = Some(&mut resp); + + let action = filter.on_response(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); + assert!( + matches!(ctx.response_body_mode, BodyMode::StreamBuffer { max_bytes: Some(_) }), + "armed append-back should upgrade response body mode to StreamBuffer" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_unarmed_keeps_stream_body_mode() { + let filter = build_test_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + ctx.response_body_mode = filter.response_body_mode(); + + let action = filter.on_response(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); + assert!( + matches!(ctx.response_body_mode, BodyMode::Stream), + "unarmed response should keep default Stream body mode" + ); +} + +// ----------------------------------------------------------------------------- +// Append-Back: on_response_body +// ----------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_body_releases_when_not_armed() { + let filter = build_test_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + + drop(filter.on_response(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(b"{}")); + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + assert!(matches!(action, FilterAction::Release)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_body_continues_when_not_end_of_stream() { + let filter = build_test_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + set_append_back_metadata(&mut ctx); + + let mut resp = make_response(); + resp.headers + .insert(http::header::CONTENT_TYPE, "application/json".parse().unwrap()); + ctx.response_header = Some(&mut resp); + drop(filter.on_response(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(b"partial")); + let action = filter.on_response_body(&mut ctx, &mut body, false).unwrap(); + assert!(matches!(action, FilterAction::Continue)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_body_skips_non_completed_status() { + let filter = build_test_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + set_append_back_metadata(&mut ctx); + + let mut resp = make_response(); + resp.headers + .insert(http::header::CONTENT_TYPE, "application/json".parse().unwrap()); + ctx.response_header = Some(&mut resp); + drop(filter.on_response(&mut ctx).await.unwrap()); + + let response_json = serde_json::json!({ + "status": "failed", + "output": [{"type": "message", "role": "assistant", "content": "oops"}] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&response_json).unwrap())); + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + assert!(matches!(action, FilterAction::Continue)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_body_skips_invalid_json() { + let filter = build_test_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + set_append_back_metadata(&mut ctx); + + let mut resp = make_response(); + resp.headers + .insert(http::header::CONTENT_TYPE, "application/json".parse().unwrap()); + ctx.response_header = Some(&mut resp); + drop(filter.on_response(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from_static(b"{not-json")); + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + assert!(matches!(action, FilterAction::Continue)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_body_skips_empty_items() { + let filter = build_test_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + set_append_back_metadata(&mut ctx); + + let mut resp = make_response(); + resp.headers + .insert(http::header::CONTENT_TYPE, "application/json".parse().unwrap()); + ctx.response_header = Some(&mut resp); + drop(filter.on_response(&mut ctx).await.unwrap()); + + let response_json = serde_json::json!({ + "status": "completed", + "output": [] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&response_json).unwrap())); + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + assert!(matches!(action, FilterAction::Continue)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_body_skips_empty_body() { + let filter = build_test_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + set_append_back_metadata(&mut ctx); + + let mut resp = make_response(); + resp.headers + .insert(http::header::CONTENT_TYPE, "application/json".parse().unwrap()); + ctx.response_header = Some(&mut resp); + drop(filter.on_response(&mut ctx).await.unwrap()); + + let mut body: Option = None; + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + assert!(matches!(action, FilterAction::Continue)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn on_response_body_appends_completed_response() { + let filter = build_test_filter(); + let conv_id = create_test_conversation(filter.as_ref(), serde_json::json!({})).await; + + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + ctx.current_filter_id = Some(0); + ctx.set_metadata("openai_responses_format.has_conversation", "true"); + ctx.set_metadata("responses.conversation_id", &conv_id); + + let input_items = vec![serde_json::json!({ + "type": "message", + "role": "user", + "content": "hello from append" + })]; + ctx.extensions.insert(ResponsesState { + input: input_items, + ..ResponsesState::default() + }); + + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut resp = make_response(); + resp.headers + .insert(http::header::CONTENT_TYPE, "application/json".parse().unwrap()); + ctx.response_header = Some(&mut resp); + drop(filter.on_response(&mut ctx).await.unwrap()); + + let response_json = serde_json::json!({ + "status": "completed", + "output": [{"type": "message", "role": "assistant", "content": "hi from model"}] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&response_json).unwrap())); + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + assert!(matches!(action, FilterAction::Continue)); + + let req = make_request(Method::GET, &format!("/v1/conversations/{conv_id}/items?order=asc")); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from list items after append-back"); + }; + assert_eq!(rejection.status, 200); + let resp = rejection_body(&rejection); + let items = resp["data"].as_array().unwrap(); + assert_eq!(items.len(), 2, "append-back should persist both input and output items"); +} + +#[test] +fn conformance_conversations_routes_match_runtime_registry() { + let spec = generated_openapi_spec(); + assert_eq!( + generated_operation_keys(&spec), + route_operation_keys(), + "generated OpenAPI paths must exactly match Conversations operation_specs()" + ); + + for operation in operation_specs() { + let path = runtime_path(operation, Some("conv_sync"), Some("item_sync")); + let matched = routes::match_route(operation.method.as_str(), &path) + .unwrap_or_else(|| panic!("runtime route table did not match {} {path}", operation.method.as_str())); + assert_eq!( + matched.spec.operation, + operation.operation, + "runtime route table matched the wrong operation for {} {path}", + operation.method.as_str(), + ); + assert_eq!( + OperationKey::new(matched.spec.method.as_str(), matched.spec.spec_path), + OperationKey::new(operation.method.as_str(), operation.spec_path), + "runtime route metadata drifted from operation_specs() for {} {path}", + operation.method.as_str(), + ); + } + println!("PRAXIS_CONFORMANCE_OK conversations route_dispatch"); +} + +#[tokio::test] +async fn conformance_conversations_success_payloads_match_generated_response_schemas() { + let filter = build_test_filter(); + let spec = generated_openapi_spec(); + let schemas = generated_response_schemas(&spec); + let payloads = successful_conversation_payloads(filter.as_ref()).await; + + let schema_operations: Vec<_> = schemas.keys().collect(); + let payload_operations: Vec<_> = payloads.keys().collect(); + assert_eq!( + payload_operations, schema_operations, + "runtime success fixtures should cover every generated Conversations response schema" + ); + + for (operation, schema) in schemas { + let payload = payloads + .get(&operation) + .unwrap_or_else(|| panic!("missing runtime success payload for {}", operation.label())); + assert_matches_schema(&spec, &format!("{} response", operation.label()), &schema, payload); + } + println!("PRAXIS_CONFORMANCE_OK conversations success_response_contract"); +} + +#[test] +fn conformance_conversations_generated_schema_check_rejects_wrong_discriminator() { + let spec = generated_openapi_spec(); + let schema = spec + .pointer("/components/schemas/ConversationResource") + .unwrap_or_else(|| panic!("generated OpenAPI missing ConversationResource")); + let invalid = serde_json::json!({ + "id": "conv_invalid", + "object": "wrong", + "created_at": 1, + "metadata": {}, + }); + + assert!( + !schema_matches_value(&spec, schema, &invalid), + "schema check must reject a response with the wrong object discriminator" + ); + println!("PRAXIS_CONFORMANCE_OK conversations schema_check_sensitivity"); +} + +// ----------------------------------------------------------------------------- +// Test Utilities +// ----------------------------------------------------------------------------- + +fn build_test_filter() -> Box { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" + backend: sqlite + database_url: "sqlite::memory:" + conversations_table: test_conversations + items_table: test_items + "#, + ) + .unwrap(); + OpenaiConversationsFilter::from_config(&yaml).unwrap() +} + +async fn create_test_conversation(filter: &dyn HttpFilter, metadata: Value) -> String { + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"metadata": metadata}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject from create conversation"); + }; + let resp = rejection_body(&rejection); + resp["id"].as_str().unwrap().to_owned() +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct OperationKey { + method: String, + path: String, +} + +impl OperationKey { + fn new(method: impl Into, path: impl Into) -> Self { + Self { + method: method.into(), + path: path.into(), + } + } + + fn label(&self) -> String { + format!("{} {}", self.method, self.path) + } +} + +async fn successful_conversation_payloads(filter: &dyn HttpFilter) -> BTreeMap { + let mut payloads = BTreeMap::new(); + let create_spec = operation_spec(ConversationOperation::CreateConversation); + + let create = successful_post_json( + filter, + &runtime_path(create_spec, None, None), + serde_json::json!({"metadata": {"project": "schema-test"}}), + ) + .await; + let conv_id = create["id"].as_str().unwrap().to_owned(); + insert_payload(&mut payloads, create_spec, create); + + let get_spec = operation_spec(ConversationOperation::GetConversation); + let get = successful_request_json(filter, Method::GET, &runtime_path(get_spec, Some(&conv_id), None)).await; + insert_payload(&mut payloads, get_spec, get); + + let update_spec = operation_spec(ConversationOperation::UpdateConversation); + let update = successful_post_json( + filter, + &runtime_path(update_spec, Some(&conv_id), None), + serde_json::json!({"metadata": {"project": "updated"}}), + ) + .await; + insert_payload(&mut payloads, update_spec, update); + + let create_items_spec = operation_spec(ConversationOperation::CreateConversationItems); + let create_items = successful_post_json( + filter, + &runtime_path(create_items_spec, Some(&conv_id), None), + serde_json::json!({ + "items": [ + {"id": "item_schema", "type": "message", "role": "user", "content": "hello"} + ] + }), + ) + .await; + insert_payload(&mut payloads, create_items_spec, create_items); + + let list_spec = operation_spec(ConversationOperation::ListConversationItems); + let list = successful_request_json(filter, Method::GET, &runtime_path(list_spec, Some(&conv_id), None)).await; + insert_payload(&mut payloads, list_spec, list); + + let get_item_spec = operation_spec(ConversationOperation::GetConversationItem); + let item = successful_request_json( + filter, + Method::GET, + &runtime_path(get_item_spec, Some(&conv_id), Some("item_schema")), + ) + .await; + insert_payload(&mut payloads, get_item_spec, item); + + let delete_item_spec = operation_spec(ConversationOperation::DeleteConversationItem); + let delete_item = successful_request_json( + filter, + Method::DELETE, + &runtime_path(delete_item_spec, Some(&conv_id), Some("item_schema")), + ) + .await; + insert_payload(&mut payloads, delete_item_spec, delete_item); + + let delete_spec = operation_spec(ConversationOperation::DeleteConversation); + let delete = + successful_request_json(filter, Method::DELETE, &runtime_path(delete_spec, Some(&conv_id), None)).await; + insert_payload(&mut payloads, delete_spec, delete); + + payloads +} + +fn operation_spec(operation: ConversationOperation) -> &'static ConversationOperationSpec { + operation_specs() + .iter() + .find(|spec| spec.operation == operation) + .unwrap_or_else(|| panic!("missing operation spec for {operation:?}")) +} + +fn runtime_path(spec: &ConversationOperationSpec, conversation_id: Option<&str>, item_id: Option<&str>) -> String { + spec.runtime_path + .replace("{conversation_id}", conversation_id.unwrap_or_default()) + .replace("{item_id}", item_id.unwrap_or_default()) +} + +fn insert_payload(payloads: &mut BTreeMap, spec: &ConversationOperationSpec, payload: Value) { + let previous = payloads.insert(OperationKey::new(spec.method.as_str(), spec.spec_path), payload); + assert!( + previous.is_none(), + "duplicate runtime success fixture for {} {}", + spec.method.as_str(), + spec.spec_path + ); +} + +async fn successful_post_json(filter: &dyn HttpFilter, path: &str, body_json: Value) -> Value { + let req = make_request(Method::POST, path); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 200); + rejection_body(&rejection) +} + +async fn successful_request_json(filter: &dyn HttpFilter, method: Method, path: &str) -> Value { + let req = make_request(method, path); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 200); + rejection_body(&rejection) +} + +fn generated_openapi_spec() -> Value { + serde_json::from_str(&super::implementation_openapi_json().unwrap()).unwrap() +} + +fn route_operation_keys() -> Vec { + let mut keys = operation_specs() + .iter() + .map(|spec| OperationKey::new(spec.method.as_str(), spec.spec_path)) + .collect::>(); + keys.sort(); + keys +} + +fn generated_operation_keys(spec: &Value) -> Vec { + let mut keys = Vec::new(); + let paths = spec + .get("paths") + .and_then(Value::as_object) + .unwrap_or_else(|| panic!("generated OpenAPI spec missing paths object")); + + for (path, path_item) in paths { + let path_item = path_item + .as_object() + .unwrap_or_else(|| panic!("generated path item {path} should be an object")); + for (method, _operation) in path_item { + if matches!(method.as_str(), "delete" | "get" | "post") { + keys.push(OperationKey::new(method.to_uppercase(), path)); + } + } + } + + keys.sort(); + keys +} + +fn generated_response_schemas(spec: &Value) -> BTreeMap { + let mut schemas = BTreeMap::new(); + let paths = spec + .get("paths") + .and_then(Value::as_object) + .unwrap_or_else(|| panic!("generated OpenAPI spec missing paths object")); + + for (path, path_item) in paths { + let path_item = path_item + .as_object() + .unwrap_or_else(|| panic!("generated path item {path} should be an object")); + for (method, operation) in path_item { + if !matches!(method.as_str(), "delete" | "get" | "post") { + continue; + } + + let schema = operation + .pointer("/responses/200/content/application~1json/schema") + .unwrap_or_else(|| panic!("generated operation {method} {path} missing 200 JSON response schema")); + let previous = schemas.insert(OperationKey::new(method.to_uppercase(), path), schema.clone()); + assert!(previous.is_none(), "duplicate generated operation {method} {path}"); + } + } + + let schema_operations = schemas.keys().cloned().collect::>(); + assert_eq!( + schema_operations, + route_operation_keys(), + "generated OpenAPI operations should match Conversations operation_specs()" + ); + + schemas +} + +fn assert_matches_schema(spec: &Value, path: &str, schema: &Value, value: &Value) { + assert!( + schema_matches_value(spec, schema, value), + "{path} does not match generated schema: {value}" + ); + let schema = resolve_schema_ref(spec, schema); + + if let Some(enum_values) = schema.get("enum").and_then(Value::as_array) { + assert!( + enum_values.contains(value), + "{path} should be one of {enum_values:?}, got {value}" + ); + } + + if let Some(schema_type) = schema.get("type").and_then(Value::as_str) { + assert_schema_type(path, schema_type, value); + } else if schema.get("properties").is_some() || schema.get("required").is_some() { + assert_schema_type(path, "object", value); + } + + if let Some(items_schema) = schema.get("items") { + for (idx, item) in value + .as_array() + .unwrap_or_else(|| panic!("{path} should be an array")) + .iter() + .enumerate() + { + assert_matches_schema(spec, &format!("{path}[{idx}]"), items_schema, item); + } + } + + let Some(obj) = value.as_object() else { + return; + }; + + if let Some(required) = schema.get("required").and_then(Value::as_array) { + for required_name in required { + let required_name = required_name.as_str().unwrap(); + assert!( + obj.contains_key(required_name), + "{path} missing required property {required_name}" + ); + } + } + + if let Some(properties) = schema.get("properties").and_then(Value::as_object) { + for (property_name, property_schema) in properties { + if let Some(property_value) = obj.get(property_name) { + assert_matches_schema( + spec, + &format!("{path}.{property_name}"), + property_schema, + property_value, + ); + } + } + } + + if let Some(additional_schema) = schema.get("additionalProperties").filter(|schema| schema.is_object()) { + let properties = schema.get("properties").and_then(Value::as_object); + for (property_name, property_value) in obj { + if properties.is_none_or(|properties| !properties.contains_key(property_name)) { + assert_matches_schema( + spec, + &format!("{path}.{property_name}"), + additional_schema, + property_value, + ); + } + } + } +} + +fn schema_matches_value(spec: &Value, schema: &Value, value: &Value) -> bool { + let schema = resolve_schema_ref(spec, schema); + + if value.is_null() && schema.get("nullable").and_then(Value::as_bool) == Some(true) { + return true; + } + if let Some(variants) = schema.get("oneOf").and_then(Value::as_array) + && variants + .iter() + .filter(|variant| schema_matches_value(spec, variant, value)) + .count() + != 1 + { + return false; + } + if let Some(variants) = schema.get("anyOf").and_then(Value::as_array) + && !variants + .iter() + .any(|variant| schema_matches_value(spec, variant, value)) + { + return false; + } + if let Some(variants) = schema.get("allOf").and_then(Value::as_array) + && !variants + .iter() + .all(|variant| schema_matches_value(spec, variant, value)) + { + return false; + } + if schema + .get("enum") + .and_then(Value::as_array) + .is_some_and(|values| !values.contains(value)) + { + return false; + } + if let Some(schema_type) = schema.get("type") { + let matches_type = schema_type.as_str().map_or_else( + || { + schema_type.as_array().is_some_and(|types| { + types + .iter() + .filter_map(Value::as_str) + .any(|kind| value_has_type(value, kind)) + }) + }, + |kind| value_has_type(value, kind), + ); + if !matches_type { + return false; + } + } + + if let Some(items_schema) = schema.get("items") { + let Some(items) = value.as_array() else { + return false; + }; + if !items.iter().all(|item| schema_matches_value(spec, items_schema, item)) { + return false; + } + } + + let Some(object) = value.as_object() else { + return schema.get("properties").is_none() && schema.get("required").is_none(); + }; + if schema + .get("required") + .and_then(Value::as_array) + .is_some_and(|required| { + required + .iter() + .filter_map(Value::as_str) + .any(|name| !object.contains_key(name)) + }) + { + return false; + } + + let properties = schema.get("properties").and_then(Value::as_object); + if let Some(properties) = properties + && properties.iter().any(|(name, property_schema)| { + object + .get(name) + .is_some_and(|property| !schema_matches_value(spec, property_schema, property)) + }) + { + return false; + } + + match schema.get("additionalProperties") { + Some(Value::Bool(false)) => { + properties.is_none_or(|properties| object.keys().all(|name| properties.contains_key(name))) + }, + Some(additional_schema @ Value::Object(_)) => object.iter().all(|(name, property)| { + properties.is_some_and(|properties| properties.contains_key(name)) + || schema_matches_value(spec, additional_schema, property) + }), + _ => true, + } +} + +fn resolve_schema_ref<'a>(spec: &'a Value, schema: &'a Value) -> &'a Value { + let Some(ref_path) = schema.get("$ref").and_then(Value::as_str) else { + return schema; + }; + let pointer = ref_path.strip_prefix('#').unwrap_or(ref_path); + spec.pointer(pointer) + .unwrap_or_else(|| panic!("missing generated schema ref {ref_path}")) +} + +fn assert_schema_type(path: &str, schema_type: &str, value: &Value) { + let matches = value_has_type(value, schema_type); + assert!(matches, "{path} should be {schema_type}, got {value}"); +} + +fn value_has_type(value: &Value, schema_type: &str) -> bool { + match schema_type { + "array" => value.is_array(), + "boolean" => value.is_boolean(), + "integer" => value.as_i64().is_some() || value.as_u64().is_some(), + "null" => value.is_null(), + "number" => value.as_f64().is_some(), + "object" => value.is_object(), + "string" => value.is_string(), + _ => false, + } +} + +#[tokio::test] +async fn create_conversation_response_field_order_matches_openai() { + let filter = build_test_filter(); + + let req = make_request(Method::POST, "/v1/conversations"); + let mut ctx = make_filter_context(&req); + drop(filter.on_request(&mut ctx).await.unwrap()); + + let body_json = serde_json::json!({"metadata": {"project": "test"}}); + let mut body = Some(Bytes::from(serde_json::to_vec(&body_json).unwrap())); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + let resp = rejection_body(&rejection); + let keys: Vec<&String> = resp.as_object().unwrap().keys().collect(); + assert_eq!(keys, &["id", "object", "created_at", "metadata"]); +} diff --git a/apis/src/openai/conversations/validate.rs b/apis/src/openai/conversations/validate.rs new file mode 100644 index 0000000000..6b66548a00 --- /dev/null +++ b/apis/src/openai/conversations/validate.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Metadata validation for conversation objects. + +use std::fmt; + +use serde_json::Value; + +/// Maximum number of metadata keys. +const MAX_METADATA_KEYS: usize = 16; + +/// Maximum length of a metadata key in bytes. +const MAX_KEY_BYTES: usize = 64; + +/// Maximum length of a metadata string value in bytes. +const MAX_VALUE_BYTES: usize = 512; + +/// Metadata validation failure. +#[derive(Debug)] +pub(crate) enum MetadataError { + /// Value is not a JSON object (type mismatch). + InvalidType(String), + /// Constraint violation (key count, key/value length). + ConstraintViolation(String), +} + +impl fmt::Display for MetadataError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidType(msg) | Self::ConstraintViolation(msg) => f.write_str(msg), + } + } +} + +/// Validate conversation metadata. +/// +/// Rules: +/// - Must be a JSON object (or null/absent → default `{}`) +/// - At most 16 keys +/// - Each key ≤ 64 bytes +/// - Each value must be a string ≤ 512 bytes +#[expect(clippy::too_many_lines, reason = "sequential validation pipeline")] +pub(crate) fn validate_metadata(metadata: &Value) -> Result<(), MetadataError> { + let obj = match metadata { + Value::Object(map) => map, + Value::Null => return Ok(()), + _ => return Err(MetadataError::InvalidType("metadata must be a JSON object".to_owned())), + }; + + if obj.len() > MAX_METADATA_KEYS { + return Err(MetadataError::ConstraintViolation(format!( + "metadata must have at most {MAX_METADATA_KEYS} keys, got {}", + obj.len() + ))); + } + + for (key, value) in obj { + if key.len() > MAX_KEY_BYTES { + return Err(MetadataError::ConstraintViolation(format!( + "metadata key exceeds {MAX_KEY_BYTES} bytes: '{key}'" + ))); + } + match value { + Value::String(s) => { + if s.len() > MAX_VALUE_BYTES { + return Err(MetadataError::ConstraintViolation(format!( + "metadata value for key '{key}' exceeds {MAX_VALUE_BYTES} bytes" + ))); + } + }, + _ => { + return Err(MetadataError::InvalidType(format!( + "metadata value for key '{key}' must be a string" + ))); + }, + } + } + + Ok(()) +} diff --git a/apis/src/openai/mod.rs b/apis/src/openai/mod.rs new file mode 100644 index 0000000000..3711dd864b --- /dev/null +++ b/apis/src/openai/mod.rs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! `OpenAI` API filters: Responses API pipeline. + +#[expect(clippy::allow_attributes, reason = "dead_code expect unfulfilled on module")] +#[allow( + dead_code, + reason = "the shared API client intentionally exposes operations used by different OpenAI filters" +)] +pub(crate) mod api_client; +pub(crate) mod conversations; +mod operation; +pub(crate) mod responses; +pub(crate) mod sse; +#[expect(clippy::allow_attributes, reason = "dead_code expect unfulfilled on module")] +#[allow( + dead_code, + reason = "Responses translation helpers are wired into the HTTP filter in a later stack entry" +)] +pub(crate) mod translation; +pub(crate) mod url_security; + +pub use conversations::{ + ConversationOperation, ConversationOperationSpec, OpenaiConversationsFilter, + implementation_openapi_json as conversations_openapi_json, operation_specs as conversations_operation_specs, +}; +pub use operation::{OpenAiHandlingMode, OpenAiOperationSpec}; +pub use responses::{ + AgenticLoopFilter, CompactFilter, DocExtractFilter, FileResolveFilter, FileSearchCalloutFilter, McpDispatchFilter, + McpToolResolveFilter, ModelRewriteFilter, OpenaiResponsesValidateFilter, RehydrateFilter, ResponseStoreFilter, + ResponsesFormatFilter, ToolParseFilter, WebSearchFilter, openai_responses_proxy::ResponsesProxyFilter, + responses_to_chat_completions::ResponsesToChatCompletionsFilter, stream_events::OpenaiStreamEventsFilter, +}; diff --git a/apis/src/openai/operation.rs b/apis/src/openai/operation.rs new file mode 100644 index 0000000000..6bf9696646 --- /dev/null +++ b/apis/src/openai/operation.rs @@ -0,0 +1,462 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Shared operation metadata and generated `OpenAPI` support. + +use std::borrow::{Borrow, Cow}; + +use utoipa::openapi::{ + Components, Content, Info, OpenApi, Paths, Ref, RefOr, Required, Tag, + path::{HttpMethod, Operation, OperationBuilder, Parameter, ParameterBuilder, ParameterIn}, + request_body::RequestBodyBuilder, + response::ResponseBuilder, + schema::Schema, +}; + +/// Component schemas recursively referenced by one contract type. +type ReferencedSchemas = Vec<(String, RefOr)>; + +/// How Praxis handles an operation at the proxy boundary. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OpenAiHandlingMode { + /// Forward the operation without inspecting its payload. + Passthrough, + /// Read selected fields while preserving the forwarded payload. + Inspect, + /// Rewrite the operation between input and output contracts. + Transform, + /// Terminate the request and produce the response locally. + Local, +} + +/// HTTP method recognized by an OpenAI operation registry. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum OpenAiHttpMethod { + /// `GET`. + Get, + /// `POST`. + Post, + /// `PUT`. + Put, + /// `DELETE`. + Delete, + /// `PATCH`. + Patch, + /// `HEAD`. + Head, + /// `OPTIONS`. + Options, + /// `TRACE`. + Trace, +} + +impl OpenAiHttpMethod { + /// Stable uppercase spelling used by runtime matching and reports. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + Self::Put => "PUT", + Self::Delete => "DELETE", + Self::Patch => "PATCH", + Self::Head => "HEAD", + Self::Options => "OPTIONS", + Self::Trace => "TRACE", + } + } + + /// Convert to Utoipa's `OpenAPI` method representation. + const fn as_openapi(self) -> HttpMethod { + match self { + Self::Get => HttpMethod::Get, + Self::Post => HttpMethod::Post, + Self::Put => HttpMethod::Put, + Self::Delete => HttpMethod::Delete, + Self::Patch => HttpMethod::Patch, + Self::Head => HttpMethod::Head, + Self::Options => HttpMethod::Options, + Self::Trace => HttpMethod::Trace, + } + } +} + +impl OpenAiHandlingMode { + /// Stable label used by conformance reports. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Passthrough => "passthrough", + Self::Inspect => "inspect", + Self::Transform => "transform", + Self::Local => "local", + } + } + + /// Whether Praxis owns the operation's externally visible contract. + #[must_use] + pub const fn owns_contract(self) -> bool { + matches!(self, Self::Transform | Self::Local) + } +} + +/// Type-erased access to one concrete `ToSchema` implementation. +#[derive(Clone, Copy)] +pub(crate) struct SchemaBinding { + /// Return the component name. + name: fn() -> Cow<'static, str>, + /// Generate the component schema. + schema: fn() -> RefOr, + /// Collect recursively referenced schemas. + referenced_schemas: fn(&mut ReferencedSchemas), +} + +impl SchemaBinding { + /// Bind one concrete Rust schema type. + pub(crate) const fn new( + name: fn() -> Cow<'static, str>, + schema: fn() -> RefOr, + referenced_schemas: fn(&mut ReferencedSchemas), + ) -> Self { + Self { + name, + schema, + referenced_schemas, + } + } + + /// Component name generated by the bound Rust type. + fn name(self) -> Cow<'static, str> { + (self.name)() + } + + /// Schema generated by the bound Rust type. + fn schema(self) -> RefOr { + (self.schema)() + } + + /// Collect schemas referenced by the bound Rust type. + fn collect_referenced(self, schemas: &mut ReferencedSchemas) { + (self.referenced_schemas)(schemas); + } +} + +/// Bind a concrete Rust contract type to its generated component schema. +macro_rules! schema_binding { + ($schema:ty) => { + $crate::openai::operation::SchemaBinding::new( + <$schema as utoipa::ToSchema>::name, + <$schema as utoipa::PartialSchema>::schema, + <$schema as utoipa::ToSchema>::schemas, + ) + }; +} + +pub(crate) use schema_binding; + +/// Location of one operation parameter. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ParameterLocation { + /// Parameter embedded in the request path. + Path, + /// Parameter read from the query string. + Query, +} + +/// `OpenAPI` metadata for one operation parameter. +#[derive(Clone, Copy)] +pub(crate) struct ParameterSpec { + /// Parameter name. + pub(crate) name: &'static str, + /// Parameter location. + pub(crate) location: ParameterLocation, + /// Whether the parameter is required. + pub(crate) required: bool, + /// Human-readable parameter description. + pub(crate) description: &'static str, + /// Generate the inline parameter schema. + schema: fn() -> RefOr, +} + +impl ParameterSpec { + /// Declare one parameter backed by a concrete Rust schema type. + pub(crate) const fn new( + name: &'static str, + location: ParameterLocation, + required: bool, + description: &'static str, + schema: fn() -> RefOr, + ) -> Self { + Self { + name, + location, + required, + description, + schema, + } + } + + /// Generate the inline parameter schema. + fn schema(self) -> RefOr { + (self.schema)() + } +} + +/// One media type and schema owned by an operation. +#[derive(Clone, Copy)] +pub(crate) struct MediaTypeSpec { + /// HTTP media type, such as `application/json` or `multipart/form-data`. + pub(crate) content_type: &'static str, + /// Schema associated with this representation. + pub(crate) schema: SchemaBinding, +} + +impl MediaTypeSpec { + /// Declare one schema-backed media type. + pub(crate) const fn new(content_type: &'static str, schema: SchemaBinding) -> Self { + Self { content_type, schema } + } +} + +/// Request body contract owned by an operation. +#[derive(Clone, Copy)] +pub(crate) struct RequestBodySpec { + /// Whether the body is required. + pub(crate) required: bool, + /// Accepted media representations. + pub(crate) content: &'static [MediaTypeSpec], +} + +/// One response contract owned by an operation. +#[derive(Clone, Copy)] +pub(crate) struct ResponseSpec { + /// HTTP response status code. + pub(crate) status: &'static str, + /// Human-readable response description. + pub(crate) description: &'static str, + /// Response media representations. Empty for bodyless responses. + pub(crate) content: &'static [MediaTypeSpec], +} + +/// `OpenAPI` contract owned by one local or transforming operation. +#[derive(Clone, Copy)] +pub(crate) struct OwnedOperationContract { + /// Operation parameters. + pub(crate) parameters: &'static [ParameterSpec], + /// Request body, when present. + pub(crate) request: Option, + /// Responses produced by the operation. + pub(crate) responses: &'static [ResponseSpec], +} + +/// Provider-neutral metadata shared by runtime registries and conformance. +#[derive(Clone, Copy)] +pub struct OpenAiOperationSpec { + /// Stable `OpenAPI` operation ID. + pub operation_id: &'static str, + /// Typed HTTP method. + pub method: OpenAiHttpMethod, + /// Path as it appears in the OpenAI spec, without `/v1`. + pub spec_path: &'static str, + /// Runtime path template handled by Praxis. + pub runtime_path: &'static str, + /// Proxy handling mode. + pub mode: OpenAiHandlingMode, + /// Contract generated into the implementation `OpenAPI` document. + pub(crate) owned_contract: Option, +} + +impl OpenAiOperationSpec { + /// Whether this operation consumes a request body. + pub(crate) const fn has_request_body(&self) -> bool { + matches!( + self.owned_contract, + Some(OwnedOperationContract { request: Some(_), .. }) + ) + } + + /// Return the locally owned `OpenAPI` contract, when applicable. + pub(crate) const fn owned_contract(&self) -> Option { + self.owned_contract + } +} + +/// Generate an implementation `OpenAPI` document from runtime operation specs. +pub(crate) fn implementation_openapi(title: &str, version: &str, tag: &str, specs: I) -> OpenApi +where + I: IntoIterator, + I::Item: Borrow, +{ + let mut openapi = OpenApi::new(Info::new(title, version), Paths::new()); + openapi.tags = Some(vec![Tag::new(tag)]); + let mut components = Components::new(); + + for spec in specs { + let spec = spec.borrow(); + let Some(contract) = spec.owned_contract() else { + continue; + }; + if !spec.mode.owns_contract() { + continue; + } + + register_contract_schemas(&mut components, contract); + openapi.paths.add_path_operation( + spec.spec_path, + vec![spec.method.as_openapi()], + build_operation(spec, contract, tag), + ); + } + + openapi.components = Some(components); + openapi +} + +/// Register all concrete schemas referenced by one operation contract. +fn register_contract_schemas(components: &mut Components, contract: OwnedOperationContract) { + if let Some(request) = contract.request { + for media in request.content { + register_schema(components, media.schema); + } + } + for response in contract.responses { + for media in response.content { + register_schema(components, media.schema); + } + } +} + +/// Register a concrete schema and all schemas referenced by it. +fn register_schema(components: &mut Components, binding: SchemaBinding) { + let mut referenced = Vec::new(); + binding.collect_referenced(&mut referenced); + components.schemas.extend(referenced); + components.schemas.insert(binding.name().into_owned(), binding.schema()); +} + +/// Build one `OpenAPI` operation from its registry metadata. +fn build_operation(spec: &OpenAiOperationSpec, contract: OwnedOperationContract, tag: &str) -> Operation { + let mut operation = OperationBuilder::new().operation_id(Some(spec.operation_id)).tag(tag); + + for response in contract.responses { + let mut builder = ResponseBuilder::new().description(response.description); + for media in response.content { + builder = builder.content(media.content_type, schema_content(media.schema)); + } + operation = operation.response(response.status, builder.build()); + } + + if !contract.parameters.is_empty() { + operation = operation.parameters(Some(contract.parameters.iter().copied().map(build_parameter))); + } + + if let Some(request) = contract.request { + let mut builder = RequestBodyBuilder::new().required(Some(required(request.required))); + for media in request.content { + builder = builder.content(media.content_type, schema_content(media.schema)); + } + operation = operation.request_body(Some(builder.build())); + } + + operation.build() +} + +/// Build one path or query parameter. +fn build_parameter(spec: ParameterSpec) -> Parameter { + ParameterBuilder::new() + .name(spec.name) + .parameter_in(match spec.location { + ParameterLocation::Path => ParameterIn::Path, + ParameterLocation::Query => ParameterIn::Query, + }) + .description(Some(spec.description)) + .required(required(spec.required)) + .schema(Some(spec.schema())) + .build() +} + +/// Build media content referencing one registered component schema. +fn schema_content(binding: SchemaBinding) -> Content { + Content::new(Some(Ref::from_schema_name(binding.name().into_owned()))) +} + +/// Convert a boolean required flag into Utoipa's representation. +const fn required(value: bool) -> Required { + if value { Required::True } else { Required::False } +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow(clippy::unwrap_used, reason = "tests")] +mod tests { + use serde_json::Value as JsonValue; + use utoipa::ToSchema; + + use super::*; + + /// Representative multipart upload form. + #[derive(ToSchema)] + #[expect(dead_code, reason = "schema-only test contract")] + struct UploadForm { + /// Upload purpose. + purpose: String, + } + + /// Representative JSON response. + #[derive(ToSchema)] + #[expect(dead_code, reason = "schema-only test contract")] + struct FileResource { + /// File identifier. + id: String, + } + + /// Files-like operation exercising non-JSON and multiple response shapes. + const FILE_OPERATION: OpenAiOperationSpec = OpenAiOperationSpec { + operation_id: "createFile", + method: OpenAiHttpMethod::Post, + spec_path: "/files", + runtime_path: "/v1/files", + mode: OpenAiHandlingMode::Local, + owned_contract: Some(OwnedOperationContract { + parameters: &[], + request: Some(RequestBodySpec { + required: true, + content: &[MediaTypeSpec::new("multipart/form-data", schema_binding!(UploadForm))], + }), + responses: &[ + ResponseSpec { + status: "200", + description: "OK", + content: &[MediaTypeSpec::new("application/json", schema_binding!(FileResource))], + }, + ResponseSpec { + status: "204", + description: "No content", + content: &[], + }, + ], + }), + }; + + #[test] + fn generated_contract_supports_files_style_media_and_responses() { + let document = implementation_openapi("Files test", "0.1.0", "Files", std::iter::once(&FILE_OPERATION)); + let value = serde_json::to_value(document).unwrap(); + + assert_eq!( + value.pointer("/paths/~1files/post/requestBody/required"), + Some(&JsonValue::Bool(true)) + ); + assert!( + value + .pointer("/paths/~1files/post/requestBody/content/multipart~1form-data/schema/$ref") + .is_some() + ); + assert!( + value + .pointer("/paths/~1files/post/responses/200/content/application~1json/schema/$ref") + .is_some() + ); + assert!(value.pointer("/paths/~1files/post/responses/204/content").is_none()); + } +} diff --git a/apis/src/openai/responses/README.md b/apis/src/openai/responses/README.md new file mode 100644 index 0000000000..aebf137417 --- /dev/null +++ b/apis/src/openai/responses/README.md @@ -0,0 +1,48 @@ +# OpenAI Responses Filters + +Pipeline overview for filters under `apis/src/openai/responses/`. + + + +- **`openai_agentic_loop`** — Agentic loop controller for the Responses API pipeline. +- **`openai_doc_extract`** — Converts `input_file` content parts to `input_text` for backends that do not support `input_file` natively (e.g. vLLM, llm-d). +- **`openai_file_resolve`** — Resolves `file_id` and `file_url` references in Responses API input by fetching content from a Files API or remote URL via `ApiClient` and inlining the base64-encoded content in the provider-native field. +- **`openai_file_search_callout`** — Executes pending file search calls against a vector store API compatible backend. +- **`openai_mcp_dispatch`** — Executes MCP tool calls against upstream MCP servers within the Responses API agentic loop. +- **`openai_mcp_tool_resolve`** — Resolves MCP tool entries from the Responses API `tools` array into concrete tool definitions by calling `tools/list` on each upstream MCP server. +- **`openai_response_store`** — Persists Responses API responses to the configured response store backend. +- **`openai_responses_compact`** — Summarizes conversation history when the token count exceeds a configured threshold. +- **`openai_responses_format`** — Classifies AI API request bodies and promotes routing facts to headers, metadata, and filter results without mutating the body. +- **`openai_responses_model_rewrite`** — Rewrites the `model` field in Responses API request bodies. +- **`openai_responses_proxy`** — Rebuilds the request body from `ResponsesState` when present. +- **`openai_responses_rehydrate`** — Validates `previous_response_id` by fetching the stored response, confirming its status is `"completed"`, and populating `ResponsesState` with the full conversation history (stored turns + current input). +- **`openai_responses_validate`** — Validates and enriches Responses API requests. +- **`openai_stream_events`** — Accumulates state from native Responses API SSE event streams. +- **`openai_tool_parse`** — Parses tool definitions and `tool_choice` from Responses API request bodies and promotes routing facts to metadata and filter results without mutating the body. +- **`openai_web_search`** — Web search filter for model-driven `web_search_call` dispatch. +- **`responses_to_chat_completions`** — Translates canonical Responses create requests for a Chat Completions backend. + +## Pipeline Hooks + +Body-phase columns show `Access / Mode` when the hook is implemented. + +| Filter | `on_request` | `on_request_body` | `on_response` | `on_response_body` | +|--------|:------------:|:-----------------:|:--------------:|:------------------:| +| `openai_agentic_loop` | — | ReadOnly / StreamBuffer | — | ReadWrite / StreamBuffer | +| `openai_doc_extract` | — | ReadWrite / StreamBuffer | — | — | +| `openai_file_resolve` | — | ReadWrite / StreamBuffer | — | — | +| `openai_file_search_callout` | ✓ | ReadOnly / StreamBuffer | — | ReadWrite / StreamBuffer | +| `openai_mcp_dispatch` | — | ReadOnly / StreamBuffer | — | ReadOnly / StreamBuffer | +| `openai_mcp_tool_resolve` | — | ReadWrite / StreamBuffer | — | — | +| `openai_response_store` | ✓ | ReadOnly / Stream | ✓ | ReadOnly / StreamBuffer | +| `openai_responses_compact` | — | ReadOnly / StreamBuffer | — | — | +| `openai_responses_format` | — | ReadOnly / StreamBuffer | — | — | +| `openai_responses_model_rewrite` | ✓ | ReadWrite / StreamBuffer | — | — | +| `openai_responses_proxy` | — | ReadWrite / StreamBuffer | — | — | +| `openai_responses_rehydrate` | — | ReadOnly / StreamBuffer | — | — | +| `openai_responses_validate` | — | ReadOnly / StreamBuffer | — | — | +| `openai_stream_events` | ✓ | — | ✓ | ReadOnly / Stream | +| `openai_tool_parse` | ✓ | ReadOnly / StreamBuffer | — | — | +| `openai_web_search` | — | ReadOnly / StreamBuffer | — | ReadOnly / StreamBuffer | +| `responses_to_chat_completions` | — | ReadWrite / StreamBuffer | ✓ | ReadWrite / Stream | diff --git a/apis/src/openai/responses/agentic_loop/config.rs b/apis/src/openai/responses/agentic_loop/config.rs new file mode 100644 index 0000000000..846df5ff94 --- /dev/null +++ b/apis/src/openai/responses/agentic_loop/config.rs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Configuration types for the agentic loop filter. + +use praxis_filter::FilterError; +use serde::Deserialize; + +// ----------------------------------------------------------------------------- +// Defaults +// ----------------------------------------------------------------------------- + +/// Default maximum inference iterations in the agentic loop. +/// +/// Not part of the OpenAI spec — this is a Praxis-only safety cap +/// on how many inference round-trips the agentic loop can perform. +const DEFAULT_MAX_INFER_ITERS: u32 = 10; + +/// Default maximum response body size for `StreamBuffer` mode (10 MiB). +pub(super) const DEFAULT_MAX_BODY_BYTES: usize = 10 * 1024 * 1024; + +/// Serde default for `max_infer_iters`. +fn default_max_infer_iters() -> u32 { + DEFAULT_MAX_INFER_ITERS +} + +/// Serde default for `max_body_bytes`. +fn default_max_body_bytes() -> usize { + DEFAULT_MAX_BODY_BYTES +} + +// ----------------------------------------------------------------------------- +// AgenticLoopConfig +// ----------------------------------------------------------------------------- + +/// Deserialized YAML config for the agentic loop filter. +/// +/// ```yaml +/// filter: openai_agentic_loop +/// max_infer_iters: 10 +/// ``` +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct AgenticLoopConfig { + /// Maximum number of inference loop iterations (Praxis-only, + /// not part of the OpenAI API spec). When the iteration counter + /// reaches this limit, the loop returns a 508 Loop Detected error. + #[serde(default = "default_max_infer_iters")] + pub max_infer_iters: u32, + + /// Maximum response body size in bytes for `StreamBuffer` mode. + #[serde(default = "default_max_body_bytes")] + pub max_body_bytes: usize, +} + +impl Default for AgenticLoopConfig { + fn default() -> Self { + Self { + max_infer_iters: DEFAULT_MAX_INFER_ITERS, + max_body_bytes: DEFAULT_MAX_BODY_BYTES, + } + } +} + +// ----------------------------------------------------------------------------- +// Config Validation +// ----------------------------------------------------------------------------- + +/// Validate the parsed configuration. +pub(super) fn build_config(cfg: AgenticLoopConfig) -> Result { + if cfg.max_infer_iters == 0 { + return Err("openai_agentic_loop: max_infer_iters must be > 0".into()); + } + if cfg.max_body_bytes == 0 { + return Err("openai_agentic_loop: max_body_bytes must be > 0".into()); + } + Ok(cfg) +} diff --git a/apis/src/openai/responses/agentic_loop/mod.rs b/apis/src/openai/responses/agentic_loop/mod.rs new file mode 100644 index 0000000000..82081633b8 --- /dev/null +++ b/apis/src/openai/responses/agentic_loop/mod.rs @@ -0,0 +1,517 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Agentic loop controller for the Responses API pipeline. +//! +//! Manages the inference loop lifecycle: iteration counting, +//! tool-choice reset, exit conditions, and the loop/done signal +//! available to `iterative_request_router` step transitions. +//! +//! Does **not** classify tool calls by type or execute them — +//! MCP classification and execution are handled by +//! `openai_mcp_dispatch`, web search execution by +//! `openai_web_search`. +//! +//! # Loop control +//! +//! Writes `filter_results` during `on_response_body` where +//! `iterative_request_router` evaluates step transitions: +//! - `openai_agentic_loop.action = "loop"` — tool calls present, loop back +//! - `openai_agentic_loop.action = "done"` — exit to client +//! +//! # Non-streaming tool call extraction +//! +//! For non-streaming responses (the only mode supported by IRR), +//! this filter parses the response body JSON and extracts +//! `function_call` items from the `output` array into +//! `state.tool_calls` and `web_search_call` items into +//! `state.web_search_calls`. It also appends these items to +//! `state.messages` so the model sees its own calls on re-entry. +//! +//! For streaming responses (future), `stream_events` populates +//! `state.tool_calls` via SSE event parsing. When the body is +//! `None` at end-of-stream (consumed by streaming filters), this +//! filter skips body parsing and checks `state.tool_calls` as-is. +//! +//! `on_request_body` handles iteration bookkeeping: clearing stale +//! tool calls and web search calls from the previous round, +//! forcing `parallel_tool_calls` to `false` (v1 supports one +//! function call per round), and resetting `tool_choice` to +//! `"auto"` on re-entry. +//! +//! # Filter order +//! +//! For tool execution, it must appear after `openai_web_search` +//! and `openai_mcp_dispatch` and before `openai_responses_proxy`. +//! Response filters execute in reverse order, so the loop +//! extracts tool calls before dispatch filters classify them and +//! publish the IRR transition. +//! +//! ```yaml +//! filter: iterative_request_router +//! initial_step: inference +//! max_iterations: 11 +//! steps: +//! - name: inference +//! filters: +//! - filter: openai_web_search +//! provider: brave +//! api_key: ${WEB_SEARCH_API_KEY} +//! - filter: openai_mcp_dispatch +//! - filter: openai_agentic_loop +//! max_infer_iters: 10 +//! - filter: openai_responses_proxy +//! - filter: router +//! routes: +//! - cluster: model-backend +//! - filter: load_balancer +//! clusters: +//! - name: model-backend +//! endpoints: ["127.0.0.1:3001"] +//! on_result: +//! - filter: openai_mcp_dispatch +//! key: action +//! value: loop +//! next: inference +//! - filter: openai_web_search +//! key: action +//! value: loop +//! next: inference +//! - default: true +//! done: true +//! ``` +//! +//! # Streaming limitation +//! +//! Streaming requests (`stream: true`) are rejected with a 400 +//! error. `iterative_request_router` fully buffers all responses +//! within the loop and cannot forward incremental SSE events. +//! +//! # State dependency +//! +//! Requires [`ResponsesState`] in request extensions. Without it +//! the filter passes through silently. State is created by +//! `openai_responses_validate` for every Responses API create +//! request. + +mod config; + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + clippy::needless_raw_strings, + clippy::needless_raw_string_hashes, + clippy::too_many_lines, + reason = "tests" +)] +mod tests; + +use async_trait::async_trait; +use bytes::Bytes; +use http::header::{CONTENT_TYPE, HeaderValue}; +use praxis_filter::{ + BodyAccess, BodyMode, FilterAction, FilterError, HttpFilter, HttpFilterContext, parse_filter_config, +}; +use serde_json::{Value, json}; +use tracing::{debug, trace}; + +use self::config::{AgenticLoopConfig, build_config}; +use super::{error::responses_error_rejection, state::ResponsesState, stream_events::accumulator::merge_usage}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Filter results key for the loop control action. +const FILTER_RESULT_KEY: &str = "openai_agentic_loop"; + +/// Action value signalling a loop-back to `responses_proxy`. +const ACTION_LOOP: &str = "loop"; + +/// Action value signalling loop exit. +const ACTION_DONE: &str = "done"; + +/// Metadata key for the response status set on incomplete exits. +const META_STATUS: &str = "responses.status"; + +// ----------------------------------------------------------------------------- +// AgenticLoopFilter +// ----------------------------------------------------------------------------- + +/// Agentic loop controller for the Responses API pipeline. +/// +/// Manages iteration bookkeeping in `on_request_body`, extracts tool +/// calls from non-streaming response bodies, and evaluates loop +/// control in `on_response_body` (end-of-stream), writing +/// `filter_results` for `iterative_request_router` transitions. +/// +/// # YAML +/// +/// ```yaml +/// filter: openai_agentic_loop +/// ``` +/// +/// # Full YAML +/// +/// ```yaml +/// filter: openai_agentic_loop +/// max_infer_iters: 10 +/// max_body_bytes: 10485760 +/// ``` +/// +/// # Example +/// +/// ```rust +/// use praxis_ai_apis::openai::AgenticLoopFilter; +/// +/// let yaml = serde_yaml::Value::Null; +/// let filter = AgenticLoopFilter::from_config(&yaml).unwrap(); +/// assert_eq!(filter.name(), "openai_agentic_loop"); +/// ``` +pub struct AgenticLoopFilter { + /// Parsed and validated configuration. + config: AgenticLoopConfig, +} + +impl AgenticLoopFilter { + /// Create from parsed YAML config. + /// + /// # Errors + /// + /// Returns [`FilterError`] if the YAML config contains unknown + /// fields or invalid values. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: AgenticLoopConfig = if config.is_null() { + AgenticLoopConfig::default() + } else { + parse_filter_config("openai_agentic_loop", config)? + }; + let validated = build_config(cfg)?; + Ok(Box::new(Self { config: validated })) + } +} + +#[async_trait] +impl HttpFilter for AgenticLoopFilter { + fn name(&self) -> &'static str { + "openai_agentic_loop" + } + + fn request_body_access(&self) -> BodyAccess { + BodyAccess::ReadOnly + } + + fn request_body_mode(&self) -> BodyMode { + BodyMode::StreamBuffer { + max_bytes: Some(self.config.max_body_bytes), + } + } + + fn response_body_access(&self) -> BodyAccess { + BodyAccess::ReadWrite + } + + fn response_body_mode(&self) -> BodyMode { + BodyMode::StreamBuffer { + max_bytes: Some(self.config.max_body_bytes), + } + } + + async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result { + Ok(FilterAction::Continue) + } + + async fn on_request_body( + &self, + ctx: &mut HttpFilterContext<'_>, + _body: &mut Option, + end_of_stream: bool, + ) -> Result { + if !end_of_stream { + return Ok(FilterAction::Continue); + } + + let Some(mut state) = ctx.extensions.remove::() else { + return Ok(FilterAction::Continue); + }; + + if state.request_body.get("stream") == Some(&Value::Bool(true)) { + ctx.extensions.insert(state); + return Ok(FilterAction::Reject(responses_error_rejection( + 400, + "invalid_request_error", + "streaming is not supported with openai_agentic_loop", + false, + ))); + } + + prepare_iteration(ctx, &mut state); + trace!(iteration = state.iteration, "openai_agentic_loop on_request_body"); + ctx.extensions.insert(state); + Ok(FilterAction::Continue) + } + + fn on_response_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + if !end_of_stream { + return Ok(FilterAction::Continue); + } + + let Some(mut state) = ctx.extensions.remove::() else { + return Ok(FilterAction::Continue); + }; + + if let Some(bytes) = body.as_ref() + && let Err(msg) = extract_tool_calls_from_body(bytes, &mut state) + { + ctx.extensions.insert(state); + return Ok(FilterAction::Reject(responses_error_rejection( + 400, + "invalid_request_error", + msg, + false, + ))); + } + + let result = evaluate_loop_decision(ctx, &mut state, body, &self.config)?; + ctx.extensions.insert(state); + Ok(result) + } +} + +// ----------------------------------------------------------------------------- +// Request-Side Bookkeeping +// ----------------------------------------------------------------------------- + +/// Prepare state for the current iteration: clear stale tool calls, +/// force `parallel_tool_calls=false`, and on re-entry reset +/// `tool_choice` and set `Content-Type` (subrequests do not inherit +/// the original client header). +fn prepare_iteration(ctx: &mut HttpFilterContext<'_>, state: &mut ResponsesState) { + state.tool_calls.clear(); + state.web_search_calls.clear(); + state.parallel_tool_calls = false; + set_request_body_field(state, "parallel_tool_calls", Value::Bool(false)); + + if state.iteration > 0 { + state.tool_choice = json!("auto"); + set_request_body_field(state, "tool_choice", json!("auto")); + ctx.request_headers_to_set + .push((CONTENT_TYPE, HeaderValue::from_static("application/json"))); + } +} + +/// Set a provider-visible request field and record whether its value changed. +fn set_request_body_field(state: &mut ResponsesState, name: &str, value: Value) { + let Some(obj) = state.request_body.as_object_mut() else { + return; + }; + if obj.get(name) != Some(&value) { + obj.insert(name.to_owned(), value); + state.mark_request_body_for_rebuild(); + } +} + +// ----------------------------------------------------------------------------- +// Loop Decision +// ----------------------------------------------------------------------------- + +/// Decide the loop outcome: done (no tool calls or model-owned finish), +/// 508 (iteration limit), or loop (continue to tool execution). +fn evaluate_loop_decision( + ctx: &mut HttpFilterContext<'_>, + state: &mut ResponsesState, + body: &mut Option, + config: &AgenticLoopConfig, +) -> Result { + if state.tool_calls.is_empty() && state.web_search_calls.is_empty() { + trace!("no tool calls, signaling done"); + finalize_response_body(state, body); + return set_done(ctx); + } + match check_exit_conditions(state, config) { + Some(ExitReason::FinishReasonLength) => { + ctx.set_metadata(META_STATUS, "incomplete"); + finalize_response_body(state, body); + set_action(ctx, ACTION_DONE)?; + Ok(FilterAction::Continue) + }, + Some(ExitReason::IterationLimit) => Ok(FilterAction::Reject(responses_error_rejection( + 508, + "server_error", + "agentic loop iteration limit exceeded", + false, + ))), + None => { + state.iteration += 1; + let (tc, wsc) = (state.tool_calls.len(), state.web_search_calls.len()); + debug!(iteration = state.iteration, tc, wsc, "pending calls, signaling loop"); + finalize_response_body(state, body); + set_action(ctx, ACTION_LOOP)?; + Ok(FilterAction::Continue) + }, + } +} + +// ----------------------------------------------------------------------------- +// Body Parsing +// ----------------------------------------------------------------------------- + +/// Extract completed function-call items from a non-streaming +/// response body and populate `state.tool_calls` and +/// `state.messages`. +/// +/// Returns `Err` if multiple function calls are found — v1 +/// supports exactly one function call per round. +fn extract_tool_calls_from_body(body: &Bytes, state: &mut ResponsesState) -> Result<(), &'static str> { + let response = serde_json::from_slice::(body) + .ok() + .filter(is_responses_api_output); + let Some(response) = response else { + state.response_object = Value::Null; + state.tool_calls.clear(); + return Ok(()); + }; + collect_output_items(&response, state); + if let Some(usage) = response.get("usage").filter(|u| !u.is_null()) { + merge_usage(&mut state.usage, usage); + } + state.response_object = response; + if state.tool_calls.len() > 1 { + return Err("openai_agentic_loop supports exactly one function call per round"); + } + Ok(()) +} + +/// Distribute output items from a parsed response into the accumulator and state vectors. +fn collect_output_items(response: &Value, state: &mut ResponsesState) { + let Some(Value::Array(output)) = response.get("output") else { + return; + }; + for item in output { + state.accumulated_output.push(item.clone()); + match item.get("type").and_then(Value::as_str) { + Some("function_call") if item.get("status").and_then(Value::as_str) == Some("completed") => { + state.tool_calls.push(item.clone()); + state.messages.push(item.clone()); + state.persisted_messages.push(item.clone()); + }, + Some("reasoning") => { + state.messages.push(item.clone()); + state.persisted_messages.push(item.clone()); + }, + Some("web_search_call") => { + state.web_search_calls.push(item.clone()); + state.messages.push(item.clone()); + state.persisted_messages.push(item.clone()); + }, + _ => {}, + } + } +} + +/// Check whether a parsed response is a valid Responses API output. +/// +/// Returns `false` for error bodies (`"object": "error"`) and +/// responses without the canonical `"object": "response"` marker, +/// preventing usage injection into upstream error responses. +fn is_responses_api_output(response: &Value) -> bool { + response + .get("object") + .and_then(Value::as_str) + .is_some_and(|v| v == "response") +} + +// ----------------------------------------------------------------------------- +// Exit Condition Checks +// ----------------------------------------------------------------------------- + +/// Why the loop should exit early. +enum ExitReason { + /// The model reported `status: "incomplete"` due to output + /// token limits — a model-owned reason, passed through as-is. + FinishReasonLength, + /// The proxy's `max_infer_iters` cap was reached — a + /// proxy-owned reason, returned as a 508 error. + IterationLimit, +} + +/// Check whether the loop should exit early. +fn check_exit_conditions(state: &ResponsesState, config: &AgenticLoopConfig) -> Option { + if is_finish_reason_length(state) { + debug!("finish_reason is length, exiting loop as incomplete"); + return Some(ExitReason::FinishReasonLength); + } + if state.iteration >= config.max_infer_iters { + debug!( + iteration = state.iteration, + max = config.max_infer_iters, + "config iteration limit reached" + ); + return Some(ExitReason::IterationLimit); + } + None +} + +/// Check whether the response finished due to length limit. +fn is_finish_reason_length(state: &ResponsesState) -> bool { + state + .response_object + .get("status") + .and_then(Value::as_str) + .is_some_and(|s| s == "incomplete") + || state + .response_object + .get("incomplete_details") + .and_then(|d| d.get("reason")) + .and_then(Value::as_str) + .is_some_and(|r| r == "max_output_tokens") +} + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +/// Build the final response body from accumulated state. +/// +/// Replaces `response_object["output"]` with the full +/// `accumulated_output` (all rounds), stamps accumulated usage, +/// and serializes back to body bytes. +fn finalize_response_body(state: &ResponsesState, body: &mut Option) { + if !state.response_object.is_object() { + return; + } + let mut response = state.response_object.clone(); + if let Some(obj) = response.as_object_mut() { + if !state.accumulated_output.is_empty() { + obj.insert("output".to_owned(), Value::Array(state.accumulated_output.clone())); + } + if !state.usage.is_null() { + obj.insert("usage".to_owned(), state.usage.clone()); + } + } + if let Ok(serialized) = serde_json::to_vec(&response) { + *body = Some(Bytes::from(serialized)); + } +} + +/// Shorthand: set `action = "done"` and return `Continue`. +fn set_done(ctx: &mut HttpFilterContext<'_>) -> Result { + set_action(ctx, ACTION_DONE)?; + Ok(FilterAction::Continue) +} + +/// Write the loop control action to filter results. +fn set_action(ctx: &mut HttpFilterContext<'_>, action: &'static str) -> Result<(), FilterError> { + let results = ctx.filter_results.entry(FILTER_RESULT_KEY).or_default(); + results.set("action", action)?; + Ok(()) +} diff --git a/apis/src/openai/responses/agentic_loop/tests.rs b/apis/src/openai/responses/agentic_loop/tests.rs new file mode 100644 index 0000000000..3ee0084d8b --- /dev/null +++ b/apis/src/openai/responses/agentic_loop/tests.rs @@ -0,0 +1,1303 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Unit tests for the agentic loop filter. + +use bytes::Bytes; +use http::Method; +use praxis_filter::{FilterAction, HttpFilter}; +use serde_json::{Value, json}; + +use super::super::state::ResponsesState; +use crate::test_utils::{make_filter_context, make_request}; + +// ----------------------------------------------------------------------------- +// Config Parsing +// ----------------------------------------------------------------------------- + +#[test] +fn from_config_accepts_null() { + let yaml = serde_yaml::Value::Null; + let filter = super::AgenticLoopFilter::from_config(&yaml).unwrap(); + assert_eq!(filter.name(), "openai_agentic_loop"); +} + +#[test] +fn from_config_accepts_empty_mapping() { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let filter = super::AgenticLoopFilter::from_config(&yaml).unwrap(); + assert_eq!(filter.name(), "openai_agentic_loop"); +} + +#[test] +fn from_config_accepts_custom_values() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_infer_iters: 5").unwrap(); + let filter = super::AgenticLoopFilter::from_config(&yaml).unwrap(); + assert_eq!(filter.name(), "openai_agentic_loop"); +} + +#[test] +fn from_config_rejects_unknown_fields() { + let yaml: serde_yaml::Value = serde_yaml::from_str("unknown_field: true").unwrap(); + let result = super::AgenticLoopFilter::from_config(&yaml); + assert!(result.is_err(), "unknown fields should be rejected"); +} + +#[test] +fn from_config_rejects_zero_max_infer_iters() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_infer_iters: 0").unwrap(); + let result = super::AgenticLoopFilter::from_config(&yaml); + assert!(result.is_err(), "max_infer_iters=0 should be rejected"); +} + +// ----------------------------------------------------------------------------- +// Passthrough Without State +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn passthrough_without_state_on_request_body() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let action = filter.on_request_body(&mut ctx, &mut None, true).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); + assert!( + ctx.filter_results.is_empty(), + "should not write filter_results without state" + ); +} + +#[test] +fn passthrough_without_state_on_response_body() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let action = filter.on_response_body(&mut ctx, &mut None, true).unwrap(); + assert!(matches!(action, FilterAction::Continue)); + assert!( + ctx.filter_results.is_empty(), + "should not write filter_results without state" + ); +} + +// ----------------------------------------------------------------------------- +// on_request_body Bookkeeping +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn on_request_body_clears_stale_tool_calls() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![json!({ + "type": "function", + "call_id": "call_1", + "name": "test", + })]); + ctx.extensions.insert(state); + + drop(filter.on_request_body(&mut ctx, &mut None, true).await.unwrap()); + + let state = ctx.extensions.get::().unwrap(); + assert!( + state.tool_calls.is_empty(), + "on_request_body must clear stale tool_calls from previous round" + ); + assert!( + ctx.filter_results.is_empty(), + "on_request_body should not set filter_results" + ); +} + +#[tokio::test] +async fn tool_choice_preserved_on_first_iteration() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let mut state = make_state_with_tool_calls(vec![]); + state.tool_choice = json!("required"); + ctx.extensions.insert(state); + + drop(filter.on_request_body(&mut ctx, &mut None, true).await.unwrap()); + + let state = ctx.extensions.get::().unwrap(); + assert_eq!( + state.tool_choice, + json!("required"), + "tool_choice should be preserved on first iteration (iteration=0)" + ); +} + +#[tokio::test] +async fn tool_choice_reset_after_first_iteration() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let mut state = make_state_with_tool_calls(vec![]); + state.tool_choice = json!("required"); + state.iteration = 1; + ctx.extensions.insert(state); + + drop(filter.on_request_body(&mut ctx, &mut None, true).await.unwrap()); + + let state = ctx.extensions.get::().unwrap(); + assert_eq!( + state.tool_choice, + json!("auto"), + "tool_choice should be reset to auto after first iteration" + ); + assert_eq!( + state.request_body["tool_choice"], "auto", + "tool_choice should be inserted into request_body for proxy serialization" + ); +} + +// ----------------------------------------------------------------------------- +// on_request_body: Content-Type on Re-entry +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn sets_content_type_on_reentry() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let mut state = make_state_with_tool_calls(vec![]); + state.iteration = 1; + ctx.extensions.insert(state); + + drop(filter.on_request_body(&mut ctx, &mut None, true).await.unwrap()); + + let has_content_type = ctx + .request_headers_to_set + .iter() + .any(|(k, v)| k == http::header::CONTENT_TYPE && v == "application/json"); + assert!(has_content_type, "IRR re-entry must set content-type: application/json"); +} + +#[tokio::test] +async fn does_not_set_content_type_on_first_pass() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + drop(filter.on_request_body(&mut ctx, &mut None, true).await.unwrap()); + + let has_content_type = ctx + .request_headers_to_set + .iter() + .any(|(k, _)| k == http::header::CONTENT_TYPE); + assert!( + !has_content_type, + "first pass relies on client content-type, filter must not set it" + ); +} + +// ----------------------------------------------------------------------------- +// on_request_body: Parallel Tool Calls +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn forces_parallel_tool_calls_false() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let body = json!({"model": "gpt-4o", "input": "test", "tools": [{"type": "function"}]}); + let mut state = ResponsesState::from_request_body(body); + assert!(state.parallel_tool_calls, "default should be true"); + assert_eq!( + state.request_body.get("parallel_tool_calls"), + None, + "client did not set parallel_tool_calls" + ); + + state.iteration = 0; + ctx.extensions.insert(state); + drop(filter.on_request_body(&mut ctx, &mut None, true).await.unwrap()); + + let state = ctx.extensions.get::().unwrap(); + assert!(!state.parallel_tool_calls, "should be forced to false"); + assert_eq!( + state.request_body["parallel_tool_calls"], false, + "request_body should contain parallel_tool_calls=false for proxy serialization" + ); + assert!( + state.request_body_requires_rebuild(), + "inserting parallel_tool_calls must require proxy serialization" + ); +} + +#[tokio::test] +async fn preserves_unmodified_parallel_tool_calls_false() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let body = json!({ + "model": "gpt-4o", + "input": "test", + "parallel_tool_calls": false, + "tools": [{"type": "function"}] + }); + ctx.extensions.insert(ResponsesState::from_request_body(body)); + + drop(filter.on_request_body(&mut ctx, &mut None, true).await.unwrap()); + + let state = ctx.extensions.get::().unwrap(); + assert!( + !state.request_body_requires_rebuild(), + "an already-disabled request should retain byte-exact passthrough" + ); +} + +// ----------------------------------------------------------------------------- +// on_request_body: Reject Streaming +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn rejects_streaming_request() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let body = json!({"model": "gpt-4o", "input": "test", "stream": true}); + let state = ResponsesState::from_request_body(body); + ctx.extensions.insert(state); + + let action = filter.on_request_body(&mut ctx, &mut None, true).await.unwrap(); + assert!( + matches!(&action, FilterAction::Reject(r) if r.status == 400), + "stream:true should produce a 400 rejection" + ); +} + +#[tokio::test] +async fn streaming_rejection_preserves_state() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let body = json!({"model": "gpt-4o", "input": "test", "stream": true}); + let state = ResponsesState::from_request_body(body); + ctx.extensions.insert(state); + + drop(filter.on_request_body(&mut ctx, &mut None, true).await.unwrap()); + + assert!( + ctx.extensions.get::().is_some(), + "ResponsesState must remain in extensions after streaming rejection" + ); +} + +// ----------------------------------------------------------------------------- +// on_response_body: No Tool Calls → Done +// ----------------------------------------------------------------------------- + +#[test] +fn no_tool_calls_sets_done() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + let action = filter.on_response_body(&mut ctx, &mut None, true).unwrap(); + assert!(matches!(action, FilterAction::Continue)); + assert_action(&ctx, "done"); +} + +#[test] +fn state_survives_done_path() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + drop(filter.on_response_body(&mut ctx, &mut None, true).unwrap()); + + let state = ctx.extensions.get::(); + assert!( + state.is_some(), + "ResponsesState must remain in extensions after done so downstream filters can read it" + ); +} + +#[test] +fn non_end_of_stream_passes_through() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![json!({ + "type": "function", + "call_id": "call_1", + "name": "test", + })]); + ctx.extensions.insert(state); + + let action = filter.on_response_body(&mut ctx, &mut None, false).unwrap(); + assert!(matches!(action, FilterAction::Continue)); + assert!( + ctx.filter_results.is_empty(), + "should not set filter_results on non-end-of-stream chunks" + ); +} + +// ----------------------------------------------------------------------------- +// on_response_body: Tool Calls Present → Loop +// ----------------------------------------------------------------------------- + +#[test] +fn tool_calls_set_loop() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![json!({ + "type": "function", + "call_id": "call_1", + "name": "get_weather", + "arguments": "{}", + })]); + ctx.extensions.insert(state); + + let action = filter.on_response_body(&mut ctx, &mut None, true).unwrap(); + assert!(matches!(action, FilterAction::Continue)); + assert_action(&ctx, "loop"); + + let state = ctx.extensions.get::().unwrap(); + assert_eq!(state.iteration, 1, "iteration should be incremented"); +} + +#[test] +fn any_tool_type_sets_loop() { + for tool_type in ["function", "mcp", "web_search", "file_search", "custom_tool"] { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![json!({ + "type": tool_type, + "call_id": "call_1", + })]); + ctx.extensions.insert(state); + + drop(filter.on_response_body(&mut ctx, &mut None, true).unwrap()); + assert_action(&ctx, "loop"); + } +} + +// ----------------------------------------------------------------------------- +// on_response_body: Config Defaults +// ----------------------------------------------------------------------------- + +#[test] +fn default_config_has_max_infer_iters_ten() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let mut state = make_state_with_tool_calls(vec![json!({ + "type": "function", + "call_id": "call_1", + "name": "test", + })]); + state.iteration = 9; + ctx.extensions.insert(state); + + drop(filter.on_response_body(&mut ctx, &mut None, true).unwrap()); + assert_action(&ctx, "loop"); + + let mut state = ctx.extensions.remove::().unwrap(); + assert_eq!(state.iteration, 10, "iteration should have incremented to 10"); + state.tool_calls = vec![json!({"type": "function", "call_id": "call_2", "name": "test"})]; + ctx.extensions.insert(state); + + let action = filter.on_response_body(&mut ctx, &mut None, true).unwrap(); + assert!( + matches!(&action, FilterAction::Reject(r) if r.status == 508), + "iteration 10 at default limit should produce 508 rejection" + ); +} + +// ----------------------------------------------------------------------------- +// on_response_body: Iteration Limit +// ----------------------------------------------------------------------------- + +#[test] +fn max_infer_iters_one_allows_exactly_one_loop() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_infer_iters: 1").unwrap(); + let filter = super::AgenticLoopFilter::from_config(&yaml).unwrap(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![json!({ + "type": "function", + "call_id": "call_1", + "name": "test", + })]); + ctx.extensions.insert(state); + + drop(filter.on_response_body(&mut ctx, &mut None, true).unwrap()); + assert_action(&ctx, "loop"); + + let mut state = ctx.extensions.remove::().unwrap(); + assert_eq!(state.iteration, 1, "should have incremented to 1"); + + state.tool_calls = vec![json!({"type": "function", "call_id": "call_2", "name": "test"})]; + ctx.extensions.insert(state); + + let action = filter.on_response_body(&mut ctx, &mut None, true).unwrap(); + assert!( + matches!(&action, FilterAction::Reject(r) if r.status == 508), + "second round at iteration limit should produce 508 rejection" + ); +} + +#[test] +fn iteration_limit_returns_508_error() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_infer_iters: 2").unwrap(); + let filter = super::AgenticLoopFilter::from_config(&yaml).unwrap(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let mut state = make_state_with_tool_calls(vec![json!({ + "type": "function", + "call_id": "call_1", + "name": "test", + })]); + state.iteration = 2; + ctx.extensions.insert(state); + + let action = filter.on_response_body(&mut ctx, &mut None, true).unwrap(); + assert!( + matches!(&action, FilterAction::Reject(r) if r.status == 508), + "iteration limit should produce a 508 rejection" + ); + + assert!( + ctx.extensions.get::().is_some(), + "ResponsesState should be preserved after iteration limit rejection" + ); +} + +// ----------------------------------------------------------------------------- +// on_response_body: Multiple Function Calls +// ----------------------------------------------------------------------------- + +#[test] +fn multiple_function_calls_returns_error() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + let response_body = json!({ + "id": "resp_1", + "object": "response", + "output": [ + { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "get_weather", + "arguments": r#"{"location":"SF"}"#, + "status": "completed" + }, + { + "type": "function_call", + "id": "fc_2", + "call_id": "call_2", + "name": "get_time", + "arguments": r#"{"timezone":"PST"}"#, + "status": "completed" + } + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&response_body).unwrap())); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + assert!( + matches!(&action, FilterAction::Reject(r) if r.status == 400), + "multiple function calls should produce a 400 rejection" + ); + + assert!( + ctx.extensions.get::().is_some(), + "ResponsesState should be preserved after rejection" + ); +} + +// ----------------------------------------------------------------------------- +// on_response_body: Reasoning Items +// ----------------------------------------------------------------------------- + +#[test] +fn reasoning_items_preserved_in_messages() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + let response_body = json!({ + "id": "resp_1", + "object": "response", + "output": [ + { + "type": "reasoning", + "id": "rs_1", + "summary": [{"type": "summary_text", "text": "thinking..."}] + }, + { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "get_weather", + "arguments": r#"{"location":"SF"}"#, + "status": "completed" + } + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&response_body).unwrap())); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + assert!( + matches!(action, FilterAction::Continue), + "single function call with reasoning should continue" + ); + + let state = ctx.extensions.get::().unwrap(); + assert_eq!(state.tool_calls.len(), 1, "only function_call goes to tool_calls"); + + let msg_types: Vec<&str> = state + .messages + .iter() + .filter_map(|m| m.get("type").and_then(Value::as_str)) + .collect(); + assert!( + msg_types.contains(&"reasoning"), + "reasoning item should be in messages: {msg_types:?}" + ); + assert!( + msg_types.contains(&"function_call"), + "function_call item should be in messages: {msg_types:?}" + ); + + let persisted_types: Vec<&str> = state + .persisted_messages + .iter() + .filter_map(|m| m.get("type").and_then(Value::as_str)) + .collect(); + assert!( + persisted_types.contains(&"reasoning"), + "reasoning item should be in persisted_messages" + ); +} + +// ----------------------------------------------------------------------------- +// on_response_body: Finish Reason Length +// ----------------------------------------------------------------------------- + +#[test] +fn finish_reason_length_exits_as_incomplete() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let mut state = make_state_with_tool_calls(vec![json!({ + "type": "function", + "call_id": "call_1", + "name": "test", + })]); + state.response_object = json!({ + "status": "incomplete", + "incomplete_details": {"reason": "max_output_tokens"}, + }); + ctx.extensions.insert(state); + + drop(filter.on_response_body(&mut ctx, &mut None, true).unwrap()); + assert_action(&ctx, "done"); + + let status = ctx.get_metadata("responses.status"); + assert_eq!( + status, + Some("incomplete"), + "should set incomplete status on finish_reason length" + ); +} + +#[test] +fn finish_reason_length_passes_body_unchanged() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + let response_body = json!({ + "id": "resp_1", + "object": "response", + "status": "incomplete", + "incomplete_details": {"reason": "max_output_tokens"}, + "output": [{ + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "get_weather", + "arguments": "{}", + "status": "completed" + }] + }); + let original_bytes = serde_json::to_vec(&response_body).unwrap(); + let mut body = Some(Bytes::from(original_bytes.clone())); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + assert!( + matches!(action, FilterAction::Continue), + "model-owned incomplete should continue, not reject" + ); + assert_action(&ctx, "done"); + + let status = ctx.get_metadata("responses.status"); + assert_eq!( + status, + Some("incomplete"), + "should set incomplete metadata for model-owned reason" + ); +} + +// ----------------------------------------------------------------------------- +// on_response_body: Iteration Counter +// ----------------------------------------------------------------------------- + +#[test] +fn iteration_incremented_on_loop() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![json!({ + "type": "function", + "call_id": "call_1", + "name": "test", + })]); + ctx.extensions.insert(state); + + drop(filter.on_response_body(&mut ctx, &mut None, true).unwrap()); + + let state = ctx.extensions.get::().unwrap(); + assert_eq!(state.iteration, 1, "iteration should increment from 0 to 1"); +} + +// ----------------------------------------------------------------------------- +// on_response_body: Filter Results Schema +// ----------------------------------------------------------------------------- + +#[test] +fn filter_results_schema_for_irr_consumers() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![json!({ + "type": "function", + "call_id": "call_1", + "name": "test", + })]); + ctx.extensions.insert(state); + + drop(filter.on_response_body(&mut ctx, &mut None, true).unwrap()); + + let results = ctx + .filter_results + .get("openai_agentic_loop") + .expect("IRR consumers require openai_agentic_loop entry"); + let action = results.get("action").expect("IRR consumers require action key"); + assert!( + action == "loop" || action == "done", + "action must be 'loop' or 'done', got: {action}" + ); +} + +// ----------------------------------------------------------------------------- +// on_response_body: Body Extraction (non-streaming) +// ----------------------------------------------------------------------------- + +#[test] +fn extracts_tool_calls_from_non_streaming_body() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + let response_body = json!({ + "id": "resp_1", + "object": "response", + "output": [ + { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "get_weather", + "arguments": r#"{"location":"SF"}"#, + "status": "completed" + } + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&response_body).unwrap())); + + drop(filter.on_response_body(&mut ctx, &mut body, true).unwrap()); + assert_action(&ctx, "loop"); + + let state = ctx.extensions.get::().unwrap(); + assert_eq!(state.tool_calls.len(), 1); + assert_eq!(state.tool_calls[0]["call_id"], "call_1"); +} + +#[test] +fn appends_function_calls_to_messages() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + let response_body = json!({ + "id": "resp_1", + "object": "response", + "output": [ + { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "get_weather", + "arguments": "{}", + "status": "completed" + } + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&response_body).unwrap())); + + drop(filter.on_response_body(&mut ctx, &mut body, true).unwrap()); + + let state = ctx.extensions.get::().unwrap(); + assert_eq!(state.messages.len(), 2, "original input + function_call"); + assert_eq!(state.messages[1]["type"], "function_call"); + assert_eq!( + state.persisted_messages.len(), + 2, + "original input + function_call in persisted_messages" + ); +} + +#[test] +fn skips_extraction_when_body_is_none() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + drop(filter.on_response_body(&mut ctx, &mut None, true).unwrap()); + assert_action(&ctx, "done"); + + let state = ctx.extensions.get::().unwrap(); + assert!(state.tool_calls.is_empty(), "should not extract from None body"); + assert_eq!(state.messages.len(), 1, "only the original normalized input"); +} + +#[test] +fn ignores_non_completed_function_calls() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + let response_body = json!({ + "id": "resp_1", + "object": "response", + "output": [ + { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "get_weather", + "arguments": "{}", + "status": "in_progress" + }, + { + "type": "message", + "content": [{"type": "output_text", "text": "hello"}] + } + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&response_body).unwrap())); + + drop(filter.on_response_body(&mut ctx, &mut body, true).unwrap()); + assert_action(&ctx, "done"); + + let state = ctx.extensions.get::().unwrap(); + assert!(state.tool_calls.is_empty(), "non-completed calls should be ignored"); +} + +#[test] +fn stores_response_object_from_body() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + let response_body = json!({ + "id": "resp_1", + "object": "response", + "status": "completed", + "output": [] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&response_body).unwrap())); + + drop(filter.on_response_body(&mut ctx, &mut body, true).unwrap()); + + let state = ctx.extensions.get::().unwrap(); + assert_eq!(state.response_object["id"], "resp_1"); + assert_eq!(state.response_object["status"], "completed"); +} + +#[test] +fn parse_failure_clears_stale_state() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let mut state = make_state_with_tool_calls(vec![json!({ + "type": "function", + "call_id": "call_stale", + "name": "leftover", + })]); + state.response_object = json!({"id": "resp_old", "status": "completed"}); + ctx.extensions.insert(state); + + let invalid: &[u8] = b"not valid json"; + let mut body = Some(Bytes::from(invalid)); + drop(filter.on_response_body(&mut ctx, &mut body, true).unwrap()); + + let state = ctx.extensions.get::().unwrap(); + assert!( + state.response_object.is_null(), + "parse failure must clear stale response_object" + ); + assert!(state.tool_calls.is_empty(), "parse failure must clear stale tool_calls"); + assert_action(&ctx, "done"); +} + +// ----------------------------------------------------------------------------- +// on_response_body: Usage Accumulation +// ----------------------------------------------------------------------------- + +#[test] +fn accumulates_usage_across_rounds() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + let round1 = json!({ + "id": "resp_1", + "object": "response", + "output": [{ + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "get_weather", + "arguments": "{}", + "status": "completed" + }], + "usage": {"input_tokens": 100, "output_tokens": 50} + }); + let mut body1 = Some(Bytes::from(serde_json::to_vec(&round1).unwrap())); + drop(filter.on_response_body(&mut ctx, &mut body1, true).unwrap()); + assert_action(&ctx, "loop"); + + let mut state = ctx.extensions.remove::().unwrap(); + assert_eq!(state.usage["input_tokens"], 100); + assert_eq!(state.usage["output_tokens"], 50); + + state.tool_calls.clear(); + ctx.extensions.insert(state); + + let round2 = json!({ + "id": "resp_2", + "object": "response", + "status": "completed", + "output": [], + "usage": {"input_tokens": 200, "output_tokens": 75} + }); + let mut body2 = Some(Bytes::from(serde_json::to_vec(&round2).unwrap())); + drop(filter.on_response_body(&mut ctx, &mut body2, true).unwrap()); + assert_action(&ctx, "done"); + + let terminal: Value = serde_json::from_slice(body2.as_ref().unwrap()).unwrap(); + assert_eq!( + terminal["usage"]["input_tokens"], 300, + "input_tokens should sum across rounds" + ); + assert_eq!( + terminal["usage"]["output_tokens"], 125, + "output_tokens should sum across rounds" + ); +} + +// ----------------------------------------------------------------------------- +// Example Config Parse +// ----------------------------------------------------------------------------- + +#[test] +fn example_config_agentic_loop_parses() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("examples/configs/openai/responses/agentic-loop.yaml"); + let yaml = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); + let config: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); + + let filters = config["filter_chains"][0]["filters"] + .as_sequence() + .expect("should have filters array"); + let irr = filters + .iter() + .find(|f| f["filter"].as_str() == Some("iterative_request_router")) + .expect("should have iterative_request_router filter"); + let inference_step = irr["steps"] + .as_sequence() + .expect("should have steps array") + .iter() + .find(|s| s["name"].as_str() == Some("inference")) + .expect("should have inference step"); + let step_filters = inference_step["filters"] + .as_sequence() + .expect("inference step should have filters"); + let al_config = step_filters + .iter() + .find(|f| f["filter"].as_str() == Some("openai_agentic_loop")) + .expect("inference step should have openai_agentic_loop filter"); + let filter = super::AgenticLoopFilter::from_config(al_config).unwrap(); + assert_eq!(filter.name(), "openai_agentic_loop"); +} + +// ----------------------------------------------------------------------------- +// on_response_body: web_search_call Extraction +// ----------------------------------------------------------------------------- + +#[test] +fn web_search_call_extracted_to_web_search_calls_not_tool_calls() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + let response_body = json!({ + "id": "resp_1", + "object": "response", + "output": [ + { + "type": "web_search_call", + "id": "ws_1", + "status": "completed", + "action": {"type": "search", "query": "rust async"} + } + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&response_body).unwrap())); + + drop(filter.on_response_body(&mut ctx, &mut body, true).unwrap()); + + let state = ctx.extensions.get::().unwrap(); + assert!( + state.tool_calls.is_empty(), + "web_search_call must not appear in tool_calls" + ); + assert_eq!( + state.web_search_calls.len(), + 1, + "web_search_call must appear in web_search_calls" + ); + assert_eq!(state.web_search_calls[0]["id"], "ws_1"); +} + +#[test] +fn web_search_call_triggers_loop() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let mut state = make_state_with_tool_calls(vec![]); + state.web_search_calls = vec![json!({ + "type": "web_search_call", + "id": "ws_1", + "action": {"type": "search", "query": "test"} + })]; + ctx.extensions.insert(state); + + let action = filter.on_response_body(&mut ctx, &mut None, true).unwrap(); + assert!(matches!(action, FilterAction::Continue)); + assert_action(&ctx, "loop"); +} + +#[test] +fn web_search_call_alone_increments_iteration() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let mut state = make_state_with_tool_calls(vec![]); + state.web_search_calls = vec![json!({ + "type": "web_search_call", + "id": "ws_1", + "action": {"type": "search", "query": "test"} + })]; + ctx.extensions.insert(state); + + drop(filter.on_response_body(&mut ctx, &mut None, true).unwrap()); + + let state = ctx.extensions.get::().unwrap(); + assert_eq!(state.iteration, 1, "iteration should increment from 0 to 1"); +} + +#[test] +fn mixed_function_and_web_search_calls() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + let response_body = json!({ + "id": "resp_1", + "object": "response", + "output": [ + { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "get_weather", + "arguments": "{}", + "status": "completed" + }, + { + "type": "web_search_call", + "id": "ws_1", + "status": "completed", + "action": {"type": "search", "query": "weather SF"} + } + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&response_body).unwrap())); + + drop(filter.on_response_body(&mut ctx, &mut body, true).unwrap()); + assert_action(&ctx, "loop"); + + let state = ctx.extensions.get::().unwrap(); + assert_eq!(state.tool_calls.len(), 1, "one function_call in tool_calls"); + assert_eq!( + state.web_search_calls.len(), + 1, + "one web_search_call in web_search_calls" + ); + assert_eq!(state.tool_calls[0]["call_id"], "call_1"); + assert_eq!(state.web_search_calls[0]["id"], "ws_1"); +} + +#[test] +fn web_search_call_subject_to_iteration_limit() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_infer_iters: 2").unwrap(); + let filter = super::AgenticLoopFilter::from_config(&yaml).unwrap(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let mut state = make_state_with_tool_calls(vec![]); + state.web_search_calls = vec![json!({ + "type": "web_search_call", + "id": "ws_1", + "action": {"type": "search", "query": "test"} + })]; + state.iteration = 2; + ctx.extensions.insert(state); + + let action = filter.on_response_body(&mut ctx, &mut None, true).unwrap(); + assert!( + matches!(&action, FilterAction::Reject(r) if r.status == 508), + "web_search_call at iteration limit should produce 508 rejection" + ); +} + +#[tokio::test] +async fn web_search_calls_cleared_on_prepare() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let mut state = make_state_with_tool_calls(vec![]); + state.web_search_calls = vec![json!({ + "type": "web_search_call", + "id": "ws_stale", + "action": {"type": "search", "query": "old query"} + })]; + ctx.extensions.insert(state); + + drop(filter.on_request_body(&mut ctx, &mut None, true).await.unwrap()); + + let state = ctx.extensions.get::().unwrap(); + assert!( + state.web_search_calls.is_empty(), + "on_request_body must clear stale web_search_calls from previous round" + ); +} + +#[test] +fn web_search_call_appended_to_messages_and_persisted() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + let response_body = json!({ + "id": "resp_1", + "object": "response", + "output": [ + { + "type": "web_search_call", + "id": "ws_1", + "status": "completed", + "action": {"type": "search", "query": "test query"} + } + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&response_body).unwrap())); + + drop(filter.on_response_body(&mut ctx, &mut body, true).unwrap()); + + let state = ctx.extensions.get::().unwrap(); + let msg_types: Vec<&str> = state + .messages + .iter() + .filter_map(|m| m.get("type").and_then(Value::as_str)) + .collect(); + assert!( + msg_types.contains(&"web_search_call"), + "web_search_call should be in messages: {msg_types:?}" + ); + + let persisted_types: Vec<&str> = state + .persisted_messages + .iter() + .filter_map(|m| m.get("type").and_then(Value::as_str)) + .collect(); + assert!( + persisted_types.contains(&"web_search_call"), + "web_search_call should be in persisted_messages: {persisted_types:?}" + ); + + assert!( + state + .accumulated_output + .iter() + .any(|item| item.get("type").and_then(Value::as_str) == Some("web_search_call")), + "web_search_call should be in accumulated_output" + ); +} + +#[test] +fn web_search_call_does_not_count_as_function_call_for_limit() { + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + + let state = make_state_with_tool_calls(vec![]); + ctx.extensions.insert(state); + + let response_body = json!({ + "id": "resp_1", + "object": "response", + "output": [ + { + "type": "web_search_call", + "id": "ws_1", + "status": "completed", + "action": {"type": "search", "query": "query 1"} + }, + { + "type": "web_search_call", + "id": "ws_2", + "status": "completed", + "action": {"type": "search", "query": "query 2"} + } + ] + }); + let mut body = Some(Bytes::from(serde_json::to_vec(&response_body).unwrap())); + + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + assert!( + matches!(action, FilterAction::Continue), + "multiple web_search_calls should not trigger the one-function-call limit" + ); + assert_action(&ctx, "loop"); + + let state = ctx.extensions.get::().unwrap(); + assert_eq!(state.web_search_calls.len(), 2); + assert!(state.tool_calls.is_empty()); +} + +// ----------------------------------------------------------------------------- +// Test Utilities +// ----------------------------------------------------------------------------- + +fn make_filter() -> Box { + super::AgenticLoopFilter::from_config(&serde_yaml::Value::Null).unwrap() +} + +fn make_state_with_tool_calls(tool_calls: Vec) -> ResponsesState { + let body = json!({"model": "gpt-4o", "input": "test"}); + let mut state = ResponsesState::from_request_body(body); + state.tool_calls = tool_calls; + state +} + +fn assert_action(ctx: &praxis_filter::HttpFilterContext<'_>, expected: &str) { + let results = ctx + .filter_results + .get("openai_agentic_loop") + .expect("filter_results should contain openai_agentic_loop entry"); + let action = results.get("action").expect("should have action key"); + assert_eq!(action, expected, "openai_agentic_loop action mismatch"); +} diff --git a/apis/src/openai/responses/compact/config.rs b/apis/src/openai/responses/compact/config.rs new file mode 100644 index 0000000000..423e7cdbda --- /dev/null +++ b/apis/src/openai/responses/compact/config.rs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Configuration for the `openai_responses_compact` filter. + +use praxis_filter::FilterError; +use serde::Deserialize; + +use crate::openai::responses::config_validation::{self, CalloutSettings, FailureMode}; + +/// Default callout timeout (30 seconds — summarization can be slow). +const DEFAULT_TIMEOUT_MS: u64 = 30_000; + +/// Default HTTP status when the summarization callout fails in closed mode. +const DEFAULT_STATUS_ON_ERROR: u16 = 502; + +// ----------------------------------------------------------------------------- +// CompactFilterConfig (YAML deserialization) +// ----------------------------------------------------------------------------- + +/// Raw YAML config, deserialized then validated. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct CompactFilterConfig { + /// URL of the inference backend for summarization calls. + /// E.g., `"http://localhost:11434/v1/chat/completions"` + pub inference_url: String, + + /// Default model for summarization when not overridden + /// in the request's `context_management`. + #[serde(default = "default_model")] + pub default_model: String, + + /// Tiktoken encoding name for local token estimation of the + /// conversation text. + #[serde(default = "default_tiktoken_encoding")] + pub tiktoken_encoding: String, + + /// Callout timeout in milliseconds. + #[serde(default)] + pub timeout_ms: Option, + + /// Failure mode for the inference callout. + #[serde(default)] + pub callout_failure_mode: Option, + + /// HTTP status code to return when rejecting on error. + #[serde(default)] + pub status_on_error: Option, +} + +/// Default summarization model when not overridden per-request. +fn default_model() -> String { + "gpt-4o-mini".to_owned() +} + +/// Default tiktoken encoding for local token estimation. +fn default_tiktoken_encoding() -> String { + "cl100k_base".to_owned() +} + +// ----------------------------------------------------------------------------- +// ValidatedConfig (post-validation) +// ----------------------------------------------------------------------------- + +/// Validated configuration with defaults applied. +#[derive(Debug)] +pub(super) struct ValidatedConfig { + /// URL of the inference backend for summarization calls. + pub inference_url: String, + + /// Default model for summarization. + pub default_model: String, + + /// Tiktoken encoding name. + pub tiktoken_encoding: String, + + /// Shared callout settings (timeout, failure mode, status). + pub callout: CalloutSettings, +} + +/// Supported tiktoken encoding names. +const SUPPORTED_ENCODINGS: &[&str] = &["cl100k_base", "o200k_base"]; + +/// Validate raw config and apply defaults. +/// +/// # Errors +/// +/// Returns [`FilterError`] if `inference_url` is empty, +/// `tiktoken_encoding` is not a supported encoding name, +/// `timeout_ms` is zero, or `status_on_error` is out of range. +pub(super) fn build_config(raw: &CompactFilterConfig) -> Result { + if raw.inference_url.is_empty() { + return Err(FilterError::from("openai_responses_compact: inference_url is empty")); + } + + if !SUPPORTED_ENCODINGS.contains(&raw.tiktoken_encoding.as_str()) { + return Err(FilterError::from(format!( + "openai_responses_compact: unsupported tiktoken_encoding {:?}; supported: {}", + raw.tiktoken_encoding, + SUPPORTED_ENCODINGS.join(", ") + ))); + } + + let timeout_ms = + config_validation::validate_timeout_ms("openai_responses_compact", raw.timeout_ms, DEFAULT_TIMEOUT_MS)?; + + let status_on_error = config_validation::validate_status_on_error( + "openai_responses_compact", + raw.status_on_error, + DEFAULT_STATUS_ON_ERROR, + )?; + + Ok(ValidatedConfig { + inference_url: raw.inference_url.clone(), + default_model: raw.default_model.clone(), + tiktoken_encoding: raw.tiktoken_encoding.clone(), + callout: CalloutSettings { + timeout_ms, + failure_mode: raw.callout_failure_mode.unwrap_or(FailureMode::Closed), + status_on_error, + }, + }) +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow(clippy::expect_used, clippy::unwrap_used, reason = "tests")] +mod yaml_tests { + use super::*; + + #[test] + fn callout_failure_mode_open_deserializes_from_yaml() { + let cfg: CompactFilterConfig = + serde_yaml::from_str("inference_url: http://localhost/v1/chat/completions\ncallout_failure_mode: open") + .expect("should deserialize"); + assert_eq!(cfg.callout_failure_mode, Some(FailureMode::Open)); + } + + #[test] + fn callout_failure_mode_closed_deserializes_from_yaml() { + let cfg: CompactFilterConfig = + serde_yaml::from_str("inference_url: http://localhost/v1/chat/completions\ncallout_failure_mode: closed") + .expect("should deserialize"); + assert_eq!(cfg.callout_failure_mode, Some(FailureMode::Closed)); + } + + #[test] + fn callout_failure_mode_absent_defaults_to_none() { + let cfg: CompactFilterConfig = + serde_yaml::from_str("inference_url: http://localhost/v1/chat/completions").expect("should deserialize"); + assert_eq!(cfg.callout_failure_mode, None); + } +} diff --git a/apis/src/openai/responses/compact/mod.rs b/apis/src/openai/responses/compact/mod.rs new file mode 100644 index 0000000000..8189d4007a --- /dev/null +++ b/apis/src/openai/responses/compact/mod.rs @@ -0,0 +1,619 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Compact filter: token counting and context window management. +//! +//! When a request's `context_management` contains a compaction +//! configuration and the token count exceeds the specified threshold, +//! this filter summarizes the conversation history via a sub-request +//! to an inference backend, replacing it with a single compaction +//! item. Runs after `rehydrate` (which populates messages and +//! previous usage) and after `openai_tool_parse`. +//! +//! # Scope +//! +//! Compaction only applies to **multi-turn requests** where +//! `openai_responses_rehydrate` has loaded stored conversation history +//! — i.e. requests that include `previous_response_id` or +//! `conversation`. Single-turn requests (no stored history, even with +//! `context_management` set) are released without compaction because +//! there is no prior history to summarize. + +pub(super) mod config; + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + reason = "tests" +)] +mod tests; + +use std::{borrow::Cow, time::Duration}; + +use async_trait::async_trait; +use base64::Engine as _; +use bytes::Bytes; +use praxis_filter::{ + BodyAccess, BodyMode, FilterAction, FilterError, HttpFilter, HttpFilterContext, body::MAX_JSON_BODY_BYTES, + parse_filter_config, +}; +use serde_json::Value; +use tracing::{debug, warn}; + +use self::config::{CompactFilterConfig, ValidatedConfig, build_config}; +use super::{error::responses_error_rejection, state::ResponsesState}; +use crate::{ + openai::responses::config_validation::FailureMode, + subrequest::{self, SubRequest, SubRequestClient}, +}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Maximum response body size for summarization callouts (1 MiB). +const MAX_SUMMARIZATION_RESPONSE_BYTES: usize = 1_048_576; + +/// System prompt for the summarization call. +const SUMMARIZATION_SYSTEM_PROMPT: &str = "\ +Summarize the following conversation concisely. \ +Preserve all key facts, decisions, code snippets, \ +user preferences, and important context. The summary \ +will replace the full conversation history, so it must \ +capture everything needed to continue coherently."; + +// ----------------------------------------------------------------------------- +// CompactionParams +// ----------------------------------------------------------------------------- + +/// Parsed compaction parameters from the request's `context_management`. +struct CompactionParams { + /// Token threshold above which compaction triggers. + compact_threshold: u64, + /// Optional model override for the summarization call. + compaction_model: Option, +} + +// ----------------------------------------------------------------------------- +// CompactFilter +// ----------------------------------------------------------------------------- + +/// Summarizes conversation history when the token count exceeds a +/// configured threshold. +/// +/// `compact_threshold` in `context_management` must be an integer. +/// Floating-point values (e.g. `0.9`) are ignored and compaction +/// is skipped. +/// +/// Compaction only applies to multi-turn requests where +/// `openai_responses_rehydrate` has loaded stored conversation +/// history. Single-turn requests are released without compaction. +/// +/// # YAML +/// +/// ```yaml +/// filter: openai_responses_compact +/// inference_url: "http://localhost:11434/v1/chat/completions" +/// default_model: llama3.2:1b +/// ``` +/// +/// # Full YAML +/// +/// ```yaml +/// filter: openai_responses_compact +/// inference_url: "http://localhost:11434/v1/chat/completions" +/// default_model: gpt-4o-mini +/// tiktoken_encoding: cl100k_base +/// timeout_ms: 30000 +/// callout_failure_mode: closed +/// status_on_error: 502 +/// ``` +pub struct CompactFilter { + /// HTTP client for the summarization inference call. + client: SubRequestClient, + /// Validated filter configuration. + config: ValidatedConfig, +} + +impl CompactFilter { + /// Create a filter from parsed YAML config. + /// + /// # Errors + /// + /// Returns [`FilterError`] if config validation fails. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let client = SubRequestClient::new(praxis_core::subrequest::SubRequestConnector::new(4, None)); + Self::build(config, client) + } + + /// Create a filter from parsed YAML config using a shared sub-request client. + /// + /// # Errors + /// + /// Returns [`FilterError`] if config validation fails. + pub fn from_config_with_client( + config: &serde_yaml::Value, + client: SubRequestClient, + ) -> Result, FilterError> { + Self::build(config, client) + } + + /// Shared constructor: parse config, validate, eager-init tiktoken, and box. + fn build(config: &serde_yaml::Value, client: SubRequestClient) -> Result, FilterError> { + let cfg: CompactFilterConfig = parse_filter_config("openai_responses_compact", config)?; + let validated = build_config(&cfg)?; + eager_init_tiktoken(&validated.tiktoken_encoding); + Ok(Box::new(Self { + client, + config: validated, + })) + } + + /// Run the summarization callout and return the summary text. + /// + /// Returns `Ok(Some(summary))` on success, `Ok(None)` when + /// compaction should be skipped, or `Err(FilterAction)` to + /// short-circuit the request. + async fn execute_compaction( + &self, + state: &ResponsesState, + params: &CompactionParams, + streaming: bool, + conversation_text: &str, + ) -> Result, FilterAction> { + let model = params.compaction_model.as_deref().unwrap_or(&self.config.default_model); + let instructions = state.request_body.get("instructions").and_then(Value::as_str); + let request = build_summarization_request(conversation_text, instructions, model); + let timeout = Duration::from_millis(self.config.callout.timeout_ms); + let result = subrequest::execute_url( + &self.client, + &self.config.inference_url, + request, + MAX_SUMMARIZATION_RESPONSE_BYTES, + timeout, + ) + .await; + self.handle_subrequest_result(result, streaming) + } + + /// Map a subrequest result to a summary string or a filter action. + fn handle_subrequest_result( + &self, + result: Result, + streaming: bool, + ) -> Result, FilterAction> { + match result { + Ok(resp) if (200..300).contains(&(resp.status as usize)) => { + parse_summarization_response(&resp.body).map(Some).or_else(|e| { + warn!(error = %e, "failed to parse summarization response"); + self.on_callout_error("failed to parse summarization response", streaming) + }) + }, + Ok(resp) => { + warn!(status = resp.status, "summarization callout returned non-2xx"); + self.on_callout_error("summarization callout rejected", streaming) + }, + Err(e) => { + warn!(error = %e, "summarization callout failed"); + self.on_callout_error("summarization callout failed", streaming) + }, + } + } + + /// Apply the configured open/closed policy on a callout error. + fn on_callout_error(&self, message: &str, streaming: bool) -> Result, FilterAction> { + match self.config.callout.failure_mode { + FailureMode::Open => Ok(None), + FailureMode::Closed => Err(FilterAction::Reject(responses_error_rejection( + self.config.callout.status_on_error, + "server_error", + message, + streaming, + ))), + } + } +} + +#[async_trait] +impl HttpFilter for CompactFilter { + fn name(&self) -> &'static str { + "openai_responses_compact" + } + + fn request_body_access(&self) -> BodyAccess { + BodyAccess::ReadOnly + } + + fn request_body_mode(&self) -> BodyMode { + BodyMode::StreamBuffer { + max_bytes: Some(MAX_JSON_BODY_BYTES), + } + } + + async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result { + Ok(FilterAction::Continue) + } + + async fn on_request_body( + &self, + ctx: &mut HttpFilterContext<'_>, + _body: &mut Option, + end_of_stream: bool, + ) -> Result { + if !end_of_stream { + return Ok(FilterAction::Continue); + } + if !is_responses_request(ctx) { + return Ok(FilterAction::Release); + } + let streaming = is_streaming(ctx); + let Some(state) = ctx.extensions.get::() else { + return Ok(FilterAction::Release); + }; + if !state.history_rehydrated { + return Ok(FilterAction::Release); + } + let Some((params, conversation_text)) = should_compact(state, &self.config.tiktoken_encoding) else { + return Ok(FilterAction::Release); + }; + let compaction = self.execute_compaction(state, ¶ms, streaming, &conversation_text); + let summary = match compaction.await { + Ok(Some(s)) => s, + Ok(None) | Err(FilterAction::Release) => return Ok(FilterAction::Release), + Err(action) => return Ok(action), + }; + let Some(state) = ctx.extensions.get_mut::() else { + return Ok(FilterAction::Release); + }; + let compaction_id = format!("compact_{}", ctx.id_generator.generate(ctx.time_source)); + replace_messages(state, build_compaction_item(&compaction_id, &summary)); + ctx.set_metadata("responses.compacted", "true"); + Ok(FilterAction::Release) + } +} + +// ----------------------------------------------------------------------------- +// Compaction Logic +// ----------------------------------------------------------------------------- + +/// Check whether compaction should run and return the params + text. +/// +/// Returns `None` if there is no compaction config, the encoding is +/// unknown, or the token count is below the threshold. +/// +/// The token estimate includes instructions and tool definitions in +/// addition to conversation messages, since all three contribute to +/// the rendered context sent to the model. +fn should_compact(state: &ResponsesState, tiktoken_encoding: &str) -> Option<(CompactionParams, String)> { + let params = extract_compaction_config(&state.context_management)?; + + let conversation_text = build_conversation_text(&state.messages); + let message_tokens = get_token_count(&conversation_text, tiktoken_encoding)?; + + let overhead_text = build_context_overhead_text(state); + let overhead_tokens = if overhead_text.is_empty() { + 0 + } else { + get_token_count(&overhead_text, tiktoken_encoding).unwrap_or(0) + }; + + let token_count = message_tokens + overhead_tokens; + if token_count <= params.compact_threshold { + debug!( + token_count, + message_tokens, + overhead_tokens, + threshold = params.compact_threshold, + "under threshold, skipping" + ); + return None; + } + debug!( + token_count, + message_tokens, + overhead_tokens, + threshold = params.compact_threshold, + "threshold exceeded, compacting" + ); + Some((params, conversation_text)) +} + +/// Build the text for instructions and tool definitions that live +/// outside the message list but still consume context window tokens. +fn build_context_overhead_text(state: &ResponsesState) -> String { + let mut buf = String::new(); + if let Some(instructions) = state.request_body.get("instructions").and_then(Value::as_str) { + buf.push_str(instructions); + } + for tool in &state.tools { + if !buf.is_empty() { + buf.push('\n'); + } + buf.push_str(&tool.to_string()); + } + buf +} + +/// Check whether this is an OpenAI Responses API request. +fn is_responses_request(ctx: &HttpFilterContext<'_>) -> bool { + ctx.get_metadata("openai_responses_format.format") == Some("openai_responses") +} + +/// Check whether the client requested streaming. +fn is_streaming(ctx: &HttpFilterContext<'_>) -> bool { + ctx.get_metadata("openai_responses_format.stream") + .is_some_and(|v| v == "true") +} + +/// Parse the `context_management` JSON to find a compaction config. +/// +/// The `context_management` field is an array like: +/// `[{"type": "compaction", "compact_threshold": 50000}]` +/// +/// Returns `None` if no compaction entry is found. +fn extract_compaction_config(context_management: &Option) -> Option { + let array = context_management.as_ref()?.as_array()?; + + for entry in array { + let Some(entry_type) = entry.get("type").and_then(|v| v.as_str()) else { + continue; + }; + if entry_type != "compaction" { + continue; + } + let Some(raw_threshold) = entry.get("compact_threshold") else { + continue; + }; + let Some(compact_threshold) = raw_threshold.as_u64() else { + warn!(value = %raw_threshold, "compact_threshold is not a valid integer, skipping compaction"); + continue; + }; + let compaction_model = entry + .get("compaction_model") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + return Some(CompactionParams { + compact_threshold, + compaction_model, + }); + } + None +} + +/// Resolve the tiktoken singleton for the given encoding name. +fn resolve_tiktoken(encoding: &str) -> Option<&'static tiktoken_rs::CoreBPE> { + match encoding { + "cl100k_base" => Some(tiktoken_rs::cl100k_base_singleton()), + "o200k_base" => Some(tiktoken_rs::o200k_base_singleton()), + other => { + warn!(encoding = other, "unknown tiktoken encoding, cannot estimate tokens"); + None + }, + } +} + +/// Pre-load the tiktoken BPE singleton at pipeline build time so the +/// first request does not pay the ~100ms merge-rule loading cost. +fn eager_init_tiktoken(encoding: &str) { + resolve_tiktoken(encoding); +} + +/// Estimate the token count for the given messages using tiktoken. +/// +/// Uses the configured encoding (e.g. `cl100k_base`, `o200k_base`) +/// to tokenize the serialized conversation text. Runs inside +/// `block_in_place` because BPE tokenization is CPU-bound. +/// +/// Returns `None` if the encoding name is not recognized. +fn get_token_count(conversation_text: &str, tiktoken_encoding: &str) -> Option { + let bpe = resolve_tiktoken(tiktoken_encoding)?; + let count = tokio::task::block_in_place(|| bpe.count_ordinary(conversation_text)) as u64; + debug!( + count, + source = "tiktoken", + encoding = tiktoken_encoding, + "token count estimated" + ); + Some(count) +} + +/// Build a Chat Completions request for summarization. +/// +/// The request body has this shape: +/// ```json +/// { +/// "model": "", +/// "messages": [ +/// {"role": "system", "content": ""}, +/// {"role": "user", "content": ""} +/// ] +/// } +/// ``` +fn build_summarization_request(conversation_text: &str, instructions: Option<&str>, model: &str) -> SubRequest { + let system_content = match instructions { + Some(inst) => format!("{inst}\n\n{SUMMARIZATION_SYSTEM_PROMPT}"), + None => SUMMARIZATION_SYSTEM_PROMPT.to_owned(), + }; + + let body = serde_json::json!({ + "model": model, + "messages": [ + {"role": "system", "content": system_content}, + {"role": "user", "content": conversation_text} + ] + }); + + let body_bytes = Bytes::from(serde_json::to_vec(&body).unwrap_or_default()); + + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + headers.insert(http::header::ACCEPT, http::HeaderValue::from_static("application/json")); + + SubRequest { + method: http::Method::POST, + uri: http::Uri::default(), + headers, + body: body_bytes, + } +} + +/// Parse the Chat Completions response and extract the summary text. +/// +/// Expected shape: `{"choices": [{"message": {"content": "..."}}]}` +fn parse_summarization_response(body: &[u8]) -> Result { + match serde_json::from_slice::(body) { + Ok(body) => body + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("message")) + .and_then(|msg| msg.get("content")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| "Chat Completions response missing choices[0].message.content".to_owned()), + Err(err) => Err(format!("failed to parse Chat Completions response JSON: {err}")), + } +} + +/// Build the compaction output item. +/// +/// Returns: `{"type": "compaction", "id": "", "encrypted_content": ""}` +/// +/// The summary is base64-encoded into `encrypted_content` to match the +/// OpenAI Responses API compaction item shape and make the content opaque +/// to clients. +fn build_compaction_item(id: &str, summary: &str) -> Value { + let encrypted_content = base64::engine::general_purpose::STANDARD.encode(summary); + serde_json::json!({ + "type": "compaction", + "id": id, + "encrypted_content": encrypted_content + }) +} + +/// Replace conversation history with the compaction item. +/// +/// After replacement: +/// - `state.messages` = `[compaction_item, ...state.input]` +/// - `state.persisted_messages` = `[compaction_item, ...state.input]` +/// +/// The compaction item is `{"type": "compaction", "encrypted_content": ""}`. +/// `state.input` holds the current request's input items (unchanged +/// by rehydrate), so the current turn's messages are preserved. +fn replace_messages(state: &mut ResponsesState, compaction_item: Value) { + let mut new_messages = Vec::with_capacity(state.input.len() + 1); + new_messages.push(compaction_item); + new_messages.extend(state.input.iter().cloned()); + state.persisted_messages = new_messages.clone(); + state.messages = new_messages; +} + +/// Format a message array as readable text for the summarization prompt. +/// +/// Each message becomes `