From 047b4e5545bb54e0ee021c35ddaab66213473453 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 17 Aug 2026 17:52:24 +0200 Subject: [PATCH 1/5] feat(providers): add dedicated llamacpp provider type llama.cpp's llama-server (and LM Studio) previously had to be registered as a vllm-type provider, and registering it as ollama silently broke embeddings because that type speaks Ollama's native /api/embed. The new llamacpp type speaks plain OpenAI protocol with an optional API key, requires LLAMACPP_BASE_URL (llama-server's default port collides with the gateway's own 8080, so no default), and routes passthrough for llama-server's native root endpoints (/health, /props, /rerank, /tokenize, ...) while OpenAI-shaped paths use the /v1 base. Enabled in the default passthrough provider list. Co-Authored-By: Claude Fable 5 --- .env.template | 10 +- CLAUDE.md | 4 +- config/config.go | 1 + config/config_test.go | 2 +- docs/advanced/configuration.mdx | 3 +- docs/features/passthrough-api.mdx | 6 +- docs/providers/llamacpp.mdx | 49 +++-- docs/providers/overview.mdx | 10 +- .../{index-DCy_ZPwB.js => index-D7ghz57V.js} | 2 +- .../admin/dashboard/static/dist/index.html | 2 +- internal/providers/llamacpp/llamacpp.go | 186 ++++++++++++++++++ internal/providers/llamacpp/llamacpp_test.go | 167 ++++++++++++++++ .../llamacpp/passthrough_semantics.go | 10 + internal/server/handlers_test.go | 2 +- internal/server/passthrough_support.go | 2 +- run/providers.go | 2 + run/providers_test.go | 2 +- .../src/pages/overview/providersLogic.js | 1 + 18 files changed, 421 insertions(+), 40 deletions(-) rename internal/admin/dashboard/static/dist/assets/{index-DCy_ZPwB.js => index-D7ghz57V.js} (99%) create mode 100644 internal/providers/llamacpp/llamacpp.go create mode 100644 internal/providers/llamacpp/llamacpp_test.go create mode 100644 internal/providers/llamacpp/passthrough_semantics.go diff --git a/.env.template b/.env.template index 962e8cc67..ee8fb6ddb 100644 --- a/.env.template +++ b/.env.template @@ -68,9 +68,9 @@ # Allow optional /p/{provider}/v1/... passthrough aliases while keeping /p/{provider}/... canonical (default: true) # ALLOW_PASSTHROUGH_V1_ALIAS=true -# Comma-separated list of provider types enabled for /p/{provider}/... passthrough (default: openai,anthropic,openrouter,kilo,zai,vllm,llmd,deepseek) +# Comma-separated list of provider types enabled for /p/{provider}/... passthrough (default: openai,anthropic,openrouter,kilo,zai,sglang,vllm,llamacpp,llmd,deepseek) # Cohere native passthrough is opt-in; add cohere when those routes are needed. -# ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,cohere,openrouter,kilo,zai,vllm,llmd,deepseek +# ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,cohere,openrouter,kilo,zai,sglang,vllm,llamacpp,llmd,deepseek # Enable the realtime (speech-to-speech) endpoints (default: true): the /v1/realtime # websocket (and /p/{provider}/v1/realtime passthrough upgrade), the WebRTC SDP @@ -547,6 +547,12 @@ # Set base URL to enable (default: http://localhost:8000/v1) # VLLM_BASE_URL=http://localhost:8000/v1 +# llama.cpp llama-server (also fits LM Studio and other OpenAI-compatible local servers) +# Base URL is required and enables the provider; include /v1. +# LLAMACPP_BASE_URL=http://localhost:8081/v1 +# LLAMACPP_API_KEY is optional; set it only if llama-server was started with --api-key. +# LLAMACPP_API_KEY=token-abc123 + # llm-d Router / Endpoint Picker (EPP) # The route is required and usually points to the EPP service. Include /v1. # LLMD_BASE_URL=http://quickstart-epp.llm-d.svc.cluster.local/v1 diff --git a/CLAUDE.md b/CLAUDE.md index 369b8a0d9..24624d8c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ Full reference: `.env.template` and `config/config.yaml` - `PID_FILE` / `server.pid_file` (`data/gomodel.pid` next to a `./data` directory, otherwise the OS per-user data dir — same resolution as `SQLITE_PATH`): where the running gateway records its process id. `gomodel --reload` reads it and signals that process (SIGHUP; `kill -HUP` works too) to reload configuration without a restart, like `nginx -s reload`. The reload re-reads `.env` (exported variables still win over the file; variables removed from the file are unset) and the whole config, then rebuilds the application — so every setting reloads, not a curated subset. The replacement is built before the running one is stopped, so a broken config keeps the current one serving; the listening socket is held across generations, so no connection is refused mid-reload. `PORT` and `PID_FILE` changes still need a restart (warned about), and in-memory state — session affinity pins, live log buffers — resets as it would on restart (request/token rate-limit windows are snapshotted to storage). `server.pid_file: ""` in `config.yaml` disables the pid file and `--reload` (an empty `PID_FILE` env var reads as unset and keeps the default). Not available on Windows (POSIX signals). - `ENABLE_PASSTHROUGH_ROUTES` (true: Enable provider-native passthrough routes under /p/{provider}/...) - `ALLOW_PASSTHROUGH_V1_ALIAS` (true: Allow /p/{provider}/v1/... aliases while keeping /p/{provider}/... canonical) - - `ENABLED_PASSTHROUGH_PROVIDERS` (openai,anthropic,openrouter,kilo,zai,sglang,vllm,deepseek: Comma-separated list of enabled passthrough providers) + - `ENABLED_PASSTHROUGH_PROVIDERS` (openai,anthropic,openrouter,kilo,zai,sglang,vllm,llamacpp,llmd,deepseek: Comma-separated list of enabled passthrough providers) - `REALTIME_ENABLED` (true: Expose the realtime speech-to-speech websocket at `/v1/realtime` and the `/p/{provider}/v1/realtime` upgrade. The canonical `/v1/realtime` route needs only `REALTIME_ENABLED`; the `/p/{provider}/v1/realtime` upgrade additionally requires passthrough routes enabled (`ENABLE_PASSTHROUGH_ROUTES`) with the provider listed in `ENABLED_PASSTHROUGH_PROVIDERS`. The gateway is a transparent websocket reverse proxy — it injects provider credentials and relays the provider's realtime event schema verbatim (no translation), so clients connect without provider API keys. Only providers implementing realtime accept sessions. Currently: OpenAI and xAI/Grok Voice Agent (both `wss://…/v1/realtime`); Z.ai/Zhipu GLM-Realtime (`wss://…/api/paas/v4/realtime`); Bailian/Qwen-Omni (`wss://dashscope…/api-ws/v1/realtime`); and Azure OpenAI (`wss:///openai/realtime?api-version=…&deployment=…`, `api-key` header). All use OpenAI's realtime event schema (Z.ai adds extensions that relay transparently). Provider-specific notes: xAI voice models (e.g. `grok-voice-latest`) aren't in upstream `/models` discovery, so configure them via `XAI_MODELS`, and xAI bills realtime per-minute (no token usage reported); Azure realtime requires a realtime-capable `AZURE_API_VERSION` (the default may be too old) and the model selects the Azure deployment. (MiniMax was evaluated but skipped — its conversational realtime schema is not OpenAI-compatible.) Sessions are gated by the same model-access and budget rules as other model endpoints; usage is tracked per `response.done` event, accepting both the OpenAI singular and Alibaba plural token-detail spellings. The same flag also exposes the OpenAI-compatible WebRTC surface (via the optional `core.RealtimeCallProvider` interface — OpenAI and xAI at the shared `…/v1/realtime/{calls,client_secrets}` shape, and Azure OpenAI at its GA `/openai/v1/realtime/{calls,client_secrets}` surface with `api-key` auth and no api-version; xAI gates WebRTC calls per team, so unauthorized accounts get the upstream 403 relayed while client_secrets works. Bailian is deliberately not wired: its WebRTC is allowlist-only with a per-customer endpoint provided by sales, plus no call id in the answer; Z.ai has no WebRTC realtime): `POST /v1/realtime/calls` exchanges SDP (raw `application/sdp` offer with `?model=`, or multipart `sdp` + `session` JSON fields; the session/query model is rewritten to the resolved provider model so aliases and virtual models work) and relays the answer with a gateway-relative `Location: /v1/realtime/calls/{call_id}` header; `POST /v1/realtime/client_secrets` mints ephemeral browser credentials routed by `session.model` (falling back to the nested transcription model); and `GET /v1/realtime?call_id=…` attaches to an existing call as a sideband websocket (an in-memory per-instance call registry recalls the route for calls created through the same instance — 6h TTL, capped; otherwise pass explicit `model`+`provider` params). WebRTC media and events flow directly between client and provider, so after creating a call the gateway attaches its own best-effort sideband observer websocket to record usage per `response.done` (entries carry endpoint `/v1/realtime/calls`; skipped when usage tracking is off, and gateway-relayed sideband attaches for registry-known calls don't tap usage to avoid double counting). WebRTC signaling counts toward request-scoped rate limits, but concurrent-scope rules can't span a WebRTC call's lifetime since only signaling transits the gateway; ephemeral client secrets authenticate clients directly against the provider, so those sessions bypass the gateway entirely and are untracked.) - **Storage:** `STORAGE_TYPE` (sqlite), `SQLITE_PATH` (default: `data/gomodel.db` when a `./data` directory exists — existing deployments, Docker; otherwise the OS per-user data dir, e.g. `~/.local/share/gomodel/gomodel.db` — see `internal/platformdir`; the local model cache resolves `.cache` vs the OS cache dir the same way), `POSTGRES_URL`, `MONGODB_URL`. `/v1/responses` snapshots and `/v1/conversations` history persist to the configured backend (30-day TTL, hourly sweep); the in-memory fallback stores are byte-capped and used only by embedded setups that skip app wiring. - **Models:** `MODELS_ENABLED_BY_DEFAULT` (true), `KEEP_ONLY_ALIASES_AT_MODELS_ENDPOINT` (false), `CONFIGURED_PROVIDER_MODELS_MODE` (`fallback` or `allowlist`, default `fallback`; `allowlist` skips upstream `/models` for providers with configured lists); persisted overrides restrict/allow selectors with `user_paths`. When alias-only models listing is enabled, `GET /v1/models` returns only model aliases, not full concrete model specs, to operators. @@ -138,5 +138,5 @@ Full reference: `.env.template` and `config/config.yaml` - **Guardrails:** Definitions are persisted in the `guardrail_definitions` store and managed via the admin API/dashboard; `config/config.yaml` entries are validated and upserted into that store at startup (a seed, not the source of truth). `GUARDRAILS_ENABLED` env var gates the feature. - **Provider API key rotation:** Any API-key provider accepts several keys: `[_SUFFIX]_API_KEY_` env vars (numbered from 2; `_1` is accepted as a synonym for the unsuffixed key) or `providers..api_keys` in `config.yaml` (merged after `api_key`, de-duplicated, unresolved `${...}` entries dropped; env replaces the whole YAML list). Identified sessions deterministically stay on one key by default, preserving provider prompt-cache affinity while spreading different sessions across the configured keys; sessionless requests remain round robin. Set `providers..session_sticky_keys: false` or untick **Session-sticky API keys** in the provider editor for strict per-request round robin. Realtime sessions use the same affinity. The trailing number names a key, not a provider: `OPENAI_API_KEY_2` is key 2 of `openai`, while `OPENAI_REGION_2_API_KEY` is the sole key of provider `openai-region-2`. Providers configured without keys are unaffected; Ollama is normally keyless, while SGLang and vLLM participate in rotation when optional API keys are configured. Non-API-key providers (Vertex, Bedrock) are unaffected. - **Provider credentials without env vars:** Every provider below can instead be configured from the admin dashboard's Providers page (or `/admin/provider-credentials` GET/PUT/DELETE), persisted to the `provider_credentials` store — the same declarative-shadows-store precedence as MCP servers: a provider name declared via env vars/`config.yaml` is read-only in the dashboard (`managed: true`), and a store row upsert/delete hot-registers or unregisters the provider into the live registry immediately, no restart. `GOMODEL` boots fine with zero providers configured (empty catalog) so this is a complete alternative to env-var credentials, not just a supplement. API keys (`api_keys`, an ordered rotation list, same semantics as `providers..api_keys`) and service-account secrets are redacted as `***********` on read; an upsert echoing any all-asterisk mask of at least three characters at a position preserves the stored value there (rejected if that position was never set). Disabling a row (`enabled: false`) unregisters it from routing without deleting the stored credentials. `GET /admin/provider-credentials/types` lists every constructible provider type with the credential form it accepts (`fields[]` of `name`/`required`/`advanced`/`options`, plus `default_base_url`), derived from each provider's `DiscoveryConfig` — the dashboard renders only those fields, so an OpenAI-type provider asks for an API key while a Vertex one asks for project/location/service account and no key at all. Upserts are validated against that form (and against Google's project-or-base-URL and service-account rules) *before* anything is persisted, so an unusable credential is rejected with a 400 naming the offending field in `error.param` rather than stored as a broken row. -- **Providers:** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_MAX_TOKENS` (optional default `max_tokens` for Anthropic-translated requests that omit it; default 4096), `GEMINI_API_KEY`, `USE_GOOGLE_GEMINI_NATIVE_API` (true by default; false uses Gemini's OpenAI-compatible chat API), `XAI_API_KEY`, `GROQ_API_KEY`, `FIREWORKS_API_KEY`, `FIREWORKS_BASE_URL` (optional Fireworks AI endpoint override; default `https://api.fireworks.ai/inference/v1`), `META_API_KEY`, `META_BASE_URL` (optional Meta Model API endpoint override; default `https://api.meta.ai/v1`; Muse Spark models, e.g. `muse-spark-1.1`), `OPENROUTER_API_KEY`, `OPENROUTER_SITE_URL`/`OPENROUTER_APP_NAME` (optional OpenRouter attribution headers), `ZAI_API_KEY`, `ZAI_BASE_URL` (optional Z.ai endpoint override), `MINIMAX_API_KEY`, `MINIMAX_BASE_URL` (optional MiniMax endpoint override), `XIAOMI_API_KEY`, `XIAOMI_BASE_URL` (optional Xiaomi MiMo endpoint override), `ELEVENLABS_API_KEY`, `ELEVENLABS_BASE_URL` (optional ElevenLabs endpoint override; default `https://api.elevenlabs.io`; voice-only provider exposing `/v1/audio/speech` and `/v1/audio/transcriptions` — no chat, `/responses`, or embeddings; the OpenAI `voice` field must be an ElevenLabs voice_id), `OPENCODE_GO_API_KEY`, `OPENCODE_GO_BASE_URL` (optional OpenCode Go/Zen endpoint override; default `https://opencode.ai/zen/go/v1`), `OPENCODE_GO_MESSAGES_MODELS` (optional comma-separated model IDs routed to the Anthropic-native `/messages` endpoint instead of `/chat/completions`; default `qwen3.7-max`), `BAILIAN_API_KEY`, `BAILIAN_BASE_URL` (optional Bailian base URL for region switching; default `https://dashscope.aliyuncs.com/compatible-mode/v1`), `AZURE_API_KEY`, `AZURE_BASE_URL` (Azure OpenAI deployment base URL), `AZURE_API_VERSION` (optional Azure API version), `ORACLE_API_KEY` (Oracle API key), `ORACLE_BASE_URL` (Oracle OpenAI-compatible base URL), `BEDROCK_BASE_URL` (Bedrock Runtime region or endpoint), `BEDROCK_MANTLE_API_KEY`, `BEDROCK_MANTLE_BASE_URL` (Mantle region or endpoint), `BEDROCK_MANTLE_API_MODE` (`auto`, `openai`, or `standard`), `[_SUFFIX]_MODELS` (comma-separated configured model list for any provider type), `OLLAMA_BASE_URL`, `SGLANG_BASE_URL`, `SGLANG_API_KEY` (optional upstream SGLang bearer token), `VLLM_BASE_URL`, `VLLM_API_KEY` (optional upstream vLLM bearer token), `LLMD_BASE_URL`, `LLMD_API_KEY` (optional Gateway bearer token), `LLMD_INFERENCE_OBJECTIVE`, `LLMD_FAIRNESS_FROM_USER_PATH` +- **Providers:** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_MAX_TOKENS` (optional default `max_tokens` for Anthropic-translated requests that omit it; default 4096), `GEMINI_API_KEY`, `USE_GOOGLE_GEMINI_NATIVE_API` (true by default; false uses Gemini's OpenAI-compatible chat API), `XAI_API_KEY`, `GROQ_API_KEY`, `FIREWORKS_API_KEY`, `FIREWORKS_BASE_URL` (optional Fireworks AI endpoint override; default `https://api.fireworks.ai/inference/v1`), `META_API_KEY`, `META_BASE_URL` (optional Meta Model API endpoint override; default `https://api.meta.ai/v1`; Muse Spark models, e.g. `muse-spark-1.1`), `OPENROUTER_API_KEY`, `OPENROUTER_SITE_URL`/`OPENROUTER_APP_NAME` (optional OpenRouter attribution headers), `ZAI_API_KEY`, `ZAI_BASE_URL` (optional Z.ai endpoint override), `MINIMAX_API_KEY`, `MINIMAX_BASE_URL` (optional MiniMax endpoint override), `XIAOMI_API_KEY`, `XIAOMI_BASE_URL` (optional Xiaomi MiMo endpoint override), `ELEVENLABS_API_KEY`, `ELEVENLABS_BASE_URL` (optional ElevenLabs endpoint override; default `https://api.elevenlabs.io`; voice-only provider exposing `/v1/audio/speech` and `/v1/audio/transcriptions` — no chat, `/responses`, or embeddings; the OpenAI `voice` field must be an ElevenLabs voice_id), `OPENCODE_GO_API_KEY`, `OPENCODE_GO_BASE_URL` (optional OpenCode Go/Zen endpoint override; default `https://opencode.ai/zen/go/v1`), `OPENCODE_GO_MESSAGES_MODELS` (optional comma-separated model IDs routed to the Anthropic-native `/messages` endpoint instead of `/chat/completions`; default `qwen3.7-max`), `BAILIAN_API_KEY`, `BAILIAN_BASE_URL` (optional Bailian base URL for region switching; default `https://dashscope.aliyuncs.com/compatible-mode/v1`), `AZURE_API_KEY`, `AZURE_BASE_URL` (Azure OpenAI deployment base URL), `AZURE_API_VERSION` (optional Azure API version), `ORACLE_API_KEY` (Oracle API key), `ORACLE_BASE_URL` (Oracle OpenAI-compatible base URL), `BEDROCK_BASE_URL` (Bedrock Runtime region or endpoint), `BEDROCK_MANTLE_API_KEY`, `BEDROCK_MANTLE_BASE_URL` (Mantle region or endpoint), `BEDROCK_MANTLE_API_MODE` (`auto`, `openai`, or `standard`), `[_SUFFIX]_MODELS` (comma-separated configured model list for any provider type), `OLLAMA_BASE_URL`, `SGLANG_BASE_URL`, `SGLANG_API_KEY` (optional upstream SGLang bearer token), `VLLM_BASE_URL`, `VLLM_API_KEY` (optional upstream vLLM bearer token), `LLAMACPP_BASE_URL` (required to register llama.cpp llama-server / LM Studio; include `/v1`), `LLAMACPP_API_KEY` (optional, only if llama-server uses `--api-key`), `LLMD_BASE_URL`, `LLMD_API_KEY` (optional Gateway bearer token), `LLMD_INFERENCE_OBJECTIVE`, `LLMD_FAIRNESS_FROM_USER_PATH` - **Provider model metadata:** `providers..models` accepts either model IDs (strings) or `{id, metadata}` objects. When `metadata` is supplied (`display_name`, `context_window`, `max_output_tokens`, `modes`, `capabilities`, `pricing`, …) it is merged onto the remote ai-model-list entry during enrichment, with operator values winning per-field. Primary use case: advertising context windows, capabilities, and pricing for local models (Ollama) and other custom endpoints whose IDs are not in the upstream registry. diff --git a/config/config.go b/config/config.go index 19d03ad9e..308135c8b 100644 --- a/config/config.go +++ b/config/config.go @@ -102,6 +102,7 @@ func buildDefaultConfig() *Config { "zai", "sglang", "vllm", + "llamacpp", "llmd", "deepseek", }, diff --git a/config/config_test.go b/config/config_test.go index 128234f09..d693604a3 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -126,7 +126,7 @@ func TestBuildDefaultConfig(t *testing.T) { if !cfg.Server.AllowPassthroughV1Alias { t.Error("expected Server.AllowPassthroughV1Alias=true") } - if got, want := cfg.Server.EnabledPassthroughProviders, []string{"openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"}; !reflect.DeepEqual(got, want) { + if got, want := cfg.Server.EnabledPassthroughProviders, []string{"openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "llamacpp", "llmd", "deepseek"}; !reflect.DeepEqual(got, want) { t.Errorf("expected Server.EnabledPassthroughProviders=%v, got %v", want, got) } if cfg.Models.ConfiguredProviderModelsMode != ConfiguredProviderModelsModeFallback { diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index e148ef840..f2051054b 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -282,9 +282,10 @@ Set these to automatically register providers. No YAML configuration required. | `OLLAMA_BASE_URL` | Ollama (no API key needed) | | `SGLANG_BASE_URL` | SGLang (no API key needed unless upstream requires) | | `VLLM_BASE_URL` | vLLM (no API key needed unless upstream requires) | +| `LLAMACPP_BASE_URL` | llama.cpp llama-server / LM Studio (no API key needed unless started with `--api-key`) | | `LLMD_BASE_URL` | llm-d Router/EPP (no API key needed unless its Gateway requires one) | -Most providers can use a custom base URL via `_BASE_URL` (for example `OPENAI_BASE_URL`). Chutes AI defaults to `https://llm.chutes.ai/v1` and can be overridden with `CHUTES_BASE_URL`. DeepSeek defaults to `https://api.deepseek.com`; set `DEEPSEEK_BASE_URL` only for a compatible proxy or alternate DeepSeek endpoint. OpenRouter defaults to `https://openrouter.ai/api/v1` and can be overridden with `OPENROUTER_BASE_URL`. Kilo AI defaults to `https://api.kilo.ai/api/gateway` and can be overridden with `KILO_BASE_URL`. Z.ai defaults to `https://api.z.ai/api/paas/v4`; set `ZAI_BASE_URL=https://api.z.ai/api/coding/paas/v4` for the GLM Coding Plan endpoint. SGLang defaults to `http://localhost:30000/v1` when `SGLANG_API_KEY` is set, but keyless deployments should set `SGLANG_BASE_URL` explicitly to register the provider. vLLM follows the same pattern at `http://localhost:8000/v1`. llm-d has no universal endpoint, so `LLMD_BASE_URL` is always required; `LLMD_API_KEY` is optional. Azure uses `AZURE_BASE_URL` for its deployment base URL and accepts an optional `AZURE_API_VERSION` override; otherwise it defaults to `2024-10-21`. Oracle requires `ORACLE_BASE_URL` because its OpenAI-compatible endpoint is region-specific. +Most providers can use a custom base URL via `_BASE_URL` (for example `OPENAI_BASE_URL`). Chutes AI defaults to `https://llm.chutes.ai/v1` and can be overridden with `CHUTES_BASE_URL`. DeepSeek defaults to `https://api.deepseek.com`; set `DEEPSEEK_BASE_URL` only for a compatible proxy or alternate DeepSeek endpoint. OpenRouter defaults to `https://openrouter.ai/api/v1` and can be overridden with `OPENROUTER_BASE_URL`. Kilo AI defaults to `https://api.kilo.ai/api/gateway` and can be overridden with `KILO_BASE_URL`. Z.ai defaults to `https://api.z.ai/api/paas/v4`; set `ZAI_BASE_URL=https://api.z.ai/api/coding/paas/v4` for the GLM Coding Plan endpoint. SGLang defaults to `http://localhost:30000/v1` when `SGLANG_API_KEY` is set, but keyless deployments should set `SGLANG_BASE_URL` explicitly to register the provider. vLLM follows the same pattern at `http://localhost:8000/v1`. llama.cpp's `LLAMACPP_BASE_URL` is always required (llama-server's default port collides with GoModel's own 8080, so there is no default); `LLAMACPP_API_KEY` is optional. llm-d has no universal endpoint, so `LLMD_BASE_URL` is always required; `LLMD_API_KEY` is optional. Azure uses `AZURE_BASE_URL` for its deployment base URL and accepts an optional `AZURE_API_VERSION` override; otherwise it defaults to `2024-10-21`. Oracle requires `ORACLE_BASE_URL` because its OpenAI-compatible endpoint is region-specific. Every provider type also accepts a comma-separated configured model list via `_MODELS`, for example `OPENROUTER_MODELS`, `ORACLE_MODELS`, diff --git a/docs/features/passthrough-api.mdx b/docs/features/passthrough-api.mdx index 6e59b89fc..9386eb454 100644 --- a/docs/features/passthrough-api.mdx +++ b/docs/features/passthrough-api.mdx @@ -131,8 +131,8 @@ from passthrough requests before forwarding them upstream. Passthrough is intentionally narrow while the API is in beta. -- `openai`, `anthropic`, `openrouter`, `kilo`, `zai`, `sglang`, `vllm`, `llmd`, and `deepseek` are enabled by - default. +- `openai`, `anthropic`, `openrouter`, `kilo`, `zai`, `sglang`, `vllm`, `llamacpp`, `llmd`, and `deepseek` + are enabled by default. - Chutes supports passthrough but requires explicit operator opt-in because passthrough can forward provider-native routes that do not identify a model. Add `chutes` to `ENABLED_PASSTHROUGH_PROVIDERS` only when you intend to expose @@ -150,7 +150,7 @@ Passthrough routes are enabled by default: ```env ENABLE_PASSTHROUGH_ROUTES=true ALLOW_PASSTHROUGH_V1_ALIAS=true -ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,openrouter,kilo,zai,sglang,vllm,llmd,deepseek +ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,openrouter,kilo,zai,sglang,vllm,llamacpp,llmd,deepseek ``` Set `ENABLED_PASSTHROUGH_PROVIDERS` to the provider types you want to expose. diff --git a/docs/providers/llamacpp.mdx b/docs/providers/llamacpp.mdx index 90ec61c38..f8e61e485 100644 --- a/docs/providers/llamacpp.mdx +++ b/docs/providers/llamacpp.mdx @@ -1,19 +1,18 @@ --- title: "llama.cpp" -description: "Route OpenAI-compatible GoModel requests to a llama.cpp llama-server instance (the same recipe works for LM Studio)." +description: "Route OpenAI-compatible GoModel requests to a llama.cpp llama-server instance (the same provider type works for LM Studio)." icon: "server" keywords: ["llama.cpp", "llama-server", "LM Studio", "local models", "self-hosted", "OpenAI-compatible server"] --- -llama.cpp's `llama-server` speaks the OpenAI API directly, so GoModel needs no -dedicated provider type for it: register the server as a **vLLM-type provider**. -That type fits llama.cpp exactly — the API key is optional (llama-server -usually runs keyless), and slash-shaped model IDs work. Do **not** register it -as an -`ollama` provider: that type speaks Ollama's native API, which llama.cpp does -not implement. +llama.cpp's `llama-server` speaks the OpenAI API directly, and GoModel has a +dedicated `llamacpp` provider type for it: the API key is optional +(llama-server usually runs keyless), and provider-native endpoints such as +`/health` and `/rerank` are reachable through passthrough. Do **not** register +llama.cpp as an `ollama` provider: that type speaks Ollama's native API, which +llama.cpp does not implement. -The same recipe applies to LM Studio and any other plain OpenAI-compatible +The same provider type fits LM Studio and any other plain OpenAI-compatible local server. Start llama-server first. Without `--alias`, the model ID in `/v1/models` is @@ -26,18 +25,21 @@ llama-server -hf ggml-org/gemma-3-4b-it-GGUF --alias gemma-3-4b-it --port 8081 ## Configure +The base URL is required and registers the provider — llama-server's default +port (8080) collides with GoModel's own, so there is no default: + ```bash -VLLM_BASE_URL=http://host.docker.internal:8081/v1 # include /v1 -# VLLM_API_KEY=token-abc123 # only if llama-server was started with --api-key +LLAMACPP_BASE_URL=http://host.docker.internal:8081/v1 # include /v1 +# LLAMACPP_API_KEY=token-abc123 # only if llama-server was started with --api-key GOMODEL_MASTER_KEY=change-me ``` These examples assume GoModel runs in Docker and llama-server is on the host at `localhost:8081` — hence `host.docker.internal`. If GoModel runs on the - host directly, use `http://localhost:8081/v1`. Already using `VLLM_BASE_URL` - for a real vLLM server? Register llama.cpp under a suffixed instance instead: - `VLLM_LLAMACPP_BASE_URL=...` creates provider `vllm-llamacpp`. + host directly, use `http://localhost:8081/v1`. Running several llama-server + instances? Register each under a suffixed name: + `LLAMACPP_STUDIO_BASE_URL=...` creates provider `llamacpp-studio`. ## Verify @@ -47,7 +49,7 @@ curl -s http://localhost:8080/v1/chat/completions \ -H "Authorization: Bearer change-me" \ -H "Content-Type: application/json" \ -d '{ - "model": "vllm/gemma-3-4b-it", + "model": "llamacpp/gemma-3-4b-it", "messages": [{"role": "user", "content": "Reply with exactly ok."}] }' ``` @@ -70,7 +72,7 @@ llama-server -hf nomic-ai/nomic-embed-text-v1.5-GGUF --embeddings --alias nomic- curl -s http://localhost:8080/v1/embeddings \ -H "Authorization: Bearer change-me" \ -H "Content-Type: application/json" \ - -d '{"model": "vllm/nomic-embed-text-v1.5", "input": "hello"}' + -d '{"model": "llamacpp/nomic-embed-text-v1.5", "input": "hello"}' ``` ## Model classification @@ -91,8 +93,13 @@ any model the provider serves regardless of category. multimodal models when llama-server is started with a projector (`--mmproj`, auto-loaded with `-hf` when available). These requests flow through `/v1/chat/completions` normally. -- **Reranking** — llama-server serves `/v1/rerank` (start with a reranker - model and `--rerank --embedding --pooling rank`), but GoModel has no rerank - endpoint; reach it through [passthrough](/features/passthrough-api): - `POST /p/vllm/v1/rerank` (use the instance name, e.g. - `/p/vllm-llamacpp/v1/rerank`, for a suffixed setup). +- **Native endpoints** — llama-server's provider-native routes are reachable + through [passthrough](/features/passthrough-api) at `/p/llamacpp/...`: + OpenAI-shaped paths (`chat/completions`, `embeddings`, ...) are served from + the `/v1` base, everything else (`/health`, `/props`, `/tokenize`, + `/infill`, ...) from the server root. For a suffixed setup use the instance + name, e.g. `/p/llamacpp-studio/health`. +- **Reranking** — llama-server serves reranking (start with a reranker model + and `--rerank --embedding --pooling rank`), but GoModel has no rerank + endpoint; reach it through passthrough: `POST /p/llamacpp/rerank` (or + `/p/llamacpp/v1/rerank`). diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index 2e8857c53..6f541c5e9 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -65,7 +65,7 @@ support, not every individual model capability exposed by an upstream provider. | Ollama | `OLLAMA_BASE_URL` | `llama3.2` | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | [Ollama](/providers/multiple-ollama) | | SGLang | `SGLANG_BASE_URL` (`SGLANG_API_KEY` optional) | `Qwen/Qwen2.5-0.5B-Instruct` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [SGLang](/providers/sglang) | | vLLM | `VLLM_BASE_URL` (`VLLM_API_KEY` optional) | `meta-llama/Llama-3.1-8B-Instruct` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [vLLM](/providers/vllm) | -| llama.cpp / LM Studio (as vLLM type) | `VLLM_BASE_URL` (`VLLM_API_KEY` optional) | `gemma-3-4b-it` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [llama.cpp](/providers/llamacpp) | +| llama.cpp / LM Studio | `LLAMACPP_BASE_URL` (`LLAMACPP_API_KEY` optional) | `gemma-3-4b-it` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [llama.cpp](/providers/llamacpp) | | llm-d | `LLMD_BASE_URL` (`LLMD_API_KEY` optional) | `Qwen/Qwen2.5-0.5B-Instruct` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [llm-d](/providers/llmd) | | Amazon Bedrock | `BEDROCK_BASE_URL` (region or endpoint) + AWS credentials | `anthropic.claude-3-5-haiku-20241022-v1:0` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | [Amazon Bedrock](/providers/bedrock) | | Amazon Bedrock Mantle | `BEDROCK_MANTLE_API_KEY` or AWS credentials | `openai.gpt-5.6-sol` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | [Bedrock Mantle](/providers/bedrock-mantle) | @@ -156,10 +156,10 @@ support, not every individual model capability exposed by an upstream provider. routes to any model the provider serves regardless of category. - **vLLM** — set `VLLM_API_KEY` only if the upstream server was started with `--api-key`. -- **llama.cpp / LM Studio** — no dedicated provider type; register the server - as a vLLM-type provider (`VLLM_BASE_URL` or a suffixed instance), not as - `ollama`, which speaks Ollama's native API. See - [llama.cpp](/providers/llamacpp). +- **llama.cpp / LM Studio** — `LLAMACPP_BASE_URL` is required (llama-server's + default port collides with GoModel's own 8080, so there is no default); + `LLAMACPP_API_KEY` is optional. Do not register these servers as `ollama`, + which speaks Ollama's native API. See [llama.cpp](/providers/llamacpp). - **llm-d** — `LLMD_BASE_URL` is required. `LLMD_API_KEY` is optional and is sent only when the Gateway in front of the Router requires bearer auth. - **SGLang** — set `SGLANG_API_KEY` only if the upstream server was started diff --git a/internal/admin/dashboard/static/dist/assets/index-DCy_ZPwB.js b/internal/admin/dashboard/static/dist/assets/index-D7ghz57V.js similarity index 99% rename from internal/admin/dashboard/static/dist/assets/index-DCy_ZPwB.js rename to internal/admin/dashboard/static/dist/assets/index-D7ghz57V.js index 0e142f2bb..dddf0169e 100644 --- a/internal/admin/dashboard/static/dist/assets/index-DCy_ZPwB.js +++ b/internal/admin/dashboard/static/dist/assets/index-D7ghz57V.js @@ -5,7 +5,7 @@ `)>-1?e.split(` `):e}function BMe(e,t){let{element:n,datasetIndex:r,index:i}=t,a=e.getDatasetMeta(r).controller,{label:o,value:s}=a.getLabelAndValue(i);return{chart:e,label:o,parsed:a.getParsed(i),raw:e.data.datasets[r].data[i],formattedValue:s,dataset:a.getDataset(),dataIndex:i,datasetIndex:r,element:n}}function w2(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=L$(t.bodyFont),l=L$(t.titleFont),u=L$(t.footerFont),d=a.length,f=i.length,p=r.length,m=I$(t.padding),h=m.height,g=0,_=r.reduce((e,t)=>e+t.before.length+t.lines.length+t.after.length,0);if(_+=e.beforeBody.length+e.afterBody.length,d&&(h+=d*l.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),_){let e=t.displayColors?Math.max(s,c.lineHeight):c.lineHeight;h+=p*e+(_-p)*c.lineHeight+(_-1)*t.bodySpacing}f&&(h+=t.footerMarginTop+f*u.lineHeight+(f-1)*t.footerSpacing);let v=0,y=function(e){g=Math.max(g,n.measureText(e).width+v)};return n.save(),n.font=l.string,_Q(e.title,y),n.font=c.string,_Q(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,_Q(r,e=>{_Q(e.before,y),_Q(e.lines,y),_Q(e.after,y)}),v=0,n.font=u.string,_Q(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function VMe(e,t){let{y:n,height:r}=t;return ne.height-r/2?`bottom`:`center`}function HMe(e,t,n,r){let{x:i,width:a}=r,o=n.caretSize+n.caretPadding;if(e===`left`&&i+a+o>t.width||e===`right`&&i-a-o<0)return!0}function UMe(e,t,n,r){let{x:i,width:a}=n,{width:o,chartArea:{left:s,right:c}}=e,l=`center`;return r===`center`?l=i<=(s+c)/2?`left`:`right`:i<=a/2?l=`left`:i>=o-a/2&&(l=`right`),HMe(l,e,t,n)&&(l=`center`),l}function T2(e,t,n){let r=n.yAlign||t.yAlign||VMe(e,n);return{xAlign:n.xAlign||t.xAlign||UMe(e,t,n,r),yAlign:r}}function WMe(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function GMe(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:t===`bottom`?r-=i+n:r-=i/2,r}function E2(e,t,n,r){let{caretSize:i,caretPadding:a,cornerRadius:o}=e,{xAlign:s,yAlign:c}=n,l=i+a,{topLeft:u,topRight:d,bottomLeft:f,bottomRight:p}=F$(o),m=WMe(t,s),h=GMe(t,c,l);return c===`center`?s===`left`?m+=l:s===`right`&&(m-=l):s===`left`?m-=Math.max(u,f)+i:s===`right`&&(m+=Math.max(d,p)+i),{x:JQ(m,0,r.width-t.width),y:JQ(h,0,r.height-t.height)}}function D2(e,t,n){let r=I$(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function O2(e){return S2([],C2(e))}function KMe(e,t,n){return z$(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function k2(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var A2={beforeTitle:cQ,title(e){if(e.length>0){let t=e[0],n=t.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode===`dataset`)return t.dataset.label||``;if(t.label)return t.label;if(r>0&&t.dataIndex{let t={before:[],lines:[],after:[]},i=k2(n,e);S2(t.before,C2(j2(i,`beforeLabel`,this,e))),S2(t.lines,j2(i,`label`,this,e)),S2(t.after,C2(j2(i,`afterLabel`,this,e))),r.push(t)}),r}getAfterBody(e,t){return O2(j2(t.callbacks,`afterBody`,this,e))}getFooter(e,t){let{callbacks:n}=t,r=j2(n,`beforeFooter`,this,e),i=j2(n,`footer`,this,e),a=j2(n,`afterFooter`,this,e),o=[];return o=S2(o,C2(r)),o=S2(o,C2(i)),o=S2(o,C2(a)),o}_createItems(e){let t=this._active,n=this.chart.data,r=[],i=[],a=[],o=[],s,c;for(s=0,c=t.length;se.filter(t,r,i,n))),e.itemSort&&(o=o.sort((t,r)=>e.itemSort(t,r,n))),_Q(o,t=>{let n=k2(e.callbacks,t);r.push(j2(n,`labelColor`,this,t)),i.push(j2(n,`labelPointStyle`,this,t)),a.push(j2(n,`labelTextColor`,this,t))}),this.labelColors=r,this.labelPointStyles=i,this.labelTextColors=a,this.dataPoints=o,o}update(e,t){let n=this.options.setContext(this.getContext()),r=this._active,i,a=[];if(!r.length)this.opacity!==0&&(i={opacity:0});else{let e=x2[n.position].call(this,r,this._eventPosition);a=this._createItems(n),this.title=this.getTitle(a,n),this.beforeBody=this.getBeforeBody(a,n),this.body=this.getBody(a,n),this.afterBody=this.getAfterBody(a,n),this.footer=this.getFooter(a,n);let t=this._size=w2(this,n),o=Object.assign({},e,t),s=T2(this.chart,n,o),c=E2(n,o,s,this.chart);this.xAlign=s.xAlign,this.yAlign=s.yAlign,i={opacity:1,x:c.x,y:c.y,width:t.width,height:t.height,caretX:e.x,caretY:e.y}}this._tooltipItems=a,this.$context=void 0,i&&this._resolveAnimations().update(this,i),e&&n.external&&n.external.call(this,{chart:this.chart,tooltip:this,replay:t})}drawCaret(e,t,n,r){let i=this.getCaretPosition(e,n,r);t.lineTo(i.x1,i.y1),t.lineTo(i.x2,i.y2),t.lineTo(i.x3,i.y3)}getCaretPosition(e,t,n){let{xAlign:r,yAlign:i}=this,{caretSize:a,cornerRadius:o}=n,{topLeft:s,topRight:c,bottomLeft:l,bottomRight:u}=F$(o),{x:d,y:f}=e,{width:p,height:m}=t,h,g,_,v,y,b;return i===`center`?(y=f+m/2,r===`left`?(h=d,g=h-a,v=y+a,b=y-a):(h=d+p,g=h+a,v=y-a,b=y+a),_=h):(g=r===`left`?d+Math.max(s,l)+a:r===`right`?d+p-Math.max(c,u)-a:this.caretX,i===`top`?(v=f,y=v-a,h=g-a,_=g+a):(v=f+m,y=v+a,h=g+a,_=g-a),b=v),{x1:h,x2:g,x3:_,y1:v,y2:y,y3:b}}drawTitle(e,t,n){let r=this.title,i=r.length,a,o,s;if(i){let c=u1(n.rtl,this.x,this.width);for(e.x=D2(this,n.titleAlign,n),t.textAlign=c.textAlign(n.titleAlign),t.textBaseline=`middle`,a=L$(n.titleFont),o=n.titleSpacing,t.fillStyle=n.titleColor,t.font=a.string,s=0;se!==0)?(e.beginPath(),e.fillStyle=i.multiKeyBackground,M$(e,{x:t,y:p,w:c,h:s,radius:o}),e.fill(),e.stroke(),e.fillStyle=a.backgroundColor,e.beginPath(),M$(e,{x:n,y:p+1,w:c-2,h:s-2,radius:o}),e.fill()):(e.fillStyle=i.multiKeyBackground,e.fillRect(t,p,c,s),e.strokeRect(t,p,c,s),e.fillStyle=a.backgroundColor,e.fillRect(n,p+1,c-2,s-2))}e.fillStyle=this.labelTextColors[n]}drawBody(e,t,n){let{body:r}=this,{bodySpacing:i,bodyAlign:a,displayColors:o,boxHeight:s,boxWidth:c,boxPadding:l}=n,u=L$(n.bodyFont),d=u.lineHeight,f=0,p=u1(n.rtl,this.x,this.width),m=function(n){t.fillText(n,p.x(e.x+f),e.y+d/2),e.y+=d+i},h=p.textAlign(a),g,_,v,y,b,x,S;for(t.textAlign=a,t.textBaseline=`middle`,t.font=u.string,e.x=D2(this,h,n),t.fillStyle=n.bodyColor,_Q(this.beforeBody,m),f=o&&h!==`right`?a===`center`?c/2+l:c+2+l:0,y=0,x=r.length;y0&&t.stroke()}_updateAnimationTarget(e){let t=this.chart,n=this.$animations,r=n&&n.x,i=n&&n.y;if(r||i){let n=x2[e.position].call(this,this._active,this._eventPosition);if(!n)return;let a=this._size=w2(this,e),o=Object.assign({},n,this._size),s=T2(t,e,o),c=E2(e,o,s,t);(r._to!==c.x||i._to!==c.y)&&(this.xAlign=s.xAlign,this.yAlign=s.yAlign,this.width=a.width,this.height=a.height,this.caretX=n.x,this.caretY=n.y,this._resolveAnimations().update(this,c))}}_willRender(){return!!this.opacity}draw(e){let t=this.options.setContext(this.getContext()),n=this.opacity;if(!n)return;this._updateAnimationTarget(t);let r={width:this.width,height:this.height},i={x:this.x,y:this.y};n=Math.abs(n)<.001?0:n;let a=I$(t.padding),o=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;t.enabled&&o&&(e.save(),e.globalAlpha=n,this.drawBackground(i,e,r,t),d1(e,t.textDirection),i.y+=a.top,this.drawTitle(i,e,t),this.drawBody(i,e,t),this.drawFooter(i,e,t),f1(e,t.textDirection),e.restore())}getActiveElements(){return this._active||[]}setActiveElements(e,t){let n=this._active,r=e.map(({datasetIndex:e,index:t})=>{let n=this.chart.getDatasetMeta(e);if(!n)throw Error(`Cannot find a dataset at index `+e);return{datasetIndex:e,element:n.data[t],index:t}}),i=!vQ(n,r),a=this._positionChanged(r,t);(i||a)&&(this._active=r,this._eventPosition=t,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(e,t,n=!0){if(t&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;let r=this.options,i=this._active||[],a=this._getActiveElements(e,i,t,n),o=this._positionChanged(a,e),s=t||!vQ(a,i)||o;return s&&(this._active=a,(r.enabled||r.external)&&(this._eventPosition={x:e.x,y:e.y},this.update(!0,t))),s}_getActiveElements(e,t,n,r){let i=this.options;if(e.type===`mouseout`)return[];if(!r)return t.filter(e=>this.chart.data.datasets[e.datasetIndex]&&this.chart.getDatasetMeta(e.datasetIndex).controller.getParsed(e.index)!==void 0);let a=this.chart.getElementsAtEventForMode(e,i.mode,i,n);return i.reverse&&a.reverse(),a}_positionChanged(e,t){let{caretX:n,caretY:r,options:i}=this,a=x2[i.position].call(this,e,t);return a!==!1&&(n!==a.x||r!==a.y)}},qMe=Object.freeze({__proto__:null,Colors:oMe,Decimation:uMe,Filler:AMe,Legend:IMe,SubTitle:zMe,Title:RMe,Tooltip:{id:`tooltip`,_element:M2,positioners:x2,afterInit(e,t,n){n&&(e.tooltip=new M2({chart:e,options:n}))},beforeUpdate(e,t,n){e.tooltip&&e.tooltip.initialize(n)},reset(e,t,n){e.tooltip&&e.tooltip.initialize(n)},afterDraw(e){let t=e.tooltip;if(t&&t._willRender()){let n={tooltip:t};if(e.notifyPlugins(`beforeTooltipDraw`,{...n,cancelable:!0})===!1)return;t.draw(e.ctx),e.notifyPlugins(`afterTooltipDraw`,n)}},afterEvent(e,t){if(e.tooltip){let n=t.replay;e.tooltip.handleEvent(t.event,n,t.inChartArea)&&(t.changed=!0)}},defaults:{enabled:!0,external:null,position:`average`,backgroundColor:`rgba(0,0,0,0.8)`,titleColor:`#fff`,titleFont:{weight:`bold`},titleSpacing:2,titleMarginBottom:6,titleAlign:`left`,bodyColor:`#fff`,bodySpacing:2,bodyFont:{},bodyAlign:`left`,footerColor:`#fff`,footerSpacing:2,footerMarginTop:6,footerFont:{weight:`bold`},footerAlign:`left`,padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(e,t)=>t.bodyFont.size,boxWidth:(e,t)=>t.bodyFont.size,multiKeyBackground:`#fff`,displayColors:!0,boxPadding:0,borderColor:`rgba(0,0,0,0)`,borderWidth:0,animation:{duration:400,easing:`easeOutQuart`},animations:{numbers:{type:`number`,properties:[`x`,`y`,`width`,`height`,`caretX`,`caretY`]},opacity:{easing:`linear`,duration:200}},callbacks:A2},defaultRoutes:{bodyFont:`font`,footerFont:`font`,titleFont:`font`},descriptors:{_scriptable:e=>e!==`filter`&&e!==`itemSort`&&e!==`external`,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:`animation`}},additionalOptionScopes:[`interaction`]}}),JMe=(e,t,n,r)=>(typeof t==`string`?(n=e.push(t)-1,r.unshift({index:n,label:t})):isNaN(t)&&(n=null),n);function YMe(e,t,n,r){let i=e.indexOf(t);return i===-1?JMe(e,t,n,r):i===e.lastIndexOf(t)?i:n}var XMe=(e,t)=>e===null?null:JQ(Math.round(e),0,t);function N2(e){let t=this.getLabels();return e>=0&&et.length-1?null:this.getPixelForValue(t[e].value)}getValueForPixel(e){return Math.round(this._startValue+this.getDecimalForPixel(e)*this._valueRange)}getBasePixel(){return this.bottom}};function QMe(e,t){let n=[],{bounds:r,step:i,min:a,max:o,precision:s,count:c,maxTicks:l,maxDigits:u,includeBounds:d}=e,f=i||1,p=l-1,{min:m,max:h}=t,g=!lQ(a),_=!lQ(o),v=!lQ(c),y=(h-m)/(u+1),b=RQ((h-m)/p/f)*f,x,S,C,w;if(b<1e-14&&!g&&!_)return[{value:m},{value:h}];w=Math.ceil(h/b)-Math.floor(m/b),w>p&&(b=RQ(w*b/p/f)*f),lQ(s)||(x=10**s,b=Math.ceil(b*x)/x),r===`ticks`?(S=Math.floor(m/b)*b,C=Math.ceil(h/b)*b):(S=m,C=h),g&&_&&i&&HOe((o-a)/i,b/1e3)?(w=Math.round(Math.min((o-a)/b,l)),b=(o-a)/w,S=a,C=o):v?(S=g?a:S,C=_?o:C,w=c-1,b=(C-S)/w):(w=(C-S)/b,w=LQ(w,Math.round(w),b/1e3)?Math.round(w):Math.ceil(w));let T=Math.max(UQ(b),UQ(S));x=10**(lQ(s)?T:s),S=Math.round(S*x)/x,C=Math.round(C*x)/x;let ee=0;for(g&&(d&&S!==a?(n.push({value:a}),So)break;n.push({value:e})}return _&&d&&C!==o?n.length&&LQ(n[n.length-1].value,o,P2(o,y,e))?n[n.length-1].value=o:n.push({value:o}):(!_||C===o)&&n.push({value:C}),n}function P2(e,t,{horizontal:n,minRotation:r}){let i=VQ(r),a=(n?Math.sin(i):Math.cos(i))||.001,o=.75*t*(``+e).length;return Math.min(t/a,o)}var F2=class extends y0{constructor(e){super(e),this.start=void 0,this.end=void 0,this._startValue=void 0,this._endValue=void 0,this._valueRange=0}parse(e,t){return lQ(e)||(typeof e==`number`||e instanceof Number)&&!isFinite(+e)?null:+e}handleTickRangeOptions(){let{beginAtZero:e}=this.options,{minDefined:t,maxDefined:n}=this.getUserBounds(),{min:r,max:i}=this,a=e=>r=t?r:e,o=e=>i=n?i:e;if(e){let e=IQ(r),t=IQ(i);e<0&&t<0?o(0):e>0&&t>0&&a(0)}if(r===i){let t=i===0?1:Math.abs(i*.05);o(i+t),e||a(r-t)}this.min=r,this.max=i}getTickLimit(){let{maxTicksLimit:e,stepSize:t}=this.options.ticks,n;return t?(n=Math.ceil(this.max/t)-Math.floor(this.min/t)+1,n>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${t} would result generating up to ${n} ticks. Limiting to 1000.`),n=1e3)):(n=this.computeTickLimit(),e||=11),e&&(n=Math.min(e,n)),n}computeTickLimit(){return 1/0}buildTicks(){let e=this.options,t=e.ticks,n=this.getTickLimit();n=Math.max(2,n);let r=QMe({maxTicks:n,bounds:e.bounds,min:e.min,max:e.max,precision:t.precision,step:t.stepSize,count:t.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:t.minRotation||0,includeBounds:t.includeBounds!==!1},this._range||this);return e.bounds===`ticks`&&BQ(r,this,`value`),e.reverse?(r.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),r}configure(){let e=this.ticks,t=this.min,n=this.max;if(super.configure(),this.options.offset&&e.length){let r=(n-t)/Math.max(e.length-1,1)/2;t-=r,n+=r}this._startValue=t,this._endValue=n,this._valueRange=n-t}getLabelForValue(e){return h$(e,this.chart.options.locale,this.options.ticks.format)}},$Me=class extends F2{static id=`linear`;static defaults={ticks:{callback:_$.formatters.numeric}};determineDataLimits(){let{min:e,max:t}=this.getMinMax(!0);this.min=fQ(e)?e:0,this.max=fQ(t)?t:1,this.handleTickRangeOptions()}computeTickLimit(){let e=this.isHorizontal(),t=e?this.width:this.height,n=VQ(this.options.ticks.minRotation),r=(e?Math.sin(n):Math.cos(n))||.001,i=this._resolveTickFontOptions(0);return Math.ceil(t/Math.min(40,i.lineHeight/r))}getPixelForValue(e){return e===null?NaN:this.getPixelForDecimal((e-this._startValue)/this._valueRange)}getValueForPixel(e){return this._startValue+this.getDecimalForPixel(e)*this._valueRange}},I2=e=>Math.floor(FQ(e)),L2=(e,t)=>10**(I2(e)+t);function R2(e){return e/10**I2(e)==1}function z2(e,t,n){let r=10**n,i=Math.floor(e/r);return Math.ceil(t/r)-i}function eNe(e,t){let n=I2(t-e);for(;z2(e,t,n)>10;)n++;for(;z2(e,t,n)<10;)n--;return Math.min(n,I2(e))}function tNe(e,{min:t,max:n}){t=pQ(e.min,t);let r=[],i=I2(t),a=eNe(t,n),o=a<0?10**Math.abs(a):1,s=10**a,c=i>a?10**i:0,l=Math.round((t-c)*o)/o,u=Math.floor((t-c)/s/10)*s*10,d=Math.floor((l-u)/10**a),f=pQ(e.min,Math.round((c+u+d*10**a)*o)/o);for(;f=10?d=d<15?15:20:d++,d>=20&&(a++,d=2,o=a>=0?1:o),f=Math.round((c+u+d*10**a)*o)/o;let p=pQ(e.max,f);return r.push({value:p,major:R2(p),significand:d}),r}var nNe=class extends y0{static id=`logarithmic`;static defaults={ticks:{callback:_$.formatters.logarithmic,major:{enabled:!0}}};constructor(e){super(e),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(e,t){let n=F2.prototype.parse.apply(this,[e,t]);if(n===0){this._zero=!0;return}return fQ(n)&&n>0?n:null}determineDataLimits(){let{min:e,max:t}=this.getMinMax(!0);this.min=fQ(e)?Math.max(0,e):null,this.max=fQ(t)?Math.max(0,t):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!fQ(this._userMin)&&(this.min=e===L2(this.min,0)?L2(this.min,-1):L2(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){let{minDefined:e,maxDefined:t}=this.getUserBounds(),n=this.min,r=this.max,i=t=>n=e?n:t,a=e=>r=t?r:e;n===r&&(n<=0?(i(1),a(10)):(i(L2(n,-1)),a(L2(r,1)))),n<=0&&i(L2(r,-1)),r<=0&&a(L2(n,1)),this.min=n,this.max=r}buildTicks(){let e=this.options,t=tNe({min:this._userMin,max:this._userMax},this);return e.bounds===`ticks`&&BQ(t,this,`value`),e.reverse?(t.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),t}getLabelForValue(e){return e===void 0?`0`:h$(e,this.chart.options.locale,this.options.ticks.format)}configure(){let e=this.min;super.configure(),this._startValue=FQ(e),this._valueRange=FQ(this.max)-FQ(e)}getPixelForValue(e){return(e===void 0||e===0)&&(e=this.min),e===null||isNaN(e)?NaN:this.getPixelForDecimal(e===this.min?0:(FQ(e)-this._startValue)/this._valueRange)}getValueForPixel(e){let t=this.getDecimalForPixel(e);return 10**(this._startValue+t*this._valueRange)}};function B2(e){let t=e.ticks;if(t.display&&e.display){let e=I$(t.backdropPadding);return mQ(t.font&&t.font.size,S$.font.size)+e.height}return 0}function rNe(e,t,n){return n=uQ(n)?n:[n],{w:ike(e,t.string,n),h:n.length*t.lineHeight}}function V2(e,t,n,r,i){return e===r||e===i?{start:t-n/2,end:t+n/2}:ei?{start:t-n,end:t}:{start:t,end:t+n}}function iNe(e){let t={l:e.left+e._padding.left,r:e.right-e._padding.right,t:e.top+e._padding.top,b:e.bottom-e._padding.bottom},n=Object.assign({},t),r=[],i=[],a=e._pointLabels.length,o=e.options.pointLabels,s=o.centerPointLabels?kQ/a:0;for(let c=0;ct.r&&(s=(r.end-t.r)/a,e.r=Math.max(e.r,t.r+s)),i.startt.b&&(c=(i.end-t.b)/o,e.b=Math.max(e.b,t.b+c))}function oNe(e,t,n){let r=e.drawingArea,{extra:i,additionalAngle:a,padding:o,size:s}=n,c=e.getPointPosition(t,r+i+o,a),l=Math.round(HQ(KQ(c.angle+MQ))),u=dNe(c.y,s.h,l),d=lNe(l),f=uNe(c.x,s.w,d);return{visible:!0,x:c.x,y:u,textAlign:d,left:f,top:u,right:f+s.w,bottom:u+s.h}}function sNe(e,t){if(!t)return!0;let{left:n,top:r,right:i,bottom:a}=e;return!(O$({x:n,y:r},t)||O$({x:n,y:a},t)||O$({x:i,y:r},t)||O$({x:i,y:a},t))}function cNe(e,t,n){let r=[],i=e._pointLabels.length,a=e.options,{centerPointLabels:o,display:s}=a.pointLabels,c={extra:B2(a)/2,additionalAngle:o?kQ/i:0},l;for(let a=0;a270||n<90)&&(e-=t),e}function fNe(e,t,n){let{left:r,top:i,right:a,bottom:o}=n,{backdropColor:s}=t;if(!lQ(s)){let n=F$(t.borderRadius),c=I$(t.backdropPadding);e.fillStyle=s;let l=r-c.left,u=i-c.top,d=a-r+c.width,f=o-i+c.height;Object.values(n).some(e=>e!==0)?(e.beginPath(),M$(e,{x:l,y:u,w:d,h:f,radius:n}),e.fill()):e.fillRect(l,u,d,f)}}function pNe(e,t){let{ctx:n,options:{pointLabels:r}}=e;for(let i=t-1;i>=0;i--){let t=e._pointLabelItems[i];if(!t.visible)continue;let a=r.setContext(e.getPointLabelContext(i));fNe(n,a,t);let o=L$(a.font),{x:s,y:c,textAlign:l}=t;j$(n,e._pointLabels[i],s,c+o.lineHeight/2,o,{color:a.color,textAlign:l,textBaseline:`middle`})}}function H2(e,t,n,r){let{ctx:i}=e;if(n)i.arc(e.xCenter,e.yCenter,t,0,AQ);else{let n=e.getPointPosition(0,t);i.moveTo(n.x,n.y);for(let a=1;a{let n=gQ(this.options.pointLabels.callback,[e,t],this);return n||n===0?n:``}).filter((e,t)=>this.chart.getDataVisibility(t))}fit(){let e=this.options;e.display&&e.pointLabels.display?iNe(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(e,t,n,r){this.xCenter+=Math.floor((e-t)/2),this.yCenter+=Math.floor((n-r)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(e,t,n,r))}getIndexAngle(e){let t=AQ/(this._pointLabels.length||1),n=this.options.startAngle||0;return KQ(e*t+VQ(n))}getDistanceFromCenterForValue(e){if(lQ(e))return NaN;let t=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-e)*t:(e-this.min)*t}getValueForDistanceFromCenter(e){if(lQ(e))return NaN;let t=e/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-t:this.min+t}getPointLabelContext(e){let t=this._pointLabels||[];if(e>=0&&e{if(t!==0||t===0&&this.min<0){s=this.getDistanceFromCenterForValue(e.value);let n=this.getContext(t),o=r.setContext(n),c=i.setContext(n);mNe(this,o,s,a,c)}}),n.display){for(e.save(),o=a-1;o>=0;o--){let r=n.setContext(this.getPointLabelContext(o)),{color:i,lineWidth:a}=r;!a||!i||(e.lineWidth=a,e.strokeStyle=i,e.setLineDash(r.borderDash),e.lineDashOffset=r.borderDashOffset,s=this.getDistanceFromCenterForValue(t.reverse?this.min:this.max),c=this.getPointPosition(o,s),e.beginPath(),e.moveTo(this.xCenter,this.yCenter),e.lineTo(c.x,c.y),e.stroke())}e.restore()}}drawBorder(){}drawLabels(){let e=this.ctx,t=this.options,n=t.ticks;if(!n.display)return;let r=this.getIndexAngle(0),i,a;e.save(),e.translate(this.xCenter,this.yCenter),e.rotate(r),e.textAlign=`center`,e.textBaseline=`middle`,this.ticks.forEach((r,o)=>{if(o===0&&this.min>=0&&!t.reverse)return;let s=n.setContext(this.getContext(o)),c=L$(s.font);if(i=this.getDistanceFromCenterForValue(this.ticks[o].value),s.showLabelBackdrop){e.font=c.string,a=e.measureText(r.label).width,e.fillStyle=s.backdropColor;let t=I$(s.backdropPadding);e.fillRect(-a/2-t.left,-i-c.size/2-t.top,a+t.width,c.size+t.height)}j$(e,r.label,0,-i,c,{color:s.color,strokeColor:s.textStrokeColor,strokeWidth:s.textStrokeWidth})}),e.restore()}drawTitle(){}},U2={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},W2=Object.keys(U2);function G2(e,t){return e-t}function K2(e,t){if(lQ(t))return null;let n=e._adapter,{parser:r,round:i,isoWeekday:a}=e._parseOpts,o=t;return typeof r==`function`&&(o=r(o)),fQ(o)||(o=typeof r==`string`?n.parse(o,r):n.parse(o)),o===null?null:(i&&(o=i===`week`&&(zQ(a)||a===!0)?n.startOf(o,`isoWeek`,a):n.startOf(o,i)),+o)}function q2(e,t,n,r){let i=W2.length;for(let a=W2.indexOf(e);a=W2.indexOf(n);a--){let n=W2[a];if(U2[n].common&&e._adapter.diff(i,r,n)>=t-1)return n}return W2[n?W2.indexOf(n):0]}function vNe(e){for(let t=W2.indexOf(e)+1,n=W2.length;t=t?n[r]:n[i];e[a]=!0}}function yNe(e,t,n,r){let i=e._adapter,a=+i.startOf(t[0].value,r),o=t[t.length-1].value,s,c;for(s=a;s<=o;s=+i.add(s,1,r))c=n[s],c>=0&&(t[c].major=!0);return t}function Y2(e,t,n){let r=[],i={},a=t.length,o,s;for(o=0;o+e.value))}initOffsets(e=[]){let t=0,n=0,r,i;this.options.offset&&e.length&&(r=this.getDecimalForValue(e[0]),t=e.length===1?1-r:(this.getDecimalForValue(e[1])-r)/2,i=this.getDecimalForValue(e[e.length-1]),n=e.length===1?i:(i-this.getDecimalForValue(e[e.length-2]))/2);let a=e.length<3?.5:.25;t=JQ(t,0,a),n=JQ(n,0,a),this._offsets={start:t,end:n,factor:1/(t+1+n)}}_generate(){let e=this._adapter,t=this.min,n=this.max,r=this.options,i=r.time,a=i.unit||q2(i.minUnit,t,n,this._getLabelCapacity(t)),o=mQ(r.ticks.stepSize,1),s=a===`week`&&i.isoWeekday,c=zQ(s)||s===!0,l={},u=t,d,f;if(c&&(u=+e.startOf(u,`isoWeek`,s)),u=+e.startOf(u,c?`day`:a),e.diff(n,t,a)>1e5*o)throw Error(t+` and `+n+` are too far apart with stepSize of `+o+` `+a);let p=r.ticks.source===`data`&&this.getDataTimestamps();for(d=u,f=0;d+e)}getLabelForValue(e){let t=this._adapter,n=this.options.time;return n.tooltipFormat?t.format(e,n.tooltipFormat):t.format(e,n.displayFormats.datetime)}format(e,t){let n=this.options.time.displayFormats,r=this._unit,i=t||n[r];return this._adapter.format(e,i)}_tickFormatFunction(e,t,n,r){let i=this.options,a=i.ticks.callback;if(a)return gQ(a,[e,t,n],this);let o=i.time.displayFormats,s=this._unit,c=this._majorUnit,l=s&&o[s],u=c&&o[c],d=n[t],f=c&&u&&d&&d.major;return this._adapter.format(e,r||(f?u:l))}generateTickLabels(e){let t,n,r;for(t=0,n=e.length;t0?o:1}getDataTimestamps(){let e=this._cache.data||[],t,n;if(e.length)return e;let r=this.getMatchingVisibleMetas();if(this._normalized&&r.length)return this._cache.data=r[0].controller.getAllParsedValues(this);for(t=0,n=r.length;t=e[r].pos&&t<=e[i].pos&&({lo:r,hi:i}=ZQ(e,`pos`,t)),{pos:a,time:s}=e[r],{pos:o,time:c}=e[i]):(t>=e[r].time&&t<=e[i].time&&({lo:r,hi:i}=ZQ(e,`time`,t)),{time:a,pos:s}=e[r],{time:o,pos:c}=e[i]);let l=o-a;return l?s+(c-s)*(t-a)/l:s}var bNe=class extends X2{static id=`timeseries`;static defaults=X2.defaults;constructor(e){super(e),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){let e=this._getTimestampsForTable(),t=this._table=this.buildLookupTable(e);this._minPos=Z2(t,this.min),this._tableRange=Z2(t,this.max)-this._minPos,super.initOffsets(e)}buildLookupTable(e){let{min:t,max:n}=this,r=[],i=[],a,o,s,c,l;for(a=0,o=e.length;a=t&&c<=n&&r.push(c);if(r.length<2)return[{time:t,pos:0},{time:n,pos:1}];for(a=0,o=r.length;ae-t)}_getTimestampsForTable(){let e=this._cache.all||[];if(e.length)return e;let t=this.getDataTimestamps(),n=this.getLabelTimestamps();return e=t.length&&n.length?this.normalize(t.concat(n)):t.length?t:n,e=this._cache.all=e,e}getDecimalForValue(e){return(Z2(this._table,e)-this._minPos)/this._tableRange}getValueForPixel(e){let t=this._offsets,n=this.getDecimalForPixel(e)/t.factor-t.end;return Z2(this._table,n*this._tableRange+this._minPos,!0)}},xNe=[SAe,$je,qMe,Object.freeze({__proto__:null,CategoryScale:ZMe,LinearScale:$Me,LogarithmicScale:nNe,RadialLinearScale:gNe,TimeScale:X2,TimeSeriesScale:bNe})];z0.register(...xNe);var SNe=z0,CNe=R(``);function Q2(e,t){D(t,!0);let n=G(t,`class`,3,``),r=G(t,`ariaLabel`,3,``);function i(e){if(Ji.tick,typeof t.build!=`function`)return;let n=t.build();if(!n)return;let r=new SNe(e.getContext(`2d`),n);return()=>r.destroy()}var a=CNe();si(a,()=>i),F(()=>{U(a,1,li(n())),W(a,`aria-label`,r())}),z(e,a),O()}var wNe=R(``),TNe=R(`
`);function $2(e,t){D(t,!0);let n=G(t,`options`,19,()=>[]),r=G(t,`ariaLabel`,3,``),i=G(t,`class`,3,``);var a=TNe();H(a,21,n,e=>e.value,(e,n)=>{var r=wNe();let i;var a=M(r,!0);E(r),F(()=>{i=U(r,1,`segmented-btn svelte-92fh5i`,null,i,{active:t.value===I(n).value}),W(r,`aria-pressed`,t.value===I(n).value),B(a,I(n).label)}),L(`click`,r,()=>t.onchange?.(I(n).value)),z(e,r)}),E(a),F(()=>{U(a,1,li([`segmented-control`,i()]),`svelte-92fh5i`),W(a,`aria-label`,r())}),z(e,a),O()}Sr([`click`]);function e4(e){return getComputedStyle(document.documentElement).getPropertyValue(e).trim()}function t4(){return{grid:e4(`--chart-grid`),text:e4(`--chart-text`),dayMarker:e4(`--chart-day-marker`),tooltipBg:e4(`--chart-tooltip-bg`),tooltipBorder:e4(`--chart-tooltip-border`),tooltipText:e4(`--chart-tooltip-text`)}}function n4(){return{size:11,family:`'SF Mono', Menlo, Consolas, monospace`}}function r4(e,t){return{backgroundColor:e.tooltipBg,borderColor:e.tooltipBorder,borderWidth:1,titleColor:e.tooltipText,bodyColor:e.tooltipText,callbacks:t}}function i4(e){if(typeof document>`u`||!document.body)return e;let t=document.createElement(`span`);t.style.display=`none`,t.style.color=e,document.body.appendChild(t);let n=getComputedStyle(t).color;return document.body.removeChild(t),n||e}var a4=[`#c2845a`,`#7a9e7e`,`#d4a574`,`#b8a98e`,`#8b9e6b`,`#7d8a97`,`#c47a5a`,`#6b8e6b`,`#a09486`,`#9b7ea4`,`#c49a6c`];function ENe(){return[...a4]}function o4(e){let t=5381,n=String(e||``);for(let e=0;egZ(e)}}var d4={seconds:{apiName:`second`,windowLabel:kl,refreshMs:2e3},minutes:{apiName:`minute`,windowLabel:Ml,refreshMs:5e3},hours:{apiName:`hour`,windowLabel:Fl,refreshMs:2e4},days:{apiName:`day`,windowLabel:Rl,refreshMs:6e4}};function ONe(){return[{value:`seconds`,label:_l()},{value:`minutes`,label:bl()},{value:`hours`,label:Cl()},{value:`days`,label:El()}]}function f4(){return{input:0,output:0,prompt:0,local:0}}function p4(e){return String(e).padStart(2,`0`)}function m4(e){let t=Number(e);return Number.isFinite(t)&&t>0?t:0}function kNe(e,t){if(!Number.isFinite(t))return``;let n=new Date(t);switch(e){case`seconds`:return p4(n.getHours())+`:`+p4(n.getMinutes())+`:`+p4(n.getSeconds());case`minutes`:return p4(n.getHours())+`:`+p4(n.getMinutes());case`hours`:return p4(n.getHours())+`:00`;default:return p4(n.getMonth()+1)+`-`+p4(n.getDate())}}function ANe(e,t){let n=[],r=[],i={input:[],output:[],prompt:[],local:[]},a=f4();for(let o of e||[]){let e=Date.parse(o&&o.start),s=m4(o&&o.input_tokens),c=m4(o&&o.output_tokens),l=m4(o&&o.prompt_cached_tokens),u=m4(o&&o.locally_cached_tokens);n.push(kNe(t,e)),r.push(Number.isFinite(e)?e:null),i.input.push(s),i.output.push(c),i.prompt.push(l),i.local.push(u),a.input+=s,a.output+=c,a.prompt+=l,a.local+=u}return{labels:n,stamps:r,cols:i,totals:a}}function jNe(e){let t=e||f4();return t.input+t.output+t.prompt+t.local>0}function h4(e,t){return gZ(Math.max(0,Math.round(e&&e[t]||0)))}function g4(e){return(d4[e]||d4.minutes).windowLabel()}function MNe(e,t){return Eu({window:g4(t).toLowerCase(),input:h4(e,`input`),output:h4(e,`output`),prompt:h4(e,`prompt`),local:h4(e,`local`)})}function NNe(e,t,n,r){let i=e=>gZ(Math.max(0,Math.round(e))),a=n.stamps,o=(e,t,n)=>({label:e,data:t,backgroundColor:n,borderWidth:0,borderRadius:0,categoryPercentage:1,barPercentage:1,stack:`tokens`});return{type:`bar`,plugins:[{id:`liveTokensDayMarks`,afterDatasetsDraw:t=>{if(r===`days`)return;let n=t.getDatasetMeta(0),i=t.chartArea;if(!n||!n.data||!i)return;let o=t.ctx;o.save(),o.font=`10px 'SF Mono', Menlo, Consolas, monospace`;let s=null;for(let t=0;t{if(!e.length)return``;let t=a[e[0].dataIndex];if(!t)return e[0].label;let n=new Date(t);return r===`days`?n.toLocaleDateString():n.toLocaleString()},label:e=>e.dataset.label+`: `+i(e.parsed.y),footer:e=>{let t=0;return e.forEach(e=>{t+=Number(e.parsed.y)||0}),ql({total:i(t)})}})}}}}var PNe=900,_4=new class{#e=A(`minutes`);get granularity(){return I(this.#e)}set granularity(e){j(this.#e,e,!0)}#t=A(Xt([]));get buckets(){return I(this.#t)}set buckets(e){j(this.#t,e,!0)}#n=A(!1);get active(){return I(this.#n)}set active(e){j(this.#n,e,!0)}#r=null;#i=null;#a=null;#o=0;#s=null;#c=!1;start(){this.stop(),this.active=!0,this.fetch(),this.#l(),this.#u()}stop(){this.active=!1,this.#r&&=(clearInterval(this.#r),null),this.#i&&=(clearTimeout(this.#i),null),this.#a&&=(clearTimeout(this.#a),null),this.#o=0,this.#s&&=(this.#s.abort(),null),this.buckets=[]}setGranularity(e){!d4[e]||e===this.granularity||(this.granularity=e,this.buckets=[],this.#l(),this.fetch())}#l(){this.#r&&=(clearInterval(this.#r),null);let e=d4[this.granularity]||d4.minutes;this.#r=setInterval(()=>{this.active&&this.fetch()},e.refreshMs)}noteUsageEvent(e){!this.active||e!==`usage.flushed`||(this.#i||=setTimeout(()=>{this.#i=null,this.fetch()},PNe))}async fetch(){if(!this.active||this.#c)return;this.#c=!0;let e=this.granularity;try{let t=await XX(`/admin/usage/throughput?granularity=`+(d4[e]||d4.minutes).apiName,{label:`token throughput`});if(t.stale||!t.ok||this.granularity!==e)return;this.buckets=t.data&&Array.isArray(t.data.buckets)?t.data.buckets:[]}catch(e){if(QX(e))return;console.error(`Failed to fetch token throughput:`,e)}finally{this.#c=!1,this.active&&this.granularity!==e&&this.fetch()}}async#u(){await $X.ensureLoaded(),this.active&&$X.liveLogsVisible()&&(typeof ReadableStream>`u`||(this.#s&&this.#s.abort(),this.#s=new AbortController,this.#d(this.#s)))}async#d(e){let t=()=>{e.signal.aborted||this.#s!==e||this.#p()};try{let n=await JX(`/admin/live/logs?types=usage`,{signal:e.signal});if(!n.ok||!n.body||typeof n.body.getReader!=`function`){t();return}this.#o=0,await s4(n.body.getReader(),e=>this.#f(e)),t()}catch(n){if(QX(n)||e.signal.aborted)return;console.error(`Live usage stream failed:`,n),t()}}#f(e){if(!e||typeof e!=`object`)return;let t=String(e.type||``).trim();t.indexOf(`usage.`)===0&&this.noteUsageEvent(t)}#p(){if(!this.active||this.#a)return;let{attempt:e,delay:t}=l4(this.#o);this.#o=e,this.#a=setTimeout(()=>{this.#a=null,this.#u()},t)}},FNe=R(`
`),INe=R(`
`),LNe=R(`

