README
¶
LLM Proxy
LLM Proxy is a lightweight HTTP service that forwards user prompts to OpenAI's Responses API, OpenAI-compatible chat providers, Anthropic's native Messages API, Google Gemini's native generateContent API, and audio transcription APIs. It exposes protected HTTP endpoints that require a tenant secret and simplify integrating provider capabilities without embedding API credentials in each client.
Features
- Minimal HTTP server that accepts:
GET /?prompt=...&key=...[&provider=...]for LLM responsesPOST /?key=...[&provider=...]for large JSON prompt bodiesPOST /v2?key=...[&provider=...]for ordered chat-message JSON bodiesPOST /dictate?key=...[&provider=...]for audio transcription
- Choose the provider per request via
provider=...; omitted provider uses the authenticated tenant default - Choose the model per request via
model=...; omitted model uses the tenant default whenprovideris omitted, otherwise the selected provider's configured default - Choose the dictation model per request via
model=...on/dictate; omitted model uses the tenant default whenprovideris omitted, otherwise the selected provider's configured default - Optional per-request web search via
web_search=1|true|yeswhen the selected provider/model is configured to support it - Optional logging at
debugorinfolevels - Forwards requests using server-side provider API keys, loaded from the database in management mode
- Optional TAuth-protected self-service UI where signed-in users save their own provider API keys and generate llm-proxy tenant secrets
- Supports plain text, JSON, XML, or CSV responses
REST Contract
llm-proxy exposes a blocking REST contract for text generation. A caller sends
one authenticated GET /, POST /, or POST /v2 request and receives the final
formatted answer in that same HTTP response.
The caller does not stream tokens, poll a job endpoint, follow a resume token, or
know whether the selected upstream provider uses synchronous responses,
background responses, or provider-specific polling internally. For OpenAI
Responses, llm-proxy always owns the background-response lifecycle: it sends
stored background requests upstream and polls OpenAI server-side until the answer
is terminal or server.request_timeout_seconds expires.
A 504 Gateway Timeout means the overall proxy request deadline expired before
the selected upstream provider produced a final answer. It is not a prompt for
the client to poll llm-proxy.
Internally, server.workers limits concurrent upstream provider HTTP
operations and server.queue_size limits upstream HTTP operations waiting for a
worker. Long OpenAI background-response poll sleeps do not occupy a worker slot;
only the actual upstream HTTP request or poll does.
Configuration
The service reads service configuration from config.yml. The default path is
config.yml in the current working directory; use --config /path/config.yml
only to select a different file. Command-line flags and environment variables
are not service configuration sources.
Before parsing YAML, the loader expands ${NAME} placeholders from process
environment variables and from an optional .env file in the same directory as
the selected config file. Process environment values override .env values.
Missing placeholders fail startup except when an api_key value is exactly one
missing placeholder; that exact missing provider credential expands to an empty
string so non-default providers can stay disabled. The loader does not mutate
process environment, and all runtime code receives only the validated config
value.
server:
port: 8080
log_level: info
workers: 4
queue_size: 100
request_timeout_seconds: 360
max_prompt_bytes: 4194304
max_input_audio_bytes: 26214400
upstream_rate_limits: []
management:
enabled: ${LLM_PROXY_MANAGEMENT_ENABLED}
public_origin: "${LLM_PROXY_MANAGEMENT_PUBLIC_ORIGIN}"
ui_description: "${LLM_PROXY_MANAGEMENT_UI_DESCRIPTION}"
ui_origins:
- "${LLM_PROXY_MANAGEMENT_PUBLIC_ORIGIN}"
- "${LLM_PROXY_MANAGEMENT_LOOPBACK_ORIGIN}"
- "${LLM_PROXY_MANAGEMENT_LOCALHOST_ORIGIN}"
admin_emails: ${LLM_PROXY_MANAGEMENT_ADMIN_EMAILS}
tauth_url: "${LLM_PROXY_MANAGEMENT_TAUTH_URL}"
tauth_tenant_id: "${LLM_PROXY_MANAGEMENT_TAUTH_TENANT_ID}"
google_client_id: "${LLM_PROXY_MANAGEMENT_GOOGLE_CLIENT_ID}"
login_path: "${LLM_PROXY_MANAGEMENT_TAUTH_LOGIN_PATH}"
logout_path: "${LLM_PROXY_MANAGEMENT_TAUTH_LOGOUT_PATH}"
nonce_path: "${LLM_PROXY_MANAGEMENT_TAUTH_NONCE_PATH}"
jwt_signing_key: "${LLM_PROXY_MANAGEMENT_JWT_SIGNING_KEY}"
jwt_issuer: "${LLM_PROXY_MANAGEMENT_JWT_ISSUER}"
session_cookie_name: "${LLM_PROXY_MANAGEMENT_SESSION_COOKIE_NAME}"
database_dialect: "${LLM_PROXY_MANAGEMENT_DATABASE_DIALECT}"
database_dsn: "${LLM_PROXY_MANAGEMENT_DATABASE_DSN}"
provider_key_encryption_key: "${LLM_PROXY_MANAGEMENT_PROVIDER_KEY_ENCRYPTION_KEY}"
management_api_origin: "${LLM_PROXY_MANAGEMENT_API_ORIGIN}"
proxy_origin: "${LLM_PROXY_MANAGEMENT_PROXY_ORIGIN}"
legacy_token_migration:
tenant_id: default
owner_email: "${LLM_PROXY_MANAGEMENT_LEGACY_TOKEN_OWNER_EMAIL}"
providers:
openai:
base_url: "https://api.openai.com/v1"
transcriptions_url: "https://api.openai.com/v1/audio/transcriptions"
text:
default_model: "gpt-4.1"
models:
- id: "gpt-4o-mini"
request_profile: "openai_responses_temperature"
- id: "gpt-4o"
request_profile: "openai_responses_temperature_tools"
web_search: true
- id: "gpt-4.1"
request_profile: "openai_responses_temperature_tools"
web_search: true
- id: "gpt-5-mini"
request_profile: "openai_responses_base"
- id: "gpt-5"
request_profile: "openai_responses_reasoning_tools"
web_search: true
reasoning_effort:
adapter: "openai_responses"
efforts: ["minimal", "low", "medium", "high"]
- id: "gpt-5.5"
request_profile: "openai_responses_reasoning_tools"
web_search: true
reasoning_effort:
adapter: "openai_responses"
efforts: ["minimal", "low", "medium", "high"]
- id: "gpt-5.5-pro"
request_profile: "openai_responses_reasoning_tools"
web_search: true
reasoning_effort:
adapter: "openai_responses"
efforts: ["minimal", "low", "medium", "high"]
- id: "gpt-5.6"
request_profile: "openai_responses_reasoning_tools"
web_search: true
reasoning_effort:
adapter: "openai_responses"
efforts: ["minimal", "low", "medium", "high"]
- id: "gpt-5.6-sol"
request_profile: "openai_responses_reasoning_tools"
web_search: true
reasoning_effort:
adapter: "openai_responses"
efforts: ["minimal", "low", "medium", "high"]
- id: "gpt-5.6-terra"
request_profile: "openai_responses_reasoning_tools"
web_search: true
reasoning_effort:
adapter: "openai_responses"
efforts: ["minimal", "low", "medium", "high"]
- id: "gpt-5.6-luna"
request_profile: "openai_responses_reasoning_tools"
web_search: true
reasoning_effort:
adapter: "openai_responses"
efforts: ["minimal", "low", "medium", "high"]
dictation:
default_model: "gpt-4o-mini-transcribe"
models:
- id: "gpt-4o-mini-transcribe"
- id: "gpt-4o-transcribe"
meta:
base_url: "https://api.meta.ai/v1"
text:
default_model: "muse-spark-1.1"
models:
- id: "muse-spark-1.1"
deepseek:
base_url: "https://api.deepseek.com"
text:
default_model: "deepseek-v4-flash"
models:
- id: "deepseek-v4-flash"
- id: "deepseek-v4-pro"
- id: "deepseek-chat"
- id: "deepseek-reasoner"
dashscope:
base_url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
text:
default_model: "qwen-plus"
models:
- id: "qwen-plus"
qwencloud:
base_url: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1"
text:
default_model: "qwen3.8-max-preview"
models:
- id: "qwen3.8-max-preview"
moonshot:
base_url: "https://api.moonshot.ai/v1"
text:
default_model: "kimi-k2-0905-preview"
models:
- id: "kimi-k2-0905-preview"
- id: "kimi-k3"
- id: "kimi-k2.7-code"
- id: "kimi-k2.7-code-highspeed"
- id: "kimi-k2.6"
minimax:
base_url: "https://api.minimax.io/v1"
text:
default_model: "MiniMax-M2.7"
models:
- id: "MiniMax-M2.7"
output_token_limit: 2048
siliconflow:
base_url: "https://api.siliconflow.com/v1"
transcriptions_url: "https://api.siliconflow.com/v1/audio/transcriptions"
text:
default_model: "deepseek-ai/DeepSeek-R1"
models:
- id: "deepseek-ai/DeepSeek-R1"
dictation:
default_model: "FunAudioLLM/SenseVoiceSmall"
models:
- id: "FunAudioLLM/SenseVoiceSmall"
zhipu:
base_url: "https://open.bigmodel.cn/api/paas/v4"
transcriptions_url: "https://api.z.ai/api/paas/v4/audio/transcriptions"
text:
default_model: "glm-5.1"
models:
- id: "glm-5.1"
- id: "glm-5.2"
output_token_limit: 131072
dictation:
default_model: "glm-asr-2512"
models:
- id: "glm-asr-2512"
gemini:
base_url: "https://generativelanguage.googleapis.com/v1"
text:
default_model: "gemini-2.5-flash"
models:
- id: "gemini-3.5-flash"
output_token_limit: 65536
- id: "gemini-3.1-pro-preview"
output_token_limit: 65536
- id: "gemini-3-flash-preview"
output_token_limit: 65536
- id: "gemini-3.1-flash-lite"
output_token_limit: 65536
- id: "gemini-2.5-flash"
output_token_limit: 65536
- id: "gemini-2.5-flash-lite"
output_token_limit: 65536
- id: "gemini-2.5-pro"
output_token_limit: 65536
anthropic:
base_url: "https://api.anthropic.com"
text:
default_model: "claude-sonnet-4-6"
models:
- id: "claude-fable-5"
output_token_limit: 128000
- id: "claude-sonnet-5"
output_token_limit: 128000
- id: "claude-opus-4-8"
output_token_limit: 128000
- id: "claude-sonnet-4-6"
output_token_limit: 64000
- id: "claude-haiku-4-5-20251001"
output_token_limit: 64000
- id: "claude-haiku-4-5"
output_token_limit: 64000
- id: "claude-sonnet-4-5-20250929"
output_token_limit: 64000
- id: "claude-sonnet-4-5"
output_token_limit: 64000
- id: "claude-opus-4-1-20250805"
output_token_limit: 32000
- id: "claude-opus-4-1"
output_token_limit: 32000
grok:
base_url: "https://api.x.ai/v1"
transcriptions_url: "https://api.x.ai/v1/stt"
text:
default_model: "grok-4.3"
models:
- id: "grok-4.3"
- id: "grok-4.3-latest"
- id: "grok-4.5"
- id: "grok-4.20-0309-reasoning"
- id: "grok-4.20-0309-non-reasoning"
- id: "grok-latest"
- id: "grok-build-0.1"
- id: "grok-code-fast"
- id: "grok-code-fast-1"
- id: "grok-code-fast-1-0825"
dictation:
default_model: "xai-stt"
models:
- id: "xai-stt"
server.workers is not the number of client requests that may be connected at
once. It is the upstream provider HTTP concurrency limit shared by text
generation and dictation. server.queue_size is the number of additional
upstream HTTP operations that may wait for that shared limit before the proxy
returns 503 request queue full.
server.upstream_rate_limits applies strict rolling-window call limits in that
same shared HTTP layer. Rules match an exact normalized upstream origin
(scheme://host[:port]), so providers that use the same origin share one
budget while different origins are independent. A delayed call remains in the
bounded upstream queue but does not occupy a worker. Every upstream attempt,
including transport retries and OpenAI response retries, consumes one call.
The shared client reserves the slot only after worker capacity is available;
if the rolling window is still full, it releases that worker before waiting.
An absent or empty list disables rate limiting; invalid and duplicate rules
fail startup.
server:
upstream_rate_limits:
- origin: "https://api.openai.com"
max_requests: 60
interval: "1m"
origin accepts only an exact http or https origin without user info,
path, query, or fragment. max_requests must be positive, and interval must
be a positive Go duration such as 500ms, 1s, or 1m. When a call must wait,
the shared client emits a structured info log with the origin, limit, interval,
and wait duration; context cancellation during the wait emits a warning and
keeps the existing request-timeout error mapping.
Provider support matrix
Provider selectors and aliases are accepted anywhere the public API accepts
provider. Omitted text models use the authenticated tenant default when
provider is omitted; otherwise they use the selected provider's configured
default text model. This table describes capabilities currently wired through
llm-proxy and the defaults shipped in configs/config.yml.
Upstream providers may expose additional speech APIs that need separate proxy
adapters before they are available through /dictate.
| Provider selector | Aliases | Text API | Configured default text model | Credential field | Default base URL | Dictation | Web search |
|---|---|---|---|---|---|---|---|
openai |
none | OpenAI Responses | gpt-4.1 |
providers.openai.api_key |
https://api.openai.com/v1 |
Yes: gpt-4o-mini-transcribe, gpt-4o-transcribe |
Yes, on marked OpenAI models |
meta |
none | Meta Model API OpenAI-compatible chat completions | muse-spark-1.1 |
providers.meta.api_key |
https://api.meta.ai/v1 |
No | No |
deepseek |
none | OpenAI-compatible chat completions | deepseek-v4-flash |
providers.deepseek.api_key |
https://api.deepseek.com |
No | No |
dashscope |
qwen |
OpenAI-compatible chat completions | qwen-plus |
providers.dashscope.api_key |
https://dashscope-intl.aliyuncs.com/compatible-mode/v1 |
No | No |
qwencloud |
none | Qwen Cloud Token Plan OpenAI-compatible chat completions | qwen3.8-max-preview |
providers.qwencloud.api_key |
https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1 |
No | No |
moonshot |
kimi |
OpenAI-compatible chat completions | kimi-k2-0905-preview |
providers.moonshot.api_key |
https://api.moonshot.ai/v1 |
No | No |
minimax |
none | MiniMax OpenAI-compatible chat completions | MiniMax-M2.7 |
providers.minimax.api_key |
https://api.minimax.io/v1 |
No | No |
siliconflow |
none | OpenAI-compatible chat completions | deepseek-ai/DeepSeek-R1 |
providers.siliconflow.api_key |
https://api.siliconflow.com/v1 |
Yes: FunAudioLLM/SenseVoiceSmall |
No |
zhipu |
glm |
OpenAI-compatible chat completions | glm-5.1 |
providers.zhipu.api_key |
https://open.bigmodel.cn/api/paas/v4 |
Yes: glm-asr-2512 |
No |
gemini |
none | Gemini native generateContent |
gemini-2.5-flash |
providers.gemini.api_key |
https://generativelanguage.googleapis.com/v1 |
No | No |
anthropic |
claude |
Anthropic native Messages | claude-sonnet-4-6 |
providers.anthropic.api_key |
https://api.anthropic.com |
No | No |
grok |
xai |
xAI OpenAI-compatible chat completions | grok-4.3 |
providers.grok.api_key |
https://api.x.ai/v1 |
Yes: xai-stt |
No |
All upstream provider credentials are server-side only. Client requests must never send OpenAI, Meta, Anthropic, xAI, Gemini, or other upstream API keys.
Model catalog schema
Model ids and per-model metadata are runtime config data. To add, remove, or
replace provider models, update the selected config.yml and restart the
service; provider transports stay code-owned.
The model-capability table below mirrors the checked-in catalog. Refresh that
table and config.yml together; provider transports remain code-owned.
Moonshot's current Kimi route receives Chat Completions
max_completion_tokens when callers set the proxy max_tokens value.
The transport deliberately omits sampling controls because Kimi K3 fixes those
values upstream.
GLM-5.2 uses the existing BigModel/Zhipu Chat Completions endpoint with a
configured 131072-token output cap; its optional thinking and
reasoning_effort controls remain outside the public request contract. The
proxy's saved tenant reasoning_effort default is separate: it is forwarded
only when a resolved route has an explicit catalog mapping, never to GLM or a
generic compatible-provider adapter.
Qwen Cloud Token Plan is separate from DashScope: select qwencloud with a
dedicated ${QWEN_CLOUD_TOKEN_PLAN_API_KEY} and its token-plan base URL; the
existing qwen alias remains DashScope-only. MiniMax M2.7 uses
max_completion_tokens, and the proxy rejects max_tokens values above the
documented 2048-token completion maximum before it calls MiniMax.
Each provider must declare a text catalog. A provider with an api_key
configured must have a valid text default_model; that default is used when a
request selects the provider and omits model.
providers:
provider_name:
text:
default_model: "provider-default-model"
models:
- id: "provider-model-id"
request_profile: "openai_responses_temperature_tools"
web_search: true
output_token_limit: 65536
reasoning_effort:
adapter: "openai_responses"
efforts: ["minimal", "low", "medium", "high"]
Dictation-capable providers must also declare a dictation catalog:
providers:
provider_name:
dictation:
default_model: "provider-default-dictation-model"
models:
- id: "provider-dictation-model-id"
Catalog validation fails startup when a provider text catalog is missing, a
dictation-capable provider dictation catalog is missing, a model id is blank or
duplicated, default_model is not present in the corresponding models list,
or web_search: true appears outside an OpenAI text model entry.
output_token_limit is optional for most providers; when set, it is used as a
proxy-side maximum for max_tokens. Anthropic text models require
output_token_limit because Anthropic Messages requires max_tokens even when
the client omits it.
reasoning_effort is an optional text capability declaration. It can appear
under providers.<provider>.text to cover every configured text model for that
provider, or under one model to cover only that model when no provider-level
declaration exists. The current catalog accepts only the exact
openai_responses adapter and ordered minimal, low, medium, high
options, and only for an OpenAI openai_responses_reasoning_tools route. The
capability declares whether the one tenant-level setting may be forwarded; it
does not create a model-owned setting or a public request parameter.
request_profile is currently required only for OpenAI text models. It selects
the stable proxy payload shape for that OpenAI model and must be one of:
| Request profile | Payload behavior |
|---|---|
openai_responses_base |
Sends model and input; omits temperature, tools, and reasoning controls. |
openai_responses_temperature |
Adds temperature. |
openai_responses_temperature_tools |
Adds temperature; includes web-search tools only when both the request and model catalog enable web search. |
openai_responses_reasoning_tools |
Adds reasoning/text controls; includes web-search tools only when both the request and model catalog enable web search. A saved tenant reasoning effort is sent only when this route declares the capability. |
All OpenAI Responses text requests also send background: true and
store: true. llm-proxy polls the stored OpenAI response server-side until it
reaches a terminal state or the normal server.request_timeout_seconds deadline
expires. Plain REST callers use one GET /, POST /, or POST /v2 request and
receive the final formatted answer; they do not stream, poll, or follow a
separate resume endpoint.
Provider-specific details:
- OpenAI is the only provider currently exposed with
web_searchsupport, and only for OpenAI model catalog entries withweb_search: true. OpenAI dictation uses the sameproviders.openai.api_keyvalue. OpenAI Responses and Models endpoint URLs are derived fromproviders.openai.base_url; dictation usesproviders.openai.transcriptions_url. - OpenAI-compatible text providers send chat completion requests with
Authorization: Bearer <api_key>and the selected provider base URL. - Qwen Cloud Token Plan uses selector
qwencloud, exact modelqwen3.8-max-preview,${QWEN_CLOUD_TOKEN_PLAN_API_KEY}, andhttps://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1. It is deliberately separate from DashScope because their API keys and base URLs are not interchangeable. The proxy exposes text generation only and sends the publicmax_tokensvalue through the compatible Chat Completions field without adding Qwen-specific thinking, tool, or multimodal controls. - MiniMax uses selector
minimax, exact modelMiniMax-M2.7,${MINIMAX_API_KEY}, andhttps://api.minimax.io/v1. The shared compatible Chat Completions adapter maps publicmax_tokensto upstreammax_completion_tokens; the catalog enforces MiniMax's documented 2048-token completion maximum. The proxy does not expose MiniMax-specific reasoning, tool, streaming, or multimodal controls. - Meta Model API requests use that shared Chat Completions adapter with the
exact
metaselector,https://api.meta.ai/v1base URL,${MODEL_API_KEY}credential, andmuse-spark-1.1model. llm-proxy exposes the publicmax_tokensinput upstream as Meta's currentmax_completion_tokensfield rather than Meta's deprecatedmax_tokensfield. The proxy exposes Muse Spark 1.1 only as text generation throughGET /,POST /, andPOST /v2: there is no Meta dictation orweb_search, no proxy tool or multimodal input contract, and no fallback to Meta's Responses API. Meta documents Muse Spark 1.1 as a public preview for U.S. developers with a 1,048,576-token context window. See Meta's Muse Spark guide, model reference, Chat Completions reference, and pricing and rate-limit documentation. - Only dictation-capable providers expose
transcriptions_urlfields: OpenAI usesproviders.openai.transcriptions_url, SiliconFlow usesproviders.siliconflow.transcriptions_url, Zhipu usesproviders.zhipu.transcriptions_url, and Grok/xAI usesproviders.grok.transcriptions_url. - Gemini text requests use the native
generateContentroute and normalize Gemini usage metadata into the same response headers and JSONusageobject used by the other text providers. - Anthropic text requests use
POST /v1/messageswithx-api-keyandanthropic-version: 2023-06-01. System messages are translated to Anthropic's top-levelsystemfield. Anthropic requiresmax_tokens, so when the client omits it the proxy sends the selected Claude model's configured output limit. - Zhipu dictation uses Z.AI GLM-ASR through
providers.zhipu.transcriptions_urlwith the selected configured dictation model. - Grok text requests use xAI's OpenAI-compatible
/chat/completionsAPI athttps://api.x.ai/v1. Grok/xAI dictation uses xAI STT throughproviders.grok.transcriptions_url; the upstream STT endpoint does not receive amodelmultipart field.
When management is disabled, provider API keys are optional until a configured
static tenant uses that provider as a default. If a non-default provider key is
blank or its whole api_key value is a missing ${...} placeholder, startup
continues and explicit requests for that provider return 503 provider not configured. Missing placeholders in other fields, or embedded inside a longer
api_key value, fail startup. If a static tenant's default text or dictation
provider lacks its API key, startup fails before the server listens. Provider
base_url values are explicit config values; leave them at
the documented URLs unless routing through a test server, proxy, or compatible
gateway. Dictation-capable provider
transcriptions_url values are also explicit config values and are required for
OpenAI, SiliconFlow, Zhipu, and Grok/xAI. Text model catalogs are required for
every supported provider, and dictation model catalogs are required for OpenAI,
SiliconFlow, Zhipu, and Grok/xAI. When management.enabled is false, startup
validates that tenants includes at least one unique id and unique secret.
When management is enabled, tenants and nonblank provider api_key fields are
invalid: all client tokens and provider credentials are user-owned database
state.
Unknown YAML keys fail startup.
Self-service management UI
Set management.enabled: true to enable TAuth-protected management APIs under
/api/management. The browser UI is static and lives in site/, which is
packaged by make release, uploaded as an immutable GitHub Release asset by
make publish, and activated on gh-pages by make deploy. GitHub Actions is not used for Pages deployment. The backend does
not serve management HTML or assets; GET / remains a proxy endpoint and
returns 403 without a tenant key. The backend does serve public
/config-ui.yaml from the loaded management config so the GitHub Pages frontend
can consume the current llm-proxy runtime, MPR UI, and TAuth bootstrap values
from llm-proxy-api.
The static UI uses the shared MPR shell through API-served config-ui.yaml,
pinned mpr-ui assets, mpr-ui-config.js,
<mpr-header data-config-url="...">, the pinned bundle marker, <mpr-user>,
and <mpr-footer>. It does not load tauth.js directly or apply MPR UI config
from application JavaScript. The Pages artifact contains no static
config-ui.yaml or llm-proxy-config.json; release rendering writes the
profile-owned PAGES_CONFIG_URL into the declarative header attribute. That
single API-served YAML points browser management API calls, generated usage
examples, and MPR UI/TAuth at the configured origins. Browser-facing values are
projected from the already-loaded backend config.yml; there is no second
environment expansion path for Pages.
MPR UI is the sole browser authentication authority. LLM Proxy registers the
documented mpr-ui:auth:authenticated and mpr-ui:auth:unauthenticated
lifecycle listeners, uses the header's documented data-mpr-auth-status only
to reconcile the current state after startup, and does not request
/api/management/profile until MPR UI reports authenticated. LLM Proxy does
not inspect TAuth cookies, storage, tokens, or claims and does not call TAuth
authentication endpoints. After MPR UI reports authentication, a management
API failure renders an explicit workspace error; it does not reinterpret the
MPR UI session as signed out.
The Go backend consumes TAuth's published pkg/sessionvalidator for the
configured session cookie. It does not maintain a second JWT parser or claims
schema; llm-proxy adds only its product-owned tenant, required-expiry, and
principal checks after TAuth validation. Authentication rejections are logged
only as stable categories such as missing_cookie, expired,
invalid_issuer, or wrong_tenant; session values and identity claims are
never logged.
Required hosted values are profile-specific:
| Field | Purpose |
|---|---|
management.public_origin |
Static frontend origin allowed for credentialed management CORS, for example https://llm-proxy.mprlab.com. |
management.ui_description |
Browser-facing MPR UI environment description. |
management.ui_origins |
Browser-facing MPR UI allowed origins served from /config-ui.yaml. |
management.admin_emails |
Exact administrator email addresses. In public config, populate this from ${LLM_PROXY_MANAGEMENT_ADMIN_EMAILS} as a YAML flow sequence such as ["admin@example.invalid","ops@example.invalid"] so personal admin addresses stay out of the repository. |
management.tauth_url |
Browser-facing TAuth API origin served from /config-ui.yaml. |
management.tauth_tenant_id |
TAuth tenant id that issues accepted sessions. |
management.google_client_id |
Browser-facing Google OAuth web client id for the llm-proxy TAuth tenant. |
management.login_path |
Browser-facing TAuth Google login path. |
management.logout_path |
Browser-facing TAuth logout path. |
management.nonce_path |
Browser-facing TAuth nonce path. |
management.jwt_signing_key |
Internal signing key used to validate the TAuth session cookie. |
management.jwt_issuer |
JWT issuer, normally tauth. |
management.session_cookie_name |
Exact app/environment TAuth session cookie name. |
management.database_dialect |
Required GORM SQL dialect for management persistence. Supported values are postgres and sqlite; SQLite uses the pure-Go GORM driver so CGO_ENABLED=0 builds remain valid. |
management.database_dsn |
Required DSN passed to the selected GORM dialect for tenant-owned provider keys, defaults, generated-secret digests, and usage events. |
management.provider_key_encryption_key |
Required base64-encoded 32-byte key used for AES-GCM encryption of tenant-owned provider API keys at rest. Generate with openssl rand -base64 32 and store it with backend deployment secrets. |
management.management_api_origin |
Browser-facing management API origin served from /config-ui.yaml under llmProxy.managementApiOrigin. |
management.proxy_origin |
Browser-facing public proxy origin served from /config-ui.yaml under llmProxy.proxyOrigin for generated examples. |
management.legacy_token_migration.tenant_id |
Optional one-off source tenant id; must be provided together with owner_email. |
management.legacy_token_migration.owner_email |
Optional normalized email that alone may claim the configured legacy tenant through a verified TAuth session. Keep the real value in deployment secrets. |
Signed-in users save provider API keys for any supported provider, choose each
provider's text model and provider-specific system prompt, choose routing
defaults, and generate llm-proxy secrets. Management mode requires
management.database_dialect and management.database_dsn so signups, enabled
providers, defaults, generated secret digests, and usage events survive restarts
in a GORM-managed database. postgres uses a Postgres DSN, while sqlite uses
a SQLite database path or SQLite DSN. The packaged management config uses
strict expandable placeholders for the hosted profile values; define every
LLM_PROXY_MANAGEMENT_* key in the runtime environment or local configs/.env.
Placeholders without matching values fail startup. The runtime config file is
never mutated for user signup, provider enablement, or usage tracking, and
database access must stay on GORM model APIs without raw SQL. Generated secrets
continue to authenticate the public proxy endpoints with the same
key=<tenant secret> query parameter. Provider API keys are accepted only
through authenticated management endpoints and are encrypted at rest with AES-GCM
before database persistence. Normal save, profile, and administrator responses
return only masked key status. The sole raw-key response is the explicit
owner-authenticated POST /api/management/provider-keys/:provider/reveal
management action, which requires the configured management origin and returns
Cache-Control: no-store. Provider-key records also store the selected text model and
provider-specific system prompt for that provider. Managed text requests that
select a provider and omit model use the saved provider text model; when
request-level system instructions are omitted, the provider-specific system
prompt is injected before routing upstream. Existing plaintext provider-key
rows are encrypted and cleared during management startup. Existing provider-key
rows without a text model are normalized to the current configured provider
default model at startup. The backend decrypts provider keys only inside the runtime
path that routes requests to upstream providers and the explicit owner reveal action,
so this protects database dumps, backups, and direct storage access; it is not a user-only decryption or
zero-knowledge guarantee. Generated tenant secrets are returned once and the
database retains only their SHA-256 digest. Revoking a generated secret
immediately makes future public proxy requests with that secret return 403.
Managed routing defaults contain two required, canonical provider/model pairs:
one for text and one for dictation, plus an independent tenant-level
reasoning_effort. PUT /api/management/defaults requires all of those fields
and validates them atomically. An empty reasoning_effort is the explicit unset
value; a nonempty value must be one of the canonical options exposed by the
catalog. A blank model, unknown provider, unsupported dictation provider, model
that belongs to a different provider, or invalid effort returns 400 managed_routing_defaults_invalid before any default is persisted.
The saved effort does not belong to a provider or model. Changing either routing
pair leaves it intact, including when the new text route cannot use it. The
profile returns the saved value, reasoning_effort_options, and structured
provider/model capability metadata so the Settings form can show the single
control and identify an inactive selected route. The browser does not repair
malformed profile data; it renders an explicit workspace-integrity error
instead. Public GET /, POST /, and POST /v2 contracts do not accept a
caller-supplied reasoning-effort field.
Management startup performs one versioned, transactional routing-defaults
migration. The version-2 migration adds the explicit unset
reasoning_effort to existing valid rows; it never infers an effort from a
model name, provider, request profile, or web-search behavior. Before its
version marker exists, it repairs only a blank model or a model known to the
other configured provider for that same endpoint by choosing the selected
provider's current catalog default. Unknown models and unknown or unsupported
providers fail startup with the tenant, endpoint, provider, and model in the
error. Once the marker exists, every stored field must already be canonical and
catalog-valid; startup rejects invalid data rather than selecting a replacement
at runtime. A request that omits routing fields therefore uses the exact
persisted text pair and independently stored effort.
The authenticated landing screen is a usage dashboard. It shows 30-day request
and token graphs, total request and token counts, success rate, and provider and
model breakdowns for the signed-in user's managed tenant. The prior dashboard
controls now live in a large Settings modal opened from the avatar dropdown;
the Settings menu item is inserted before Sign out through the shared
<mpr-user> menu contract. The modal contains client access, generated secret,
routing defaults, copyable default request examples, copyable selected-provider
request examples, and one selected-provider editor for API key, provider text
model, and provider system prompt settings. The routing-default form owns one
tenant-level Reasoning effort control whenever the profile exposes it; it
preserves its value while the text route changes and labels the control inactive
when that route lacks the declared capability. Default examples omit provider;
selected-provider examples include the current provider selector and text model.
Administrators are configured only through management.admin_emails; use the
plural ${LLM_PROXY_MANAGEMENT_ADMIN_EMAILS} placeholder in public config files
and define the real value as a YAML flow sequence in the runtime environment or
ignored configs/.env. When the validated TAuth
session email matches that list, the profile response includes
user.is_admin: true, the shared avatar menu gets an Admin item, and
GET /api/management/admin/users returns all managed users with tenant facts
and 30-day usage summaries. Admin responses never include provider API keys,
masked provider-key strings, generated tenant secrets, secret digests, prompts,
audio names, transcripts, or model responses. Authenticated non-admin users get
403 Forbidden from admin-only APIs.
GET /api/management/usage returns the dashboard data for the authenticated
user. Usage events are recorded only for managed tenants when they call the
public proxy endpoints with a generated secret. Stored usage metadata includes
endpoint, provider, model, status code, success flag, latency, and normalized
request/response/total token counts. Prompts, audio, transcripts, responses,
tenant secrets, and provider API keys are not stored in usage events.
Management mode no longer imports config tenants or global provider keys, and
startup removes the obsolete static-import marker table. A deployment that
still contains one prior static-config:<tenant-id> database row must configure
the exact legacy_token_migration tenant id and target
email. The unowned token returns 403 until that email signs in. Drain every
old service instance before the matching account signs in. On that account's
first authenticated management request, llm-proxy atomically rekeys the tenant
to the verified TAuth subject, preserves the tenant id,
existing token digest, defaults, creation time, and all usage events, and
re-encrypts provider keys for the new owner id. A different email cannot claim
the row. If an earlier sign-in created a destination account with no secret,
provider settings, or usage, the same transaction removes that empty account
before rekeying the legacy tenant. A populated destination account returns
409 Conflict without partial changes. Once production claim verification succeeds, remove the
temporary migration block and owner-email environment value. Server/runtime
settings, backend auth validation settings, provider base URLs, transcription
URLs, model catalogs, and browser-facing MPR
UI/TAuth bootstrap settings remain config-file-owned. The GitHub Pages artifact
is only the static shell; API-served browser config endpoints are projections of
backend config.yml, not independent configuration sources.
Hosted split-origin setup
Production is split-origin:
| Hostname | Owner | Purpose |
|---|---|---|
llm-proxy.mprlab.com |
GitHub Pages | Static self-service frontend from site/. |
llm-proxy-api.mprlab.com |
MPR gateway/backend | llm-proxy API, management API, /, /v2, and /dictate. |
tauth-api.mprlab.com |
TAuth backend | Google login, nonce, logout, /auth/session, and session-cookie issuance. |
Add these DNS records:
CNAME llm-proxy.mprlab.com -> tyemirov.github.io- Point
llm-proxy-api.mprlab.comat the MPR gateway public endpoint. Use aCNAMEwhen the gateway has a hostname, orA/AAAArecords when it is addressed by public IP.
Then configure GitHub Pages for this repository:
- Use branch publishing from
gh-pagesat/. - Set the Pages custom domain to
llm-proxy.mprlab.com. - Run
make releaseto render and validate the Pages archive,make publishto upload that immutable archive, andmake deployto activate it ongh-pages. Deployment configures the repository Pages source and verifies/.mprlab-release.jsonat the public origin. - Configure real backend deployment secrets outside the Pages artifact:
LLM_PROXY_MANAGEMENT_ADMIN_EMAILS,LLM_PROXY_MANAGEMENT_JWT_SIGNING_KEY,LLM_PROXY_MANAGEMENT_DATABASE_DSN,LLM_PROXY_MANAGEMENT_PROVIDER_KEY_ENCRYPTION_KEY, and, only for the bounded ownership migration,LLM_PROXY_MANAGEMENT_LEGACY_TOKEN_OWNER_EMAIL. - Do not store browser runtime config in the Pages branch. Production browser
config is served only by
https://llm-proxy-api.mprlab.com/config-ui.yamlfrom the running backend's loaded management config.make releasewrites that URL intompr-header[data-config-url]throughPAGES_CONFIG_URLand validates the declarative mpr-ui bundle marker.
Configure TAuth for tenant llm-proxy with:
- allowed tenant origin
https://llm-proxy.mprlab.com - browser-facing API origin
https://tauth-api.mprlab.com - session cookie name matching
management.session_cookie_name - cookie domain
.mprlab.com - HTTPS-only cookies
- JWT signing key matching
management.jwt_signing_key
The gateway llm-proxy deployment profile treats this as one runtime contract:
it stages the TAuth and llm-proxy env/config inputs, restarts both tauth-api
and llm-proxy, and verifies both public health checks before Pages activation.
This prevents a newly deployed backend from validating sessions against stale
TAuth cookie or signing configuration.
The same boundary is executable locally without Google OAuth or deployed services:
make test-management-auth-blackbox
The target builds the TAuth version pinned in go.mod and the current
llm-proxy binary, starts both on disposable local ports, and opens the real
static management app in Playwright. The page signs in through TAuth's seeded
password-login endpoint with a credentialed cross-origin browser request, so
the test enforces TAuth login CORS and receives the configured HttpOnly access
and refresh cookies. It then drives the mounted header through the documented
MPRUI.testing.authenticate adapter, which emits the normal authenticated
lifecycle event and persists MPR UI's session-restore hint. The test proves the
anonymous/authorized behavior of /api/management/profile, proves the browser
makes no protected profile request before MPR UI authentication and exactly one
to hydrate the workspace afterward, and waits for the pinned mpr-ui shell
plus the dashboard to report the authenticated state. It then proves an ordinary reload
stays authenticated, removes only the access cookie and proves /auth/session
recovers it from the refresh cookie without rendering the signed-out panel, and
uses the visible Sign out action to prove /auth/logout clears both cookies
and returns TAuth plus the management API to anonymous responses. CDN
application assets are served from pinned local npm dependencies during this
test; TAuth and management API routes are never mocked.
Normal navigation, page refreshes, and access-cookie expiration do not sign the user out. The pinned MPR UI shell silently restores the TAuth session while its rotating refresh cookie remains valid. Only the explicit Sign out action calls TAuth logout and clears the browser session; LLM Proxy does not own a second session store or an automatic logout path.
Configure the gateway/backend route for llm-proxy-api.mprlab.com to the
llm-proxy service, and remove any backend route that still claims
llm-proxy.mprlab.com; that hostname is now owned by GitHub Pages. The backend
must run with management.public_origin: "https://llm-proxy.mprlab.com" so
/config-ui.yaml and /api/management/* return credentialed CORS headers only
to the static frontend.
Web search is per request and currently supported only on OpenAI models that
support the OpenAI web search tool.
Text output length is also per request: pass max_tokens when a client wants
to cap one generation. When omitted, the proxy does not send a provider
max-token field, except Anthropic Messages where max_tokens is required
upstream and the proxy sends the selected model's configured output limit.
Provider-specific output-token limits are enforced at the request edge when
known. MiniMax M2.7 rejects max_tokens above 2048; Gemini text models
currently reject values above 65536; Claude models reject values above the
configured synchronous Messages output limit. Those errors return 400 Bad Request before any upstream provider call.
Running
Generate a secret:
openssl rand -hex 32
Run the service with OpenAI defaults:
./llm-proxy --config config.yml
With management.enabled: false, set a static tenant's default text
provider/model to route omitted-provider requests to DeepSeek. Static tenant
blocks are invalid in management mode, where every token is owned by an
authenticated user:
tenants:
- id: deepseek
secret: "${SERVICE_SECRET}"
defaults:
provider: deepseek
model: deepseek-v4-flash
For a static tenant, reasoning_effort is the same independent default used by
managed tenants. Set it only when the catalog declares a compatible route; it
is not a per-request or per-model setting. For example, a supported OpenAI
route can use one canonical value:
tenants:
- id: openai-reasoning
secret: "${SERVICE_SECRET}"
defaults:
provider: openai
model: gpt-5
reasoning_effort: high
The only set values are minimal, low, medium, and high; omit the field
or use an empty value to leave it explicitly unset. The proxy preserves the
saved value if a later default route has no capability and forwards it only
when the resolved route declares support.
Set Gemini as the default text provider:
tenants:
- id: gemini
secret: "${SERVICE_SECRET}"
defaults:
provider: gemini
model: gemini-2.5-flash
Set Anthropic as the default text provider:
tenants:
- id: anthropic
secret: "${SERVICE_SECRET}"
defaults:
provider: anthropic
model: claude-sonnet-4-6
Set Grok as the default text provider:
tenants:
- id: grok
secret: "${SERVICE_SECRET}"
defaults:
provider: grok
model: grok-4.3
Set Meta Muse Spark 1.1 as the default text provider:
tenants:
- id: meta
secret: "${SERVICE_SECRET}"
defaults:
provider: meta
model: muse-spark-1.1
Local Automation
This repository exposes the standard local targets used by MPR app repos:
| Command | Purpose |
|---|---|
npm ci |
Install pinned frontend validation dependencies before running local frontend checks. |
make ci |
Run format checks, Go lint (go vet, staticcheck, ineffassign), Python strict mypy, frontend syntax checks, the 100% coverage-gated Go test suite, Python pytest, Playwright browser tests, repository-owned release integration tests, and the non-paid live-harness preflight. |
make test-live-provider-harness |
Generate the temporary static-mode live-test config and verify authenticated routing without an upstream call. |
make test-live-providers |
Generate a complete temporary static-mode config and run live text smoke tests for every provider whose API key is present; use LIVE_ENV_FILE=/path/to/env to load interpolation values. |
make test-live-gemini |
Compatibility wrapper for make test-live-providers with LLM_PROXY_LIVE_PROVIDERS=gemini. |
make release |
Run CI and prepare the local tag, container archives, and validated Pages archive under .git/mprlab-release without remote writes. |
make publish |
Publish the exact prepared Git refs, GitHub Release assets, and container archives without rebuilding or deploying. |
make deploy |
Verify and deploy the published backend through the sibling gateway, then activate and verify the published Pages archive. |
Live provider smoke tests are intentionally not part of make ci; they call
paid upstream APIs and depend on local or CI secret availability. The dynamic
target discovers these provider keys after loading LIVE_ENV_FILE. By default,
smoke requests omit model, so each provider uses the default configured in
the selected configs/config.yml; set a model override only when debugging a
specific provider/model pair.
| Provider | Key variable | Model override |
|---|---|---|
| OpenAI | OPENAI_API_KEY |
LLM_PROXY_LIVE_OPENAI_MODEL |
| Meta Muse Spark | MODEL_API_KEY |
LLM_PROXY_LIVE_META_MODEL |
| DeepSeek | DEEPSEEK_API_KEY |
LLM_PROXY_LIVE_DEEPSEEK_MODEL |
| DashScope/Qwen | DASHSCOPE_API_KEY |
LLM_PROXY_LIVE_DASHSCOPE_MODEL |
| Qwen Cloud Token Plan | QWEN_CLOUD_TOKEN_PLAN_API_KEY |
LLM_PROXY_LIVE_QWEN_CLOUD_MODEL |
| Moonshot/Kimi | MOONSHOT_API_KEY |
LLM_PROXY_LIVE_MOONSHOT_MODEL |
| MiniMax | MINIMAX_API_KEY |
LLM_PROXY_LIVE_MINIMAX_MODEL |
| SiliconFlow | SILICONFLOW_API_KEY |
LLM_PROXY_LIVE_SILICONFLOW_MODEL |
| Zhipu/GLM | ZHIPU_API_KEY |
LLM_PROXY_LIVE_ZHIPU_MODEL |
| Gemini | GEMINI_API_KEY |
LLM_PROXY_LIVE_GEMINI_MODEL |
| Anthropic/Claude | ANTHROPIC_API_KEY |
LLM_PROXY_LIVE_ANTHROPIC_MODEL |
| Grok/xAI | XAI_API_KEY |
LLM_PROXY_LIVE_GROK_MODEL |
Run every provider with an available key:
make test-live-providers LIVE_ENV_FILE=configs/.env
Run only selected providers. When LLM_PROXY_LIVE_PROVIDERS is set, every
listed provider must have its key:
LLM_PROXY_LIVE_PROVIDERS=openai,gemini \
make test-live-providers LIVE_ENV_FILE=configs/.env
The live harness parses LIVE_ENV_FILE as dotenv data without executing it as
shell code. It always writes a disposable static-mode config with management
disabled, a temporary tenant, and placeholder values for unused provider keys,
so live checks never open the configured management database or reuse its
migration state. Inspect that generated contract without building or calling a
paid provider with ./scripts/test_live_providers.sh --write-config /tmp/llm-proxy-live.yml.
make release and make deploy run the local make ci gate with the standard
350-second timeout. Override both with
LLM_PROXY_CI_TIMEOUT_SECONDS=<seconds>, or use the command-specific
RELEASE_CI_TIMEOUT_SECONDS and DEPLOY_CI_TIMEOUT_SECONDS variables.
make publish verifies and uploads only the already-prepared immutable
artifacts; it does not rebuild or rerun CI.
llm-proxy is a gateway-local service in mprlab-gateway, so make deploy
uses the sole gateway deploy-llm-proxy-backend target after the gateway-owned
verify-llm-proxy-deployment-contract preflight proves the coupled TAuth
service, runtime assets, and health checks. Override only the checkout with
GATEWAY_DIR=/path/to/mprlab-gateway; the selected gateway checkout must be a
clean, synchronized origin/master. Override Pages preparation and activation
with PAGES_DOMAIN=<domain>, PAGES_CONFIG_URL=<https-config-url>,
PAGES_BRANCH=<branch>, PAGES_URL=<url>, or
DEPLOY_PAGES_ARGS="--skip-configure".
Usage
Installable prompt client
Install the reusable JSON POST client:
go install github.com/tyemirov/llm-proxy/llm-proxy-client@latest
Use it with explicit flags:
llm-proxy-client \
--base-url "http://localhost:8080/?provider=gemini" \
--secret "$SERVICE_SECRET" \
--prompt "Summarize this"
Or read configuration and prompt text from environment/stdin:
export LLM_PROXY_BASE_URL="http://localhost:8080/"
export LLM_PROXY_SECRET="$SERVICE_SECRET"
printf 'large prompt...\n' | llm-proxy-client --max-tokens 4096
The client always uses canonical POST /v2?key=... with a JSON body. It keeps
non-payload query parameters such as provider, strips body-owned query fields
such as prompt and model, and sends the prompt as a v2 user message.
--system-prompt becomes a v2 system message. Optional model,
web_search, and max_tokens values remain body fields. When --model is
omitted, the body omits model so llm-proxy uses the selected provider's
configured default model.
The reusable Go package under pkg/llmproxyclient is v2-only: construct a
MessagesRequest with NewMessagesRequest and send it with
Client.PostMessages.
Model selection without application redeployment
Every bundled client deliberately leaves model out of a request when the
caller does not set it. This is the correct integration when LLM Proxy owns
model selection: a managed-tenant owner can change that tenant's routing
default in the LLM Proxy Settings UI, and the next model-omitting request uses
the saved default without an application code or deployment change. An explicit
--model or request model pins that one request and does not follow a tenant
default.
Changing providers.<provider>.text.default_model in the service config affects
only requests that resolve through that provider catalog default. It does not
rewrite a managed tenant's saved routing default or a saved provider text model.
Application-user model profiles
For application-owned, per-user selection, configure one client instance with that user's JSON model-profile path. The document contains exactly these two nonblank string fields and never contains credentials or TAuth material:
{
"provider": "gemini",
"model": "gemini-2.5-flash"
}
The client reads this file for every outbound v2 request. An application can write a replacement in the same filesystem and atomically rename it onto the user's profile path; the next request from the existing client instance then uses the new provider/model pair. The application continues to own the user identity, authorization, storage, and atomic publication of that file.
Use the profile directly from the installable CLI:
llm-proxy-client \
--base-url "http://localhost:8080/" \
--secret "$SERVICE_SECRET" \
--model-profile "/var/lib/my-app/users/42/model.json" \
--prompt "Summarize this"
For the Go package, inject the application's file reader when creating the validated config once:
config, err := llmproxyclient.NewConfig(llmproxyclient.ConfigInput{
BaseURL: "http://localhost:8080/",
Secret: serviceSecret,
ModelProfilePath: userModelProfilePath,
ModelProfileReader: os.ReadFile,
Timeout: 390 * time.Second,
})
if err != nil {
return err
}
client, err := llmproxyclient.NewClient(config, http.DefaultClient)
if err != nil {
return err
}
Config.MessagesPostURL also resolves the current profile and therefore returns
(string, error) in profile-capable client versions.
The profile is the sole provider/model source in this mode. Do not combine it
with --model, a request model, --provider, ConfigInput.Provider, or a
provider or model query parameter on the base URL. The clients reject those
competing inputs; they never choose a winner or retain a previous parsed
profile. A missing, unreadable, malformed, incomplete, or unsupported profile
also fails that request before HTTP with ErrInvalidModelProfile (Go) or
LLMProxyModelProfileError (Python). The proxy remains responsible for
validating whether the resulting provider/model pair is supported.
Without a profile path, the model-omitting tenant/provider-default path above remains the separate normal contract.
Python client package
The same transport contract is available as an importable Python package:
uv pip install "llm-proxy-client @ git+https://github.com/tyemirov/llm-proxy.git"
from llm_proxy_client import Client, ClientConfig, ClientMessagesRequest, ClientMessage
client = Client(
ClientConfig(
base_url="http://localhost:8080/?provider=gemini",
secret="mysecret",
)
)
text = client.post_messages(
ClientMessagesRequest(
messages=(ClientMessage(role="user", content="Summarize this"),),
max_tokens=512,
)
)
The Python package is v2-only. For chat-transcript callers, send the same
post_messages request with multiple messages:
chat_text = client.post_messages(
ClientMessagesRequest(
messages=(
ClientMessage(role="user", content="Summarize this", order=2),
ClientMessage(role="system", content="Be concise.", order=1),
),
model="deepseek-v4-flash",
)
)
Pass model only when an application intentionally pins one request instead
of using the tenant or selected-provider default.
To give one application user a reloadable model choice, configure that user's profile path and reader once. The client does not cache its parsed contents:
from pathlib import Path
def read_model_profile(path: str) -> str:
return Path(path).read_text(encoding="utf-8")
user_client = Client(
ClientConfig(
base_url="http://localhost:8080/",
secret="mysecret",
model_profile_path="/var/lib/my-app/users/42/model.json",
model_profile_reader=read_model_profile,
)
)
Publish a complete replacement JSON document atomically at that path after the
user selects a new provider/model pair. Do not set provider on the config or
base URL, or model on the request, when this profile is configured.
The optional order field is for callers that do not want to rely on array
position. When any message includes order, every submitted message must
include a unique non-negative integer order; the proxy sorts ascending before
provider routing and echoes provided order values in JSON responses.
For local development:
uv pip install -e .
make python-test
make python-lint
Basic request (default provider and model, no web search)
curl --get \
--data-urlencode "prompt=Hello, how are you?" \
--data-urlencode "key=mysecret" \
"http://localhost:8080/"
Choose a provider
curl --get \
--data-urlencode "prompt=Summarize this cheaply" \
--data-urlencode "key=mysecret" \
--data-urlencode "provider=deepseek" \
--data-urlencode "model=deepseek-v4-flash" \
"http://localhost:8080/"
Gemini text generation:
curl --get \
--data-urlencode "prompt=Summarize this with Gemini" \
--data-urlencode "key=mysecret" \
--data-urlencode "provider=gemini" \
--data-urlencode "model=gemini-2.5-flash" \
--data-urlencode "max_tokens=512" \
"http://localhost:8080/"
Anthropic Claude text generation:
curl --get \
--data-urlencode "prompt=Summarize this with Claude" \
--data-urlencode "key=mysecret" \
--data-urlencode "provider=anthropic" \
--data-urlencode "model=claude-sonnet-4-6" \
--data-urlencode "max_tokens=512" \
"http://localhost:8080/"
Grok text generation:
curl --get \
--data-urlencode "prompt=Summarize this with Grok" \
--data-urlencode "key=mysecret" \
--data-urlencode "provider=grok" \
--data-urlencode "model=grok-4.3" \
--data-urlencode "max_tokens=512" \
"http://localhost:8080/"
Meta Muse Spark 1.1 text generation:
curl --get \
--data-urlencode "prompt=Summarize this with Muse Spark" \
--data-urlencode "key=mysecret" \
--data-urlencode "provider=meta" \
--data-urlencode "model=muse-spark-1.1" \
--data-urlencode "max_tokens=512" \
"http://localhost:8080/"
Large text request
Use POST / with a JSON body when the prompt is too large for a URL query
parameter or when the caller already has a chat transcript. Authentication
still uses the key query parameter, which is the configured tenant secret.
Provider selection also stays in the query parameter. Do not send upstream
provider secrets in the request body; the proxy reads them from server-side
configuration. The JSON body is capped by server.max_prompt_bytes.
curl -X POST \
-H "Content-Type: application/json" \
--data '{"prompt":"large text...","model":"gpt-5.5","web_search":false,"system_prompt":"optional","max_tokens":4096}' \
"http://localhost:8080/?key=mysecret"
Compatibility chat transcript on /:
curl -X POST \
-H "Content-Type: application/json" \
--data '{"messages":[{"role":"user","content":"Summarize this","order":2},{"role":"system","content":"Be concise.","order":1}],"model":"deepseek-v4-flash","max_tokens":4096}' \
"http://localhost:8080/?key=mysecret&provider=deepseek"
Canonical v2 chat transcript:
curl -X POST \
-H "Content-Type: application/json" \
--data '{"messages":[{"role":"user","content":"Summarize this","order":2},{"role":"system","content":"Be concise.","order":1}],"model":"deepseek-v4-flash","max_tokens":4096}' \
"http://localhost:8080/v2?key=mysecret&provider=deepseek"
JSON body fields:
| Field | Required | Default | Description |
|---|---|---|---|
prompt |
Yes, unless messages is provided |
none | Full text to send to the LLM. Use this body field for large or non-ASCII prompts. |
messages |
Yes, unless prompt is provided |
none | Chat messages using role and string content. Supported roles are system, user, and assistant; at least one user message is required. Each item may include numeric order; if any item includes it, every submitted item must include a unique non-negative order, and messages are sorted ascending before routing. |
model |
No | tenant or configured provider default | Model identifier from the selected provider's configured model list. Omitted model uses the tenant default when provider is omitted; otherwise it uses the selected provider's configured default. |
web_search |
No | false |
Enables OpenAI web search when the selected provider/model supports it. |
system_prompt |
No | authenticated tenant default | Per-request system prompt override. With messages, it is prepended as a system message only when the body does not already contain a system message. |
max_tokens |
No | provider default | Positive integer output-token cap for this request. The proxy maps it to OpenAI max_output_tokens, Meta, Moonshot, and MiniMax max_completion_tokens, other OpenAI-compatible providers' max_tokens, Anthropic max_tokens, or Gemini generationConfig.maxOutputTokens. |
For POST /, provider remains a query parameter. Query model may override
the JSON body only when the body omits model or provides the same value;
conflicting values return 400 Bad Request.
Bodies that provide both prompt and messages, empty messages, unsupported
message roles, empty message content, partially specified order, duplicate
or negative order, or both system_prompt and a system message return
400 Bad Request before any upstream call.
MiniMax M2.7 max_tokens values above 2048, Gemini values above 65536, and
Anthropic values above the configured Claude model output limit return 400 Bad Request before the proxy calls the selected provider.
POST /v2 is the canonical chat endpoint. It accepts the same messages,
model, web_search, and max_tokens body fields, but rejects prompt and
body system_prompt; send a system role message instead. The tenant default
system prompt is still prepended when the submitted messages do not include a
system message.
Choose an OpenAI model
curl --get \
--data-urlencode "prompt=Summarize quantum error correction" \
--data-urlencode "key=mysecret" \
--data-urlencode "model=gpt-4o" \
"http://localhost:8080/"
Enable web search
curl --get \
--data-urlencode "prompt=What changed in the 2025 child tax credit?" \
--data-urlencode "key=mysecret" \
--data-urlencode "web_search=1" \
"http://localhost:8080/"
You can enable web search with GPT-5 by specifying the model:
curl --get \
--data-urlencode "prompt=Latest research on quantum gravity" \
--data-urlencode "key=mysecret" \
--data-urlencode "model=gpt-5" \
--data-urlencode "web_search=1" \
"http://localhost:8080/"
Dictation request
curl -X POST \
-F "audio=@./recording.webm" \
"http://localhost:8080/dictate?key=mysecret"
SiliconFlow dictation:
curl -X POST \
-F "audio=@./recording.webm" \
"http://localhost:8080/dictate?key=mysecret&provider=siliconflow"
Optional model override:
curl -X POST \
-F "audio=@./recording.webm" \
"http://localhost:8080/dictate?key=mysecret&model=gpt-4o-mini-transcribe"
Response formats
You can request alternative formats using either the format query parameter or
the Accept header. Supported values are:
text/csv- the reply as a single CSV cell with internal quotes doubled and a trailing newlineapplication/json- JSON object containingrequestandresponsefields, plususagewhen upstream token usage is availableapplication/xml- XML document<response request="...">...</response>
If no supported value is provided, text/plain is returned.
When upstream text providers return token usage, the proxy also sets these response headers without changing the plain text, XML, or CSV response bodies:
| Header | Description |
|---|---|
X-LLM-Proxy-Request-Tokens |
Normalized request/input token count |
X-LLM-Proxy-Response-Tokens |
Normalized response/output token count |
X-LLM-Proxy-Total-Tokens |
Normalized total token count |
JSON-format LLM responses include the same normalized counts:
{
"request": "Hello",
"response": "Hi",
"object": "chat.completion",
"model": "gpt-4.1",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "Hi"
}
}
],
"messages": [
{
"role": "user",
"content": "Hello"
}
],
"usage": {
"request_tokens": 1,
"response_tokens": 1,
"total_tokens": 2
}
}
The response messages field echoes only caller-visible request messages.
Server-injected tenant default system prompts are sent upstream when applicable,
but are not returned in response metadata.
Endpoint
LLM endpoint
GET /
?prompt=STRING # required
&key=SERVICE_SECRET # required
&provider=PROVIDER # optional; tenant default
&model=MODEL_NAME # optional; tenant or configured provider default
&web_search=1|true|yes # optional; requires configured model support
&max_tokens=N # optional positive integer per-request cap
&format=CONTENT_TYPE # optional; or use Accept header
POST /
?key=SERVICE_SECRET # required
&provider=PROVIDER # optional; tenant default
&model=MODEL_NAME # optional; overrides JSON body if absent or equal
&format=CONTENT_TYPE # optional; or use Accept header
Content-Type: application/json
{
"prompt": "STRING", # required unless messages is provided
"messages": [ # required unless prompt is provided
{"role": "user", "content": "STRING", "order": 1}
],
"model": "MODEL_NAME", # optional; tenant or configured provider default
"web_search": false, # optional; defaults to false
"system_prompt": "STRING",# optional; tenant default
"max_tokens": 512 # optional positive integer per-request cap
}
POST /v2
?key=SERVICE_SECRET # required
&provider=PROVIDER # optional; tenant default
&model=MODEL_NAME # optional; overrides JSON body if absent or equal
&format=CONTENT_TYPE # optional; or use Accept header
Content-Type: application/json
{
"messages": [ # required
{"role": "user", "content": "STRING", "order": 1}
],
"model": "MODEL_NAME", # optional; tenant or configured provider default
"web_search": false, # optional; defaults to false
"max_tokens": 512 # optional positive integer per-request cap
}
The POST JSON body carries only LLM request parameters. The tenant secret
remains in the key query parameter, and upstream provider API keys are
never accepted from client requests.
Dictation endpoint
POST /dictate
?key=SERVICE_SECRET # required
&provider=PROVIDER # optional; tenant default
&model=MODEL_NAME # optional; tenant or configured provider default
Content-Type: multipart/form-data
audio=<file> # required (alias: file)
Success response:
{ "text": "..." }
The default model catalog in configs/config.yml
declares the LLM endpoint models below. The /dictate endpoint defaults to
OpenAI's audio transcriptions API and also supports SiliconFlow, Zhipu, and
Grok/xAI through their provider selectors. Not all configured models support
tools; use a model marked Yes below for web search. A dash in the proxy
max_tokens limit column means the proxy validates only that max_tokens is
positive and lets the upstream provider enforce any provider-side model limit.
Model capabilities
| Model | Provider | Provider default | Proxy max_tokens limit |
Web search |
|---|---|---|---|---|
gpt-4.1 |
OpenAI | Yes | - | Yes |
gpt-4o |
OpenAI | No | - | Yes |
gpt-4o-mini |
OpenAI | No | - | No |
gpt-5 |
OpenAI | No | - | Yes |
gpt-5-mini |
OpenAI | No | - | No |
gpt-5.5 |
OpenAI | No | - | Yes |
gpt-5.5-pro |
OpenAI | No | - | Yes |
gpt-5.6 |
OpenAI | No | - | Yes |
gpt-5.6-sol |
OpenAI | No | - | Yes |
gpt-5.6-terra |
OpenAI | No | - | Yes |
gpt-5.6-luna |
OpenAI | No | - | Yes |
muse-spark-1.1 |
Meta | Yes | - | No |
deepseek-v4-flash |
DeepSeek | Yes | - | No |
deepseek-v4-pro |
DeepSeek | No | - | No |
deepseek-chat |
DeepSeek | No | - | No |
deepseek-reasoner |
DeepSeek | No | - | No |
qwen-plus |
DashScope/Qwen | Yes | - | No |
qwen3.8-max-preview |
Qwen Cloud Token Plan | Yes | - | No |
kimi-k2-0905-preview |
Moonshot/Kimi | Yes | - | No |
kimi-k3 |
Moonshot/Kimi | No | - | No |
kimi-k2.7-code |
Moonshot/Kimi | No | - | No |
kimi-k2.7-code-highspeed |
Moonshot/Kimi | No | - | No |
kimi-k2.6 |
Moonshot/Kimi | No | - | No |
MiniMax-M2.7 |
MiniMax | Yes | 2048 |
No |
deepseek-ai/DeepSeek-R1 |
SiliconFlow | Yes | - | No |
glm-5.1 |
Zhipu/GLM | Yes | - | No |
glm-5.2 |
Zhipu/GLM | No | 131072 |
No |
gemini-3.5-flash |
Gemini | No | 65536 |
No |
gemini-3.1-pro-preview |
Gemini | No | 65536 |
No |
gemini-3-flash-preview |
Gemini | No | 65536 |
No |
gemini-3.1-flash-lite |
Gemini | No | 65536 |
No |
gemini-2.5-flash |
Gemini | Yes | 65536 |
No |
gemini-2.5-flash-lite |
Gemini | No | 65536 |
No |
gemini-2.5-pro |
Gemini | No | 65536 |
No |
claude-opus-4-8 |
Anthropic/Claude | No | 128000 |
No |
claude-fable-5 |
Anthropic/Claude | No | 128000 |
No |
claude-sonnet-5 |
Anthropic/Claude | No | 128000 |
No |
claude-sonnet-4-6 |
Anthropic/Claude | Yes | 64000 |
No |
claude-haiku-4-5-20251001 |
Anthropic/Claude | No | 64000 |
No |
claude-haiku-4-5 |
Anthropic/Claude | No | 64000 |
No |
claude-sonnet-4-5-20250929 |
Anthropic/Claude | No | 64000 |
No |
claude-sonnet-4-5 |
Anthropic/Claude | No | 64000 |
No |
claude-opus-4-1-20250805 |
Anthropic/Claude | No | 32000 |
No |
claude-opus-4-1 |
Anthropic/Claude | No | 32000 |
No |
grok-4.3 |
Grok/xAI | Yes | - | No |
grok-4.3-latest |
Grok/xAI | No | - | No |
grok-4.5 |
Grok/xAI | No | - | No |
grok-4.20-0309-reasoning |
Grok/xAI | No | - | No |
grok-4.20-0309-non-reasoning |
Grok/xAI | No | - | No |
grok-latest |
Grok/xAI | No | - | No |
grok-build-0.1 |
Grok/xAI | No | - | No |
grok-code-fast |
Grok/xAI | No | - | No |
grok-code-fast-1 |
Grok/xAI | No | - | No |
grok-code-fast-1-0825 |
Grok/xAI | No | - | No |
Dictation capabilities
| Provider selector | Models | Credential field | Transcription URL field | Notes |
|---|---|---|---|---|
openai |
gpt-4o-mini-transcribe, gpt-4o-transcribe |
providers.openai.api_key |
providers.openai.transcriptions_url |
Default dictation provider and default model gpt-4o-mini-transcribe. |
siliconflow |
FunAudioLLM/SenseVoiceSmall |
providers.siliconflow.api_key |
providers.siliconflow.transcriptions_url |
OpenAI-compatible audio transcription. |
zhipu / glm |
glm-asr-2512 |
providers.zhipu.api_key |
providers.zhipu.transcriptions_url |
Z.AI GLM-ASR; sends model=glm-asr-2512. |
grok / xai |
xai-stt |
providers.grok.api_key |
providers.grok.transcriptions_url |
xAI STT; the proxy model name selects the provider but is not sent as a multipart model field. |
Status codes
200 OK- success400 Bad Request- missing/invalid parameters, invalid multipart audio form, unknown provider/model, or unsupported provider capability403 Forbidden- missing or invalidkey413 Payload Too Large- JSON prompt body exceedsmax_prompt_bytes429 Too Many Requests- upstream provider rate limit503 Service Unavailable- selected provider credential is unavailable because that non-default provider is disabled or missing its API key504 Gateway Timeout- the overall proxy request timed out before the selected upstream provider returned a final result502 Bad Gateway- upstream provider API returned an error
Security
- All requests must include a configured tenant secret via
key=.... - Client requests must not include upstream provider API keys; public proxy endpoints reject provider-key-like query, JSON, and multipart form fields.
- Request logs record only the query-free path plus method, status, latency, client IP, and tenant metadata; they do not record query strings, request bodies, cookies, or authorization headers.
- Self-service provider API keys are accepted only through TAuth-protected management endpoints and are not returned raw after save.
- Do not expose this service to the public internet without appropriate network controls.
Implementation Plans
Current scoped implementation plans are tracked under docs/implementation/.
MPR Integration Verification
For Marco Polo Research Lab integration workflows, use the Codex
mpr-integration skill when a change needs contract/profile/task-based
black-box verification against an MPR app or fixture. Keep app-specific
hostnames, cookie names, ports, OAuth callbacks, and environment literals in
the selected integration profile or deployment docs, not in this README.
Releasing
Use make release from a clean local master branch. It runs make ci, builds
the multi-platform container archives and static Pages archive under
.git/mprlab-release, updates CHANGELOG.md, and creates only a local release
commit and tag. The release implementation is repository-owned under
tools/gitrelease, uses the single canonical vMAJOR.MINOR.PATCH SemVer
contract, and builds containers from git archive HEAD so ignored credentials,
.git, and local artifacts never enter BuildKit. It performs no remote writes.
Use make publish to push the prepared Git refs, GitHub Release assets, and
container archives without rebuilding. Use make deploy only after publish;
it verifies the published image, deploys the backend through the gateway, then
activates the exact pages.tar.gz asset on the live Pages branch.
License
This project is licensed under the MIT License. See LICENSE for details.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
cli
command
Package main starts the llm-proxy application.
|
Package main starts the llm-proxy application. |
|
internal
|
|
|
apperrors
Package apperrors provides shared application error values.
|
Package apperrors provides shared application error values. |
|
constants
Package constants defines symbolic names for shared literal values and log field identifiers used throughout the proxy.
|
Package constants defines symbolic names for shared literal values and log field identifiers used throughout the proxy. |
|
proxy
Package proxy contains configuration, middleware, routing, and model interaction logic for the language model proxy server.
|
Package proxy contains configuration, middleware, routing, and model interaction logic for the language model proxy server. |
|
utils
Package utils provides helper routines for HTTP requests, string transformations, and request fingerprinting.
|
Package utils provides helper routines for HTTP requests, string transformations, and request fingerprinting. |
|
Package main provides the installable llm-proxy-client command.
|
Package main provides the installable llm-proxy-client command. |
|
pkg
|
|
|
llmproxyclient
Package llmproxyclient provides an HTTP client for llm-proxy v2 JSON POST requests.
|
Package llmproxyclient provides an HTTP client for llm-proxy v2 JSON POST requests. |