diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..ea687980 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,61 @@ +# LLM Proxy + +This context describes the language used to discuss interactive model requests +and their performance through routatic-proxy. + +## Language + +**Time to First Token (TTFT)**: +For a streaming request, the elapsed time from when the proxy starts reading +the request until it writes the first non-empty text, thinking, or tool content +to the client. +_Avoid_: First-byte time, total response time + +**Repeated Conversation**: +A model request that includes a stable prefix from earlier turns plus a new +turn. +_Avoid_: Warm request, duplicate request + +**Cache Directive**: +A client-supplied instruction that marks a prompt boundary as eligible for +reuse by the Provider Prompt Cache. +_Avoid_: Automatic cache rule, proxy cache marker + +**Cache Usage**: +Provider-reported token counts describing prompt data read from or written to +the Provider Prompt Cache. +_Avoid_: Token Count Cache hit, inferred cache use + +**Provider Prompt Cache**: +An upstream provider feature that reuses an unchanged prompt prefix across +model requests. +_Avoid_: Token Count Cache, response cache + +**Token Count Cache**: +A process-local cache that reuses tokenizer results without storing raw prompt +text. +_Avoid_: Provider Prompt Cache, response cache + +**Completion Record**: +Best-effort analytics data describing one finished model request. Losing this +record must not change the model response seen by the client. +_Avoid_: Response, durable event + +**Catalog Snapshot**: +The last successfully loaded set of providers, models, and routing scenarios +used for model selection. +_Avoid_: Live catalog, catalog request + +**Known Model**: +A model present in the active configuration or catalog snapshot. +_Avoid_: Requested model, arbitrary model name + +**Model Identity**: +The canonical `provider/model` name used to distinguish a model across +providers. +_Avoid_: Short model name, display name + +**Unknown Content Block**: +A client content block whose type is not yet modeled by the proxy but whose raw +data and position remain intact. +_Avoid_: Invalid block, ignored block diff --git a/docs/adr/0001-user-responses-before-analytics.md b/docs/adr/0001-user-responses-before-analytics.md new file mode 100644 index 00000000..af4afc5b --- /dev/null +++ b/docs/adr/0001-user-responses-before-analytics.md @@ -0,0 +1,7 @@ +# User responses take priority over analytics completeness + +Completion records are best-effort analytics. The proxy never delays a model +response when the storage queue is full, so it drops the newest analytics +record and reports the drop. During normal shutdown it drains accepted records +until the existing shutdown deadline, then reports any remaining loss. This +trades complete analytics for predictable user-facing latency. diff --git a/docs/adr/0002-client-owned-cache-directives.md b/docs/adr/0002-client-owned-cache-directives.md new file mode 100644 index 00000000..f158555d --- /dev/null +++ b/docs/adr/0002-client-owned-cache-directives.md @@ -0,0 +1,6 @@ +# Provider Prompt Cache directives are owned by the client + +The proxy preserves cache directives supplied at the request, tool, system, and +message-block levels, but it does not add, move, or infer cache boundaries. +Provider adapters may omit a directive only when their wire format cannot use +it. This favors predictable request behavior over automatic cache tuning. diff --git a/docs/adr/0003-ordered-normalized-content.md b/docs/adr/0003-ordered-normalized-content.md new file mode 100644 index 00000000..34b55d24 --- /dev/null +++ b/docs/adr/0003-ordered-normalized-content.md @@ -0,0 +1,8 @@ +# Normalized content keeps client block order + +The canonical request model uses one ordered list of content blocks for system +and message content. Text, images, thinking, tool calls, tool results, and cache +directives stay at their original positions. This requires a larger core +refactor, but it prevents separate convenience fields from changing request +order or losing cache boundaries. Unknown block types remain as ordered raw +JSON for compatible provider formats and are never silently dropped. diff --git a/docs/adr/0004-last-valid-catalog-remains-usable.md b/docs/adr/0004-last-valid-catalog-remains-usable.md new file mode 100644 index 00000000..35190795 --- /dev/null +++ b/docs/adr/0004-last-valid-catalog-remains-usable.md @@ -0,0 +1,7 @@ +# The last valid catalog remains usable + +After the proxy loads a valid catalog, refresh failures never make that catalog +unusable solely because of age. The proxy reports snapshot age and refresh +errors, but keeps routing with the last valid data. Legacy routing is used only +when no valid catalog has ever loaded, favoring request availability over +catalog freshness. diff --git a/docs/cache-and-latency-implementation-plan.md b/docs/cache-and-latency-implementation-plan.md new file mode 100644 index 00000000..8f3cd68a --- /dev/null +++ b/docs/cache-and-latency-implementation-plan.md @@ -0,0 +1,757 @@ +# Cache Hit Rate and Request Latency Implementation Plan + +Status: Proposed + +Created: 2026-08-25 + +Branch: `perf/cache-hit-and-request-latency-plan` + +Base: `main` at `d74b160` + +## Objective + +Improve repeated-request efficiency and reduce proxy-added latency without +changing routing decisions, provider-visible request semantics, or response +correctness. + +The primary success measure is lower p95 time to first token for repeated +streaming conversations. Proxy-added latency and Provider Prompt Cache read +tokens are supporting measures. Request correctness is a hard requirement. +Non-streaming requests use total response time and are never included in TTFT +results. + +Use a controlled repeated-conversation benchmark as the release gate. The +proxy has no reliable conversation identifier, so production metrics must not +guess that separate requests belong to one conversation. Production reports +show TTFT and Token Count Cache reuse as separate measurements. + +The work covers five areas: + +1. Preserve Provider Prompt Cache directives and cache-usage telemetry. +2. Cache repeated token-count results. +3. Remove SQLite telemetry writes from the response critical path. +4. Correct and extend performance measurements. +5. Make catalog refresh and cost-based routing cheaper. + +General LLM response caching and in-flight request coalescing are explicitly +out of scope. Responses can be non-deterministic, streamed, tool-bearing, and +authorization-sensitive. This program does not store or share model responses. + +## Delivery Principles + +- Measure before and after every optimization. +- Change one bottleneck per performance commit. +- Keep caches bounded by an explicit capacity suited to the data they retain. +- Publish immutable data to request handlers. +- Treat analytics persistence as best-effort telemetry, not part of response + correctness. +- Preserve stale-but-valid catalog data when a refresh fails. +- Keep provider-specific cache behavior behind explicit capability decisions. +- Include benchmark evidence in performance commit messages. + +## Success Criteria + +The implementation is complete when: + +- p95 time to first token improves for repeated streaming conversations. +- Anthropic request, tool, system-block, and message-block cache directives + survive normalization and reach supported upstream wire formats unchanged. +- Unsupported wire formats still omit cache directives intentionally. +- Cache read and cache creation token counts are visible in in-memory metrics, + request history, and SQLite history when the upstream supplies them. +- Repeated conversation history produces Token Count Cache hits. +- The Token Count Cache has bounded memory, exposes hits, misses, and evictions, + and remains race-free. +- A slow or locked SQLite database cannot delay a successful HTTP response. +- Storage queue overflow and shutdown-drain failures are observable. +- p95 and p99 calculations operate on sorted samples and are covered by tests. +- Catalog cache hits require no mutex acquisition. +- Catalog refresh does not make concurrent requests wait for disk or SQLite. +- Cost selection uses the provider index and a one-pass minimum rather than + scanning the entire catalog once per provider and sorting all candidates. +- Each optimized path shows a statistically significant improvement under its + targeted benchmark. +- Cold and unrelated paths do not regress by more than 5%. +- `go test ./... -race`, `make lint`, and `make lint-strict` pass. + +## Measurement Foundation + +This phase is delivered first because the current latency percentile methods +index samples in arrival order rather than sorted order. + +### Benchmarks + +Add focused benchmarks using Go 1.25 `b.Loop()`: + +- `BenchmarkCountMessages` + - cold cache + - warm cache + - 10, 50, and 200 message histories + - growing conversation where one message is appended per turn + - repeated system prompt + - parallel callers +- `BenchmarkMetricsRecordSuccess` + - empty buffer + - full buffer + - parallel writers + - concurrent snapshot readers +- `BenchmarkSelectorSelectCheapest` + - small, medium, and large catalogs + - one and several eligible providers + - restrictive and permissive constraints +- `BenchmarkModelRouterCatalogHit` + - fresh snapshot + - current snapshot while a background refresh is active +- `BenchmarkAsyncStorageWriter` + - enqueue only + - batch sizes + - queue saturation + - graceful drain +- request-path integration benchmark using a fake provider with a fixed response + and no external network. +- streaming repeated-conversation integration benchmark that appends one new + turn per request and reports p95 TTFT + +Run benchmarks serially and retain reports outside the repository: + +```bash +GOCACHE=/tmp/routatic-perf-go-cache \ + go test -run '^$' -bench=. -benchmem -count=10 ./internal/token ./internal/metrics ./internal/router ./internal/handlers \ + | tee /tmp/routatic-perf-before.txt + +GOCACHE=/tmp/routatic-perf-go-cache \ + go test -run '^$' -bench=. -benchmem -count=10 ./internal/token ./internal/metrics ./internal/router ./internal/handlers \ + | tee /tmp/routatic-perf-after.txt + +benchstat /tmp/routatic-perf-before.txt /tmp/routatic-perf-after.txt +``` + +Do not claim an improvement from a single run or from statistically +insignificant output. + +### Request-stage timing + +Record separate durations for: + +- body read and JSON parsing +- message extraction +- token counting +- request-fact analysis and routing +- provider request transformation +- upstream time to first non-empty model content +- upstream total duration +- response transformation +- storage enqueue +- total proxy duration + +Measure TTFT from the moment the proxy begins reading the request until it +writes the first non-empty text, thinking, or tool content to the client. +Response headers, empty SSE events, and metadata-only events do not complete the +TTFT measurement. Record TTFT only for streaming requests. Record total response +time for non-streaming requests. + +Use monotonic `time.Time` values already carried by Go timestamps. Avoid logging +every stage per request at info level; aggregate measurements in +`internal/metrics`. + +## Workstream 1: Preserve Provider Prompt Cache Directives + +### Current problem + +Anthropic requests may carry cache directives at the top request level and on +tools, system blocks, and message content blocks. The current request types +only model system-block directives. `core.NormalizeRequest` then flattens the +system field and message content, so the provider registry path cannot preserve +the complete client request. + +### Design + +Use one ordered normalized content-block model for system and message content: + +```go +type NormalizedCacheControl struct { + Type string +} + +type NormalizedContentBlock struct { + Type NormalizedContentType + Text string + Image *NormalizedImage + ToolCall *NormalizedToolCall + ToolResult *NormalizedToolResult + Thinking string + CacheControl *NormalizedCacheControl + Raw json.RawMessage +} +``` + +`NormalizedRequest` stores ordered system blocks. `NormalizedMessage` stores +ordered message blocks. Replace the current separate text, image, thinking, +tool-call, and tool-result fields rather than retaining two representations +that can diverge. + +Add read-only helpers such as `SystemText()` and `MessageText()` for routing and +token counting. Provider transformers iterate the ordered blocks directly so +they preserve the client's content order. + +Extend the provider interface with a pure request-compatibility check. Each +provider adapter validates the normalized request against the selected wire +format before any network call. Return a typed compatibility error containing +the unsupported block or feature. The router does not contain wire-format +rules. + +Normalization rules: + +- A top-level cache directive remains attached to the normalized request. +- A string system prompt becomes one cacheless normalized block. +- An array remains an ordered set of blocks. +- `cache_control.type` is copied without provider interpretation. +- Tool cache directives remain attached to their tool definitions. +- Message cache directives remain attached to the ordered content blocks where + the client placed them. +- An unknown content-block type remains in order as raw JSON. + +Denormalization rules: + +- Emit a JSON string for one cacheless text block to preserve the common wire + shape. +- Emit an ordered block array when any cache directive is present or multiple + blocks must be retained. +- Anthropic-format providers receive the block array unchanged. +- OpenAI Chat transformations retain the existing DeepSeek support and existing + stripping behavior for unsupported models. +- Responses and Gemini transformations omit the directive until their provider + contracts explicitly support an equivalent. +- Anthropic-format providers may round-trip an unknown raw content block. +- Other formats mark the block as unsupported before making an upstream call. + The fallback handler then tries only models whose provider format can + preserve the block. +- If no compatible fallback exists, return a clear client error naming the + unsupported block type. +- Compatibility failures do not count as provider failures and do not affect + circuit breakers. +- When a selected provider format cannot use a cache directive, remove the + directive and continue the request. Increment a bounded-cardinality metric + by provider and wire format, and emit a sampled debug log. Never fail a + request only because its cache directive is unsupported. + +The provider or wire-format boundary owns the support decision. Core +normalization only preserves information. + +Cache boundaries remain client-owned. The proxy must not add, move, or infer a +cache directive. It only preserves directives already present in the incoming +request and forwards them when the selected provider format supports them. + +### Cache-usage telemetry + +Extend `history.RequestRecord` with: + +- `CacheReadTokens` +- `CacheCreationTokens` +- `CacheUsageReported` + +Add nullable cache-token columns and a `cache_usage_reported` column through an +idempotent SQLite migration. When `cache_usage_reported` is false, persist the +token columns as null. When it is true, zero is a real provider-reported value. +Populate the fields from both streaming and non-streaming responses. + +Extend the Responses usage type only after verifying the actual upstream +payload shape with provider fixtures. Do not infer a cached-token JSON field. + +Expose raw cache counters per provider and model. Avoid a universal "hit rate" +formula until the provider-specific token accounting denominator is defined. +Do not include records with unreported cache usage in provider cache totals. + +### Tests + +- Normalize string system prompts. +- Normalize multiple system blocks while retaining order. +- Round-trip mixed text, image, thinking, tool-call, and tool-result blocks + without reordering them. +- Round-trip unknown content blocks as raw JSON through Anthropic formats. +- Round-trip top-level, tool, system-block, and message-block cache directives. +- Verify the Zen Anthropic request body retains cache directives. +- Verify the DeepSeek Chat request retains supported directives. +- Verify unsupported Chat, Responses, and Gemini bodies omit them. +- Verify unsupported directives increment the omission metric without changing + response behavior. +- Verify an unsupported content block skips incompatible models, uses a + compatible fallback, and does not affect circuit breakers. +- Verify no compatible fallback returns a clear client error containing the + block type. +- Verify provider compatibility validation performs no network or mutable + provider-state work. +- Verify stream and non-stream usage populate history and storage. +- Verify reported zero remains distinct from unreported cache usage. +- Verify migrations work on both new and existing databases. +- Add golden provider-body fixtures where practical. + +### Live provider verification + +Before releasing Provider Prompt Cache support for a provider format: + +- run an opt-in test outside CI with user-supplied credentials +- send the same stable prompt prefix twice +- change only the latest turn +- confirm the second response reports provider cache reuse +- record provider, model, wire format, TTFT, cache-read tokens, and + cache-creation tokens +- never persist the prompt text in the test report + +Unit and golden-body tests prove request fidelity but do not prove that an +upstream provider actually reuses the prefix. + +If a provider accepts the request but does not expose observable cache-read +data, mark that cache path as unverified or experimental. Do not advertise a +cache-hit improvement without provider-reported evidence. + +### Acceptance + +- No cache directive disappears before provider capability handling. +- Existing provider stripping tests continue to pass. +- Requests without cache directives keep their existing common wire shape. +- Every advertised provider cache path has a successful live verification + result. Unverified paths are labeled experimental and make no cache-hit claim. + +## Workstream 2: Cache Repeated Token Counts + +### Current problem + +Every request tokenizes the system prompt and every message again. In an +interactive session, most previous message text is identical to the prior turn. + +### Design + +Add a bounded cache owned by `token.Counter`. + +Each cache entry represents one system block or one message. Do not cache a +whole conversation as one entry because adding a new turn would invalidate the +entire key and prevent reuse of the stable history. + +Initial implementation: + +- one concurrency-safe LRU protected by one lock +- fixed-size SHA-256 keys built from the tokenizer name and input text +- no retained copy of the raw prompt text +- maximum of 8,192 entries by default +- skip entries below a measured minimum string length +- no unbounded `sync.Map` +- no `sync.Pool` unless a profile identifies allocation churn it can solve + +The count remains deterministic and encoding-specific. Including the tokenizer +name in the fingerprint prevents a future model-specific tokenizer from +reusing an incompatible count. A cryptographic hash collision is treated as +negligible; the cache must not keep raw text only to check collisions. + +Add explicit configuration: + +```json +{ + "performance": { + "token_cache_enabled": true, + "token_cache_max_entries": 8192 + } +} +``` + +Defaults must be safe for desktop use. A disabled cache must preserve current +behavior exactly. The cache is enabled by default only after its benchmark and +race-test gates pass. Keep the disable switch for troubleshooting. + +When cache settings change during config reload, build a new empty cache and +publish it atomically. Do not resize or mutate the active cache in place. Old +entries are discarded. + +Do not add miss coalescing initially. Add it only if a parallel benchmark shows +that concurrent identical misses are common enough to outweigh coordination +cost. Do not shard the LRU initially. Add shards only if the parallel benchmark +shows lock contention. + +### Metrics + +Record: + +- hits +- misses +- evictions +- skipped-small-input counts +- current entries +- tokenization duration + +### Tests + +- deterministic hit after first count +- distinct strings and encodings produce distinct fingerprints +- entry limit evicts +- disabled cache bypasses storage +- small-entry policy works +- parallel race test +- large input does not cause unbounded retention or retain raw prompt text +- cached and uncached `CountMessages` return identical totals + +### Acceptance + +- Warm repeated-history benchmarks improve significantly. +- Cold-cache performance has no meaningful regression. +- Memory remains within configured bounds under adversarial unique input. + +## Workstream 3: Move SQLite Writes Off the Response Path + +### Current problem + +Successful requests execute separate request and latency inserts synchronously. +SQLite is configured with one open connection, so concurrent request +completions serialize before non-streaming response bodies are written. + +### Design + +Use `requests.duration_ms` as the single source for latency reports. Stop +writing new rows to `latency_samples`, and update latency queries to read from +`requests`. This removes the duplicate write before asynchronous batching is +introduced. Leave the old table and its existing rows untouched in this branch +for rollback safety. A later release may remove the table after the new reports +have been proven stable. + +Replace the two-method handler-facing storage interface with one completion +operation: + +```go +type CompletionRecorder interface { + RecordCompletion(history.RequestRecord) + Shutdown(context.Context) error +} +``` + +`RecordCompletion` enqueues without waiting for SQLite. A dedicated writer: + +- owns one bounded channel +- batches by maximum count or short flush interval +- writes request rows in one transaction +- preserves record order within each batch +- keeps the existing single SQLite writer connection +- emits counters for enqueued, persisted, dropped, failed, queue depth, batch + size, and drain duration +- samples repeated error logs + +Do not add application-level write retries. SQLite already applies its +configured busy timeout for temporary lock contention. If a batch still fails, +count and drop it, emit a sampled error log, and continue with the next batch. + +Queue capacity, batch size, and flush interval are internal constants selected +by benchmarks. Do not add user-facing settings for them in this work. Expose +queue depth, drops, batch size, failures, and drain time so later production +evidence can justify configuration if needed. + +Because this data is analytics telemetry, queue saturation should not block a +user response. When the queue is full, drop the newest analytics record and +increment an explicit counter. Older already-accepted records remain ordered. +Expose the drop count through metrics and show a dashboard warning when it is +non-zero. + +Keep the in-memory history update synchronous because it is O(1) and supplies +the live dashboard immediately. + +Enable async storage by default after its test and benchmark gates pass. Keep a +temporary setting that switches back to the current synchronous writer for one +stable release. Remove the setting and synchronous path after that release if +no rollback is needed. + +### Shutdown lifecycle + +Change server shutdown ordering: + +1. Stop accepting new HTTP requests and wait for active handlers. +2. Close the completion recorder to new entries. +3. Drain accepted records within the caller's existing shutdown deadline. +4. Stop retention work. +5. Close SQLite. + +Use the same lifecycle for signal-based and programmatic shutdown. The current +paths must not close SQLite while request handlers can still enqueue work. If +the deadline expires before the queue drains, log and count the records that +were not persisted. + +### Tests + +- a blocking storage backend cannot delay an HTTP response +- latency reports use `requests.duration_ms` +- one completion produces one SQLite write +- existing `latency_samples` data and schema remain untouched +- batches flush by size and by interval +- queue saturation follows the documented policy +- persistence errors do not stop later batches +- failed batches are counted and are not retried +- shutdown drains accepted records +- shutdown deadline returns a clear error +- enqueue after shutdown is safe and observable +- race tests cover enqueue, flush, and shutdown + +### Acceptance + +- Handler storage-enqueue time remains bounded and independent of SQLite delay. +- No successful request fails because telemetry persistence fails. +- Accepted records drain on normal shutdown. + +## Workstream 4: Correct and Extend Performance Metrics + +### Correctness fixes + +- Replace latency slice shifting with fixed ring buffers holding the latest + 1,000 samples for each global metric. +- Calculate percentiles from a sorted copy. +- Sort once when calculating multiple percentiles. +- Copy samples while holding the lock, then release the lock before sorting. +- Use the same ring-buffer implementation with 200 samples for each known + `provider/model`. +- Report the sample count with every percentile. +- Report p50, p90, and p95 for per-model data. Do not report per-model p99 from + only 200 samples. +- Add table tests for empty, one-element, ordered, reverse-ordered, and repeated + samples. + +### New measurements + +Add counters and bounded timing samples for the request stages listed in +Measurement Foundation. + +Keep detailed stage timings in bounded memory only. Do not add SQLite columns +for stage breakdowns. Persistent request records continue to store total +duration, input and output tokens, and Provider Prompt Cache usage. + +Create per-model metric state only for models known by the active config or +catalog. Group arbitrary or unknown requested model names under `other` so +request input cannot grow metric maps without a bound. + +Key per-model metrics by canonical `provider/model` identity. Do not combine +the same model name across providers because their latency and cache behavior +may differ. + +For streaming requests: + +- record time to first non-empty model content separately from total stream + duration +- use `sync.Once` or equivalent so first-content timing is recorded exactly once +- distinguish headers and metadata events from real model content + +Do not add DNS, TLS, or connection-level `httptrace` instrumentation in this +program. Add it later only if request-stage timing shows connection setup is a +meaningful bottleneck. Do not alter HTTP pool sizes or enable a protocol based +only on intuition. + +### Exposure + +- Keep `/health` compact. +- Add detailed performance data to the existing metrics/dashboard API. +- Include Token Count Cache and storage queue state. +- Include raw provider cache-token counters. +- Document whether every duration includes or excludes upstream time. + +### Tests and benchmarks + +- percentile correctness independent of insertion order +- ring-buffer eviction order +- concurrent record/snapshot race tests +- unknown model names remain grouped under `other` +- identical model names on different providers remain separate +- first-SSE timing recorded once +- `RecordSuccess` full-buffer benchmark +- metrics snapshot benchmark with several models + +### Acceptance + +- Reported percentiles match a reference implementation. +- Metrics collection does not become a top allocation or lock-contention source. +- Proxy overhead and upstream latency can be distinguished. + +## Workstream 5: Speed Up Catalog and Cost-Based Routing + +### Current problem + +All catalog hits acquire one mutex. When the 30-second entry expires, the +request holding that mutex performs SQLite or file loading while concurrent +requests wait. Cost selection then scans the full model map for every eligible +provider and sorts every candidate to select one. + +### Catalog snapshot design + +Publish an immutable snapshot through `atomic.Pointer`: + +```go +type catalogSnapshot struct { + Catalog *catalog.IndexedCatalog + LoadedAt time.Time + Err error +} +``` + +Request behavior: + +- Every request returns the current snapshot with one atomic load. +- Requests never start refresh work, wait for refresh work, or check files or + SQLite for freshness. +- A successful refresh atomically publishes a new immutable snapshot. +- A failed refresh records the error and retains the last valid snapshot with + no maximum age. +- Startup performs one bounded synchronous load when a catalog source exists. +- One background loop refreshes every 30 seconds. +- Catalog update events signal the same loop to refresh early. + +Do not mutate an `IndexedCatalog` after publishing it. +Expose snapshot age and the latest refresh error. Use legacy routing only when +the process has never loaded a valid catalog. + +### Selector design + +Use `IndexedCatalog.ListProviderModels(provider)` or an equivalent precomputed +provider-keyed resolved-model index. Remove the nested full-catalog scan. + +Replace candidate collection and sorting with a one-pass best-candidate +comparison using the existing deterministic ordering: + +1. lower effective cost +2. larger context window +3. lexicographically smaller model ID + +Build enabled-provider state once per immutable configuration/catalog +generation rather than once per request. Register an `AtomicConfig.OnReload` +callback to publish a new selector state. + +Do not cache the final selected model. Token count, tools, images, reasoning +needs, provider state, and configuration can differ per request. Reuse immutable +indexes, then run the one-pass comparison for each request. + +Pass already-computed `RequestFacts` and constraints through routing rather than +re-running message scans and lowercasing. + +### Tests + +- fresh catalog hit performs no refresh +- requests do not trigger refresh or block during refresh +- background timer and catalog-update events trigger one refresh at a time +- callers continue using the current snapshot during refresh +- failed refresh preserves the last valid catalog +- an arbitrarily old valid catalog remains usable and reports its age +- first startup failure falls back to legacy config +- indexed selector matches current selector results +- one-pass tie breaking exactly matches current sort order +- config reload rebuilds enabled-provider state +- routing facts are computed once without changing scenario results +- race tests cover refresh, config reload, and selection + +### Acceptance + +- Catalog-hit benchmark has no request-path mutex contention. +- Selection scales with models belonging to eligible providers, not the full + catalog multiplied by provider count. +- Routing output is unchanged for the existing fixture suite. + +## Recommended Commit Sequence + +Keep the work reviewable and reversible: + +1. `test(perf): add request-path performance baselines` +2. `fix(metrics): correct latency percentiles and ring buffers` +3. `feat(metrics): record request stages and cache usage` +4. `refactor(core): preserve ordered normalized content blocks` +5. `feat(core): preserve all client cache directives` +6. `feat(storage): persist provider cache token usage` +7. `perf(token): cache repeated token counts` +8. `refactor(storage): remove duplicate latency writes` +9. `refactor(storage): add completion recorder` +10. `perf(storage): batch telemetry writes asynchronously` +11. `perf(router): publish immutable catalog snapshots` +12. `perf(router): use indexed one-pass model selection` +13. `perf(router): reuse analyzed request facts` +14. `docs(perf): record benchmark and rollout results` + +Run the affected unit and benchmark suites after every commit. Run the complete +verification suite before merging. + +## Pull Request Delivery + +Ship the work as one pull request with five ordered phases: + +1. **Metrics and benchmarks** + - benchmark foundations + - percentile correctness + - bounded metric rings + - TTFT and request-stage measurements +2. **Ordered content and Provider Prompt Cache fidelity** + - ordered normalized content blocks + - request, tool, system, and message cache directives + - cache-usage storage and metrics +3. **Token Count Cache** + - SHA-256 fingerprints + - 8,192-entry LRU + - cache metrics and configuration +4. **Async SQLite storage** + - read latency from `requests.duration_ms` + - stop writing duplicate latency samples + - bounded completion queue, batching, and shutdown drain +5. **Catalog and routing speed** + - background catalog refresh + - atomic immutable snapshots + - indexed one-pass selection + - one request-fact analysis pass + +Keep the commits small and in the recommended order. Each phase must pass its +focused tests and benchmarks before work moves to the next phase. The pull +request is ready to merge only when all five phases and the full verification +suite pass. + +Performance gates are specific to each phase: + +- the targeted benchmark must show a statistically significant improvement +- cold and unrelated paths must not regress by more than 5% +- correctness-only fixes may ship without claiming a speed improvement +- the complete pull request must improve repeated-conversation p95 TTFT + +## Rollout Strategy + +1. Build and verify metric correctness and stage timing first within the pull + request. +2. Observe a representative workload before enabling new optimizations by + default. +3. Enable Provider Prompt Cache preservation because it is a fidelity fix, + guarded by provider capability tests. +4. Enable the bounded Token Count Cache by default with 8,192 entries after + benchmark and race-test gates pass. Keep the disable switch. +5. Enable async persistence with queue-depth and dropped-record visibility. +6. Enable the routing snapshot and indexed selector after result-equivalence + tests pass. +7. Compare proxy overhead, TTFT, cache tokens, queue behavior, CPU, allocations, + and memory before and after. +8. Run the opt-in live Provider Prompt Cache checks before advertising provider + support. + +Keep the Token Count Cache disable switch for troubleshooting. Keep the async +storage rollback switch for one stable release, then remove it together with +the synchronous writer if no rollback is needed. + +## Risks and Mitigations + +| Risk | Mitigation | +| --- | --- | +| Cache metadata changes provider request shape | Golden provider-body tests and explicit capability handling | +| Token Count Cache retains prompt text | Store only fixed SHA-256 fingerprints, enforce the entry limit, and never persist entries | +| Token Count Cache lock becomes contended | Parallel benchmark first; shard only with evidence | +| Async storage drops analytics | Bounded queue, dropped counter, dashboard warning, graceful drain | +| Shutdown loses accepted records | One lifecycle owner and deadline-aware drain tests | +| Stale catalog persists after refresh failure | Expose snapshot age and refresh error; retain correctness-preserving legacy fallback | +| Selector optimization changes tie breaking | Differential tests against the existing implementation | +| Metrics create their own hot path | Bounded storage, ring buffers, and allocation benchmarks | +| Benchmark noise produces false wins | Ten serial runs and `benchstat`; retain hardware and Go version context | + +## Final Verification + +```bash +GOCACHE=/tmp/routatic-perf-go-cache go test ./... -count=1 +GOCACHE=/tmp/routatic-perf-go-cache go test ./... -count=1 -race +make lint +make lint-strict +git diff --check +``` + +Attach the final `benchstat` comparison and the request-path latency breakdown +to the pull request. Clearly distinguish local benchmark results from deployed +production observations. diff --git a/internal/config/config.go b/internal/config/config.go index 252875d1..a3d6c9ed 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,10 +30,17 @@ type Config struct { Logging LoggingConfig `json:"logging"` Debug DebugConfig `json:"debug"` Catalog CatalogConfig `json:"catalog"` + Performance PerformanceConfig `json:"performance,omitempty"` Storage *StorageConfig `json:"storage,omitempty"` UpdateChannel string `json:"update_channel,omitempty"` } +// PerformanceConfig controls bounded in-process latency optimizations. +type PerformanceConfig struct { + TokenCountCacheEnabled *bool `json:"token_count_cache_enabled,omitempty"` + TokenCountCacheCapacity int `json:"token_count_cache_capacity,omitempty"` +} + // CostRoutingConfig controls cost-aware model selection. type CostRoutingConfig struct { Enabled bool `json:"enabled"` diff --git a/internal/core/errors.go b/internal/core/errors.go index 70662a05..5796d20d 100644 --- a/internal/core/errors.go +++ b/internal/core/errors.go @@ -1,6 +1,9 @@ package core -import "errors" +import ( + "errors" + "fmt" +) // Sentinel errors for common provider and routing failures. var ( @@ -22,6 +25,21 @@ type NormalizedError struct { ModelID string } +// CompatibilityError means the selected provider/model cannot represent the +// request. It is safe to skip during fallback without recording a provider +// failure. +type CompatibilityError struct { + Provider string + ModelID string + Reason string +} + +func (e *CompatibilityError) Error() string { + return fmt.Sprintf("model %s/%s is incompatible with request: %s", e.Provider, e.ModelID, e.Reason) +} + +func (e *CompatibilityError) IsCompatibility() bool { return true } + // Error implements the error interface. func (e *NormalizedError) Error() string { return e.Message diff --git a/internal/core/normalize.go b/internal/core/normalize.go index e505e6c1..1f8d403d 100644 --- a/internal/core/normalize.go +++ b/internal/core/normalize.go @@ -17,13 +17,15 @@ type thinkingConfig struct { // This is a lossless extraction: all data from the Anthropic format survives. func NormalizeRequest(anthropicReq *types.MessageRequest) *NormalizedRequest { nr := &NormalizedRequest{ - Model: anthropicReq.Model, - MaxTokens: anthropicReq.MaxTokens, - Stream: anthropicReq.Stream != nil && *anthropicReq.Stream, + Model: anthropicReq.Model, + MaxTokens: anthropicReq.MaxTokens, + Stream: anthropicReq.Stream != nil && *anthropicReq.Stream, + CacheControl: anthropicReq.CacheControl, } // Extract system prompt (string or array of content blocks). nr.SystemPrompt = anthropicReq.SystemText() + nr.SystemBlocks = normalizeSystemBlocks(anthropicReq.System) // Set temperature if provided. if anthropicReq.Temperature != nil { @@ -47,35 +49,7 @@ func NormalizeRequest(anthropicReq *types.MessageRequest) *NormalizedRequest { blocks := msg.ContentBlocks() for _, block := range blocks { - switch block.Type { - case "text": - nm.Content += block.Text - case "tool_use": - nm.ToolCalls = append(nm.ToolCalls, NormalizedToolCall{ - ID: block.ID, - Name: block.Name, - Arguments: string(block.Input), - }) - case "tool_result": - nm.ToolResults = append(nm.ToolResults, NormalizedToolResult{ - ToolCallID: block.ToolUseID, - Content: block.TextContent(), - }) - case "thinking": - nm.Thinking += block.Thinking - case "image": - // Preserve image data so the downstream transformer can convert - // to image_url (or append a [Image] placeholder if the model - // does not support vision). Previously this was collapsed to - // the literal text "[Image]" which destroyed the image bytes - // before the transformer could inspect them. - if block.Source != nil && block.Source.Data != "" { - nm.Images = append(nm.Images, NormalizedImage{ - MediaType: block.Source.MediaType, - Data: block.Source.Data, - }) - } - } + nm.Blocks = append(nm.Blocks, normalizeContentBlock(block)) } nr.Messages = append(nr.Messages, nm) @@ -84,9 +58,10 @@ func NormalizeRequest(anthropicReq *types.MessageRequest) *NormalizedRequest { // Convert tools. for _, tool := range anthropicReq.Tools { nt := NormalizedToolDef{ - Name: tool.Name, - Description: tool.Description, - InputSchema: tool.InputSchema, + Name: tool.Name, + Description: tool.Description, + InputSchema: tool.InputSchema, + CacheControl: tool.CacheControl, } nr.Tools = append(nr.Tools, nt) } @@ -94,6 +69,56 @@ func NormalizeRequest(anthropicReq *types.MessageRequest) *NormalizedRequest { return nr } +func normalizeSystemBlocks(raw json.RawMessage) []NormalizedContentBlock { + if len(raw) == 0 { + return nil + } + var text string + if json.Unmarshal(raw, &text) == nil { + return []NormalizedContentBlock{{Type: "text", Text: text}} + } + var blocks []types.ContentBlock + if json.Unmarshal(raw, &blocks) != nil { + return nil + } + out := make([]NormalizedContentBlock, 0, len(blocks)) + for _, block := range blocks { + out = append(out, normalizeContentBlock(block)) + } + return out +} + +func normalizeContentBlock(block types.ContentBlock) NormalizedContentBlock { + content := block.Content + if len(content) == 0 && len(block.Output) > 0 { + content = block.Output + } + + return NormalizedContentBlock{ + Type: block.Type, + Text: block.Text, + ID: block.ID, + ToolUseID: block.ToolUseID, + Name: block.Name, + Input: append(json.RawMessage(nil), block.Input...), + Content: append(json.RawMessage(nil), content...), + IsError: block.IsError, + Thinking: block.Thinking, + Signature: block.Signature, + Image: func() *NormalizedImage { + if block.Source == nil { + return nil + } + return &NormalizedImage{ + MediaType: block.Source.MediaType, + Data: block.Source.Data, + } + }(), + CacheControl: block.CacheControl, + Raw: append(json.RawMessage(nil), block.Raw...), + } +} + // DenormalizeResponse converts a NormalizedResponse to an Anthropic MessageResponse. func DenormalizeResponse(nr *NormalizedResponse) *types.MessageResponse { resp := &types.MessageResponse{ @@ -113,30 +138,14 @@ func DenormalizeResponse(nr *NormalizedResponse) *types.MessageResponse { switch msg.Role { case "assistant": resp.Role = "assistant" - - // Add thinking block if present. - if msg.Thinking != "" { - resp.Content = append(resp.Content, types.ContentBlock{ - Type: "thinking", - Thinking: msg.Thinking, - }) - } - - // Add text block if present. - if msg.Content != "" { - resp.Content = append(resp.Content, types.ContentBlock{ - Type: "text", - Text: msg.Content, - }) - } - - // Add tool_use blocks. - for _, tc := range msg.ToolCalls { + for _, block := range msg.Blocks { resp.Content = append(resp.Content, types.ContentBlock{ - Type: "tool_use", - ID: tc.ID, - Name: tc.Name, - Input: []byte(tc.Arguments), + Type: block.Type, Text: block.Text, ID: block.ID, + ToolUseID: block.ToolUseID, Name: block.Name, + Input: block.Input, Content: block.Content, + IsError: block.IsError, Thinking: block.Thinking, + Signature: block.Signature, CacheControl: block.CacheControl, + Raw: block.Raw, }) } } diff --git a/internal/core/normalize_test.go b/internal/core/normalize_test.go new file mode 100644 index 00000000..c19c7b31 --- /dev/null +++ b/internal/core/normalize_test.go @@ -0,0 +1,63 @@ +package core + +import ( + "encoding/json" + "testing" + + "github.com/routatic/proxy/pkg/types" +) + +func TestNormalizeRequestPreservesOrderedBlocksAndCacheDirectives(t *testing.T) { + cache := &types.CacheControl{Type: "ephemeral"} + req := &types.MessageRequest{ + Model: "test", + Messages: []types.Message{{ + Role: "user", + Content: json.RawMessage(`[ + {"type":"text","text":"before","cache_control":{"type":"ephemeral"}}, + {"type":"custom_provider_block","payload":{"value":42}}, + {"type":"text","text":"after"} + ]`), + }}, + Tools: []types.Tool{{ + Name: "lookup", InputSchema: json.RawMessage(`{"type":"object"}`), CacheControl: cache, + }}, + } + + normalized := NormalizeRequest(req) + if len(normalized.Messages) != 1 || len(normalized.Messages[0].Blocks) != 3 { + t.Fatalf("ordered blocks were not retained: %+v", normalized.Messages) + } + if normalized.Messages[0].Blocks[0].CacheControl == nil || + normalized.Messages[0].Blocks[0].CacheControl.Type != "ephemeral" { + t.Fatalf("text cache directive was lost: %+v", normalized.Messages[0].Blocks[0]) + } + if got := string(normalized.Messages[0].Blocks[1].Raw); got == "" || + normalized.Messages[0].Blocks[1].Type != "custom_provider_block" { + t.Fatalf("unknown block was not preserved: type=%q raw=%q", + normalized.Messages[0].Blocks[1].Type, got) + } + if normalized.Tools[0].CacheControl == nil { + t.Fatal("tool cache directive was lost") + } +} + +func TestNormalizeRequestPreservesLegacyToolResultOutput(t *testing.T) { + req := &types.MessageRequest{ + Model: "test", + Messages: []types.Message{{ + Role: "tool", + Content: json.RawMessage(`[ + {"type":"tool_result","tool_use_id":"call_1","output":"legacy result"} + ]`), + }}, + } + + normalized := NormalizeRequest(req) + if got, want := normalized.Messages[0].ToolResultsList()[0].Content, "legacy result"; got != want { + t.Fatalf("legacy tool result content = %q, want %q", got, want) + } + if got, want := string(normalized.Messages[0].Blocks[0].Content), `"legacy result"`; got != want { + t.Fatalf("normalized tool result content = %s, want %s", got, want) + } +} diff --git a/internal/core/normalized.go b/internal/core/normalized.go index 1608004d..dc4e9f08 100644 --- a/internal/core/normalized.go +++ b/internal/core/normalized.go @@ -1,5 +1,29 @@ package core +import ( + "encoding/json" + + "github.com/routatic/proxy/pkg/types" +) + +// NormalizedContentBlock is one ordered content block. Raw preserves unknown +// provider-specific JSON so adapters can decide whether and how to forward it. +type NormalizedContentBlock struct { + Type string + Text string + ID string + ToolUseID string + Name string + Input json.RawMessage + Content json.RawMessage + IsError *bool + Thinking string + Signature string + Image *NormalizedImage + CacheControl *types.CacheControl + Raw json.RawMessage +} + // NormalizedToolResult represents a single tool result in the normalized format. type NormalizedToolResult struct { ToolCallID string @@ -16,13 +40,84 @@ type NormalizedImage struct { // All wire formats (Anthropic, OpenAI, Responses, Gemini) map to and from // this representation. type NormalizedMessage struct { - Role string // "user", "assistant", "system", "tool" - Content string // Concatenated text content - Images []NormalizedImage // Image attachments (user messages only) - ToolCalls []NormalizedToolCall // Present on assistant messages - ToolResults []NormalizedToolResult // Present on user messages with tool results - ToolCallID string // Deprecated: use ToolResults instead. Kept for backward compat. - Thinking string // Reasoning/thinking content (assistant only) + Role string // "user", "assistant", "system", "tool" + Blocks []NormalizedContentBlock // Ordered content, including unknown blocks. +} + +func (m NormalizedMessage) TextContent() string { + var text string + for _, block := range m.Blocks { + if block.Type == "text" { + text += block.Text + } + } + return text +} + +func (m NormalizedMessage) ThinkingContent() string { + var thinking string + for _, block := range m.Blocks { + if block.Type == "thinking" { + thinking += block.Thinking + } + } + return thinking +} + +func (m NormalizedMessage) ToolCallsList() []NormalizedToolCall { + var calls []NormalizedToolCall + for _, block := range m.Blocks { + if block.Type == "tool_use" { + calls = append(calls, NormalizedToolCall{ + ID: block.ID, Name: block.Name, Arguments: string(block.Input), + }) + } + } + return calls +} + +func (m NormalizedMessage) ToolResultsList() []NormalizedToolResult { + var results []NormalizedToolResult + for _, block := range m.Blocks { + if block.Type == "tool_result" { + content := toolResultText(block.Content) + results = append(results, NormalizedToolResult{ + ToolCallID: block.ToolUseID, Content: content, + }) + } + } + return results +} + +func toolResultText(raw json.RawMessage) string { + var text string + if json.Unmarshal(raw, &text) == nil { + return text + } + var blocks []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if json.Unmarshal(raw, &blocks) == nil { + for _, block := range blocks { + if block.Type == "text" { + text += block.Text + } + } + if text != "" { + return text + } + } + return string(raw) +} + +func (m NormalizedMessage) HasToolCallID() bool { + for _, block := range m.Blocks { + if block.Type == "tool_result" && block.ToolUseID != "" { + return true + } + } + return false } // NormalizedToolCall represents a tool invocation in the internal format. @@ -36,6 +131,8 @@ type NormalizedToolCall struct { type NormalizedRequest struct { Model string SystemPrompt string + SystemBlocks []NormalizedContentBlock + CacheControl *types.CacheControl Messages []NormalizedMessage MaxTokens int Temperature *float64 @@ -48,9 +145,10 @@ type NormalizedRequest struct { // NormalizedToolDef is a tool definition in the internal format. type NormalizedToolDef struct { - Name string - Description string - InputSchema []byte // JSON bytes of the schema + Name string + Description string + InputSchema []byte // JSON bytes of the schema + CacheControl *types.CacheControl } // NormalizedResponse is the canonical internal response format. diff --git a/internal/core/provider.go b/internal/core/provider.go index 9e2e66da..3fd1a504 100644 --- a/internal/core/provider.go +++ b/internal/core/provider.go @@ -116,3 +116,10 @@ type Provider interface { // stream before it is treated as stuck and aborted. StreamIdleTimeout(model config.ModelConfig) time.Duration } + +// RequestValidator is optionally implemented by providers that can reject +// normalized content before an upstream call. Compatibility failures are +// client errors and must not affect circuit-breaker state. +type RequestValidator interface { + ValidateRequest(req *NormalizedRequest, model config.ModelConfig) error +} diff --git a/internal/core/request_compat.go b/internal/core/request_compat.go new file mode 100644 index 00000000..837bdf1c --- /dev/null +++ b/internal/core/request_compat.go @@ -0,0 +1,58 @@ +package core + +import ( + "fmt" + + "github.com/routatic/proxy/internal/config" +) + +// ValidateRequestCompatibility applies provider capability rules to ordered +// normalized blocks. Provider implementations call this after resolving their +// wire format and model capabilities. +func ValidateRequestCompatibility(req *NormalizedRequest, model config.ModelConfig, caps ProviderCapabilities, wire WireFormat) error { + if req == nil { + return &CompatibilityError{Provider: model.Provider, ModelID: model.ModelID, Reason: "request is nil"} + } + for _, block := range req.SystemBlocks { + if err := validateBlock(block, model, caps, wire); err != nil { + return err + } + } + for _, msg := range req.Messages { + for _, block := range msg.Blocks { + if err := validateBlock(block, model, caps, wire); err != nil { + return err + } + } + } + return nil +} + +func validateBlock(block NormalizedContentBlock, model config.ModelConfig, caps ProviderCapabilities, wire WireFormat) error { + switch block.Type { + case "", "text", "tool_use", "tool_result", "thinking", "image": + default: + if wire != WireFormatAnthropic { + return &CompatibilityError{ + Provider: model.Provider, + ModelID: model.ModelID, + Reason: fmt.Sprintf("content block type %q is only supported by the Anthropic wire format", block.Type), + } + } + } + switch block.Type { + case "tool_use", "tool_result": + if !caps.SupportsTools { + return &CompatibilityError{Provider: model.Provider, ModelID: model.ModelID, Reason: "tools are not supported"} + } + case "thinking": + if !caps.SupportsThinking { + return &CompatibilityError{Provider: model.Provider, ModelID: model.ModelID, Reason: "thinking blocks are not supported"} + } + case "image": + if !caps.SupportsImageInput { + return &CompatibilityError{Provider: model.Provider, ModelID: model.ModelID, Reason: "image input is not supported"} + } + } + return nil +} diff --git a/internal/core/validate.go b/internal/core/validate.go index 41764a90..c60d620b 100644 --- a/internal/core/validate.go +++ b/internal/core/validate.go @@ -21,13 +21,13 @@ func ValidateRequest(req *NormalizedRequest) error { } // Tool-result messages must have a ToolCallID. - if msg.Role == "tool" && msg.ToolCallID == "" { + if msg.Role == "tool" && !msg.HasToolCallID() { return fmt.Errorf("messages[%d]: tool-result message missing tool_call_id", i) } // Assistant messages with tool calls must have non-empty tool calls. - if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { - for j, tc := range msg.ToolCalls { + if msg.Role == "assistant" && len(msg.ToolCallsList()) > 0 { + for j, tc := range msg.ToolCallsList() { if tc.ID == "" { return fmt.Errorf("messages[%d].tool_calls[%d]: missing id", i, j) } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index ba056970..3abc48a6 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -6,6 +6,7 @@ const TRANSLATIONS = { 'status.running': 'Running', 'status.stopped': 'Stopped', 'status.connected': 'Connected', + 'warning.storageDrops': 'Warning: {count} completion record(s) were dropped before storage.', 'tab.overview': 'Overview', 'tab.history': 'History', 'tab.performance': 'Performance', @@ -125,6 +126,7 @@ const TRANSLATIONS = { 'status.running': '运行中', 'status.stopped': '已停止', 'status.connected': '已连接', + 'warning.storageDrops': '警告:有 {count} 条完成记录在写入存储前被丢弃。', 'tab.overview': '概览', 'tab.history': '历史请求', 'tab.fallback': '降级策略', @@ -431,6 +433,15 @@ async function refreshMetrics() { if (!r.ok) return; const d = await r.json(); + const storageWarning = document.getElementById('storage-warning'); + const storageDropped = Number(d.storage_dropped || 0); + if (storageWarning) { + storageWarning.textContent = storageDropped > 0 + ? t('warning.storageDrops').replace('{count}', fmt(storageDropped)) + : ''; + storageWarning.classList.toggle('hidden', storageDropped === 0); + } + // status badge const running = d.proxy_running; const connected = d.connected_to_existing; @@ -1919,4 +1930,3 @@ const AnalyticsModule = { setTimeout(() => { AnalyticsModule.init(); }, 250); - diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 8c7b93e5..77215a79 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -43,6 +43,7 @@