`);function RNe(e,t){D(t,!0);let n=k(()=>ANe(_4.buckets,_4.granularity)),r=k(()=>I(n).totals);function i(){return{input:i4(`var(--token-input)`),output:i4(`var(--token-output)`),prompt:i4(`var(--token-prompt)`),local:i4(`var(--token-local)`)}}let a=[{metric:`input`,label:Jo,colorVar:`--token-input`},{metric:`output`,label:Yo,colorVar:`--token-output`},{metric:`prompt`,label:Vl,colorVar:`--token-prompt`},{metric:`local`,label:Wl,colorVar:`--token-local`}];var o=LNe(),s=M(o),c=M(s),l=M(c),u=M(l,!0);E(l);var d=P(l,2),f=M(d);let p;var m=P(f,2),h=M(m,!0);E(m),E(d),E(c);var g=P(c,2);{let e=k(()=>dl()),t=k(ONe);$2(g,{get ariaLabel(){return I(e)},get options(){return I(t)},get value(){return _4.granularity},onchange:e=>_4.setGranularity(e)})}E(s);var _=P(s,2);H(_,21,()=>a,e=>e.metric,(e,t)=>{var n=FNe(),i=M(n),a=P(i,2),o=M(a,!0);E(a);var s=P(a,2),c=M(s,!0);E(s),E(n),F((e,n)=>{mi(i,`background: var(${I(t).colorVar??``})`),B(o,e),B(c,n)},[()=>I(t).label(),()=>h4(I(r),I(t).metric)]),z(e,n)}),E(_);var v=P(_,2),y=M(v);{let e=k(()=>MNe(I(r),_4.granularity));Q2(y,{get ariaLabel(){return I(e)},build:()=>NNe(t4(),i(),I(n),_4.granularity)})}var b=P(y,2),x=e=>{var t=INe(),n=M(t),r=M(n,!0);E(n),E(t),F(e=>B(r,e),[()=>ml()]),z(e,t)},S=k(()=>!jNe(I(r)));V(b,e=>{I(S)&&e(x)}),E(v),E(o),F((e,t)=>{B(u,e),p=U(f,1,`live-dot`,null,p,{"is-streaming":_4.active}),B(h,t)},[()=>cl(),()=>g4(_4.granularity)]),z(e,o),O()}function zNe(e){let t=e||{};if(t.total_tokens!==null&&t.total_tokens!==void 0){let e=Number(t.total_tokens);if(Number.isFinite(e))return e}let n=Number(t.total_input_tokens||0),r=Number(t.total_output_tokens||0);return(Number.isFinite(n)?n:0)+(Number.isFinite(r)?r:0)}function v4(e,t){if(!t)return 0;let n=e&&e.summary?e.summary:{},r=Number(n.total_hits||0);return Number.isFinite(r)&&r>0?r:0}function y4(e,t,n){let r=Number(e&&e.total_requests||0);return(Number.isFinite(r)?r:0)+v4(t,n)}function BNe(e,t,n){let r=v4(t,n);return r<=0?``:Xl({provider:DZ(y4(e,t,n)-r),cache:DZ(r)})}function VNe(e){let t=e&&e.summary?e.summary:{},n=Number(t.total_input_tokens||0),r=Number(t.total_output_tokens||0);return(Number.isFinite(n)?n:0)+(Number.isFinite(r)?r:0)}function b4(e,t,n){let r=e=>{let t=Number(e||0);return Number.isFinite(t)&&t>0?t:0},i=e||{},a=r(i.uncached_input_tokens),o=r(i.cached_input_tokens),s=r(i.cache_write_input_tokens),c=t&&t.summary?t.summary:{},l=n?r(c.total_input_tokens):0;return[{key:`uncached`,label:mc(),tokens:a+s,colorVar:`--cache-meter-uncached`,note:s>0?_c({count:DZ(s)}):``},{key:`prompt`,label:bc(),tokens:o,colorVar:`--cache-meter-prompt`,note:Cc()},{key:`local`,label:Ec(),tokens:l,colorVar:`--cache-meter-local`,note:kc()}]}function HNe(e,t,n){return b4(e,t,n).reduce((e,t)=>e+t.tokens,0)}function UNe(e,t,n){return HNe(e,t,n)>0}function x4(e,t,n){let r=b4(e,t,n),i=r.reduce((e,t)=>e+t.tokens,0);if(i<=0)return r.map(e=>Object.assign({},e,{pct:0}));let a=r.map(e=>{let t=e.tokens/i*100,n=Math.floor(t);return Object.assign({},e,{pct:n,remainder:t-n})}),o=100-a.reduce((e,t)=>e+t.pct,0);return a.map((e,t)=>({index:t,remainder:e.remainder,tokens:e.tokens})).filter(e=>e.tokens>0).sort((e,t)=>t.remainder-e.remainder).forEach(e=>{o>0&&(a[e.index].pct+=1,--o)}),a}function WNe(e,t,n){return x4(e,t,n).filter(e=>e.tokens>0)}function S4(e){let t=[$l({label:e.label,tokens:DZ(e.tokens),percent:e.pct})];return e.note&&t.push(e.note),t.join(` -`)}function GNe(e){let t=(e||[]).map(e=>e.label+` `+e.pct+`%`);return Mc({details:t.length?t.join(`, `):nu()})}function KNe(e){return e.getUTCFullYear()+`-`+String(e.getUTCMonth()+1).padStart(2,`0`)+`-`+String(e.getUTCDate()).padStart(2,`0`)}function C4(e,t,n,r){if(t!==`daily`||!n||!r)return e;let i={};(e||[]).forEach(e=>{i[e.date]=e});let a=[];for(let e=new Date(n);e<=r;e.setUTCDate(e.getUTCDate()+1)){let t=KNe(e);a.push(i[t]||{date:t,input_tokens:0,output_tokens:0,total_tokens:0,requests:0,input_cost:null,output_cost:null,total_cost:null})}return a}function qNe(e,t){let n=e=>Number(e)||0,r=e.map(e=>e.date),i=e.map(e=>n(e.uncached_input_tokens)+n(e.cache_write_input_tokens)+n(e.cached_input_tokens)>0?n(e.uncached_input_tokens)+n(e.cache_write_input_tokens):n(e.input_tokens)),a=e.map(e=>n(e.output_tokens)),o=e.map(e=>n(e.cached_input_tokens)),s={};return(t||[]).forEach(e=>{s[e.date]=e}),{labels:r,inputPaid:i,output:a,prompt:o,local:r.map(e=>{let t=s[e];return t?n(t.input_tokens)+n(t.output_tokens):0})}}function w4(e){let t=e||{},n=Math.max(0,Number(t.uncached_input_tokens)||0),r=Math.max(0,Number(t.cached_input_tokens)||0),i=Math.max(0,Number(t.cache_write_input_tokens)||0),a=n+r+i;return a>0?r/a*100:0}function JNe(e){let t=e||{};return(Number(t.uncached_input_tokens)||0)+(Number(t.cached_input_tokens)||0)+(Number(t.cache_write_input_tokens)||0)>0}function T4(e){return JNe(e)?Math.round(w4(e))+`%`:`—`}function YNe(e,t,n={}){let r=!!n.cacheEnabled,i=n.resolve||(e=>e),a=(e,t)=>i(`color-mix(in srgb, `+e+` `+t+`%, transparent)`),o=(e,t,n,r)=>Object.assign({label:e,data:t,borderColor:n,backgroundColor:n,fill:!1,tension:.3,borderWidth:2,pointRadius:0,pointHoverRadius:4},r||{}),s=[o(Jo(),t.inputPaid,i(`var(--token-input)`),{fill:`origin`}),o(Yo(),t.output,i(`var(--token-output)`),{fill:`-1`}),o(Vl(),t.prompt,i(`var(--token-prompt)`),{fill:`-1`,borderDash:[6,4]})];return r&&s.push(o(Wl(),t.local,a(`var(--info)`,35),{fill:`-1`,borderDash:[2,3]})),{type:`line`,data:{labels:t.labels,datasets:s},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:0},interaction:{mode:`index`,intersect:!1},plugins:{legend:{labels:{color:e.text,font:{size:12}}},tooltip:r4(e,{label:e=>e.dataset.label+`: `+DZ(e.parsed.y),footer:e=>{let t=0;return e.forEach(e=>{t+=Number(e.parsed.y)||0}),ql({total:DZ(t)})}})},scales:{x:{stacked:!0,grid:{color:e.grid},border:{display:!1},ticks:{color:e.text,font:n4(),maxRotation:0,autoSkip:!0,maxTicksLimit:10}},y:{stacked:!0,beginAtZero:!0,grid:{color:e.grid},border:{display:!1},ticks:u4(e)}}}}}function XNe(e,t,n){let r=Math.max(0,Math.min(100,e));return{type:`doughnut`,data:{datasets:[{data:[r,100-r],backgroundColor:[t,n],borderWidth:0,spacing:0}]},options:{rotation:-90,circumference:180,cutout:`84%`,responsive:!0,maintainAspectRatio:!1,animation:{duration:0},layout:{padding:1},events:[],plugins:{legend:{display:!1},tooltip:{enabled:!1}}}}}var E4=`gomodel_provider_status_details_expanded`,D4=`gomodel_provider_card_expanded_overrides`,ZNe=3e3,QNe=`https://gomodel.enterpilot.io/docs/providers/`,$Ne={anthropic:`anthropic`,azure:`azure`,bailian:`bailian`,bedrock:`bedrock`,"bedrock-mantle":`bedrock-mantle`,cohere:`cohere`,deepseek:`deepseek`,elevenlabs:`elevenlabs`,gemini:`gemini`,llmd:`llmd`,opencode_go:`opencode-go`,oracle:`oracle`,sglang:`sglang`,vertex:`vertex`,vllm:`vllm`,xiaomi:`xiaomi`};function O4(){return{summary:{total:0,healthy:0,degraded:0,unhealthy:0,overall_status:`degraded`},providers:[]}}function ePe(e){let t={detailsExpanded:!1,cardOverrides:{}};try{if(e){let n=e.getItem(E4);n===`true`||n===`false`?t.detailsExpanded=n===`true`:e.setItem(E4,`false`);let r=JSON.parse(e.getItem(D4)||`{}`);r&&typeof r==`object`&&!Array.isArray(r)&&(t.cardOverrides=r)}}catch{}return t}function tPe(e,t){if(e)try{e.setItem(E4,t?`true`:`false`)}catch{}}function k4(e,t){if(e)try{e.setItem(D4,JSON.stringify(t))}catch{}}function nPe(e,t,n){let r=n&&n.name?String(n.name):``;return r&&Object.prototype.hasOwnProperty.call(e,r)?e[r]===!0:t}function rPe(e){return`is-`+(String(e&&e.overall_status||`degraded`).trim()||`degraded`)}function iPe(e){return`is-`+(String(e||`degraded`).trim()||`degraded`)}function aPe(e){let t=e||{};return String(t.healthy||0)+`/`+String(t.total||0)}function oPe(e){let t=e||{},n=Number(t.total||0),r=Number(t.healthy||0);return n>0&&rString(e&&e.status_label||``).trim().toLowerCase()===`starting`)}function A4(e){if(!e||!e.runtime)return``;let t=e.runtime.last_model_fetch_at||``,n=e.runtime.last_availability_check_at||``;return t?n&&Date.parse(n)>Date.parse(t)?n:t:n}function lPe(e,t){let n=A4(e);if(!n||typeof t!=`function`)return`-`;let r=t(n);if(!r||r===`-`)return`-`;let i=String(r).split(` `);return i.length>1?i.slice(1).join(` `):r}function uPe(e,t){let n=A4(e);return n?typeof t==`function`?t(n):String(n):``}function j4(e){if(!e)return``;let t=String(e.name||``).trim(),n=String(e.type||e.config&&e.config.type||``).trim();return!n||n===t?``:n}function M4(e){let t=String(e&&(e.type||e.config&&e.config.type)||``).trim().toLowerCase(),n=t?$Ne[t]:``;return n?QNe+n+`?utm_source=gomodel_dashboard`:``}function dPe(e){let t=e&&e.config&&e.config.resilience?e.config.resilience.retry:null;return t?String(t.max_retries)+` retries, `+t.initial_backoff+` initial, `+t.max_backoff+` max, factor `+t.backoff_factor+`, jitter `+t.jitter_factor:`-`}function fPe(e){let t=e&&e.config&&e.config.resilience?e.config.resilience.circuit_breaker:null;return t?String(t.failure_threshold)+` fail, `+String(t.success_threshold)+` success, `+t.timeout+` timeout`:`-`}function pPe(e){let t=e&&e.config&&Array.isArray(e.config.models)?e.config.models.filter(Boolean):[];return t.length===0?$s():t.join(`, `)}function mPe(e){if(!e)return``;let t=[];return e.status_reason&&t.push(String(e.status_reason)),e.last_error&&t.push(nc({error:String(e.last_error)})),t.join(` +`)}function GNe(e){let t=(e||[]).map(e=>e.label+` `+e.pct+`%`);return Mc({details:t.length?t.join(`, `):nu()})}function KNe(e){return e.getUTCFullYear()+`-`+String(e.getUTCMonth()+1).padStart(2,`0`)+`-`+String(e.getUTCDate()).padStart(2,`0`)}function C4(e,t,n,r){if(t!==`daily`||!n||!r)return e;let i={};(e||[]).forEach(e=>{i[e.date]=e});let a=[];for(let e=new Date(n);e<=r;e.setUTCDate(e.getUTCDate()+1)){let t=KNe(e);a.push(i[t]||{date:t,input_tokens:0,output_tokens:0,total_tokens:0,requests:0,input_cost:null,output_cost:null,total_cost:null})}return a}function qNe(e,t){let n=e=>Number(e)||0,r=e.map(e=>e.date),i=e.map(e=>n(e.uncached_input_tokens)+n(e.cache_write_input_tokens)+n(e.cached_input_tokens)>0?n(e.uncached_input_tokens)+n(e.cache_write_input_tokens):n(e.input_tokens)),a=e.map(e=>n(e.output_tokens)),o=e.map(e=>n(e.cached_input_tokens)),s={};return(t||[]).forEach(e=>{s[e.date]=e}),{labels:r,inputPaid:i,output:a,prompt:o,local:r.map(e=>{let t=s[e];return t?n(t.input_tokens)+n(t.output_tokens):0})}}function w4(e){let t=e||{},n=Math.max(0,Number(t.uncached_input_tokens)||0),r=Math.max(0,Number(t.cached_input_tokens)||0),i=Math.max(0,Number(t.cache_write_input_tokens)||0),a=n+r+i;return a>0?r/a*100:0}function JNe(e){let t=e||{};return(Number(t.uncached_input_tokens)||0)+(Number(t.cached_input_tokens)||0)+(Number(t.cache_write_input_tokens)||0)>0}function T4(e){return JNe(e)?Math.round(w4(e))+`%`:`—`}function YNe(e,t,n={}){let r=!!n.cacheEnabled,i=n.resolve||(e=>e),a=(e,t)=>i(`color-mix(in srgb, `+e+` `+t+`%, transparent)`),o=(e,t,n,r)=>Object.assign({label:e,data:t,borderColor:n,backgroundColor:n,fill:!1,tension:.3,borderWidth:2,pointRadius:0,pointHoverRadius:4},r||{}),s=[o(Jo(),t.inputPaid,i(`var(--token-input)`),{fill:`origin`}),o(Yo(),t.output,i(`var(--token-output)`),{fill:`-1`}),o(Vl(),t.prompt,i(`var(--token-prompt)`),{fill:`-1`,borderDash:[6,4]})];return r&&s.push(o(Wl(),t.local,a(`var(--info)`,35),{fill:`-1`,borderDash:[2,3]})),{type:`line`,data:{labels:t.labels,datasets:s},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:0},interaction:{mode:`index`,intersect:!1},plugins:{legend:{labels:{color:e.text,font:{size:12}}},tooltip:r4(e,{label:e=>e.dataset.label+`: `+DZ(e.parsed.y),footer:e=>{let t=0;return e.forEach(e=>{t+=Number(e.parsed.y)||0}),ql({total:DZ(t)})}})},scales:{x:{stacked:!0,grid:{color:e.grid},border:{display:!1},ticks:{color:e.text,font:n4(),maxRotation:0,autoSkip:!0,maxTicksLimit:10}},y:{stacked:!0,beginAtZero:!0,grid:{color:e.grid},border:{display:!1},ticks:u4(e)}}}}}function XNe(e,t,n){let r=Math.max(0,Math.min(100,e));return{type:`doughnut`,data:{datasets:[{data:[r,100-r],backgroundColor:[t,n],borderWidth:0,spacing:0}]},options:{rotation:-90,circumference:180,cutout:`84%`,responsive:!0,maintainAspectRatio:!1,animation:{duration:0},layout:{padding:1},events:[],plugins:{legend:{display:!1},tooltip:{enabled:!1}}}}}var E4=`gomodel_provider_status_details_expanded`,D4=`gomodel_provider_card_expanded_overrides`,ZNe=3e3,QNe=`https://gomodel.enterpilot.io/docs/providers/`,$Ne={anthropic:`anthropic`,azure:`azure`,bailian:`bailian`,bedrock:`bedrock`,"bedrock-mantle":`bedrock-mantle`,cohere:`cohere`,deepseek:`deepseek`,elevenlabs:`elevenlabs`,gemini:`gemini`,llamacpp:`llamacpp`,llmd:`llmd`,opencode_go:`opencode-go`,oracle:`oracle`,sglang:`sglang`,vertex:`vertex`,vllm:`vllm`,xiaomi:`xiaomi`};function O4(){return{summary:{total:0,healthy:0,degraded:0,unhealthy:0,overall_status:`degraded`},providers:[]}}function ePe(e){let t={detailsExpanded:!1,cardOverrides:{}};try{if(e){let n=e.getItem(E4);n===`true`||n===`false`?t.detailsExpanded=n===`true`:e.setItem(E4,`false`);let r=JSON.parse(e.getItem(D4)||`{}`);r&&typeof r==`object`&&!Array.isArray(r)&&(t.cardOverrides=r)}}catch{}return t}function tPe(e,t){if(e)try{e.setItem(E4,t?`true`:`false`)}catch{}}function k4(e,t){if(e)try{e.setItem(D4,JSON.stringify(t))}catch{}}function nPe(e,t,n){let r=n&&n.name?String(n.name):``;return r&&Object.prototype.hasOwnProperty.call(e,r)?e[r]===!0:t}function rPe(e){return`is-`+(String(e&&e.overall_status||`degraded`).trim()||`degraded`)}function iPe(e){return`is-`+(String(e||`degraded`).trim()||`degraded`)}function aPe(e){let t=e||{};return String(t.healthy||0)+`/`+String(t.total||0)}function oPe(e){let t=e||{},n=Number(t.total||0),r=Number(t.healthy||0);return n>0&&rString(e&&e.status_label||``).trim().toLowerCase()===`starting`)}function A4(e){if(!e||!e.runtime)return``;let t=e.runtime.last_model_fetch_at||``,n=e.runtime.last_availability_check_at||``;return t?n&&Date.parse(n)>Date.parse(t)?n:t:n}function lPe(e,t){let n=A4(e);if(!n||typeof t!=`function`)return`-`;let r=t(n);if(!r||r===`-`)return`-`;let i=String(r).split(` `);return i.length>1?i.slice(1).join(` `):r}function uPe(e,t){let n=A4(e);return n?typeof t==`function`?t(n):String(n):``}function j4(e){if(!e)return``;let t=String(e.name||``).trim(),n=String(e.type||e.config&&e.config.type||``).trim();return!n||n===t?``:n}function M4(e){let t=String(e&&(e.type||e.config&&e.config.type)||``).trim().toLowerCase(),n=t?$Ne[t]:``;return n?QNe+n+`?utm_source=gomodel_dashboard`:``}function dPe(e){let t=e&&e.config&&e.config.resilience?e.config.resilience.retry:null;return t?String(t.max_retries)+` retries, `+t.initial_backoff+` initial, `+t.max_backoff+` max, factor `+t.backoff_factor+`, jitter `+t.jitter_factor:`-`}function fPe(e){let t=e&&e.config&&e.config.resilience?e.config.resilience.circuit_breaker:null;return t?String(t.failure_threshold)+` fail, `+String(t.success_threshold)+` success, `+t.timeout+` timeout`:`-`}function pPe(e){let t=e&&e.config&&Array.isArray(e.config.models)?e.config.models.filter(Boolean):[];return t.length===0?$s():t.join(`, `)}function mPe(e){if(!e)return``;let t=[];return e.status_reason&&t.push(String(e.status_reason)),e.last_error&&t.push(nc({error:String(e.last_error)})),t.join(` `)}function N4(e){let t=e&&e.request_health;return t&&typeof t==`object`?t:null}function P4(e){let t=N4(e);return t?String(t.circuit_state||``).trim():``}function hPe(e){let t=P4(e);return t?t.charAt(0).toUpperCase()+t.slice(1):``}function gPe(e){let t=P4(e);return t===`open`?`is-unhealthy`:t===`half-open`?`is-degraded`:`is-healthy`}function _Pe(e){let t=N4(e);if(!t)return``;let n=Number(t.requests||0),r=Number(t.errors||0),i=Math.round(Number(t.window_seconds||0)/60),a=i>0?`last `+i+` min`:`recent`;return String(n)+` request`+(n===1?``:`s`)+` · `+String(r)+` error`+(r===1?``:`s`)+` (`+a+`)`}function F4(e){let t=N4(e);return t&&Array.isArray(t.models)?t.models:[]}function vPe(e){return e?String(Number(e.errors||0))+`/`+String(Number(e.requests||0))+` failed`:``}function yPe(e){let t=e&&e.last_error;return!t||!t.message?``:(t.status_code?`HTTP `+String(t.status_code)+`: `:``)+t.message}function I4(){return{name:``,slug:``,url:``,transport:`http`,description:``,enabled:!0,headers:[],allowed_tools:``,disallowed_tools:``,user_paths:``,tool_timeout_seconds:``}}function L4(){return{server:``,status:``,instructions:``,tools:[],prompts:[],resources:[],templates:[]}}function R4(e){return String(e&&(e.slug||e.name)||``).trim()}function z4(e){return String(e&&e.status||``).trim()||`connecting`}function bPe(e){let t={connected:qwe,degraded:Xwe,connecting:$we,disabled:nTe},n=z4(e);return t[n]?.()||n}function B4(e){switch(z4(e)){case`connected`:return`status-success`;case`degraded`:return String(e&&e.last_error||``).trim()?`status-error`:`status-warning`;case`connecting`:return`status-neutral`;default:return`status-unknown`}}function xPe(e,t){let n=z4(e),r=String(e&&e.last_error||``).trim();return r&&n!==`connected`?r:n===`connected`&&e&&e.connected_at?xV({date:(typeof t==`function`?t:String)(e.connected_at)}):``}function V4(e){return String(e&&e.transport||``)===`stdio`?wV():String(e&&e.url||``).trim()||`—`}function SPe(e){return DV({prompts:Number(e&&e.prompt_count||0),resources:Number(e&&e.resource_count||0)})}function H4(e){let t=String(e||``).normalize(`NFKD`).toLowerCase(),n=t.replace(/[\u0300-\u036f]/g,``).replace(/[^a-z0-9]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,64).replace(/-+$/g,``);if(n)return n;let r=2166136261;for(let e of t)r=Math.imul((r^e.codePointAt(0))>>>0,16777619)>>>0;return`mcp-`+r.toString(16).padStart(8,`0`)}function CPe(e){return String(e||``).split(` `).map(e=>e.trim()).filter(e=>e)}function wPe(e){return!e||typeof e!=`object`||Array.isArray(e)?[]:Object.keys(e).sort().map(t=>({name:t,value:String(e[t]||``)}))}function TPe(e){let t={};return(Array.isArray(e)?e:[]).forEach(e=>{let n=String(e&&e.name||``).trim();n&&(t[n]=String(e&&e.value||``))}),t}function EPe(e,t){let n=Array.isArray(e)?e:[];if(!t)return n;let r=String(t).toLowerCase();return n.filter(e=>[e.name,e.slug,e.url,e.transport,e.description,e.status].some(e=>String(e||``).toLowerCase().includes(r)))}function DPe(e){return{name:String(e.name||``).trim(),slug:R4(e),url:String(e.url||``).trim(),transport:e.transport===`sse`?`sse`:`http`,description:String(e.description||``).trim(),enabled:e.enabled!==!1,headers:wPe(e.headers),allowed_tools:(Array.isArray(e.allowed_tools)?e.allowed_tools:[]).join(`, `),disallowed_tools:(Array.isArray(e.disallowed_tools)?e.disallowed_tools:[]).join(`, `),user_paths:(Array.isArray(e.user_paths)?e.user_paths:[]).join(` diff --git a/internal/admin/dashboard/static/dist/index.html b/internal/admin/dashboard/static/dist/index.html index 07e27d7df..aff61dce3 100644 --- a/internal/admin/dashboard/static/dist/index.html +++ b/internal/admin/dashboard/static/dist/index.html @@ -7,7 +7,7 @@ GoModel Dashboard - + diff --git a/internal/providers/llamacpp/llamacpp.go b/internal/providers/llamacpp/llamacpp.go new file mode 100644 index 000000000..b156e9b89 --- /dev/null +++ b/internal/providers/llamacpp/llamacpp.go @@ -0,0 +1,186 @@ +// Package llamacpp provides llama.cpp llama-server OpenAI-compatible API +// integration for the LLM gateway. The same provider type fits LM Studio and +// other plain OpenAI-compatible local servers. +package llamacpp + +import ( + "context" + "io" + "net/http" + "strings" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" + "github.com/enterpilot/gomodel/internal/providers/openai" +) + +// Registration provides factory registration for the llama.cpp provider. +// llama-server's default port (8080) collides with the gateway's own, so the +// base URL is required rather than defaulted; the API key is optional and only +// needed when llama-server was started with --api-key. +var Registration = providers.Registration{ + Type: "llamacpp", + New: New, + PassthroughSemanticEnricher: passthroughSemanticEnricher, + Discovery: providers.DiscoveryConfig{ + RequireBaseURL: true, + AllowAPIKeyless: true, + }, +} + +// Provider implements the core.Provider interface for llama.cpp. +type Provider struct { + compatible *openai.CompatibleProvider + rootClient *llmclient.Client +} + +var ( + _ core.Provider = (*Provider)(nil) + _ core.PassthroughProvider = (*Provider)(nil) +) + +// New creates a new llama.cpp provider. +func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { + baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + return &Provider{ + compatible: openai.NewCompatibleProvider(cfg.APIKey, opts, openai.CompatibleProviderConfig{ + ProviderName: "llamacpp", + BaseURL: baseURL, + SetHeaders: setHeaders, + }), + rootClient: llmclient.New(llmclient.Config{ + ProviderName: "llamacpp", + BaseURL: passthroughBaseURL(baseURL), + Retry: opts.Resilience.Retry, + Hooks: opts.Hooks, + CircuitBreaker: opts.Resilience.CircuitBreaker, + }, func(req *http.Request) { + setHeaders(req, cfg.APIKey) + }), + } +} + +// NewWithHTTPClient creates a new llama.cpp provider with a custom HTTP client. +// If httpClient is nil, http.DefaultClient is used. +func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, hooks llmclient.Hooks) *Provider { + resolvedBaseURL := strings.TrimRight(strings.TrimSpace(baseURL), "/") + rootClientCfg := llmclient.DefaultConfig("llamacpp", passthroughBaseURL(resolvedBaseURL)) + rootClientCfg.Hooks = hooks + return &Provider{ + compatible: openai.NewCompatibleProviderWithHTTPClient(apiKey, httpClient, hooks, openai.CompatibleProviderConfig{ + ProviderName: "llamacpp", + BaseURL: resolvedBaseURL, + SetHeaders: setHeaders, + }), + rootClient: llmclient.NewWithHTTPClient(httpClient, rootClientCfg, func(req *http.Request) { + setHeaders(req, apiKey) + }), + } +} + +// SetBaseURL allows configuring a custom base URL for the provider. +func (p *Provider) SetBaseURL(url string) { + p.compatible.SetBaseURL(url) + p.rootClient.SetBaseURL(passthroughBaseURL(url)) +} + +func setHeaders(req *http.Request, apiKey string) { + providers.SetAuthHeaders(req, apiKey, providers.AuthHeaderConfig{ + AuthScheme: "Bearer ", + RequestIDHeader: "X-Request-Id", + OptionalAPIKey: true, + }) +} + +// ChatCompletion sends a chat completion request to llama-server. +func (p *Provider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { + return p.compatible.ChatCompletion(ctx, req) +} + +// StreamChatCompletion returns a raw response body for streaming. +func (p *Provider) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { + return p.compatible.StreamChatCompletion(ctx, req) +} + +// ListModels retrieves the list of available models from llama-server. +func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) { + return p.compatible.ListModels(ctx) +} + +// Responses sends a Responses API request to llama-server. +func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + return p.compatible.Responses(ctx, req) +} + +// StreamResponses streams a Responses API request to llama-server. +func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { + return p.compatible.StreamResponses(ctx, req) +} + +// Embeddings sends an embeddings request to llama-server. The loaded model +// must use a pooling type other than none for llama-server to serve it. +func (p *Provider) Embeddings(ctx context.Context, req *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { + return p.compatible.Embeddings(ctx, req) +} + +// Passthrough routes an opaque provider-native request to llama-server. +// OpenAI-shaped endpoints go through the /v1 base; llama-server's native +// endpoints (/health, /props, /slots, /rerank, /infill, /tokenize, ...) live +// at the server root. +func (p *Provider) Passthrough(ctx context.Context, req *core.PassthroughRequest) (*core.PassthroughResponse, error) { + if req == nil { + return nil, core.NewInvalidRequestError("passthrough request is required", nil) + } + endpoint := providers.PassthroughEndpoint(req.Endpoint) + if !usesV1PassthroughBase(endpoint) { + resp, err := p.rootClient.DoPassthrough(ctx, llmclient.Request{ + Method: req.Method, + Endpoint: endpoint, + Operation: req.Operation, + Model: req.Model, + Stream: req.Stream, + StreamUncertain: req.StreamUncertain, + RawBodyReader: req.Body, + Headers: req.Headers, + }) + if err != nil { + return nil, err + } + return &core.PassthroughResponse{ + StatusCode: resp.StatusCode, + Headers: providers.CloneHTTPHeaders(resp.Header), + Body: resp.Body, + }, nil + } + return p.compatible.Passthrough(ctx, req) +} + +func passthroughBaseURL(baseURL string) string { + trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if before, ok := strings.CutSuffix(trimmed, "/v1"); ok { + return before + } + return trimmed +} + +func usesV1PassthroughBase(endpoint string) bool { + endpoint = providers.PassthroughEndpoint(endpoint) + if strings.HasPrefix(endpoint, "/v1/") { + return false + } + + v1Prefixes := []string{ + "/models", + "/chat/completions", + "/responses", + "/completions", + "/embeddings", + } + for _, prefix := range v1Prefixes { + if endpoint == prefix || strings.HasPrefix(endpoint, prefix+"/") { + return true + } + } + return false +} diff --git a/internal/providers/llamacpp/llamacpp_test.go b/internal/providers/llamacpp/llamacpp_test.go new file mode 100644 index 000000000..5f77a6daa --- /dev/null +++ b/internal/providers/llamacpp/llamacpp_test.go @@ -0,0 +1,167 @@ +package llamacpp + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" +) + +func TestChatCompletion_UsesOptionalBearerAuthAndChatEndpoint(t *testing.T) { + tests := []struct { + name string + apiKey string + wantAuth string + }{ + {name: "with api key", apiKey: "llamacpp-key", wantAuth: "Bearer llamacpp-key"}, + {name: "without api key"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotPath string + var gotAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-llamacpp", + "created":1677652288, + "model":"gemma-3-4b-it", + "choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient(tt.apiKey, server.URL+"/v1", server.Client(), llmclient.Hooks{}) + + resp, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "gemma-3-4b-it", + Messages: []core.Message{ + {Role: "user", Content: "hi"}, + }, + }) + if err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if resp.Model != "gemma-3-4b-it" { + t.Fatalf("resp.Model = %q, want gemma-3-4b-it", resp.Model) + } + if gotPath != "/v1/chat/completions" { + t.Fatalf("path = %q, want /v1/chat/completions", gotPath) + } + if gotAuth != tt.wantAuth { + t.Fatalf("authorization = %q, want %q", gotAuth, tt.wantAuth) + } + }) + } +} + +func TestEmbeddings_DelegatesToCompatibleProvider(t *testing.T) { + var gotPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "object":"list", + "model":"nomic-embed-text-v1.5", + "data":[{"object":"embedding","embedding":[0.1,0.2],"index":0}], + "usage":{"prompt_tokens":3,"total_tokens":3} + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("", server.URL+"/v1", server.Client(), llmclient.Hooks{}) + + resp, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{ + Model: "nomic-embed-text-v1.5", + Input: "hello", + }) + if err != nil { + t.Fatalf("Embeddings() error = %v", err) + } + if resp.Model != "nomic-embed-text-v1.5" { + t.Fatalf("resp.Model = %q, want nomic-embed-text-v1.5", resp.Model) + } + if gotPath != "/v1/embeddings" { + t.Fatalf("path = %q, want /v1/embeddings", gotPath) + } +} + +func TestProvider_ExposesPassthroughButNotOptionalNativeInterfaces(t *testing.T) { + provider := NewWithHTTPClient("", "", nil, llmclient.Hooks{}) + + if _, ok := any(provider).(core.PassthroughProvider); !ok { + t.Fatal("llamacpp provider should implement passthrough provider") + } + if _, ok := any(provider).(core.NativeBatchProvider); ok { + t.Fatal("llamacpp provider should not implement native batch provider") + } + if _, ok := any(provider).(core.NativeFileProvider); ok { + t.Fatal("llamacpp provider should not implement native file provider") + } + if _, ok := any(provider).(core.NativeResponseLifecycleProvider); ok { + t.Fatal("llamacpp provider should not implement native response lifecycle provider") + } + if _, ok := any(provider).(core.AudioProvider); ok { + t.Fatal("llamacpp provider should not implement audio provider") + } +} + +func TestPassthrough_RoutesNativeEndpointsToServerRoot(t *testing.T) { + tests := []struct { + name string + endpoint string + wantPath string + }{ + {name: "rerank", endpoint: "rerank", wantPath: "/rerank"}, + {name: "health", endpoint: "health", wantPath: "/health"}, + {name: "tokenize", endpoint: "tokenize", wantPath: "/tokenize"}, + {name: "explicit v1 rerank", endpoint: "v1/rerank", wantPath: "/v1/rerank"}, + {name: "chat completions", endpoint: "chat/completions", wantPath: "/v1/chat/completions"}, + {name: "embeddings", endpoint: "embeddings", wantPath: "/v1/embeddings"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotPath string + var gotAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("llamacpp-key", server.URL+"/v1", server.Client(), llmclient.Hooks{}) + + resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ + Method: http.MethodPost, + Endpoint: tt.endpoint, + Body: io.NopCloser(strings.NewReader("{}")), + Headers: http.Header{"Content-Type": []string{"application/json"}}, + }) + if err != nil { + t.Fatalf("Passthrough() error = %v", err) + } + defer resp.Body.Close() + + if gotPath != tt.wantPath { + t.Fatalf("path = %q, want %q", gotPath, tt.wantPath) + } + if gotAuth != "Bearer llamacpp-key" { + t.Fatalf("authorization = %q, want Bearer llamacpp-key", gotAuth) + } + }) + } +} diff --git a/internal/providers/llamacpp/passthrough_semantics.go b/internal/providers/llamacpp/passthrough_semantics.go new file mode 100644 index 000000000..0c430e5e5 --- /dev/null +++ b/internal/providers/llamacpp/passthrough_semantics.go @@ -0,0 +1,10 @@ +package llamacpp + +import "github.com/enterpilot/gomodel/internal/providers" + +var passthroughSemanticEnricher = providers.NewSemanticEnricher("llamacpp", map[string]providers.PassthroughEndpointSemantics{ + "/chat/completions": {Operation: "llamacpp.chat_completions", GenAIOperation: "chat", AuditPath: "/v1/chat/completions"}, + "/responses": {Operation: "llamacpp.responses", GenAIOperation: "chat", AuditPath: "/v1/responses"}, + "/embeddings": {Operation: "llamacpp.embeddings", GenAIOperation: "embeddings", AuditPath: "/v1/embeddings"}, + "/completions": {Operation: "llamacpp.completions", GenAIOperation: "text_completion", AuditPath: "/v1/completions"}, +}) diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 4da44adb0..aeef05124 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -7212,7 +7212,7 @@ func TestProviderPassthrough_RejectsUnsupportedProvider(t *testing.T) { if !strings.Contains(rec.Body.String(), `provider passthrough for \"groq\" is not enabled`) { t.Fatalf("unexpected error body: %s", rec.Body.String()) } - if !strings.Contains(rec.Body.String(), "anthropic, deepseek, kilo, llmd, openai, openrouter, sglang, vllm, zai") { + if !strings.Contains(rec.Body.String(), "anthropic, deepseek, kilo, llamacpp, llmd, openai, openrouter, sglang, vllm, zai") { t.Fatalf("unexpected error body: %s", rec.Body.String()) } } diff --git a/internal/server/passthrough_support.go b/internal/server/passthrough_support.go index 21a87c4d4..4e7af290d 100644 --- a/internal/server/passthrough_support.go +++ b/internal/server/passthrough_support.go @@ -16,7 +16,7 @@ import ( "github.com/enterpilot/gomodel/internal/usage" ) -var defaultEnabledPassthroughProviders = []string{"openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"} +var defaultEnabledPassthroughProviders = []string{"openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "llamacpp", "llmd", "deepseek"} const llmdDroppedReasonHeader = "X-Llm-D-Request-Dropped-Reason" diff --git a/run/providers.go b/run/providers.go index 8df1ff882..b1cad50dd 100644 --- a/run/providers.go +++ b/run/providers.go @@ -18,6 +18,7 @@ import ( "github.com/enterpilot/gomodel/internal/providers/groq" "github.com/enterpilot/gomodel/internal/providers/kilo" "github.com/enterpilot/gomodel/internal/providers/kimicode" + "github.com/enterpilot/gomodel/internal/providers/llamacpp" "github.com/enterpilot/gomodel/internal/providers/llmd" "github.com/enterpilot/gomodel/internal/providers/meta" "github.com/enterpilot/gomodel/internal/providers/minimax" @@ -61,6 +62,7 @@ func defaultProviderFactory(cfg *config.Config) *providers.ProviderFactory { factory.Add(groq.Registration) factory.Add(kilo.Registration) factory.Add(kimicode.Registration) + factory.Add(llamacpp.Registration) factory.Add(llmd.Registration) factory.Add(meta.Registration) factory.Add(minimax.Registration) diff --git a/run/providers_test.go b/run/providers_test.go index 8522ed741..112069f82 100644 --- a/run/providers_test.go +++ b/run/providers_test.go @@ -176,7 +176,7 @@ var credentialPayloadFields = []string{ func TestDefaultProviderFactoryRegistersAllProviderTypes(t *testing.T) { expected := []string{ "anthropic", "azure", "bailian", "bedrock", "bedrock-mantle", "chutes", "cohere", "deepseek", "elevenlabs", - "fireworks", "gemini", "groq", "kilo", "kimicode", "llmd", "meta", "minimax", "ollama", "openai", "opencode_go", + "fireworks", "gemini", "groq", "kilo", "kimicode", "llamacpp", "llmd", "meta", "minimax", "ollama", "openai", "opencode_go", "openrouter", "oracle", "sglang", "vertex", "vllm", "xai", "xiaomi", "zai", } diff --git a/web/dashboard/src/pages/overview/providersLogic.js b/web/dashboard/src/pages/overview/providersLogic.js index 2396081c5..effd45bca 100644 --- a/web/dashboard/src/pages/overview/providersLogic.js +++ b/web/dashboard/src/pages/overview/providersLogic.js @@ -25,6 +25,7 @@ const PROVIDER_DOC_SLUGS = { deepseek: "deepseek", elevenlabs: "elevenlabs", gemini: "gemini", + llamacpp: "llamacpp", llmd: "llmd", opencode_go: "opencode-go", oracle: "oracle", From 98933f199210ca8a6f56901d54c8665fb871b1f8 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 17 Aug 2026 18:57:17 +0200 Subject: [PATCH 2/5] fix(llamacpp): rotate keys on native passthrough and classify endpoints by path The root client for llama-server's native endpoints captured the static primary key, so multi-key setups never rotated on /health, /rerank, etc., unlike the OpenAI-compatible surface; it now resolves the shared keyring per request. Endpoint classification also compared the full endpoint including the query string the server appends, sending e.g. chat/completions?stream=true to the server root; the path is now cut at the query before matching. Co-Authored-By: Claude Fable 5 --- internal/providers/llamacpp/llamacpp.go | 10 ++++- internal/providers/llamacpp/llamacpp_test.go | 43 ++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/internal/providers/llamacpp/llamacpp.go b/internal/providers/llamacpp/llamacpp.go index b156e9b89..256aadcdc 100644 --- a/internal/providers/llamacpp/llamacpp.go +++ b/internal/providers/llamacpp/llamacpp.go @@ -43,6 +43,11 @@ var ( // New creates a new llama.cpp provider. func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + // One keyring shared by both clients, resolved per request rather than + // captured, so native root endpoints rotate across configured keys exactly + // like the OpenAI-compatible surface. + keys := opts.Keyring(cfg.APIKey) + opts.Keys = keys return &Provider{ compatible: openai.NewCompatibleProvider(cfg.APIKey, opts, openai.CompatibleProviderConfig{ ProviderName: "llamacpp", @@ -56,7 +61,7 @@ func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Prov Hooks: opts.Hooks, CircuitBreaker: opts.Resilience.CircuitBreaker, }, func(req *http.Request) { - setHeaders(req, cfg.APIKey) + setHeaders(req, keys.NextForContext(req.Context())) }), } } @@ -166,6 +171,9 @@ func passthroughBaseURL(baseURL string) string { func usesV1PassthroughBase(endpoint string) bool { endpoint = providers.PassthroughEndpoint(endpoint) + // Classify by path only; the server appends the request's query string to + // the endpoint before it reaches the provider. + endpoint, _, _ = strings.Cut(endpoint, "?") if strings.HasPrefix(endpoint, "/v1/") { return false } diff --git a/internal/providers/llamacpp/llamacpp_test.go b/internal/providers/llamacpp/llamacpp_test.go index 5f77a6daa..dd4ab1d8e 100644 --- a/internal/providers/llamacpp/llamacpp_test.go +++ b/internal/providers/llamacpp/llamacpp_test.go @@ -10,6 +10,7 @@ import ( "github.com/enterpilot/gomodel/internal/core" "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" ) func TestChatCompletion_UsesOptionalBearerAuthAndChatEndpoint(t *testing.T) { @@ -128,6 +129,8 @@ func TestPassthrough_RoutesNativeEndpointsToServerRoot(t *testing.T) { {name: "explicit v1 rerank", endpoint: "v1/rerank", wantPath: "/v1/rerank"}, {name: "chat completions", endpoint: "chat/completions", wantPath: "/v1/chat/completions"}, {name: "embeddings", endpoint: "embeddings", wantPath: "/v1/embeddings"}, + {name: "chat completions with query", endpoint: "chat/completions?stream=true", wantPath: "/v1/chat/completions"}, + {name: "native endpoint with query", endpoint: "health?include_slots=true", wantPath: "/health"}, } for _, tt := range tests { @@ -165,3 +168,43 @@ func TestPassthrough_RoutesNativeEndpointsToServerRoot(t *testing.T) { }) } } + +func TestPassthrough_NativeEndpointRotatesConfiguredKeys(t *testing.T) { + var gotAuths []string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuths = append(gotAuths, r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + provider := New(providers.ProviderConfig{ + APIKey: "key-1", + APIKeys: []string{"key-1", "key-2"}, + BaseURL: server.URL + "/v1", + }, providers.ProviderOptions{ + Keys: providers.NewKeyringWithSessionStickiness(false, "key-1", "key-2"), + }).(*Provider) + + for range 2 { + resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ + Method: http.MethodGet, + Endpoint: "health", + Headers: http.Header{}, + }) + if err != nil { + t.Fatalf("Passthrough() error = %v", err) + } + resp.Body.Close() + } + + if len(gotAuths) != 2 || gotAuths[0] == gotAuths[1] { + t.Fatalf("native passthrough should rotate keys per request, got %v", gotAuths) + } + for _, auth := range gotAuths { + if auth != "Bearer key-1" && auth != "Bearer key-2" { + t.Fatalf("unexpected authorization %q", auth) + } + } +} From d04fa25b2e9b9edd906a96a16d11ec20c3916be0 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 17 Aug 2026 20:04:04 +0200 Subject: [PATCH 3/5] test(llamacpp): cover query relay, error relay, and default construction Co-Authored-By: Claude Fable 5 --- internal/providers/llamacpp/llamacpp_test.go | 74 ++++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/internal/providers/llamacpp/llamacpp_test.go b/internal/providers/llamacpp/llamacpp_test.go index dd4ab1d8e..abd2d2c85 100644 --- a/internal/providers/llamacpp/llamacpp_test.go +++ b/internal/providers/llamacpp/llamacpp_test.go @@ -119,9 +119,10 @@ func TestProvider_ExposesPassthroughButNotOptionalNativeInterfaces(t *testing.T) func TestPassthrough_RoutesNativeEndpointsToServerRoot(t *testing.T) { tests := []struct { - name string - endpoint string - wantPath string + name string + endpoint string + wantPath string + wantQuery string }{ {name: "rerank", endpoint: "rerank", wantPath: "/rerank"}, {name: "health", endpoint: "health", wantPath: "/health"}, @@ -129,17 +130,19 @@ func TestPassthrough_RoutesNativeEndpointsToServerRoot(t *testing.T) { {name: "explicit v1 rerank", endpoint: "v1/rerank", wantPath: "/v1/rerank"}, {name: "chat completions", endpoint: "chat/completions", wantPath: "/v1/chat/completions"}, {name: "embeddings", endpoint: "embeddings", wantPath: "/v1/embeddings"}, - {name: "chat completions with query", endpoint: "chat/completions?stream=true", wantPath: "/v1/chat/completions"}, - {name: "native endpoint with query", endpoint: "health?include_slots=true", wantPath: "/health"}, + {name: "chat completions with query", endpoint: "chat/completions?stream=true", wantPath: "/v1/chat/completions", wantQuery: "stream=true"}, + {name: "native endpoint with query", endpoint: "health?include_slots=true", wantPath: "/health", wantQuery: "include_slots=true"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var gotPath string + var gotQuery string var gotAuth string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path + gotQuery = r.URL.RawQuery gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok":true}`)) @@ -162,6 +165,9 @@ func TestPassthrough_RoutesNativeEndpointsToServerRoot(t *testing.T) { if gotPath != tt.wantPath { t.Fatalf("path = %q, want %q", gotPath, tt.wantPath) } + if gotQuery != tt.wantQuery { + t.Fatalf("query = %q, want %q", gotQuery, tt.wantQuery) + } if gotAuth != "Bearer llamacpp-key" { t.Fatalf("authorization = %q, want Bearer llamacpp-key", gotAuth) } @@ -208,3 +214,61 @@ func TestPassthrough_NativeEndpointRotatesConfiguredKeys(t *testing.T) { } } } + +func TestNew_DefaultOptionsAuthenticatesAndRelaysNativeErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer llamacpp-key" { + t.Errorf("authorization = %q, want Bearer llamacpp-key", r.Header.Get("Authorization")) + } + switch r.URL.Path { + case "/health": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + case "/slots": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotImplemented) + _, _ = w.Write([]byte(`{"error":{"message":"slots endpoint is disabled"}}`)) + default: + t.Errorf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + + provider := New(providers.ProviderConfig{ + APIKey: "llamacpp-key", + BaseURL: server.URL + "/v1", + }, providers.ProviderOptions{}).(*Provider) + + resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ + Method: http.MethodGet, + Endpoint: "health", + Headers: http.Header{}, + }) + if err != nil { + t.Fatalf("Passthrough(health) error = %v", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"ok"`) { + t.Fatalf("health status = %d body = %s, want 200 with ok", resp.StatusCode, body) + } + + // Provider-native errors relay status and body verbatim instead of being + // converted into gateway errors. + resp, err = provider.Passthrough(context.Background(), &core.PassthroughRequest{ + Method: http.MethodGet, + Endpoint: "slots", + Headers: http.Header{}, + }) + if err != nil { + t.Fatalf("Passthrough(slots) error = %v", err) + } + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusNotImplemented { + t.Fatalf("slots status = %d, want 501", resp.StatusCode) + } + if !strings.Contains(string(body), "slots endpoint is disabled") { + t.Fatalf("slots body = %s, want relayed upstream error", body) + } +} From 9b3ce443e5a5cc2c3dd54d58eb011b86926cb145 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 17 Aug 2026 20:37:30 +0200 Subject: [PATCH 4/5] chore: retrigger CI after GitHub outage Co-Authored-By: Claude Fable 5 From e80adf6e4a9a0a5533930ca99e6c4f3ba10cb03a Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 17 Aug 2026 20:42:38 +0200 Subject: [PATCH 5/5] chore: retrigger CodeQL after GitHub outage Co-Authored-By: Claude Fable 5