22 KiB
CLAUDE.md
What This Is
anyllm-proxy is an API translation proxy in Rust. Accepts Anthropic Messages API and OpenAI Chat Completions requests, translates between formats, forwards to any supported backend (OpenAI, Azure, Vertex, Gemini, Bedrock, Anthropic passthrough), and translates back. Supports streaming SSE, tool calling, file/document blocks, virtual key management, batch API, and optional OpenTelemetry export.
Build and Test
cargo build # build everything
cargo build --features otel # with OpenTelemetry support
cargo test # ~1100+ tests, 10 ignored (live API)
cargo test -p anyllm_client # client crate only
cargo test -p anyllm_translate # translator crate only
cargo test -p anyllm_proxy # proxy crate only
cargo test -p anyllm_providers # provider/model catalog tests
cargo test health_endpoint # single test by name
cargo test --test virtual_keys # virtual key + rate limit integration tests
cargo clippy -- -D warnings # lint
cargo fmt --check # format check
Run the proxy:
OPENAI_API_KEY=sk-... cargo run -p anyllm_proxy
# Listens on 0.0.0.0:3000, health at GET /health
Admin UI (separate port 3001):
OPENAI_API_KEY=sk-... cargo run -p anyllm_proxy -- --webui
Essential Env Vars
| Var | Purpose |
|---|---|
OPENAI_API_KEY |
Required for default backend |
BACKEND |
openai (default), azure, vertex, gemini, anthropic, bedrock, or any provider id from crates/providers (e.g. groq, mistral, together_ai) |
PROXY_CONFIG |
Path to config file (simple YAML, LiteLLM YAML, or TOML) |
PROXY_API_KEYS |
Comma-separated allowed keys (if unset and no PROXY_OPEN_RELAY, all requests rejected) |
PROXY_OPEN_RELAY |
true to accept any key (local dev only) |
RUST_LOG |
Tracing filter (e.g., info, anyllm_proxy=debug) |
Full env var reference: crates/proxy/src/config/mod.rs or docs/ENV.md.
LiteLLM env var aliases: search for litellm_env_aliases in main.rs.
Not Fully Validated
- OpenAI Responses API backend (
OPENAI_API_FORMAT=responses): wired up, not live-tested - AWS Bedrock backend (
BACKEND=bedrock): SigV4 signing + Event Stream decoding, not live-tested - Azure OpenAI backend (
BACKEND=azure): not live-tested - Live integration tests:
cargo test --test live_api -- --ignored --test-threads=1(needs real API key)
Docker
Published as followthewhit3rabbit/anyllm-proxy. See Docker section commands:
docker compose up # uses .env file
# Smoke tests (no real key needed):
docker compose -f docker-compose.test.yml up -d --build
bash scripts/docker-smoke-test.sh
docker compose -f docker-compose.test.yml down -v
Debian Package
cargo build --release -p anyllm_proxy
cargo deb -p anyllm_proxy --no-build --no-strip
After install: sudo systemctl enable --now anyllm-proxy, edit /etc/default/anyllm-proxy.
Config Directory
Data lives in ~/.anyllm/ by default. Override with ANYLLM_HOME.
See docs/CONFIG.md for lookup order, file layout, and config format docs.
Architecture
Five-crate Cargo workspace: providers (metadata catalog), client (Anthropic HTTP client), translator (pure format mapping, no IO), batch_engine (job queue + webhook), proxy (axum HTTP server + admin UI). See docs/proxy-architecture.md for crate details and data flow.
Key Design Decisions
- Translator crate is IO-free: pure
fn(A) -> Bmapping, testable without mocks. - Tool call IDs pass through directly (Anthropic
tool_use.id= OpenAItool_call.id). - OpenAI
argumentsis a JSON string; Anthropicinputis a JSON object. Mapping layer handles serialization. - Streaming uses a state machine (
streaming_map.rs) with bounded channel (32) for backpressure. ChatCompletionRequestuses#[serde(flatten)] pub extra: serde_json::Mapfor unknown OpenAI fields. Only fields needing translation logic get explicit struct fields.reasoning_contentmaps bidirectionally to Anthropic thinking blocks (DeepSeek/Qwen support).- Backoff jitter is deterministic (upper bound, not random) to keep tests predictable.
- Golden-file testing with JSON fixtures in
fixtures/anthropic/andfixtures/openai/.
Gotchas
- Managed backend fields cannot be cleared to NULL.
ManagedBackendPatchhas no sentinel to distinguish "omitted" from "set to null". Once a field likeapi_baseis set, it cannot be cleared via PATCH. UI should always send the current value in edit forms, not omit fields. OPENAI_API_KEYtakes precedence over provider-specific keys for stub backends.config/mod.rstriesOPENAI_API_KEYfirst, then falls back toGROQ_API_KEY/MISTRAL_API_KEY/ etc. IfOPENAI_API_KEYis set globally, it gets sent to Groq/Mistral/etc. even whenBACKEND=groq. Unset it or clear it from.anyllm.envbefore switching to a stub provider.BACKEND=sagemakerpanics at startup. ItsProviderProtocol::Custommakesresolve_backend()returnNone, triggering the "unknown backend" panic. UseBACKEND=bedrockfor AWS-hosted Anthropic models instead.- Adding a passthrough route (Translate mode): Reuse
passthrough_to_backend(&state, &headers, body, "/v2/path")inroutes.rs— it handles content-type forwarding and error mapping. The Anthropic mode equivalent isanthropic_generic_passthroughinpassthrough.rsviaAnthropicClient::forward_generic. - Header
&strslices lifetime: When building&[(&str, &str)]fromHeaderMap, collect values into ownedStringlocals first, then create references — the borrow checker rejects inline.to_str()in the slice. - CPU-bound work in handlers: Token counting and similar CPU work must use
tokio::task::spawn_blocking.count_request_tokens_sync(intoken_counting.rs) ispub(crate)for reuse. - Gemini input actions:
parse_model_actioningemini_input.rsreturns aGeminiActionenum. Extend it (not a bool) when adding new:actionsuffixes. - CSRF tokens are one-time-use. Fetch a fresh token from
GET /admin/csrf-tokenbefore each admin POST/PUT/DELETE. The SPA does this automatically; scripts must too. - Admin UI defaults on for bare invocation. Running
anyllm-proxywith no args starts proxy + admin UI and auto-opens the default browser to the admin page (the zero-arg desktop default). Passing--webui/--admin(orWEBUI=1/ADMIN=1env) forces it on alongside other args but does not open a browser. Passing any other arg (e.g.--env-file,--redact-secrets) keeps the proxy CLI-only.DISABLE_ADMIN=1force-disables it in all cases. Gate + zero-arg logic live inmain_helpers/bootstrap.rs(admin_enabled/admin_requested/is_default_launch); browser open inmain_helpers/browser.rs. - Virtual key OnceLock in tests.
set_virtual_keysuses a globalOnceLock<DashMap>. Integration tests incrates/proxy/tests/virtual_keys.rsuse a sharedOnceLockto avoid conflicts. - Auth defaults to reject-all. Without
PROXY_API_KEYS,PROXY_OPEN_RELAY=true, virtual keys, or OIDC, every request gets 401, including from localhost.effective_auth_mode()(keys/open_relay/auth_required) is surfaced viaGET /admin/api/statusand the admin UI banner. - Admin rate limiter resets on restart. 10 RPM per source IP, in-memory sliding window.
set_admin_rpm()overrides for tests. - Docker admin needs
ADMIN_BIND=0.0.0.0. Default binds to 127.0.0.1 which is unreachable from outside the container. - PLAN.md references in source comments are stale. Some files reference line ranges in a removed PLAN.md.
reqwest::Error::is_timeout()fires on read timeouts too. A read timeout means the server already processed the POST. Onlyis_connect()is safe to retry on POST endpoints. Theretry_transport_errorsflag inRetryPolicygates onis_connect()only by design — do not addis_timeout()back.- Two retry loops exist; keep them in sync. The shared
anyllm_client::retry::send_with_retry_policy(client crate) is canonical; the proxy'sbackend/mod.rs::send_with_retryandclient/anthropic_client.rsboth delegate to it (OpenAI/Gemini/Azure/Vertex/Anthropic). Butbedrock_client.rsstill has its own hand-rolledfor attempt in 0..=MAX_RETRIESloop. A retry-policy change (backoff, quota fast-fail, status classification) must be applied to both or they diverge silently. anthropic::ErrorType::as_wire_str()is the canonical snake_case stringifier. Use it for error-event wire strings; do NOT round-trip throughserde_json::to_value(&et).as_str()...unwrap_or("api_error")(silent fallback masks bugs). Adding a variant fails to compile until handled in thematch.- Two unrelated
extra_headersmechanisms exist; don't conflate them.HttpClientConfig.extra_headers(crates/client/src/http.rs, applied once at client-construction time viaHeaderMap::insertinbuild_http_client) really is last-writer-wins — push builder-level headers to the END of that Vec so they overwrite any caller-supplied duplicate; inserting at index 0 loses priority. But the PER-REQUESTextra_headers: &[(&str, &str)]param used inanthropic_client.rs'sforward/forward_stream/forward_genericapplies viareqwest::RequestBuilder::header(), which APPENDS rather than replaces — calling it twice for the same header name sends two conflicting header lines upstream, not an override. Overriding a credential set earlier on that path (e.g.ANTHROPIC_FORWARD_CLIENT_AUTH) needs a realOption<(&str, &str)>parameter that skips the default.header()call entirely, not a Vec entry. Ipv4Addr::is_broadcast()matches only255.255.255.255, not directed subnet broadcasts like10.0.0.255. Directed broadcasts slip through SSRF filters whenallow_private=true.2u64.pow(attempt)overflows at attempt ≥ 64.backoff_delaycaps withattempt.min(62). Any new backoff formula needs the same guard —RetryPolicy::max_retrieshas no upper bound.- SSE streaming: use
run_sse_taskincrates/client/src/streaming.rs. New stream types must use this shared helper (BytesMut +find_double_newlineloop + channel send). Implement asFnMut(SseEvent<'_>) -> Vec<...>. Do not duplicate the frame-reading loop. - SSE byte-framing is owned by
SseFrameBuffer(client/src/sse.rs):push(&bytes) -> Vec<Bytes>, caps size before append. All proxy stream loops use it EXCEPTserver/streaming.rs::observe_anthropic_sse_frames, still on the rawBytesMut+find_double_newlinepath. Per-framedata:line parsing is NOT shared (duplicated ~7x). - Avoid
.clone()before serialization when only one field changes. Useserde_json::to_value(req)+ field patch instead.MessageCreateRequestholdsVec<Message>+serde_json::Map— clone is O(content size). - Env-var tests must share
crate::config::ENV_TEST_LOCK. Tests that read/mutate process env (ANTHROPIC_BASE_URL,OPENAI_API_KEY,*_API_KEY, etc.) serialize on the crate-wide lock inconfig/mod.rs(poison-safe viaunwrap_or_else(|e| e.into_inner())). A per-modulestatic Mutex<()>does NOT serialize across modules within one--libtest binary, so cross-module env tests race and flake. Acquire the lock at the top of each such test. - Adding a
RuntimeConfigfield touches 6 sites; 2 are not compiler-caught. Struct +RuntimeConfigDefaults(admin/state.rs) and 3 constructors (SharedState::new_for_test,cost/mod.rstest,main_helpers/async_main/admin.rs) fail to compile if missed — but the SQLite override-applymatch(main_helpers/async_main/admin.rs) and thedelete_config_overridereset (admin/routes/config.rs) do NOT, so a new field silently won't persist or reset across restart unless added there too. (main.rsitself is now a thin bootstrap entry point post-refactor and holds neither site.) anthropic::ContentBlock/Toolhave nocache_controlfield and no#[serde(flatten)] extra. An unrecognized block"type"collapses to a bare#[serde(other)] Unknownunit variant that loses every other field on deserialize, and re-serializes as just{"type":"Unknown"}. Never deserialize-then-reserialize a wholeMessageCreateRequestwhen only one field changed (e.g. a repair/patch pass) — it silently dropscache_controlprompt-cache breakpoints and any block/tool type the struct doesn't model yet, on every message, not just the one touched. Patch the rawserde_json::Valuesubtree instead.- Bulk edits that touch many struct-literal fields (e.g. adding a field to every call site of a struct) reliably break
cargo fmt --check. Runcargo fmtafter any such edit, before considering the change done — CI's fmt gate fails even whencargo build/clippyare clean. admin-ui/dist/index.htmlis git-tracked and embedded at compile time viainclude_str!inadmin/routes/mod.rs. It's a single-file Vite bundle (viteSingleFile+inlineDynamicImports, ~800KB, all JS/CSS inlined). Deleting it (e.g. during a UI redesign) failscargo buildwith "couldn't read .../dist/index.html". Fix:make ui-buildto regenerate, then commit it. Do NOT restore the old file from git (ships stale UI without the new bundle).
Release Process
Publishing is fully automated via CI. There is no separate release script.
To cut a release:
- Update
CHANGELOG.md: move bullet points from[Unreleased]into a new## [X.Y.Z] - YYYY-MM-DDsection. - Bump the version:
Cargo.toml(workspace),crates/client/Cargo.toml(pinned directly), and inter-crateversion = "X.Y.Z"refs incrates/batch_engine/Cargo.tomlandcrates/proxy/Cargo.toml. - Update
README.md: the deb filename in the Linux install snippet is pinned (anyllm-proxy_X.Y.Z-1_amd64.deb). The/releases/latest/download/URL resolves to the latest release but still needs the exact current filename, so bump the version there. - Run
act -j testlocally before pushing — it runs the samecargo auditstep as CI (fresh advisory-db pull), catching new RUSTSEC advisories before they show up as a CI failure post-push. - Commit and push to main.
- Push a tag:
git tag vX.Y.Z && git push origin vX.Y.Z
CI does the rest on tag push: builds binaries (Linux/macOS/Windows), packages debs, runs deb install tests, creates the GitHub Release using the CHANGELOG section for that version as the release body, uploads release assets, publishes all crates to crates.io in dependency order, and updates the Homebrew tap.
Changelog discipline: Every PR that changes user-visible behavior should add a bullet to [Unreleased] before merging. The release step is then just moving those bullets to the version section.
Conventions
- Adding a provider:
crates/providers/src/providers/<name>.rs(copy any stub) → add toproviders/mod.rs+registry.rs→ done. OpenAI-compat providers need no HTTP code. - Test files live alongside source (
#[cfg(test)]) and incrates/proxy/tests/for integration tests. - Error types use
thiserrorderive macros. - Fixture-based golden tests for translation correctness.
- Model pricing is auto-updated.
scripts/update_pricing.pypulls from LiteLLM'smodel_prices_and_context_window.jsonand writesassets/model_pricing.jsonplus the packaged proxy copy atcrates/proxy/assets/model_pricing.json. Run manually or via.github/workflows/update-pricing.yml(weekly, Monday 06:00 UTC). The proxy copy is embedded at compile time (include_str!incrates/proxy/src/cost/mod.rs); editing it requires recompile. Override at runtime withMODEL_PRICING_FILE.
Active Technologies
- Rust stable (1.83+, workspace edition 2021)
- SQLite, Redis (optional rate-limit/cache), Qdrant (optional semantic cache,
--features qdrant)
CI / Workflow Validation
- Validate workflows before pushing:
brew install actionlint && actionlint .github/workflows/*.ymlRuby YAML parser validates syntax only; actionlint catches GitHub Actions semantic errors. - secrets context in
ifconditions: Not allowed at job or step level. Pass viaenv:and check in shell:if [ -z "${SECRET}" ]; then echo "skipping"; exit 0; fi - Heredocs in
run:blocks: Content must be indented to match the block level. Unindented heredoc content (col 0) breaks YAML parsing. Useprintf '%s\n' ...or{ echo ...; } > file. gh release uploadrequires the release to exist. Add acreate-releasejob before upload jobs:gh release create "$TAG" --notes-file release_notes.txt || echo "already exists".- cargo publish exit 101 = version already exists on crates.io (not an error for re-runs).
Pattern:
cargo publish -p FOO || { ec=$?; [ "$ec" -eq 101 ] && echo "already published" || exit "$ec"; }
npm / Frontend
@vitejs/plugin-react@4.7.0declares peer deps only up to vite 7. With vite 8, usenpm ci --legacy-peer-deps(in ci.yml AND Dockerfile stage that runs npm ci).
crates.io Publish Order
anyllm_translate → anyllm_providers → anyllm_client → anyllm_batch_engine → anyllm_pxpipe → anyllm_rtk → anyllm_optimize_core → anyllm_optimize_passes → anyllm_optimize_scorer → anyllm_proxy (sleep 30 between each for index propagation)
anyllm_proxy depends on all of the above, so every new leaf crate (pxpipe, rtk,
optimize_*) MUST be added to the publish job in ci.yml before anyllm_proxy, or
cargo publish -p anyllm_proxy fails on the missing dep. The exit 101 guard masks that
failure (cargo returns 101 for most errors), so the job goes green while proxy never lands.
Version Bumping
crates/client/Cargo.tomlpinsversiondirectly (notversion.workspace = true). When bumping the workspace version, also update it there and all inter-crateversion = "X.Y.Z"path deps. Quick check:grep -r 'version.*0\.' crates/*/Cargo.toml Cargo.toml | grep -v "workspace"- Deb package version = Cargo workspace version, NOT the release tag. Keep them in sync.
Code Intelligence
A synrepo MCP server is configured (.mcp.json) for structured codebase context. Start with synrepo_orient, use synrepo_ask for a cited context packet, synrepo_find/synrepo_search to locate code, synrepo_impact before edits, synrepo_tests before claiming done. Read raw source after synrepo narrows the target.
References
- OpenAI API spec: https://github.com/openai/openai-openapi/blob/manual_spec/openapi.yaml (very large, ~70k+ lines). Reference specific sections, do not load full spec.
- Endpoint inventory: docs/ENDPOINTS.md
2026-07-04: forge-guardrails integration finish (Eatahorse run)
Goal: finish integrating forge-guardrails (opt-in tool-call guardrails for local LLMs — lsp_first/quiet_command/write_payload_cap nudges, fingerprint dedup) into anyllm-proxy. The backend port itself (crates/proxy/src/tools/guardrails.rs, wiring into both tool loops, SimpleConfig.build_tool_guardrail_config, FORGE_TOOL_CALL_POLICY env fallback) was already done before this run; the run closed the verified gaps around it.
Completed (board cleared, 5 done / 1 dropped, 0 blocked, 11 iterations):
- EH-0001: Fixed
init_tool_engine(main_helpers/async_main/tools.rs) soFORGE_TOOL_CALL_POLICYalone (no YAMLtool_executionblock, noPROXY_CONFIG) now initializes the guardrail engine — previouslytool_config.filter(|tc| tc.has_any())?short-circuited toNonebefore the env var was ever consulted, so the target local-LLM-env-var-only use case was silently dead. Env resolution now runs before thehas_any()gate; YAML precedence over env is preserved. - EH-0002: Documented (docs/CONFIG.md, docs/codedocs/configuration-and-modes.md) that the LiteLLM
model_list:YAML format has notool_configpath at all —tool_execution/guardrails are silently ignored on that format; only simple-YAMLtool_execution.guardrailsorFORGE_TOOL_CALL_POLICYwork. No LiteLLM parsing support was added (by design). - EH-0003: Added the
[Unreleased]CHANGELOG.md bullet for the guardrails feature. - EH-0005: Added
tool_guardrail_modeas a fullRuntimeConfigfield (struct +RuntimeConfigDefaultsinadmin/state.rs, all 3 constructors, SQLite override-apply arm,delete_config_overridereset, GET/PUT config-route support), wired intotools::resolve_runtime_guardrails/AppState::effective_tool_guardrails, consumed byhandler.rs,routes/messages.rs, and the streamingtool_loop.rs. - EH-0006: Added the guardrail-mode
<select>control toadmin-ui/src/tabs/settings/Settings.tsx, wired to the existing config GET/PUT route.
Dropped: EH-0004 ("optional admin UI + RuntimeConfig toggle") was split into EH-0005 (backend) + EH-0006 (UI) and dropped as a standalone card once superseded — not a dead end, just a decomposition artifact. No re-run action needed.
No cards were blocked this run.
Board invariants a rerun should keep honoring:
- Backend-then-UI split for RuntimeConfig-touching work (EH-0005 before EH-0006) so the UI card has a real field to bind to.
- Docs-only changes for format limitations (EH-0002) — explicitly do not build LiteLLM
tool_configparsing support; that's an accepted gap, not a TODO. - Env-var precedence rule: YAML-set guardrails config always wins over
FORGE_TOOL_CALL_POLICY; do not invert this. - Env var is the existing convention for backend toggles here — no second (e.g. CLI flag) mechanism was added for guardrail mode.
- New
RuntimeConfigfields must touch the 6-site checklist (struct + defaults, 3 constructors, SQLite override-apply match inmain_helpers/async_main/admin.rs,delete_config_overridereset inadmin/routes/config.rs) — note the checklist's override-apply site ismain_helpers/async_main/admin.rs, NOTmain.rs(main.rs is now a thin 167-line bootstrap entry point post-refactor; an earlier acceptance-check wording referencingmain.rsfor this was stale and was corrected in EH-0005's notes).
No manual-verification residuals were left behind: no card's ## Notes contains a literal residual: line. (EH-0006's manual/browser acceptance item was itself discharged headlessly during the run — by driving the admin API directly, since no browser was available — rather than deferred; see its Notes for the exact curl sequence if a human wants to re-confirm via an actual browser click in --webui.)