Imported from xuerzong/openproxy (
apps/api/AGENTS.md). Install upstream withnpx skills add xuerzong/openproxy --skill api. Copyright stays with the author.
apps/api — Rust Proxy Conventions
Tech Stack
- Rust 2024 edition, axum 0.7, tokio, reqwest, sqlx (PostgreSQL)
- RSA encryption via
rsacrate, base64 encoding
Code Style
- Use
snake_casefor functions and variables,PascalCasefor types and structs. - Prefer
implblocks over free functions when methods belong to a struct. - Use
thiserroror explicit error types; avoid.unwrap()in non-test code. - In tests, use
#[tokio::test]for async tests.
Project Structure
src/
├── main.rs # Entry point
├── lib.rs # Re-exports
├── router.rs # Axum router setup
├── adapters/ # Provider-specific adapters
│ ├── bailian.rs # Alibaba Cloud Bailian adapter
│ ├── default.rs # Fallback adapter (passthrough)
│ ├── deepseek.rs # DeepSeek adapter
│ ├── kimi.rs # Moonshot / Kimi adapter
│ ├── minimax.rs # MiniMax adapter (OpenAI-compat endpoints only)
│ ├── opencode.rs # OpenCode Zen gateway adapter
│ ├── openai.rs # OpenAI official adapter
│ ├── openrouter.rs # OpenRouter adapter
│ ├── vercel.rs # Vercel AI Gateway adapter
│ ├── zai.rs # Z.ai / 智谱 BigModel adapter
│ └── mod.rs # Adapter trait, factory & shared helpers
├── db/ # Database queries (sqlx)
├── handlers/ # Route handlers
├── middleware/ # Auth, logging middleware
├── models/ # Data models / DTOs
├── services/ # Business logic
├── shared/ # Shared state (cache, proxy logic)
└── utils/ # Helpers
├── tokens.rs # Token counting with tiktoken
├── balance.rs # Balance validation & output limiting
├── chat.rs # Chat-specific utilities
└── ... # encryption, hash, etc.
Testing
- Run tests:
cargo test - Place unit tests in the same file using
#[cfg(test)] mod tests { ... }. - Use
tokio::net::TcpListener+axum::Routerfor integration test servers. - Prefer self-contained unit tests that generate their own fixtures and keys instead of depending on
.env.
Environment
.envfile withDATABASE_URL,RSA_PRIVATE_KEY,PORT.- Redis-backed cache/rate-limit features use optional
REDIS_URL. - Load via
dotenvy::dotenv().
Redis Usage (API only)
- Redis is required for API caching and rate limiting. Configure
REDIS_URLin runtime environments. - Shared low-level Redis utilities live in
src/shared/redis.rs. - Shared request IP/header parsing utilities live in
src/utils/ip.rs. - Keep business-specific cache key wrappers in the owning service/handler (for example, access validation cache logic in
src/services/access.rs) instead of centralizing all wrappers in shared module. - Provider sticky combo wrappers (fingerprint -> last successful provider/api-key) are owned by
src/shared/proxy.rs;src/shared/redis.rsonly exposes generic Redis primitives. - Keep keys namespaced with
openproxy:prefix:openproxy:cache:decrypted_provider_key:{encrypted_key}openproxy:cache:sticky_combo:{api_key_id}:{fingerprint_hash}openproxy:access:rows:{hash_api_key}:{model_id}openproxy:access:index:{api_key_id}(set of access cache keys for targeted invalidation)openproxy:models:public:v1openproxy:rate:{hashed_key}:{minute_bucket}
- Keep TTLs short for auth/model data (tens of seconds to minutes); sticky combo cache currently uses 30 minutes, while decrypted provider keys can stay longer.
- After usage is committed, invalidate access-cache keys by
api_key_idto ensure balance/quota changes are visible immediately. API_RATE_LIMIT_PER_MINUTEdefaults to600when env is not provided.
Token Counting & Balance Management
Token Counting
- Use
tiktoken-rscrate for accurate token counting via OpenAI's tokenizer. - Token counting utilities live in
src/utils/tokens.rs:count_input_tokens(body, model): Parse request messages and count tokens, including formatting overhead.count_tokens_for_content(content, model): Count tokens for a single string.
- Support both string messages (
"content": "text") and multimodal/vision format ("content": [{"type": "text", ...}]). - Add message overhead: ~4 tokens per message + system prompt tokens.
- Fallback gracefully to character-based estimation (1 token ≈ 4 chars) if tiktoken unavailable.
Balance Validation & Output Token Limiting
- All public model requests must validate user balance before forwarding to upstream.
- Balance validation utilities live in
src/utils/balance.rs:check_balance_and_available_output(user, input_tokens, requested_max_tokens): Validate balance and calculate available output tokens.apply_balance_check_to_body(body, result): Auto-adjustmax_tokensin request if needed.
- Base formula without tiers: $O_{available} = \left\lfloor \frac{Balance - I_{cost}}{P_{output}} \times 1,000,000 \right\rfloor$, where $I_{cost} = \frac{I \times P_{input}}{1,000,000}$
- If
output_tiersorinput_cache_read_tiersare present, bill them progressively by tier range, not by picking a single flat tier for the whole token count. - Balance pre-check must use the same progressive
output_tierslogic as final usage charging somax_tokenscapping matches eventual billing. - Return 402 Payment Required if balance insufficient for input tokens; do not forward upstream.
- Automatically cap
requested_max_tokensto available output tokens (transparent to user). - Skip validation for private models (different billing model).
- Log balance checks at INFO level for audit trail.
Integration Pattern
- Validation happens in request handlers (e.g.,
src/handlers/chat_completions.rs):- Parse request body using
parse_proxy_request() - Count input tokens via
count_input_tokens() - Check balance via
check_balance_and_available_output()→ return 402 if insufficient - Apply adjustments via
apply_balance_check_to_body() - Forward modified request to upstream handler
- Parse request body using
- Each endpoint handler repeats this pattern; centralize token counting/balance logic in
src/utils/modules. - Do not duplicate validation logic; reuse utilities from
utils::tokensandutils::balance.
Provider Adapters
Architecture
- Provider metadata used by the Rust runtime is database-backed.
GET /v1/providersreads fromai_providers, and request-time adapter selection usesProviderInfo.adapter_kind(sourced from theai_providers.adapter_kindcolumn) rather than any static registry. packages/config/src/ai-providers.jsonis the seed source consumed by the server'sseedAIProviders.ts; it is not read byapps/apiat runtime or build time.- When a new provider is added, update the shared JSON (including
adapterKindif the provider needs anything beyond the default no-op adapter) and run the server's seed step so the new row — including itsadapter_kindvalue — is written to the database. No Rust rebuild or artifact regeneration is required unless a brand-new adapter kind is being introduced. - Most OpenAI-compatible providers share
StreamUsageProviderAdapter; only providers with materially different behavior should keep dedicated adapter files. adapters/contains provider-specific request/response adapters.ProviderAdaptertrait has three request-style methods (all default to no-op):adapt_openai_request(body, is_stream)— OpenAI/v1/chat/completionsstyle.adapt_anthropic_request(body, is_stream)— Anthropic/v1/messagesstyle.adapt_responses_request(body, is_stream)— OpenAI/v1/responsesstyle (currently unused internally; responses are pre-translated to chat/completions, but the hook is available for future direct routing).
ProviderAdapterFactory::for_provider()dispatches byProviderInfo.adapter_kind→ adapter impl. Values are"default"|"openai"|"stream_usage"; unknown / empty values fall back toDefaultProviderAdapter(passthrough). Custom providers created via the admin UI default to"default"unless an admin sets a different kind.- Shared helper
ensure_stream_options_include_usage(body)lives inadapters/mod.rs. Adapters that need the finalusagechunk on streaming OpenAI responses should call it fromadapt_openai_request.
/v1/providers Endpoint
GET /v1/providers(public, no auth) returns provider metadata from theai_providerstable withid,name,base_url, per-stylebase_urls,supported_styles, anddocs_url.- The server app exposes the same database-backed list at
/providersfor admin UI consumption.
Adapter Selection
- Add an entry to
packages/config/src/ai-providers.json. Set"adapterKind"to one of"openai"|"stream_usage"if the provider needs more than the default no-op behavior; omit the field for default behavior. - Run the server seed (
apps/server/scripts/seedAIProviders.ts) so the new row — including itsadapter_kindvalue — is written into theai_providerstable. - Only add a new adapter file / dispatch arm in
adapters/mod.rswhen an entirely new request-mutation behavior is required. - Document the provider in the Provider Registry section below (base URLs, styles, quirks, docs URL).
- Add tests that cover request transformations for any non-default behavior.
Provider Registry (single source in packages/config/src/ai-providers.json)
| id | Display | Styles | Notes |
|---|---|---|---|
bailian |
百炼 / Bailian | OpenAI chat, Embeddings | stream_options.include_usage required on streaming. Regional hosts: dashscope-us.aliyuncs.com, dashscope-intl.aliyuncs.com. |
vercel |
Vercel AI Gateway | OpenAI chat, Anthropic messages, Responses, Embeddings | Split base URLs per style. Model IDs must be provider/model. Enforce include_usage on streaming. |
kimi |
Kimi (Moonshot) | OpenAI chat | Enforce include_usage. Supports thinking (k2.6+) and prompt_cache_key. |
deepseek |
DeepSeek | OpenAI chat, Anthropic messages | Separate /anthropic base. Enforce include_usage. Supports reasoning_effort / thinking. |
minimax |
MiniMax | OpenAI chat, Anthropic messages | Use ONLY the OpenAI-compat base — native /v1/text/chatcompletion* endpoints are not supported. Enforce include_usage. Separate /anthropic base. |
opencode |
OpenCode Zen | OpenCode |
OpenAI chat, Anthropic messages, Responses |
openrouter |
OpenRouter | OpenRouter |
OpenAI chat |
openai |
OpenAI | OpenAI |
OpenAI chat, Responses, Embeddings |
zai |
智谱 BigModel / Z.ai | Zai |
OpenAI chat, Anthropic messages |
Keep this table in sync with the provider seed/config and adapter dispatch map. When you update provider docs or endpoints, refresh this section as part of the same commit.