From a1dced597c8d40a8783a8edd50cf12aa7cb1d039 Mon Sep 17 00:00:00 2001 From: lyc Date: Fri, 24 Jul 2026 16:08:59 +0800 Subject: [PATCH] Fri, 24 Jul 2026 16:08:59 +0800 --- .agents/skills/domain-modeling/ADR-FORMAT.md | 47 + .../skills/domain-modeling/CONTEXT-FORMAT.md | 60 + .agents/skills/domain-modeling/SKILL.md | 74 + .../skills/domain-modeling/agents/openai.yaml | 3 + .agents/skills/grill-me/SKILL.md | 7 + .agents/skills/grill-me/agents/openai.yaml | 5 + .agents/skills/grill-with-docs/SKILL.md | 7 + .../skills/grill-with-docs/agents/openai.yaml | 5 + .agents/skills/grilling/SKILL.md | 12 + .agents/skills/grilling/agents/openai.yaml | 3 + .agents/skills/handoff/SKILL.md | 16 + .agents/skills/handoff/agents/openai.yaml | 5 + .gitignore | 1 + AGENTS.md | 44 + CONTEXT.md | 78 + Cargo.lock | 2159 +++++++++++++++++ Cargo.toml | 19 + config.toml | 14 + docs/adr/0001-static-backend-config.md | 38 + ...0002-replica-aware-routing-reservations.md | 13 + kvca_proxy.tar.gz | Bin 0 -> 122908 bytes skills-lock.json | 35 + src/blockmap.rs | 216 ++ src/cbr.rs | 166 ++ src/config.rs | 95 + src/main.rs | 85 + src/merkle.rs | 97 + src/proxy.rs | 334 +++ src/router.rs | 194 ++ 29 files changed, 3832 insertions(+) create mode 100644 .agents/skills/domain-modeling/ADR-FORMAT.md create mode 100644 .agents/skills/domain-modeling/CONTEXT-FORMAT.md create mode 100644 .agents/skills/domain-modeling/SKILL.md create mode 100644 .agents/skills/domain-modeling/agents/openai.yaml create mode 100644 .agents/skills/grill-me/SKILL.md create mode 100644 .agents/skills/grill-me/agents/openai.yaml create mode 100644 .agents/skills/grill-with-docs/SKILL.md create mode 100644 .agents/skills/grill-with-docs/agents/openai.yaml create mode 100644 .agents/skills/grilling/SKILL.md create mode 100644 .agents/skills/grilling/agents/openai.yaml create mode 100644 .agents/skills/handoff/SKILL.md create mode 100644 .agents/skills/handoff/agents/openai.yaml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CONTEXT.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 config.toml create mode 100644 docs/adr/0001-static-backend-config.md create mode 100644 docs/adr/0002-replica-aware-routing-reservations.md create mode 100644 kvca_proxy.tar.gz create mode 100644 skills-lock.json create mode 100644 src/blockmap.rs create mode 100644 src/cbr.rs create mode 100644 src/config.rs create mode 100644 src/main.rs create mode 100644 src/merkle.rs create mode 100644 src/proxy.rs create mode 100644 src/router.rs diff --git a/.agents/skills/domain-modeling/ADR-FORMAT.md b/.agents/skills/domain-modeling/ADR-FORMAT.md new file mode 100644 index 0000000..da7e78e --- /dev/null +++ b/.agents/skills/domain-modeling/ADR-FORMAT.md @@ -0,0 +1,47 @@ +# ADR Format + +ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc. + +Create the `docs/adr/` directory lazily — only when the first ADR is needed. + +## Template + +```md +# {Short title of the decision} + +{1-3 sentences: what's the context, what did we decide, and why.} +``` + +That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections. + +## Optional sections + +Only include these when they add genuine value. Most ADRs won't need them. + +- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited +- **Considered Options** — only when the rejected alternatives are worth remembering +- **Consequences** — only when non-obvious downstream effects need to be called out + +## Numbering + +Scan `docs/adr/` for the highest existing number and increment by one. + +## When to offer an ADR + +All three of these must be true: + +1. **Hard to reverse** — the cost of changing your mind later is meaningful +2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?" +3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons + +If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing." + +### What qualifies + +- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres." +- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP." +- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out. +- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s. +- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate. +- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract." +- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months. diff --git a/.agents/skills/domain-modeling/CONTEXT-FORMAT.md b/.agents/skills/domain-modeling/CONTEXT-FORMAT.md new file mode 100644 index 0000000..eaf2a18 --- /dev/null +++ b/.agents/skills/domain-modeling/CONTEXT-FORMAT.md @@ -0,0 +1,60 @@ +# CONTEXT.md Format + +## Structure + +```md +# {Context Name} + +{One or two sentence description of what this context is and why it exists.} + +## Language + +**Order**: +{A one or two sentence description of the term} +_Avoid_: Purchase, transaction + +**Invoice**: +A request for payment sent to a customer after delivery. +_Avoid_: Bill, payment request + +**Customer**: +A person or organization that places orders. +_Avoid_: Client, buyer, account +``` + +## Rules + +- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others under `_Avoid_`. +- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does. +- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs. +- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine. + +## Single vs multi-context repos + +**Single context (most repos):** One `CONTEXT.md` at the repo root. + +**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other: + +```md +# Context Map + +## Contexts + +- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders +- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments +- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping + +## Relationships + +- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking +- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices +- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money` +``` + +The skill infers which structure applies: + +- If `CONTEXT-MAP.md` exists, read it to find contexts +- If only a root `CONTEXT.md` exists, single context +- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved + +When multiple contexts exist, infer which one the current topic relates to. If unclear, ask. diff --git a/.agents/skills/domain-modeling/SKILL.md b/.agents/skills/domain-modeling/SKILL.md new file mode 100644 index 0000000..d0f7e1a --- /dev/null +++ b/.agents/skills/domain-modeling/SKILL.md @@ -0,0 +1,74 @@ +--- +name: domain-modeling +description: Build and sharpen a project's domain model. Use when the user wants to pin down domain terminology or a ubiquitous language, record an architectural decision, or when another skill needs to maintain the domain model. +--- + +# Domain Modeling + +Actively build and sharpen the project's domain model as you design. This is the *active* discipline — challenging terms, inventing edge-case scenarios, and writing the glossary and decisions down the moment they crystallise. (Merely *reading* `CONTEXT.md` for vocabulary is not this skill — that's a one-line habit any skill can do. This skill is for when you're changing the model, not just consuming it.) + +## File structure + +Most repos have a single context: + +``` +/ +├── CONTEXT.md +├── docs/ +│ └── adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives: + +``` +/ +├── CONTEXT-MAP.md +├── docs/ +│ └── adr/ ← system-wide decisions +├── src/ +│ ├── ordering/ +│ │ ├── CONTEXT.md +│ │ └── docs/adr/ ← context-specific decisions +│ └── billing/ +│ ├── CONTEXT.md +│ └── docs/adr/ +``` + +Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed. + +## During the session + +### Challenge against the glossary + +When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?" + +### Sharpen fuzzy language + +When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things." + +### Discuss concrete scenarios + +When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts. + +### Cross-reference with code + +When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?" + +### Update CONTEXT.md inline + +When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md). + +`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else. + +### Offer ADRs sparingly + +Only offer to create an ADR when all three are true: + +1. **Hard to reverse** — the cost of changing your mind later is meaningful +2. **Surprising without context** — a future reader will wonder "why did they do it this way?" +3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons + +If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md). diff --git a/.agents/skills/domain-modeling/agents/openai.yaml b/.agents/skills/domain-modeling/agents/openai.yaml new file mode 100644 index 0000000..7f1522d --- /dev/null +++ b/.agents/skills/domain-modeling/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Domain Modeling" + short_description: "Build and sharpen a domain model" diff --git a/.agents/skills/grill-me/SKILL.md b/.agents/skills/grill-me/SKILL.md new file mode 100644 index 0000000..9470cfc --- /dev/null +++ b/.agents/skills/grill-me/SKILL.md @@ -0,0 +1,7 @@ +--- +name: grill-me +description: A relentless interview to sharpen a plan or design. +disable-model-invocation: true +--- + +Run a `/grilling` session. diff --git a/.agents/skills/grill-me/agents/openai.yaml b/.agents/skills/grill-me/agents/openai.yaml new file mode 100644 index 0000000..4d6fb0c --- /dev/null +++ b/.agents/skills/grill-me/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Grill Me" + short_description: "Sharpen a plan through interview" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/grill-with-docs/SKILL.md b/.agents/skills/grill-with-docs/SKILL.md new file mode 100644 index 0000000..bed05d2 --- /dev/null +++ b/.agents/skills/grill-with-docs/SKILL.md @@ -0,0 +1,7 @@ +--- +name: grill-with-docs +description: A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go. +disable-model-invocation: true +--- + +Run a `/grilling` session, using the `/domain-modeling` skill. diff --git a/.agents/skills/grill-with-docs/agents/openai.yaml b/.agents/skills/grill-with-docs/agents/openai.yaml new file mode 100644 index 0000000..5dbe278 --- /dev/null +++ b/.agents/skills/grill-with-docs/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Grill with Docs" + short_description: "Grill a design and write its docs" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/grilling/SKILL.md b/.agents/skills/grilling/SKILL.md new file mode 100644 index 0000000..52d8eb3 --- /dev/null +++ b/.agents/skills/grilling/SKILL.md @@ -0,0 +1,12 @@ +--- +name: grilling +description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases. +--- + +Interview me relentlessly about every aspect of this until we reach a shared understanding. Walk down each branch of the decision tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time, waiting for feedback on each question before continuing. Asking multiple questions at once is bewildering. + +If a *fact* can be found by exploring the environment (filesystem, tools, etc.), look it up rather than asking me. The *decisions*, though, are mine — put each one to me and wait for my answer. + +Do not act on it until I confirm we have reached a shared understanding. diff --git a/.agents/skills/grilling/agents/openai.yaml b/.agents/skills/grilling/agents/openai.yaml new file mode 100644 index 0000000..85b1260 --- /dev/null +++ b/.agents/skills/grilling/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Grilling" + short_description: "Stress-test thinking one question at a time" diff --git a/.agents/skills/handoff/SKILL.md b/.agents/skills/handoff/SKILL.md new file mode 100644 index 0000000..043d9e1 --- /dev/null +++ b/.agents/skills/handoff/SKILL.md @@ -0,0 +1,16 @@ +--- +name: handoff +description: Compact the current conversation into a handoff document for another agent to pick up. +argument-hint: "What will the next session be used for?" +disable-model-invocation: true +--- + +Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save to the temporary directory of the user's OS - not the current workspace. + +Include a "suggested skills" section in the document, which suggests skills that the agent should invoke. + +Do not duplicate content already captured in other artifacts (specs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead. + +Redact any sensitive information, such as API keys, passwords, or personally identifiable information. + +If the user passed arguments, treat them as a description of what the next session will focus on and tailor the doc accordingly. diff --git a/.agents/skills/handoff/agents/openai.yaml b/.agents/skills/handoff/agents/openai.yaml new file mode 100644 index 0000000..6e1d8da --- /dev/null +++ b/.agents/skills/handoff/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Handoff" + short_description: "Compact a conversation into a handoff" +policy: + allow_implicit_invocation: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..edcc085 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,44 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This Rust 2024 project is a KV-cache-aware proxy for OpenAI-compatible chat-completion backends. + +- `src/main.rs`: Axum server setup and background cache vacuum worker. +- `src/config.rs`: validation and loading for `config.toml`. +- `src/cbr.rs` and `src/merkle.rs`: canonical prompt identity and Merkle block chain. +- `src/blockmap.rs`: block-to-backend replica locations, expiry deadlines, and vacuum heap. +- `src/router.rs`: cache-aware selection and atomic load reservations. +- `src/proxy.rs`: header-preserving streaming upstream proxy. +- `docs/adr/`: accepted architectural decisions; update these when changing durable design choices. +- `CONTEXT.md`: domain glossary; keep it free of implementation details. + +Tests are co-located with implementation code in `#[cfg(test)] mod tests` blocks. Build artifacts belong under `target/`. + +## Build, Test, and Development Commands + +- `cargo build`: compile a debug binary. +- `cargo build --release`: build the optimized production binary. +- `cargo test`: run all unit and async tests. +- `cargo test router`: run tests whose names contain `router`. +- `cargo fmt --check`: verify Rust formatting; use `cargo fmt` to apply it. +- `cargo clippy --all-targets -- -D warnings`: enforce warning-free idiomatic Rust. +- `./target/release/kvca_proxy --config `: run with a selected TOML file; omitting the option uses `config.toml`. + +## Coding Style & Naming Conventions + +Use four-space indentation and standard Rust naming: `snake_case` for functions and variables, `CamelCase` for types, and `SCREAMING_SNAKE_CASE` for constants. Group imports as `std`, external crates, then `crate::`, separated by blank lines. Prefer recoverable `Result` errors; reserve `expect` or `unwrap` for startup invariants and tests. Avoid unused public APIs and unrelated refactors. + +## Testing Guidelines + +Name tests `test_`. Use `#[tokio::test]` for asynchronous routing, expiry, and proxy behavior. Non-trivial public behavior should have a happy-path test and an edge-case or failure-path test. Keep tests isolated and use short deterministic timeouts where lifecycle behavior is involved. + +Before handing off changes, run formatting, the full test suite, strict Clippy, and a release build. + +## Security & Configuration + +Never commit credentials to `config.toml`. Request authorization and backend-specific end-to-end headers are forwarded upstream. Preserve hop-by-hop header filtering and avoid total timeouts that can truncate response streams. + +## Commit & Pull Request Guidelines + +No established Git history is available. Use imperative, capitalized commit subjects of about 50 characters; wrap bodies at 72 columns. PRs should explain the change, motivation, validation performed, and relevant trade-offs. Link issues and ADRs when applicable; screenshots are unnecessary unless user-visible output changes. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..adf4ae7 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,78 @@ +# KVCA Proxy — Ubiquitous Language + +## Core Concepts + +### KV Cache-Aware Proxy +An LLM gateway that routes requests to backend inference servers with awareness of KV cache state. The goal is to minimize recomputation by pinning conversational sequences to the same backend, while also choosing the best backend for initial requests based on a cache-locality utility metric. + +### First Request / Bootstrap Request +A request whose `messages` array contains no prior model output or tool result. A request whose prompt identity is shorter than one block is treated the same way because it offers negligible reusable cache locality. + +### Follow-up Request +A request whose `messages` array contains an `assistant`, `tool`, or legacy `function` role. The gateway routes it to a suitable cache location for its longest known prefix. + +### Backend +An upstream LLM inference server (e.g., vLLM, TGI). The gateway is completely agnostic to the backend's internal caching — no custom protocol, headers, or cooperation is required. + +## Routing + +### Dispatch Phase 1 — Bootstrap Routing +On a bootstrap request, the gateway computes the prompt CBR, builds its Merkle chain, and atomically reserves the least-loaded backend. Cache locations are learned only after that backend successfully begins responding. + +### Dispatch Phase 2 — Follow-up Routing +On a follow-up request, the gateway walks the Merkle chain until its first block without an unexpired location. It atomically reserves the least-loaded replica of the preceding prefix and refreshes the selected location only after a successful upstream response begins. + +## Cache Tracking + +### Canonical Byte Representation (CBR) +The deterministic byte encoding of every request input that can affect prompt rendering. It includes `messages`, model and tool configuration, chat-template options, and unknown extension fields, while excluding generation-only controls. + +### Block +A fixed-size, complete segment of the canonical byte representation. Block size is configurable at startup (must be a multiple of 8). Partial blocks at the end of the CBR are discarded — they are unconditionally novel for any backend. + +### Merkle Block Chain +Each complete block is content-addressed via SipHash: + +``` +hash[0] = SipHash(0_u64 || block[0]) +hash[i] = SipHash(hash[i-1] || block[i]) +``` + +`block_size` must be a multiple of 8 so that each hash input (`prev` + `block[i]`) fills a whole number of 8-byte words with no trailing fragment. SipHash's `finish()` handles its own final padding and length suffix. + +### Global Block Map +A best-effort association from each block hash to all unexpired backend cache locations for that prefix. A location is learned or refreshed only after the backend accepts and successfully begins the request. + +### Cache-Location TTL +The maximum idle time for one backend's association with a cached block. An expired association is forgotten during routing; a successful access refreshes the associations for the selected prefix. + +### Cache-Location Vacuum +Deadline-driven reclamation of expired cache-location associations, including removal of a block when it has no remaining locations. It complements expiry checks performed during routing. + +### Divergence Point +The index of the first block in a follow-up request's Merkle chain with no known cache location. The locations associated with the preceding block are replicas of the longest known prefix. + +### Novel Block Cost +The number of blocks from the divergence point to the end of the chain. These blocks will need to be computed from scratch by the selected backend. + +## Bookkeeping + +### Fallback Routing +If the chain is empty or has no known prefix, every backend is eligible and the least-loaded backend is selected. + +### Staleness +The gateway is optimistic between a location's last successful access and its TTL. A backend restart or early eviction can therefore cause a temporary suboptimal route, never an incorrect response. + +## Load Tracking + +### Novel-Block Load +The estimated prefill work for blocks not already cached on the target backend. It is reserved during routing and released when the backend returns response headers. + +### Active-Request Load +A baseline unit reserved for every request, including a fully cached follow-up. It remains reserved while the response is active and is released on completion, error, or client disconnect. + +### Least-Loaded Selection +Routing selects the least-loaded eligible backend and reserves its estimated load as one indivisible decision. For a follow-up, eligible backends are the known replicas of the longest cached prefix. + +### Releasing Load +The prefill portion is released when the backend returns headers. The active-request portion is released when the response completes, errors, or is abandoned by the client. A connection failure releases both portions. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..26b3405 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2159 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[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 = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.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 = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kvca_proxy" +version = "0.1.0" +dependencies = [ + "axum", + "bytes", + "clap", + "futures-core", + "pin-project-lite", + "reqwest", + "serde", + "serde_json", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[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", +] + +[[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", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "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" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +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", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_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 = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +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" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "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]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +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", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +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 = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +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 = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[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 = "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 = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..97c7749 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "kvca_proxy" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.8.9" +reqwest = { version = "0.13.4", features = ["stream"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0" +toml = "0.8" +uuid = { version = "1.0", features = ["v4"] } +bytes = "1.0" +clap = { version = "4.5", features = ["derive"] } +tokio = { version = "1.53.1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = "0.3.23" +futures-core = "0.3" +pin-project-lite = "0.2" diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..d58882b --- /dev/null +++ b/config.toml @@ -0,0 +1,14 @@ +[server] +bind = "0.0.0.0:3000" + +[[backend]] +name = "gpu-01" +address = "http://10.0.1.10:8000" + +[[backend]] +name = "gpu-02" +address = "http://10.0.1.11:8000" + +[cache] +block_size = 512 +expiry_seconds = 300 diff --git a/docs/adr/0001-static-backend-config.md b/docs/adr/0001-static-backend-config.md new file mode 100644 index 0000000..96d0a84 --- /dev/null +++ b/docs/adr/0001-static-backend-config.md @@ -0,0 +1,38 @@ +# ADR 0001 — Static Backend Configuration + +**Status**: Draft +**Date**: 2026-07-23 + +## Context + +The gateway needs to know which upstream inference servers to route requests to. During initial development, the set of backends is known at deploy time. + +## Decision + +Backends are configured via a static TOML config file. The binary reads `config.toml` from the working directory by default, or accepts another path through `--config `. + +```toml +[[backend]] +name = "gpu-01" +address = "http://10.0.1.10:8000" + +[[backend]] +name = "gpu-02" +address = "http://10.0.1.11:8000" + +[cache] +block_size = 512 +expiry_seconds = 300 +``` + +`name` is a human-readable identifier for logging/metrics. `address` is the backend base URL. Cache block size and cache-location idle expiry are global startup settings. + +## Consequences + +- Simple, no external dependencies. +- Adding/removing backends requires a config change and restart. +- Dynamic registration can be added later. + +## Alternatives Considered + +- CLI flags per backend, environment variables, dynamic API. diff --git a/docs/adr/0002-replica-aware-routing-reservations.md b/docs/adr/0002-replica-aware-routing-reservations.md new file mode 100644 index 0000000..34f3ad0 --- /dev/null +++ b/docs/adr/0002-replica-aware-routing-reservations.md @@ -0,0 +1,13 @@ +# ADR 0002 — Replica-Aware Routing with Atomic Reservations + +**Status**: Accepted + +Each block hash is associated with every backend believed to cache that prefix, rather than one overwritable owner. A follow-up chooses the least-loaded location of its longest known prefix; a first request or sub-block prompt chooses from all backends. Selection and load reservation occur atomically so concurrent requests cannot all select from the same stale load snapshot. + +The Merkle chain identifies the complete rendered-prompt namespace, not only `messages`: it includes model, tool definitions, chat-template inputs such as `chat_template_kwargs`, and unknown prompt extensions while excluding known generation-only controls. A follow-up is identified by an `assistant`, `tool`, or legacy `function` message role. + +Cache locations are committed only after a successful upstream response begins. Load has two phases: novel-block prefill cost is released at response headers, while one active-request unit remains until completion, error, or client disconnect. This preserves cache replicas, avoids poisoning locality state on failed requests, and accounts for generation work even when the prompt is fully cached. + +Each block/backend association also has a configurable idle TTL. Routing prunes expired associations as it walks the prefix, and a successful request refreshes every association accessed on its selected backend. This bounds stale cache assumptions without requiring backend cooperation. + +A background vacuum uses an expiry heap rather than scanning the full block map or polling on a fixed interval. It sleeps until the earliest deadline and is notified when an insertion moves that deadline earlier. Refreshing an association appends a new deadline; when an older deadline reaches the heap front, the vacuum compares it with the association's current deadline and ignores it if the location was refreshed. Empty block entries are removed with their final expired location. diff --git a/kvca_proxy.tar.gz b/kvca_proxy.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..be1cf286d697c8af34d3df9c7fea682b0f431ccc GIT binary patch literal 122908 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≥Si6B6z?F6R7E(_^t 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 z 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

s)r6g=bUlF;@w&bjiwItmE&nO_jKwSsJGh(M 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 Uz&|Afe*d@s{-4kO{+rMK7kpUeF8}}l literal 0 HcmV?d00001 diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..7dffee4 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "skills": { + "domain-modeling": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/domain-modeling/SKILL.md", + "computedHash": "363cb0f53b0b431e7c00086ad1f823500b7e1b70b5616ee969c979f0934e9e6e" + }, + "grill-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grill-me/SKILL.md", + "computedHash": "f361db4e15e6bfd562a9282b1dccda513910a50061f9e838ce017be9c69dde3f" + }, + "grill-with-docs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/grill-with-docs/SKILL.md", + "computedHash": "9c460cbd94fd3c63cdef967dbdb6e66ca687103cdc380cd37834e4d10b738f78" + }, + "grilling": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grilling/SKILL.md", + "computedHash": "368d3dd7251247c69f3656d93dc83c8f1577792eacac2111f1e7981db2ece49b" + }, + "handoff": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/handoff/SKILL.md", + "computedHash": "ad03e8d4ea3cbbff66420eb7ba3cc375b5cbe1821a2449b53e863256cf5b5cde" + } + } +} diff --git a/src/blockmap.rs b/src/blockmap.rs new file mode 100644 index 0000000..7c2bd18 --- /dev/null +++ b/src/blockmap.rs @@ -0,0 +1,216 @@ +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashMap, HashSet}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::sync::{Notify, RwLock}; + +type Expiry = Reverse<(Instant, u64, usize)>; + +#[derive(Default)] +struct BlockMapState { + locations: HashMap>, + expirations: BinaryHeap, +} + +struct CacheLocation { + deadline: Instant, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct VacuumStats { + pub locations_removed: usize, + pub blocks_removed: usize, +} + +/// Best-effort knowledge of when each backend last accessed a block prefix. +#[derive(Clone)] +pub struct BlockMap { + inner: Arc>, + expiry_changed: Arc, +} + +impl BlockMap { + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(BlockMapState::default())), + expiry_changed: Arc::new(Notify::new()), + } + } + + /// Return unexpired locations and remove stale associations for `hash`. + pub async fn fresh_locations(&self, hash: u64) -> HashSet { + let now = Instant::now(); + let mut state = self.inner.write().await; + let mut remove_hash = false; + let locations = match state.locations.get_mut(&hash) { + Some(locations) => { + locations.retain(|_, location| location.deadline > now); + remove_hash = locations.is_empty(); + locations.keys().copied().collect() + } + None => HashSet::new(), + }; + if remove_hash { + state.locations.remove(&hash); + } + locations + } + + /// Record or refresh a backend's access time for every supplied block. + pub async fn record_access(&self, hashes: &[u64], backend_idx: usize, ttl: Duration) { + let now = Instant::now(); + let deadline = now.checked_add(ttl).expect("cache TTL is too large"); + let mut state = self.inner.write().await; + let previous_earliest = state + .expirations + .peek() + .map(|Reverse((deadline, _, _))| *deadline); + for &hash in hashes { + state + .locations + .entry(hash) + .or_default() + .insert(backend_idx, CacheLocation { deadline }); + state + .expirations + .push(Reverse((deadline, hash, backend_idx))); + } + drop(state); + if !hashes.is_empty() && previous_earliest.is_none_or(|earliest| deadline < earliest) { + self.expiry_changed.notify_one(); + } + } + + /// Remove every location whose current access timestamp has expired. + /// Stale heap entries created by refreshes are ignored. + pub async fn vacuum(&self) -> VacuumStats { + let now = Instant::now(); + let mut state = self.inner.write().await; + let mut stats = VacuumStats::default(); + + while state + .expirations + .peek() + .is_some_and(|Reverse((deadline, _, _))| *deadline <= now) + { + let Reverse((deadline, hash, backend_idx)) = state.expirations.pop().unwrap(); + let mut remove_block = false; + if let Some(locations) = state.locations.get_mut(&hash) { + let expired = locations + .get(&backend_idx) + .is_some_and(|location| location.deadline == deadline); + if expired { + locations.remove(&backend_idx); + stats.locations_removed += 1; + remove_block = locations.is_empty(); + } + } + if remove_block { + state.locations.remove(&hash); + stats.blocks_removed += 1; + } + } + + stats + } + + /// Wait until the current heap minimum is due. Earlier insertions wake the + /// wait so the deadline can be recalculated. + pub async fn wait_until_expiry(&self) { + loop { + let notified = self.expiry_changed.notified(); + let deadline = self + .inner + .read() + .await + .expirations + .peek() + .map(|Reverse((deadline, _, _))| *deadline); + + match deadline { + Some(deadline) => { + tokio::select! { + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => return, + _ = notified => {} + } + } + None => notified.await, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TTL: Duration = Duration::from_millis(10); + + #[tokio::test] + async fn test_unknown_hash_has_no_locations() { + let map = BlockMap::new(); + assert!(map.fresh_locations(42).await.is_empty()); + } + + #[tokio::test] + async fn test_record_preserves_and_refreshes_all_locations() { + let map = BlockMap::new(); + map.record_access(&[42], 0, TTL).await; + map.record_access(&[42], 1, TTL).await; + assert_eq!(map.fresh_locations(42).await, HashSet::from([0, 1])); + } + + #[tokio::test] + async fn test_expired_location_is_invalidated_lazily() { + let map = BlockMap::new(); + map.record_access(&[42], 0, Duration::from_millis(1)).await; + tokio::time::sleep(Duration::from_millis(2)).await; + assert!(map.fresh_locations(42).await.is_empty()); + } + + #[tokio::test] + async fn test_vacuum_removes_expired_location_and_block() { + let map = BlockMap::new(); + let ttl = Duration::from_millis(1); + map.record_access(&[42], 0, ttl).await; + tokio::time::sleep(Duration::from_millis(2)).await; + + assert_eq!( + map.vacuum().await, + VacuumStats { + locations_removed: 1, + blocks_removed: 1, + } + ); + } + + #[tokio::test] + async fn test_vacuum_ignores_stale_deadline_after_refresh() { + let map = BlockMap::new(); + let ttl = Duration::from_millis(4); + map.record_access(&[42], 0, ttl).await; + tokio::time::sleep(Duration::from_millis(3)).await; + map.record_access(&[42], 0, ttl).await; + tokio::time::sleep(Duration::from_millis(2)).await; + + assert_eq!(map.vacuum().await, VacuumStats::default()); + assert_eq!(map.fresh_locations(42).await, HashSet::from([0])); + } + + #[tokio::test] + async fn test_earlier_deadline_wakes_waiter() { + let map = BlockMap::new(); + map.record_access(&[1], 0, Duration::from_millis(100)).await; + + let waiting_map = map.clone(); + let waiter = tokio::spawn(async move { waiting_map.wait_until_expiry().await }); + tokio::time::sleep(Duration::from_millis(2)).await; + map.record_access(&[2], 0, Duration::from_millis(5)).await; + + tokio::time::timeout(Duration::from_millis(30), waiter) + .await + .expect("waiter should reschedule to the earlier deadline") + .unwrap(); + } +} diff --git a/src/cbr.rs b/src/cbr.rs new file mode 100644 index 0000000..dfd0100 --- /dev/null +++ b/src/cbr.rs @@ -0,0 +1,166 @@ +use serde_json::Value; + +/// Canonical Byte Representation of the `messages` field. +/// +/// This produces a deterministic byte sequence from the messages JSON, +/// independent of the original key ordering or whitespace in the request. +/// Semantically identical messages arrays always produce identical bytes. +pub fn canonical_messages_bytes(messages: &Value) -> Vec { + let canonical = canonicalize_value(messages); + // Compact (no extraneous whitespace), deterministic key order + serde_json::to_vec(&canonical).expect("canonical JSON serialization is infallible") +} + +/// Canonical cache identity for every request field that can affect prompt +/// rendering, followed by `messages` so conversation growth extends the tail. +pub fn canonical_prompt_bytes(body: &Value) -> Vec { + let Some(object) = body.as_object() else { + return canonical_messages_bytes(&Value::Null); + }; + + let messages = object.get("messages").unwrap_or(&Value::Null); + let mut prompt_options = serde_json::Map::new(); + for (key, value) in object { + if key != "messages" && !is_generation_only_field(key) { + prompt_options.insert(key.clone(), canonicalize_value(value)); + } + } + + let mut bytes = serde_json::to_vec(&Value::Object(prompt_options)) + .expect("canonical JSON serialization is infallible"); + bytes.push(0); + bytes.extend(canonical_messages_bytes(messages)); + bytes +} + +/// Fields known not to alter tokenized prompt construction. Unknown extension +/// fields are deliberately included so backend-specific template kwargs and +/// future prompt controls cannot silently alias an unrelated cache namespace. +fn is_generation_only_field(key: &str) -> bool { + matches!( + key, + "frequency_penalty" + | "logit_bias" + | "logprobs" + | "max_completion_tokens" + | "max_tokens" + | "metadata" + | "n" + | "parallel_tool_calls" + | "presence_penalty" + | "seed" + | "service_tier" + | "stop" + | "store" + | "stream" + | "stream_options" + | "temperature" + | "top_logprobs" + | "top_p" + | "user" + ) +} + +/// Recursively sort object keys for deterministic serialization. +fn canonicalize_value(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + let mut out = serde_json::Map::with_capacity(map.len()); + for k in keys { + out.insert(k.clone(), canonicalize_value(&map[k])); + } + Value::Object(out) + } + Value::Array(arr) => Value::Array(arr.iter().map(canonicalize_value).collect()), + other => other.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_canonical_stable_key_order() { + let a: Value = + serde_json::from_str(r#"{"messages":[{"role":"user","content":"hi"}]}"#).unwrap(); + let b: Value = + serde_json::from_str(r#"{"messages":[{"content":"hi","role":"user"}]}"#).unwrap(); + + let messages_a = &a["messages"]; + let messages_b = &b["messages"]; + + assert_eq!( + canonical_messages_bytes(messages_a), + canonical_messages_bytes(messages_b) + ); + } + + #[test] + fn test_canonical_whitespace_agnostic() { + let a: Value = + serde_json::from_str(r#"{"messages":[{"role":"user","content":"hello world"}]}"#) + .unwrap(); + let b: Value = serde_json::from_str( + r#"{ + "messages": [ + { + "role": "user", + "content": "hello world" + } + ] + }"#, + ) + .unwrap(); + + assert_eq!( + canonical_messages_bytes(&a["messages"]), + canonical_messages_bytes(&b["messages"]) + ); + } + + #[test] + fn test_different_content_different_bytes() { + let a: Value = + serde_json::from_str(r#"{"messages":[{"role":"user","content":"hi"}]}"#).unwrap(); + let b: Value = + serde_json::from_str(r#"{"messages":[{"role":"user","content":"hello"}]}"#).unwrap(); + + assert_ne!( + canonical_messages_bytes(&a["messages"]), + canonical_messages_bytes(&b["messages"]) + ); + } + + #[test] + fn test_prompt_identity_includes_template_inputs() { + let a = serde_json::json!({ + "model": "model-a", + "messages": [{"role": "user", "content": "hi"}], + "chat_template_kwargs": {"enable_thinking": true} + }); + let b = serde_json::json!({ + "model": "model-a", + "messages": [{"role": "user", "content": "hi"}], + "chat_template_kwargs": {"enable_thinking": false} + }); + assert_ne!(canonical_prompt_bytes(&a), canonical_prompt_bytes(&b)); + } + + #[test] + fn test_prompt_identity_ignores_generation_controls() { + let a = serde_json::json!({ + "model": "model-a", + "messages": [{"role": "user", "content": "hi"}], + "temperature": 0.1 + }); + let b = serde_json::json!({ + "model": "model-a", + "messages": [{"role": "user", "content": "hi"}], + "temperature": 0.9 + }); + assert_eq!(canonical_prompt_bytes(&a), canonical_prompt_bytes(&b)); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..275ca43 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,95 @@ +use serde::Deserialize; +use std::path::Path; + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + #[serde(default)] + pub server: ServerConfig, + pub backend: Vec, + #[serde(default)] + pub cache: CacheConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ServerConfig { + #[serde(default = "default_bind")] + pub bind: String, +} + +fn default_bind() -> String { + "0.0.0.0:3000".to_string() +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BackendConfig { + pub name: String, + pub address: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CacheConfig { + #[serde(default = "default_block_size")] + pub block_size: usize, + #[serde(default = "default_expiry_seconds")] + pub expiry_seconds: u64, +} + +fn default_block_size() -> usize { + 512 +} + +fn default_expiry_seconds() -> u64 { + 300 +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + bind: default_bind(), + } + } +} + +impl Default for CacheConfig { + fn default() -> Self { + Self { + block_size: default_block_size(), + expiry_seconds: default_expiry_seconds(), + } + } +} + +impl Config { + pub fn load(path: impl AsRef) -> Result> { + let content = std::fs::read_to_string(path.as_ref())?; + let config: Config = toml::from_str(&content)?; + if config.backend.is_empty() { + return Err("at least one backend must be configured".into()); + } + if config.cache.block_size == 0 || !config.cache.block_size.is_multiple_of(8) { + return Err("cache.block_size must be a positive multiple of 8".into()); + } + if config.cache.expiry_seconds == 0 { + return Err("cache.expiry_seconds must be greater than zero".into()); + } + Ok(config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_cache_expiry() { + let config: Config = toml::from_str( + r#" + [[backend]] + name = "gpu" + address = "http://localhost:8000" + "#, + ) + .unwrap(); + assert_eq!(config.cache.expiry_seconds, 300); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..bcfe4f8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,85 @@ +mod blockmap; +mod cbr; +mod config; +mod merkle; +mod proxy; +mod router; + +use std::path::PathBuf; +use std::sync::Arc; + +use axum::{ + Router as AxumRouter, + extract::DefaultBodyLimit, + routing::{get, post}, +}; +use clap::Parser; +use reqwest::Client; + +use crate::config::Config; +use crate::proxy::{AppState, BackendInfo, chat_completions, health}; +use crate::router::Router; + +#[derive(Debug, Parser)] +#[command(version)] +struct Cli { + /// Path to the TOML configuration file. + #[arg(long, default_value = "config.toml", value_name = "PATH")] + config: PathBuf, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + let cli = Cli::parse(); + + let config = Config::load(&cli.config).unwrap_or_else(|e| { + tracing::error!("failed to load config: {e}"); + eprintln!("Error: {e}"); + std::process::exit(1); + }); + + let backend_count = config.backend.len(); + let cache_location_ttl = std::time::Duration::from_secs(config.cache.expiry_seconds); + let backends: Vec = config + .backend + .into_iter() + .map(|b| BackendInfo { + name: b.name, + address: b.address, + }) + .collect(); + + let router = Router::new(backend_count, cache_location_ttl); + let vacuum_router = router.clone(); + tokio::spawn(async move { vacuum_router.run_cache_vacuum().await }); + + let client = Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .expect("failed to create HTTP client"); + + let state = Arc::new(AppState { + router, + client, + block_size: config.cache.block_size, + backends, + }); + + let app = AxumRouter::new() + .route("/", get(health)) + .route("/health", get(health)) + .route("/v1/chat/completions", post(chat_completions)) + .layer(DefaultBodyLimit::max(64 * 1024 * 1024)) + .with_state(state); + + let bind = config.server.bind.clone(); + tracing::info!("KVCA proxy listening on {bind}"); + + let listener = tokio::net::TcpListener::bind(&bind) + .await + .expect("failed to bind address"); + + axum::serve(listener, app).await.expect("server error"); +} diff --git a/src/merkle.rs b/src/merkle.rs new file mode 100644 index 0000000..3704347 --- /dev/null +++ b/src/merkle.rs @@ -0,0 +1,97 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +/// A Merkle block chain over the canonical byte representation. +/// +/// The CBR is split into fixed-size blocks; any trailing partial block +/// is discarded (it is unconditionally novel for every backend). +/// +/// Each block is content-addressed via SipHash: +/// +/// hash[0] = SipHash(0_u64.to_le_bytes() || block[0]) +/// hash[i] = SipHash(hash[i-1].to_le_bytes() || block[i]) +/// +/// `block_size` must be a multiple of 8 so that `prev || block[i]` +/// is a whole number of 8-byte words with no trailing fragment. +#[derive(Debug, Clone)] +pub struct MerkleChain { + pub hashes: Vec, +} + +impl MerkleChain { + /// Compute the Merkle chain from canonical message bytes. + pub fn from_cbr(cbr: &[u8], block_size: usize) -> Self { + debug_assert!( + block_size > 0 && block_size.is_multiple_of(8), + "block_size must be a positive multiple of 8" + ); + + let full_blocks: Vec<&[u8]> = cbr + .chunks(block_size) + .filter(|chunk| chunk.len() == block_size) + .collect(); + + let mut hashes = Vec::with_capacity(full_blocks.len()); + let mut prev: u64 = 0; + + for block in &full_blocks { + let mut hasher = DefaultHasher::new(); + prev.hash(&mut hasher); + block.hash(&mut hasher); + prev = hasher.finish(); + hashes.push(prev); + } + + Self { hashes } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_cbr() { + let chain = MerkleChain::from_cbr(b"", 32); + assert_eq!(chain.hashes.len(), 0); + } + + #[test] + fn test_exactly_one_block() { + let data = vec![0u8; 32]; + let chain = MerkleChain::from_cbr(&data, 32); + assert_eq!(chain.hashes.len(), 1); + } + + #[test] + fn test_partial_block_discarded() { + let data = vec![0u8; 48]; + let chain = MerkleChain::from_cbr(&data, 32); + assert_eq!(chain.hashes.len(), 1); + } + + #[test] + fn test_two_full_blocks() { + let data = vec![0u8; 64]; + let chain = MerkleChain::from_cbr(&data, 32); + assert_eq!(chain.hashes.len(), 2); + } + + #[test] + fn test_deterministic() { + let data = b"Hello, world! This is a test prompt for deterministic hashing."; + let block_size = 16; + let a = MerkleChain::from_cbr(data, block_size); + let b = MerkleChain::from_cbr(data, block_size); + assert_eq!(a.hashes, b.hashes); + } + + #[test] + fn test_different_input_different_chain() { + let a = MerkleChain::from_cbr(b"Hello world with more text to fill a block!", 16); + let b = MerkleChain::from_cbr(b"Hello World with more text to fill a block!", 16); + assert!(!a.hashes.is_empty()); + assert!(!b.hashes.is_empty()); + assert_ne!(a.hashes, b.hashes); + } +} diff --git a/src/proxy.rs b/src/proxy.rs new file mode 100644 index 0000000..242e969 --- /dev/null +++ b/src/proxy.rs @@ -0,0 +1,334 @@ +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use axum::{ + Json, + body::Body, + extract::State, + http::{HeaderMap, HeaderName, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use bytes::Bytes; +use futures_core::Stream; +use reqwest::Client; +use serde_json::Value; + +use crate::cbr::canonical_prompt_bytes; +use crate::merkle::MerkleChain; +use crate::router::Router; + +const UPSTREAM_HEADER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +/// Shared application state. +pub struct AppState { + pub router: Router, + pub client: Client, + pub block_size: usize, + pub backends: Vec, +} + +pub struct BackendInfo { + pub name: String, + pub address: String, +} + +/// Determine whether a request has no prior model output or tool result. +fn is_bootstrap(messages: &Value) -> bool { + let arr = match messages.as_array() { + Some(a) => a, + None => return true, + }; + for msg in arr { + if let Some(role) = msg.get("role").and_then(|r| r.as_str()) + && matches!(role, "assistant" | "tool" | "function") + { + return false; + } + } + true +} + +/// Build the upstream URL for a backend. +fn upstream_uri(backend: &BackendInfo) -> String { + format!( + "{}{}", + backend.address.trim_end_matches('/'), + "/v1/chat/completions" + ) +} + +/// Preserve end-to-end request headers while letting reqwest rebuild framing +/// and authority headers for the selected upstream. +fn upstream_headers(incoming: &HeaderMap) -> HeaderMap { + let mut forwarded = incoming.clone(); + let connection_headers: Vec = incoming + .get_all(header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|name| name.trim().parse().ok()) + .collect(); + + for name in connection_headers { + forwarded.remove(name); + } + for name in [ + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + ] { + forwarded.remove(name); + } + forwarded +} + +pin_project_lite::pin_project! { + /// Releases active-request load when the stream ends or is dropped. + struct LoadTrackingStream { + #[pin] + inner: S, + guard: LoadGuard, + } +} + +struct LoadGuard { + router: Router, + backend_idx: usize, + prefill_cost: usize, + active: bool, +} + +impl LoadGuard { + fn release_prefill(&mut self) { + if self.prefill_cost > 0 { + self.router + .release_prefill(self.backend_idx, self.prefill_cost); + self.prefill_cost = 0; + } + } + + fn release(&mut self) { + self.release_prefill(); + if self.active { + self.active = false; + self.router.release_request(self.backend_idx); + } + } +} + +impl Drop for LoadGuard { + fn drop(&mut self) { + self.release(); + } +} + +impl Stream for LoadTrackingStream +where + S: Stream>, +{ + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.project(); + match this.inner.poll_next(cx) { + Poll::Ready(Some(Ok(chunk))) => Poll::Ready(Some(Ok(chunk))), + Poll::Ready(Some(Err(e))) => { + this.guard.release(); + Poll::Ready(Some(Err(e))) + } + Poll::Ready(None) => { + this.guard.release(); + Poll::Ready(None) + } + Poll::Pending => Poll::Pending, + } + } +} + +/// GET /health — simple liveness check. +pub async fn health() -> &'static str { + "OK" +} + +/// POST /v1/chat/completions — the main LLM proxy endpoint. +pub async fn chat_completions( + State(state): State>, + headers: HeaderMap, + body: Json, +) -> Response { + let body_value = body.0; + let messages = match body_value.get("messages") { + Some(m) => m, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "missing messages field"})), + ) + .into_response(); + } + }; + + // 1. Compute Merkle chain + let cbr = canonical_prompt_bytes(&body_value); + let chain = MerkleChain::from_cbr(&cbr, state.block_size); + + // 2. Bootstrap or follow-up? + let decision = if is_bootstrap(messages) { + state.router.route_bootstrap(&chain).await + } else { + state.router.route_followup(&chain).await + }; + + let backend = &state.backends[decision.backend_idx]; + let upstream_url = upstream_uri(backend); + let load_guard = LoadGuard { + router: state.router.clone(), + backend_idx: decision.backend_idx, + prefill_cost: decision.prefill_cost, + active: true, + }; + + // 4. Forward the request upstream + match proxy_request( + state.client.clone(), + &upstream_url, + &headers, + &body_value, + load_guard, + decision.accessed_hashes, + ) + .await + { + Ok(upstream_resp) => upstream_resp, + Err(e) => { + tracing::error!( + backend = %backend.name, + error = %e, + "upstream proxy failed" + ); + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({"error": "upstream request failed"})), + ) + .into_response() + } + } +} + +/// Proxy a JSON request body to the upstream LLM endpoint, streaming the response +/// back as-is. The response stream owns the remaining load reservation. +async fn proxy_request( + client: Client, + url: &str, + headers: &HeaderMap, + body: &Value, + mut load_guard: LoadGuard, + accessed_hashes: Vec, +) -> Result> { + let req_body = serde_json::to_vec(body)?; + + let request = client + .post(url) + .headers(upstream_headers(headers)) + .body(req_body); + let upstream_resp = tokio::time::timeout(UPSTREAM_HEADER_TIMEOUT, request.send()).await??; + + let status = upstream_resp.status(); + load_guard.release_prefill(); + if status.is_success() { + load_guard + .router + .record_cache_access(&accessed_hashes, load_guard.backend_idx) + .await; + } + let mut response_builder = Response::builder().status(status); + + for (key, value) in upstream_resp.headers() { + if key == "transfer-encoding" || key == "connection" { + continue; + } + response_builder = response_builder.header(key.as_str(), value.as_bytes()); + } + + let stream = upstream_resp.bytes_stream(); + let tracked = LoadTrackingStream { + inner: stream, + guard: load_guard, + }; + let body = Body::from_stream(tracked); + let axum_resp = response_builder.body(body)?; + + Ok(axum_resp) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tool_result_is_followup() { + let messages = serde_json::json!([ + {"role": "user", "content": "weather?"}, + {"role": "tool", "tool_call_id": "call-1", "content": "sunny"} + ]); + assert!(!is_bootstrap(&messages)); + } + + #[test] + fn test_user_only_messages_are_bootstrap() { + let messages = serde_json::json!([ + {"role": "system", "content": "be concise"}, + {"role": "user", "content": "hello"} + ]); + assert!(is_bootstrap(&messages)); + } + + #[test] + fn test_upstream_headers_preserve_end_to_end_headers() { + let mut incoming = HeaderMap::new(); + incoming.insert(header::AUTHORIZATION, "Bearer secret".parse().unwrap()); + incoming.insert("x-backend-option", "enabled".parse().unwrap()); + + let forwarded = upstream_headers(&incoming); + assert_eq!(forwarded[header::AUTHORIZATION], "Bearer secret"); + assert_eq!(forwarded["x-backend-option"], "enabled"); + } + + #[test] + fn test_upstream_headers_remove_hop_by_hop_and_framing_headers() { + let mut incoming = HeaderMap::new(); + incoming.insert(header::HOST, "client.example".parse().unwrap()); + incoming.insert(header::CONTENT_LENGTH, "123".parse().unwrap()); + incoming.insert(header::CONNECTION, "x-remove-me".parse().unwrap()); + incoming.insert("x-remove-me", "value".parse().unwrap()); + + let forwarded = upstream_headers(&incoming); + assert!(!forwarded.contains_key(header::HOST)); + assert!(!forwarded.contains_key(header::CONTENT_LENGTH)); + assert!(!forwarded.contains_key(header::CONNECTION)); + assert!(!forwarded.contains_key("x-remove-me")); + } + + #[tokio::test] + async fn test_load_guard_releases_on_drop() { + let router = Router::new(1, std::time::Duration::from_secs(60)); + let decision = router + .route_bootstrap(&MerkleChain { hashes: vec![1, 2] }) + .await; + drop(LoadGuard { + router: router.clone(), + backend_idx: decision.backend_idx, + prefill_cost: decision.prefill_cost, + active: true, + }); + assert_eq!(router.load(0), 0); + } +} diff --git a/src/router.rs b/src/router.rs new file mode 100644 index 0000000..d740124 --- /dev/null +++ b/src/router.rs @@ -0,0 +1,194 @@ +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::blockmap::BlockMap; +use crate::merkle::MerkleChain; + +const ACTIVE_REQUEST_COST: usize = 1; + +#[derive(Debug)] +pub struct RouteDecision { + pub backend_idx: usize, + pub accessed_hashes: Vec, + pub prefill_cost: usize, +} + +/// Cache-aware router with atomic per-backend load reservations. +#[derive(Clone)] +pub struct Router { + backend_count: usize, + pub block_map: BlockMap, + loads: Arc>>, + cache_location_ttl: Duration, +} + +impl Router { + pub fn new(backend_count: usize, cache_location_ttl: Duration) -> Self { + Self { + backend_count, + block_map: BlockMap::new(), + loads: Arc::new(Mutex::new(vec![0; backend_count])), + cache_location_ttl, + } + } + + fn reserve(&self, candidates: &HashSet, prefill_cost: usize) -> usize { + let mut loads = self.loads.lock().expect("load lock poisoned"); + let backend_idx = candidates + .iter() + .copied() + .min_by_key(|&idx| (loads[idx], idx)) + .unwrap_or_else(|| { + (0..self.backend_count) + .min_by_key(|&idx| (loads[idx], idx)) + .expect("router requires at least one backend") + }); + loads[backend_idx] += ACTIVE_REQUEST_COST + prefill_cost; + backend_idx + } + + pub fn release_prefill(&self, idx: usize, prefill_cost: usize) { + self.release(idx, prefill_cost); + } + + pub fn release_request(&self, idx: usize) { + self.release(idx, ACTIVE_REQUEST_COST); + } + + pub async fn record_cache_access(&self, hashes: &[u64], backend_idx: usize) { + self.block_map + .record_access(hashes, backend_idx, self.cache_location_ttl) + .await; + } + + pub async fn run_cache_vacuum(&self) { + loop { + self.block_map.wait_until_expiry().await; + let stats = self.block_map.vacuum().await; + if stats.locations_removed > 0 { + tracing::debug!( + locations_removed = stats.locations_removed, + blocks_removed = stats.blocks_removed, + "vacuumed expired cache locations" + ); + } + } + } + + fn release(&self, idx: usize, cost: usize) { + let mut loads = self.loads.lock().expect("load lock poisoned"); + debug_assert!(loads[idx] >= cost, "load reservation underflow"); + loads[idx] = loads[idx].saturating_sub(cost); + } + + /// Route a first or too-short request solely by current load. + pub async fn route_bootstrap(&self, chain: &MerkleChain) -> RouteDecision { + let prefill_cost = chain.hashes.len(); + let backend_idx = self.reserve(&HashSet::new(), prefill_cost); + RouteDecision { + backend_idx, + accessed_hashes: chain.hashes.clone(), + prefill_cost, + } + } + + /// Route to a least-loaded replica of the longest known prefix. + pub async fn route_followup(&self, chain: &MerkleChain) -> RouteDecision { + if chain.hashes.is_empty() { + return self.route_bootstrap(chain).await; + } + + let mut last_locations = HashSet::new(); + let mut novel_start = 0; + for (idx, &hash) in chain.hashes.iter().enumerate() { + let locations = self.block_map.fresh_locations(hash).await; + if locations.is_empty() { + novel_start = idx; + break; + } + last_locations = locations; + novel_start = idx + 1; + } + + let prefill_cost = chain.hashes.len() - novel_start; + let backend_idx = self.reserve(&last_locations, prefill_cost); + RouteDecision { + backend_idx, + accessed_hashes: chain.hashes.clone(), + prefill_cost, + } + } + + #[cfg(test)] + pub(crate) fn load(&self, idx: usize) -> usize { + self.loads.lock().unwrap()[idx] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chain(hashes: Vec) -> MerkleChain { + MerkleChain { hashes } + } + + fn router(backend_count: usize) -> Router { + Router::new(backend_count, Duration::from_secs(60)) + } + + #[tokio::test] + async fn test_empty_followup_falls_back_to_load_balance() { + let router = router(2); + let first = router.route_followup(&chain(vec![])).await; + let second = router.route_followup(&chain(vec![])).await; + assert_eq!((first.backend_idx, second.backend_idx), (0, 1)); + } + + #[tokio::test] + async fn test_selection_reserves_load_atomically() { + let router = router(3); + let mut selected = Vec::new(); + for _ in 0..3 { + selected.push(router.route_bootstrap(&chain(vec![1])).await.backend_idx); + } + assert_eq!(selected, vec![0, 1, 2]); + } + + #[tokio::test] + async fn test_followup_selects_least_loaded_replica() { + let router = router(3); + router.record_cache_access(&[10], 0).await; + router.record_cache_access(&[10], 1).await; + let busy = router.route_bootstrap(&chain(vec![20, 21])).await; + assert_eq!(busy.backend_idx, 0); + + let decision = router.route_followup(&chain(vec![10, 30])).await; + assert_eq!(decision.backend_idx, 1); + assert_eq!(decision.accessed_hashes, vec![10, 30]); + assert_eq!(decision.prefill_cost, 1); + } + + #[tokio::test] + async fn test_followup_ignores_expired_location() { + let router = Router::new(2, Duration::from_millis(1)); + router.record_cache_access(&[10], 0).await; + tokio::time::sleep(Duration::from_millis(2)).await; + + let decision = router.route_followup(&chain(vec![10, 20])).await; + assert_eq!(decision.backend_idx, 0); + assert_eq!(decision.prefill_cost, 2); + } + + #[tokio::test] + async fn test_two_phase_load_release() { + let router = router(1); + let decision = router.route_bootstrap(&chain(vec![1, 2])).await; + assert_eq!(router.load(0), 3); + router.release_prefill(0, decision.prefill_cost); + assert_eq!(router.load(0), 1); + router.release_request(0); + assert_eq!(router.load(0), 0); + } +}