From f8f6b984ce5e8c80a77ead55d6ce5607a1b969e9 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 8 Aug 2026 18:42:38 +0200 Subject: [PATCH 1/2] feat(sglang): add SGLang provider --- CLAUDE.md | 4 +- README.md | 2 +- config/config.example.yaml | 8 +- config/config.go | 1 + config/config_test.go | 5 +- config/server.go | 2 +- docs/advanced/config-yaml.mdx | 2 +- docs/advanced/configuration.mdx | 14 +- docs/docs.json | 1 + docs/features/passthrough-api.mdx | 4 +- docs/guides/production.mdx | 5 +- docs/providers/overview.mdx | 5 +- docs/providers/sglang.mdx | 125 ++++++++ helm/README.md | 17 +- helm/values.schema.json | 49 ++++ helm/values.yaml | 10 +- .../{index-DbvfJbHk.js => index-CiIXCM_n.js} | 2 +- .../admin/dashboard/static/dist/index.html | 2 +- internal/providers/config_test.go | 39 +++ .../providers/sglang/passthrough_semantics.go | 10 + internal/providers/sglang/sglang.go | 181 ++++++++++++ internal/providers/sglang/sglang_test.go | 277 ++++++++++++++++++ internal/server/handlers_test.go | 2 +- internal/server/passthrough_support.go | 2 +- internal/usage/cost.go | 2 +- internal/usage/cost_test.go | 4 +- run/providers.go | 2 + run/providers_test.go | 8 +- .../src/pages/overview/providersLogic.js | 1 + .../tests/overview-providers.test.js | 4 + 30 files changed, 765 insertions(+), 25 deletions(-) create mode 100644 docs/providers/sglang.mdx rename internal/admin/dashboard/static/dist/assets/{index-DbvfJbHk.js => index-CiIXCM_n.js} (99%) create mode 100644 internal/providers/sglang/passthrough_semantics.go create mode 100644 internal/providers/sglang/sglang.go create mode 100644 internal/providers/sglang/sglang_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 0901a1cb9..c77815192 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,7 +136,7 @@ Full reference: `.env.template` and `config/config.yaml` - **Resilience:** Configured via `config/config.yaml` - global `resilience.retry.*` and `resilience.circuit_breaker.*` defaults with optional per-provider overrides under `providers..resilience.retry.*` and `providers..resilience.circuit_breaker.*`. Retry defaults: `max_retries` (3), `initial_backoff` (1s), `max_backoff` (30s), `backoff_factor` (2.0), `jitter_factor` (0.1). Circuit breaker defaults: `failure_threshold` (5), `success_threshold` (2), `timeout` (30s). Breaker state is per-process and exported as the `gomodel_circuit_breaker_state` gauge when metrics are enabled. The dashboard's provider status also folds in real-traffic request health: each provider's runtime row carries `request_health` (last observed breaker state plus per-model request/error counts over a 10-minute sliding window; a model with ≥3 errors and a ≥50% error rate is flagged). An open breaker turns the provider card's status pill to "Circuit Open" (unhealthy), a half-open breaker or a flagged model degrades an otherwise healthy provider ("Recovering"/"Degraded"), and the expanded card details list recent per-model traffic with the latest error. Signals only worsen the discovery-based status, never improve it; tracking is in-memory per instance, and providers with no recent requests show discovery-based status only. - **Metrics:** `METRICS_ENABLED` (false), `METRICS_ENDPOINT` (/metrics) - **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`. Keyless (Ollama, vLLM) and non-API-key providers (Vertex, Bedrock) are unaffected. +- **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`. Keyless (Ollama, SGLang, vLLM) and 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), `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`, `VLLM_BASE_URL`, `VLLM_API_KEY` (optional upstream vLLM bearer token) +- **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), `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) - **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/README.md b/README.md index 97b87325c..7d7d0dfc8 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ const client = new Anthropic({ GoModel supports OpenAI, Anthropic, Cohere, Google Gemini, Vertex AI, DeepSeek, Groq, Fireworks AI, Meta (Muse Spark), OpenRouter, Z.ai, xAI (Grok), Alibaba Cloud Model Studio (Bailian), Kilo AI, MiniMax, Xiaomi MiMo, OpenCode Go, Azure -OpenAI, Oracle, Ollama, vLLM, Amazon Bedrock Runtime, Amazon Bedrock Mantle, and +OpenAI, Oracle, Ollama, SGLang, vLLM, Amazon Bedrock Runtime, Amazon Bedrock Mantle, and all OpenAI-compatible providers. See the [Providers Overview](https://gomodel.enterpilot.io/docs/providers/overview?utm_source=readme) for the full diff --git a/config/config.example.yaml b/config/config.example.yaml index e9e804e87..18747c096 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -13,7 +13,7 @@ server: enable_passthrough_routes: true # expose /p/{provider}/{endpoint} passthrough routes allow_passthrough_v1_alias: true # allow /p/{provider}/v1/... while keeping /p/{provider}/... canonical user_path_header: "X-GoModel-User-Path" # env: USER_PATH_HEADER; inbound header used for user_path scoping - enabled_passthrough_providers: ["openai", "anthropic", "cohere", "openrouter", "kilo", "zai", "vllm", "deepseek", "bailian"] # providers enabled on /p/{provider}/... + enabled_passthrough_providers: ["openai", "anthropic", "cohere", "openrouter", "kilo", "zai", "sglang", "vllm", "deepseek", "bailian"] # providers enabled on /p/{provider}/... realtime_enabled: true # env: REALTIME_ENABLED; expose /v1/realtime websocket and /p/{provider}/v1/realtime upgrades (OpenAI only) pid_file: "data/gomodel.pid" # env: PID_FILE; where the running gateway records its process id so `gomodel --reload` can find it. Set per instance when several gateways share a host; empty writes no pid file and disables --reload; changing it needs a restart, not a reload @@ -418,6 +418,12 @@ providers: # Optional: set this only when vllm serve was started with --api-key. # api_key: "token-abc123" + sglang: + type: sglang + base_url: "http://localhost:30000/v1" + # Optional: set this only when launch_server uses --api-key. + # api_key: "token-abc123" + # Custom OpenAI-compatible provider # my-provider: # type: openai diff --git a/config/config.go b/config/config.go index 5738c8477..da100f6c8 100644 --- a/config/config.go +++ b/config/config.go @@ -100,6 +100,7 @@ func buildDefaultConfig() *Config { "openrouter", "kilo", "zai", + "sglang", "vllm", "deepseek", }, diff --git a/config/config_test.go b/config/config_test.go index 6dce267c4..6122cce3b 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -30,6 +30,7 @@ func clearProviderEnvVars(t *testing.T) { "AZURE_API_KEY", "AZURE_BASE_URL", "AZURE_API_VERSION", "AZURE_MODELS", "ORACLE_API_KEY", "ORACLE_BASE_URL", "ORACLE_MODELS", "VLLM_API_KEY", "VLLM_BASE_URL", "VLLM_MODELS", + "SGLANG_API_KEY", "SGLANG_BASE_URL", "SGLANG_MODELS", "OLLAMA_API_KEY", "OLLAMA_BASE_URL", "OLLAMA_MODELS", } { t.Setenv(key, "") @@ -124,7 +125,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", "vllm", "deepseek"}; !reflect.DeepEqual(got, want) { + if got, want := cfg.Server.EnabledPassthroughProviders, []string{"openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "deepseek"}; !reflect.DeepEqual(got, want) { t.Errorf("expected Server.EnabledPassthroughProviders=%v, got %v", want, got) } if cfg.Models.ConfiguredProviderModelsMode != ConfiguredProviderModelsModeFallback { @@ -1201,7 +1202,7 @@ func TestLoad_ConfigExample_UsesNestedModelCacheSettings(t *testing.T) { t.Fatalf("expected Cache.Model.Redis to be nil in example config, got %+v", result.Config.Cache.Model.Redis) } gotProviders := result.Config.Server.EnabledPassthroughProviders - wantProviders := []string{"openai", "anthropic", "cohere", "openrouter", "kilo", "zai", "vllm", "deepseek", "bailian"} + wantProviders := []string{"openai", "anthropic", "cohere", "openrouter", "kilo", "zai", "sglang", "vllm", "deepseek", "bailian"} if !reflect.DeepEqual(gotProviders, wantProviders) { t.Fatalf("Server.EnabledPassthroughProviders = %v, want %v", gotProviders, wantProviders) } diff --git a/config/server.go b/config/server.go index cabf5370c..69a97a05c 100644 --- a/config/server.go +++ b/config/server.go @@ -39,7 +39,7 @@ type ServerConfig struct { UserPathHeader string `yaml:"user_path_header" env:"USER_PATH_HEADER"` // EnabledPassthroughProviders lists the provider types enabled on // /p/{provider}/... passthrough routes. Default: - // ["openai", "anthropic", "openrouter", "kilo", "zai", "vllm", "deepseek"]. + // ["openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "deepseek"]. EnabledPassthroughProviders []string `yaml:"enabled_passthrough_providers" env:"ENABLED_PASSTHROUGH_PROVIDERS"` // RealtimeEnabled exposes the realtime (speech-to-speech) websocket endpoint // at /v1/realtime and the /p/{provider}/v1/realtime passthrough upgrade. diff --git a/docs/advanced/config-yaml.mdx b/docs/advanced/config-yaml.mdx index 9d77740b8..731e7dc97 100644 --- a/docs/advanced/config-yaml.mdx +++ b/docs/advanced/config-yaml.mdx @@ -34,7 +34,7 @@ must use the same suffix for each instance: for example, pair `gcp_adc`. Configured provider model lists can stay in env via `_MODELS`, for -example `OPENROUTER_MODELS`, `ORACLE_MODELS`, `AZURE_MODELS`, or `VLLM_MODELS`. +example `OPENROUTER_MODELS`, `ORACLE_MODELS`, `AZURE_MODELS`, `SGLANG_MODELS`, or `VLLM_MODELS`. Set `CONFIGURED_PROVIDER_MODELS_MODE=fallback` (default) to use those lists only when upstream `/models` fails or is empty, or `allowlist` to expose only the configured models for providers that define a list and skip their upstream diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 2eb2e0b83..9e1249b30 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -279,13 +279,14 @@ Set these to automatically register providers. No YAML configuration required. | `AZURE_API_KEY` | Azure OpenAI (`AZURE_BASE_URL` also required) | | `ORACLE_API_KEY` | Oracle GenAI (`ORACLE_BASE_URL` also 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) | -Most providers can use a custom base URL via `_BASE_URL` (for example `OPENAI_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. vLLM defaults to `http://localhost:8000/v1` when `VLLM_API_KEY` is set, but keyless deployments should set `VLLM_BASE_URL` explicitly to register the provider. 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`). 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`. 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`, -`AZURE_MODELS`, or `VLLM_MODELS`. By default, +`AZURE_MODELS`, `SGLANG_MODELS`, or `VLLM_MODELS`. By default, `CONFIGURED_PROVIDER_MODELS_MODE=fallback` uses configured lists only when upstream `/models` fails, returns nil, or returns an empty list. Set `CONFIGURED_PROVIDER_MODELS_MODE=allowlist` to expose only configured models for @@ -420,6 +421,8 @@ export OPENROUTER_MODELS="openai/gpt-oss-120b,anthropic/claude-sonnet-4" export KILO_MODELS="anthropic/claude-sonnet-4.5,openai/gpt-5.5" export CONFIGURED_PROVIDER_MODELS_MODE="fallback" # fallback or allowlist export OLLAMA_BASE_URL="http://localhost:11434/v1" # Registers "ollama" provider +export SGLANG_BASE_URL="http://localhost:30000/v1" # Registers keyless "sglang" provider +# Optional: export SGLANG_API_KEY="token-abc123" export VLLM_BASE_URL="http://localhost:8000/v1" # Registers keyless "vllm" provider # Optional: export VLLM_API_KEY="token-abc123" ``` @@ -501,6 +504,13 @@ providers: # api_key is optional; set it only when vllm serve uses --api-key. # api_key: "token-abc123" + # Add an SGLang OpenAI-compatible server + sglang: + type: sglang + base_url: "http://localhost:30000/v1" + # api_key is optional; set it only when launch_server uses --api-key. + # api_key: "token-abc123" + # Configure a model list for fallback or allowlist mode gemini: type: gemini diff --git a/docs/docs.json b/docs/docs.json index b407149e6..e78b52075 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -169,6 +169,7 @@ "providers/xiaomi", "providers/minimax", "providers/opencode-go", + "providers/sglang", "providers/vllm", "providers/multiple-ollama", "providers/kimicode", diff --git a/docs/features/passthrough-api.mdx b/docs/features/passthrough-api.mdx index 88dca73ca..64836f425 100644 --- a/docs/features/passthrough-api.mdx +++ b/docs/features/passthrough-api.mdx @@ -131,7 +131,7 @@ from passthrough requests before forwarding them upstream. Passthrough is intentionally narrow while the API is in beta. -- `openai`, `anthropic`, `openrouter`, `kilo`, `zai`, `vllm`, and `deepseek` are enabled by +- `openai`, `anthropic`, `openrouter`, `kilo`, `zai`, `sglang`, `vllm`, and `deepseek` are enabled by default. - GoModel does not translate passthrough request bodies or response bodies. - Provider-native error bodies and status codes are proxied instead of converted @@ -146,7 +146,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,vllm,deepseek +ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,openrouter,kilo,zai,sglang,vllm,deepseek ``` Set `ENABLED_PASSTHROUGH_PROVIDERS` to the provider types you want to expose. diff --git a/docs/guides/production.mdx b/docs/guides/production.mdx index b63133d41..0b4de9ed6 100644 --- a/docs/guides/production.mdx +++ b/docs/guides/production.mdx @@ -175,8 +175,9 @@ call the gateway directly. Add the headers at your proxy if you need that. **GoModel runs fully air-gapped.** There is no telemetry, no phone-home, no update check, and no license check. The admin dashboard is embedded in the binary and its fonts are vendored, so the UI loads no CDN assets. Paired with -local model servers such as [Ollama](/providers/multiple-ollama) or -[vLLM](/providers/vllm), the gateway needs no route to the public internet. +local model servers such as [Ollama](/providers/multiple-ollama), +[SGLang](/providers/sglang), or [vLLM](/providers/vllm), the gateway needs no +route to the public internet. There is exactly one outbound call that is not to a configured provider: the model metadata registry at `MODEL_LIST_URL`, which supplies pricing, context diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index f1700113b..eed1ee1dc 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -61,6 +61,7 @@ support, not every individual model capability exposed by an upstream provider. | Azure OpenAI | `AZURE_API_KEY` + `AZURE_BASE_URL` (`AZURE_API_VERSION` optional) | `gpt-5` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | [Azure OpenAI](/providers/azure) | | Oracle GenAI | `ORACLE_API_KEY` + `ORACLE_BASE_URL` | `openai.gpt-oss-120b` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | [Oracle GenAI](/providers/oracle) | | 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) | | 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) | @@ -122,6 +123,8 @@ support, not every individual model capability exposed by an upstream provider. for providers that define a list, skipping their upstream `/models` calls. - **vLLM** — set `VLLM_API_KEY` only if the upstream server was started with `--api-key`. +- **SGLang** — set `SGLANG_API_KEY` only if the upstream server was started + with `--api-key`; otherwise `SGLANG_BASE_URL` is enough. - **Multiple API keys for one provider** — set `OPENAI_API_KEY_2`, `OPENAI_API_KEY_3`, and so on to spread sessions across keys while keeping each conversation on one key for prompt-cache affinity. See @@ -154,7 +157,7 @@ These are the providers most users hit friction on: - **Anthropic** — reasoning effort maps to Claude's adaptive thinking and effort control, which differ across model generations. - **DeepSeek** — reasoning effort mapping quirks for DeepSeek V4. -- **Ollama / vLLM** — local-model hosting with optional multi-instance setup +- **Ollama / SGLang / vLLM** — local-model hosting with optional multi-instance setup through suffixed env vars and provider-qualified model IDs. - **Xiaomi MiMo** — thinking mode on by default, a `[1m]` context suffix, and TTS/ASR that run through chat completions rather than native audio endpoints. diff --git a/docs/providers/sglang.mdx b/docs/providers/sglang.mdx new file mode 100644 index 000000000..05c0b9ca2 --- /dev/null +++ b/docs/providers/sglang.mdx @@ -0,0 +1,125 @@ +--- +title: "SGLang" +description: "Route OpenAI-compatible GoModel requests to one or more self-hosted SGLang servers." +icon: "server" +keywords: ["SGLang", "self-hosted", "Hugging Face models", "OpenAI-compatible server"] +--- + +GoModel talks to SGLang through its OpenAI-compatible `/v1` API and exposes +SGLang-native endpoints through provider passthrough. Hugging Face model IDs +with slashes work because GoModel splits provider-qualified selectors on the +first slash only. + +Start SGLang first: + +```bash +python -m sglang.launch_server \ + --model-path Qwen/Qwen2.5-0.5B-Instruct \ + --host 0.0.0.0 \ + --port 30000 +# Add --api-key token-abc123 if the server should require bearer auth. +``` + +See SGLang's [OpenAI-compatible API documentation](https://docs.sglang.io/docs/basic_usage/openai_api_completions) +for current launch and accelerator-specific options. + +## Configure + +```bash +SGLANG_BASE_URL=http://host.docker.internal:30000/v1 # include /v1 +# SGLANG_API_KEY=token-abc123 # only with --api-key +GOMODEL_MASTER_KEY=change-me +``` + + + These examples assume GoModel runs in Docker and SGLang runs on the host. If + both run in the same Docker or Kubernetes network, use the SGLang service + name. If GoModel runs directly on the host, use + `http://localhost:30000/v1`. + + +## Run GoModel + + + +```bash Docker (.env file) +docker run --rm -p 8080:8080 --env-file .env enterpilot/gomodel +``` + +```bash Docker (inline -e) +docker run --rm -p 8080:8080 \ + -e GOMODEL_MASTER_KEY="change-me" \ + -e SGLANG_BASE_URL="http://host.docker.internal:30000/v1" \ + enterpilot/gomodel +``` + +```bash Binary (make build) +make build +./bin/gomodel +``` + + + +## Verify + +```bash +curl -s http://localhost:8080/v1/chat/completions \ + -H "Authorization: Bearer change-me" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "sglang/Qwen/Qwen2.5-0.5B-Instruct", + "messages": [{"role": "user", "content": "Reply with exactly ok."}], + "max_tokens": 8 + }' +``` + +`GET /v1/models` returns SGLang model IDs prefixed by provider name, for +example `sglang/Qwen/Qwen2.5-0.5B-Instruct`. + +## Multiple SGLang instances + +Use suffixed environment variables to register more than one instance without +YAML: + +```bash +SGLANG_BASE_URL=http://host.docker.internal:30000/v1 +SGLANG_TEST_BASE_URL=http://host.docker.internal:30001/v1 +``` + +This registers `sglang` and `sglang-test`. The suffix is lowercased and +underscores become hyphens. + +## Native passthrough + +Passthrough is enabled by default. Root-relative SGLang endpoints such as +`/generate` are sent without the configured `/v1` prefix: + +```bash +curl -s http://localhost:8080/p/sglang/generate \ + -H "Authorization: Bearer change-me" \ + -H "Content-Type: application/json" \ + -d '{ + "text": "Hello", + "sampling_params": {"max_new_tokens": 8} + }' +``` + +Keep the explicit `v1/` segment for SGLang endpoints that include it, such as +`/v1/rerank` or `/v1/tokenize`: + +```text +/p/sglang/v1/rerank +/p/sglang/v1/tokenize +``` + +GoModel strips client authorization before forwarding and applies +`SGLANG_API_KEY` when configured. + +## Capability notes + +- Chat completions, streaming, model listing, Responses, and embeddings use + SGLang's OpenAI-compatible API. +- Embeddings and model-specific features depend on the model loaded by SGLang. +- Native batch, file, and stored-response lifecycle interfaces are not yet + exposed as typed GoModel provider capabilities; use passthrough where the + installed SGLang version supports them. diff --git a/helm/README.md b/helm/README.md index c18de6110..17155aaee 100644 --- a/helm/README.md +++ b/helm/README.md @@ -1,6 +1,6 @@ # GoModel Helm Chart -High-performance AI gateway for multiple LLM providers (OpenAI, Anthropic, Cohere, Gemini, DeepSeek, Groq, Kilo AI, Z.ai, xAI, Oracle). +High-performance AI gateway for multiple LLM providers (OpenAI, Anthropic, Cohere, Gemini, DeepSeek, Groq, Kilo AI, Z.ai, xAI, Oracle, SGLang, vLLM). ## Prerequisites @@ -72,6 +72,8 @@ helm install gomodel ./helm \ | `providers.oracle.baseUrl` | Oracle OpenAI-compatible base URL mapped to `ORACLE_BASE_URL`; required when Oracle is enabled | `""` | | `providers.vllm.enabled` | Enable vLLM | `false` | | `providers.vllm.baseUrl` | vLLM OpenAI-compatible base URL mapped to `VLLM_BASE_URL`; required when vLLM is enabled | `""` | +| `providers.sglang.enabled` | Enable SGLang | `false` | +| `providers.sglang.baseUrl` | SGLang OpenAI-compatible base URL mapped to `SGLANG_BASE_URL`; required when enabled | `""` | | `cache.type` | Cache type (local/redis) | `"redis"` | | `redis.enabled` | Deploy Redis subchart | `true` | | `metrics.enabled` | Enable Prometheus metrics | `true` | @@ -99,6 +101,7 @@ stringData: ZAI_API_KEY: "..." KILO_API_KEY: "..." ORACLE_API_KEY: "..." + SGLANG_API_KEY: "..." VLLM_API_KEY: "..." ``` @@ -109,6 +112,10 @@ vLLM does not require an API key unless the upstream server was started with `--api-key`. The chart maps `providers.vllm.baseUrl` to the container env var `VLLM_BASE_URL`. +SGLang does not require an API key unless the upstream server was started with +`--api-key`. The chart maps `providers.sglang.baseUrl` to the container env var +`SGLANG_BASE_URL`. + Then reference it (use `enabled=true` when using existingSecret since apiKey isn't set directly): ```bash @@ -134,6 +141,14 @@ helm install gomodel ./helm \ --set providers.vllm.baseUrl="http://vllm.default.svc.cluster.local:8000/v1" ``` +Example keyless SGLang setup: + +```bash +helm install gomodel ./helm \ + --set providers.sglang.enabled=true \ + --set providers.sglang.baseUrl="http://sglang.default.svc.cluster.local:30000/v1" +``` + ### Ingress Example ```yaml diff --git a/helm/values.schema.json b/helm/values.schema.json index 296099638..a2ed2a2f1 100644 --- a/helm/values.schema.json +++ b/helm/values.schema.json @@ -109,6 +109,33 @@ } } } + }, + { + "if": { + "properties": { + "providers": { + "properties": { + "sglang": { + "properties": { "enabled": { "const": true } } + } + } + } + } + }, + "then": { + "properties": { + "providers": { + "properties": { + "sglang": { + "properties": { + "baseUrl": { "minLength": 1 } + }, + "required": ["baseUrl"] + } + } + } + } + } } ], "anyOf": [ @@ -234,6 +261,20 @@ } } } + }, + { + "properties": { + "providers": { + "properties": { + "sglang": { + "properties": { + "enabled": { "const": true }, + "baseUrl": { "minLength": 1 } + } + } + } + } + } } ], "properties": { @@ -336,6 +377,14 @@ "apiKey": { "type": "string" }, "baseUrl": { "type": "string" } } + }, + "sglang": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "apiKey": { "type": "string" }, + "baseUrl": { "type": "string" } + } } } }, diff --git a/helm/values.yaml b/helm/values.yaml index f06099f46..338f06a2c 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -42,7 +42,7 @@ auth: # LLM Provider configuration providers: # -- Use an existing secret for all provider API keys - # Secret should contain keys for enabled providers, such as OPENAI_API_KEY, ANTHROPIC_API_KEY, COHERE_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, XAI_API_KEY, ZAI_API_KEY, KILO_API_KEY, ORACLE_API_KEY, or VLLM_API_KEY + # Secret should contain keys for enabled providers, such as OPENAI_API_KEY, ANTHROPIC_API_KEY, COHERE_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, XAI_API_KEY, ZAI_API_KEY, KILO_API_KEY, ORACLE_API_KEY, SGLANG_API_KEY, or VLLM_API_KEY existingSecret: "" openai: @@ -127,6 +127,14 @@ providers: # -- vLLM OpenAI-compatible base URL baseUrl: "" + sglang: + # -- Enable SGLang provider (API key optional; baseUrl required when enabled) + enabled: false + # -- Optional SGLang API key, matching launch_server --api-key when configured + apiKey: "" + # -- SGLang OpenAI-compatible base URL + baseUrl: "" + # Cache configuration cache: redis: diff --git a/internal/admin/dashboard/static/dist/assets/index-DbvfJbHk.js b/internal/admin/dashboard/static/dist/assets/index-CiIXCM_n.js similarity index 99% rename from internal/admin/dashboard/static/dist/assets/index-DbvfJbHk.js rename to internal/admin/dashboard/static/dist/assets/index-CiIXCM_n.js index 371493809..5de4cf58f 100644 --- a/internal/admin/dashboard/static/dist/assets/index-DbvfJbHk.js +++ b/internal/admin/dashboard/static/dist/assets/index-CiIXCM_n.js @@ -5,7 +5,7 @@ `)>-1?e.split(` `):e}function Av(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 jv(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=_f(t.bodyFont),l=_f(t.titleFont),u=_f(t.footerFont),d=a.length,f=i.length,p=r.length,m=gf(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,hu(e.title,y),n.font=c.string,hu(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,hu(r,e=>{hu(e.before,y),hu(e.lines,y),hu(e.after,y)}),v=0,n.font=u.string,hu(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function Mv(e,t){let{y:n,height:r}=t;return ne.height-r/2?`bottom`:`center`}function Nv(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 Pv(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`),Nv(l,e,t,n)&&(l=`center`),l}function Fv(e,t,n){let r=n.yAlign||t.yAlign||Mv(e,n);return{xAlign:n.xAlign||t.xAlign||Pv(e,t,n,r),yAlign:r}}function Iv(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function Lv(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:t===`bottom`?r-=i+n:r-=i/2,r}function Rv(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}=hf(o),m=Iv(t,s),h=Lv(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:rd(m,0,r.width-t.width),y:rd(h,0,r.height-t.height)}}function zv(e,t,n){let r=gf(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function Bv(e){return Ov([],kv(e))}function Vv(e,t,n){return bf(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function Hv(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var Uv={beforeTitle:iu,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=Hv(n,e);Ov(t.before,kv(Wv(i,`beforeLabel`,this,e))),Ov(t.lines,Wv(i,`label`,this,e)),Ov(t.after,kv(Wv(i,`afterLabel`,this,e))),r.push(t)}),r}getAfterBody(e,t){return Bv(Wv(t.callbacks,`afterBody`,this,e))}getFooter(e,t){let{callbacks:n}=t,r=Wv(n,`beforeFooter`,this,e),i=Wv(n,`footer`,this,e),a=Wv(n,`afterFooter`,this,e),o=[];return o=Ov(o,kv(r)),o=Ov(o,kv(i)),o=Ov(o,kv(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))),hu(o,t=>{let n=Hv(e.callbacks,t);r.push(Wv(n,`labelColor`,this,t)),i.push(Wv(n,`labelPointStyle`,this,t)),a.push(Wv(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=Dv[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=jv(this,n),o=Object.assign({},e,t),s=Fv(this.chart,n,o),c=Rv(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}=hf(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=vp(n.rtl,this.x,this.width);for(e.x=zv(this,n.titleAlign,n),t.textAlign=c.textAlign(n.titleAlign),t.textBaseline=`middle`,a=_f(n.titleFont),o=n.titleSpacing,t.fillStyle=n.titleColor,t.font=a.string,s=0;se!==0)?(e.beginPath(),e.fillStyle=i.multiKeyBackground,cf(e,{x:t,y:p,w:c,h:s,radius:o}),e.fill(),e.stroke(),e.fillStyle=a.backgroundColor,e.beginPath(),cf(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=_f(n.bodyFont),d=u.lineHeight,f=0,p=vp(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=zv(this,h,n),t.fillStyle=n.bodyColor,hu(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=Dv[e.position].call(this,this._active,this._eventPosition);if(!n)return;let a=this._size=jv(this,e),o=Object.assign({},n,this._size),s=Fv(t,e,o),c=Rv(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=gf(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),yp(e,t.textDirection),i.y+=a.top,this.drawTitle(i,e,t),this.drawBody(i,e,t),this.drawFooter(i,e,t),bp(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=!gu(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||!gu(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=Dv[i.position].call(this,e,t);return a!==!1&&(n!==a.x||r!==a.y)}},Kv=Object.freeze({__proto__:null,Colors:k_,Decimation:F_,Filler:fv,Legend:xv,SubTitle:Ev,Title:wv,Tooltip:{id:`tooltip`,_element:Gv,positioners:Dv,afterInit(e,t,n){n&&(e.tooltip=new Gv({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:Uv},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`]}}),qv=(e,t,n,r)=>(typeof t==`string`?(n=e.push(t)-1,r.unshift({index:n,label:t})):isNaN(t)&&(n=null),n);function Jv(e,t,n,r){let i=e.indexOf(t);return i===-1?qv(e,t,n,r):i===e.lastIndexOf(t)?i:n}var Yv=(e,t)=>e===null?null:rd(Math.round(e),0,t);function Xv(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 Qv(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=!ou(a),_=!ou(o),v=!ou(c),y=(h-m)/(u+1),b=Uu((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=Uu(w*b/p/f)*f),ou(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&&qu((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=Hu(w,Math.round(w),b/1e3)?Math.round(w):Math.ceil(w));let T=Math.max(Zu(b),Zu(S));x=10**(ou(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&&Hu(n[n.length-1].value,o,$v(o,y,e))?n[n.length-1].value=o:n.push({value:o}):(!_||C===o)&&n.push({value:C}),n}function $v(e,t,{horizontal:n,minRotation:r}){let i=Yu(r),a=(n?Math.sin(i):Math.cos(i))||.001,o=.75*t*(``+e).length;return Math.min(t/a,o)}var ey=class extends Jh{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 ou(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=Vu(r),t=Vu(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=Qv({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`&&Ju(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 Fd(e,this.chart.options.locale,this.options.ticks.format)}},ty=class extends ey{static id=`linear`;static defaults={ticks:{callback:Rd.formatters.numeric}};determineDataLimits(){let{min:e,max:t}=this.getMinMax(!0);this.min=lu(e)?e:0,this.max=lu(t)?t:1,this.handleTickRangeOptions()}computeTickLimit(){let e=this.isHorizontal(),t=e?this.width:this.height,n=Yu(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}},ny=e=>Math.floor(Bu(e)),ry=(e,t)=>10**(ny(e)+t);function iy(e){return e/10**ny(e)==1}function ay(e,t,n){let r=10**n,i=Math.floor(e/r);return Math.ceil(t/r)-i}function oy(e,t){let n=ny(t-e);for(;ay(e,t,n)>10;)n++;for(;ay(e,t,n)<10;)n--;return Math.min(n,ny(e))}function sy(e,{min:t,max:n}){t=uu(e.min,t);let r=[],i=ny(t),a=oy(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=uu(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=uu(e.max,f);return r.push({value:p,major:iy(p),significand:d}),r}var cy=class extends Jh{static id=`logarithmic`;static defaults={ticks:{callback:Rd.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=ey.prototype.parse.apply(this,[e,t]);if(n===0){this._zero=!0;return}return lu(n)&&n>0?n:null}determineDataLimits(){let{min:e,max:t}=this.getMinMax(!0);this.min=lu(e)?Math.max(0,e):null,this.max=lu(t)?Math.max(0,t):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!lu(this._userMin)&&(this.min=e===ry(this.min,0)?ry(this.min,-1):ry(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(ry(n,-1)),a(ry(r,1)))),n<=0&&i(ry(r,-1)),r<=0&&a(ry(n,1)),this.min=n,this.max=r}buildTicks(){let e=this.options,t=sy({min:this._userMin,max:this._userMax},this);return e.bounds===`ticks`&&Ju(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`:Fd(e,this.chart.options.locale,this.options.ticks.format)}configure(){let e=this.min;super.configure(),this._startValue=Bu(e),this._valueRange=Bu(this.max)-Bu(e)}getPixelForValue(e){return(e===void 0||e===0)&&(e=this.min),e===null||isNaN(e)?NaN:this.getPixelForDecimal(e===this.min?0:(Bu(e)-this._startValue)/this._valueRange)}getValueForPixel(e){let t=this.getDecimalForPixel(e);return 10**(this._startValue+t*this._valueRange)}};function ly(e){let t=e.ticks;if(t.display&&e.display){let e=gf(t.backdropPadding);return du(t.font&&t.font.size,Wd.font.size)+e.height}return 0}function uy(e,t,n){return n=su(n)?n:[n],{w:qd(e,t.string,n),h:n.length*t.lineHeight}}function dy(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 fy(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?Mu/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 my(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(Xu(td(c.angle+Lu))),u=yy(c.y,s.h,l),d=_y(l),f=vy(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 hy(e,t){if(!t)return!0;let{left:n,top:r,right:i,bottom:a}=e;return!(Qd({x:n,y:r},t)||Qd({x:n,y:a},t)||Qd({x:i,y:r},t)||Qd({x:i,y:a},t))}function gy(e,t,n){let r=[],i=e._pointLabels.length,a=e.options,{centerPointLabels:o,display:s}=a.pointLabels,c={extra:ly(a)/2,additionalAngle:o?Mu/i:0},l;for(let a=0;a270||n<90)&&(e-=t),e}function by(e,t,n){let{left:r,top:i,right:a,bottom:o}=n,{backdropColor:s}=t;if(!ou(s)){let n=hf(t.borderRadius),c=gf(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(),cf(e,{x:l,y:u,w:d,h:f,radius:n}),e.fill()):e.fillRect(l,u,d,f)}}function xy(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));by(n,a,t);let o=_f(a.font),{x:s,y:c,textAlign:l}=t;sf(n,e._pointLabels[i],s,c+o.lineHeight/2,o,{color:a.color,textAlign:l,textBaseline:`middle`})}}function Sy(e,t,n,r){let{ctx:i}=e;if(n)i.arc(e.xCenter,e.yCenter,t,0,Nu);else{let n=e.getPointPosition(0,t);i.moveTo(n.x,n.y);for(let a=1;a{let n=mu(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?fy(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=Nu/(this._pointLabels.length||1),n=this.options.startAngle||0;return td(e*t+Yu(n))}getDistanceFromCenterForValue(e){if(ou(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(ou(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);Cy(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=_f(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=gf(s.backdropPadding);e.fillRect(-a/2-t.left,-i-c.size/2-t.top,a+t.width,c.size+t.height)}sf(e,r.label,0,-i,c,{color:s.color,strokeColor:s.textStrokeColor,strokeWidth:s.textStrokeWidth})}),e.restore()}drawTitle(){}},Ey={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}},Dy=Object.keys(Ey);function Oy(e,t){return e-t}function ky(e,t){if(ou(t))return null;let n=e._adapter,{parser:r,round:i,isoWeekday:a}=e._parseOpts,o=t;return typeof r==`function`&&(o=r(o)),lu(o)||(o=typeof r==`string`?n.parse(o,r):n.parse(o)),o===null?null:(i&&(o=i===`week`&&(Ku(a)||a===!0)?n.startOf(o,`isoWeek`,a):n.startOf(o,i)),+o)}function Ay(e,t,n,r){let i=Dy.length;for(let a=Dy.indexOf(e);a=Dy.indexOf(n);a--){let n=Dy[a];if(Ey[n].common&&e._adapter.diff(i,r,n)>=t-1)return n}return Dy[n?Dy.indexOf(n):0]}function My(e){for(let t=Dy.indexOf(e)+1,n=Dy.length;t=t?n[r]:n[i];e[a]=!0}}function Py(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 Fy(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=rd(t,0,a),n=rd(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||Ay(i.minUnit,t,n,this._getLabelCapacity(t)),o=du(r.ticks.stepSize,1),s=a===`week`&&i.isoWeekday,c=Ku(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 mu(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}=sd(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}=sd(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 Ry=class extends Iy{static id=`timeseries`;static defaults=Iy.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=Ly(t,this.min),this._tableRange=Ly(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(Ly(this._table,e)-this._minPos)/this._tableRange}getValueForPixel(e){let t=this._offsets,n=this.getDecimalForPixel(e)/t.factor-t.end;return Ly(this._table,n*this._tableRange+this._minPos,!0)}},zy=[Am,__,Kv,Object.freeze({__proto__:null,CategoryScale:Zv,LinearScale:ty,LogarithmicScale:cy,RadialLinearScale:Ty,TimeScale:Iy,TimeSeriesScale:Ry})];Ig.register(...zy);var By=Ig,Vy=z(``);function Hy(e,t){D(t,!0);let n=K(t,`class`,3,``),r=K(t,`ariaLabel`,3,``);function i(e){if(Da.tick,typeof t.build!=`function`)return;let n=t.build();if(!n)return;let r=new By(e.getContext(`2d`),n);return()=>r.destroy()}var a=Vy();ji(a,()=>i),I(()=>{W(a,1,Pi(n())),G(a,`aria-label`,r())}),B(e,a),O()}var Uy=z(``),Wy=z(`
`);function Gy(e,t){D(t,!0);let n=K(t,`options`,19,()=>[]),r=K(t,`ariaLabel`,3,``),i=K(t,`class`,3,``);var a=Wy();U(a,21,n,e=>e.value,(e,n)=>{var r=Uy();let i;var a=N(r,!0);E(r),I(()=>{i=W(r,1,`segmented-btn svelte-92fh5i`,null,i,{active:t.value===L(n).value}),G(r,`aria-pressed`,t.value===L(n).value),V(a,L(n).label)}),R(`click`,r,()=>t.onchange?.(L(n).value)),B(e,r)}),E(a),I(()=>{W(a,1,Pi([`segmented-control`,i()]),`svelte-92fh5i`),G(a,`aria-label`,r())}),B(e,a),O()}Vr([`click`]);function Ky(e){return getComputedStyle(document.documentElement).getPropertyValue(e).trim()}function qy(){return{grid:Ky(`--chart-grid`),text:Ky(`--chart-text`),dayMarker:Ky(`--chart-day-marker`),tooltipBg:Ky(`--chart-tooltip-bg`),tooltipBorder:Ky(`--chart-tooltip-border`),tooltipText:Ky(`--chart-tooltip-text`)}}function Jy(){return{size:11,family:`'SF Mono', Menlo, Consolas, monospace`}}function Yy(e,t){return{backgroundColor:e.tooltipBg,borderColor:e.tooltipBorder,borderWidth:1,titleColor:e.tooltipText,bodyColor:e.tooltipText,callbacks:t}}function Xy(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 Zy=[`#c2845a`,`#7a9e7e`,`#d4a574`,`#b8a98e`,`#8b9e6b`,`#7d8a97`,`#c47a5a`,`#6b8e6b`,`#a09486`,`#9b7ea4`,`#c49a6c`];function Qy(){return[...Zy]}function $y(e){let t=5381,n=String(e||``);for(let e=0;exc(e)}}var ab={seconds:{apiName:`second`,windowLabel:`Last 60 seconds`,refreshMs:2e3},minutes:{apiName:`minute`,windowLabel:`Last 60 minutes`,refreshMs:5e3},hours:{apiName:`hour`,windowLabel:`Last 24 hours`,refreshMs:2e4},days:{apiName:`day`,windowLabel:`Last 30 days`,refreshMs:6e4}},ob=[{value:`seconds`,label:`Seconds`},{value:`minutes`,label:`Minutes`},{value:`hours`,label:`Hours`},{value:`days`,label:`Days`}];function sb(){return{input:0,output:0,prompt:0,local:0}}function cb(e){return String(e).padStart(2,`0`)}function lb(e){let t=Number(e);return Number.isFinite(t)&&t>0?t:0}function ub(e,t){if(!Number.isFinite(t))return``;let n=new Date(t);switch(e){case`seconds`:return cb(n.getHours())+`:`+cb(n.getMinutes())+`:`+cb(n.getSeconds());case`minutes`:return cb(n.getHours())+`:`+cb(n.getMinutes());case`hours`:return cb(n.getHours())+`:00`;default:return cb(n.getMonth()+1)+`-`+cb(n.getDate())}}function db(e,t){let n=[],r=[],i={input:[],output:[],prompt:[],local:[]},a=sb();for(let o of e||[]){let e=Date.parse(o&&o.start),s=lb(o&&o.input_tokens),c=lb(o&&o.output_tokens),l=lb(o&&o.prompt_cached_tokens),u=lb(o&&o.locally_cached_tokens);n.push(ub(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 fb(e){let t=e||sb();return t.input+t.output+t.prompt+t.local>0}function pb(e,t){return xc(Math.max(0,Math.round(e&&e[t]||0)))}function mb(e){return(ab[e]||ab.minutes).windowLabel}function hb(e,t){return`Live token throughput, `+mb(t).toLowerCase()+`. Input `+pb(e,`input`)+`, output `+pb(e,`output`)+`, prompt cached `+pb(e,`prompt`)+`, locally cached `+pb(e,`local`)+` tokens.`}function gb(e,t,n,r){let i=e=>xc(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}),`Total: `+i(t)}})}}}}var _b=900,vb=new class{#e=A(`minutes`);get granularity(){return L(this.#e)}set granularity(e){j(this.#e,e,!0)}#t=A(M([]));get buckets(){return L(this.#t)}set buckets(e){j(this.#t,e,!0)}#n=A(!1);get active(){return L(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){!ab[e]||e===this.granularity||(this.granularity=e,this.buckets=[],this.#l(),this.fetch())}#l(){this.#r&&=(clearInterval(this.#r),null);let e=ab[this.granularity]||ab.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()},_b))}async fetch(){if(!this.active||this.#c)return;this.#c=!0;let e=this.granularity;try{let t=await Ts(`/admin/usage/throughput?granularity=`+(ab[e]||ab.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(Ds(e))return;console.error(`Failed to fetch token throughput:`,e)}finally{this.#c=!1,this.active&&this.granularity!==e&&this.fetch()}}async#u(){await As.ensureLoaded(),this.active&&As.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 Cs(`/admin/live/logs?types=usage`,{signal:e.signal});if(!n.ok||!n.body||typeof n.body.getReader!=`function`){t();return}this.#o=0,await tb(n.body.getReader(),e=>this.#f(e)),t()}catch(n){if(Ds(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}=rb(this.#o);this.#o=e,this.#a=setTimeout(()=>{this.#a=null,this.#u()},t)}},yb=z(`
`),bb=z(`
Waiting for live requests…
`),xb=z(`

Live Token Throughput

`);function Sb(e,t){D(t,!0);let n=k(()=>db(vb.buckets,vb.granularity)),r=k(()=>L(n).totals);function i(){return{input:Xy(`var(--token-input)`),output:Xy(`var(--token-output)`),prompt:Xy(`var(--token-prompt)`),local:Xy(`var(--token-local)`)}}let a=[{metric:`input`,label:`Input Tokens`,colorVar:`--token-input`},{metric:`output`,label:`Output Tokens`,colorVar:`--token-output`},{metric:`prompt`,label:`Prompt (Input) Cached`,colorVar:`--token-prompt`},{metric:`local`,label:`Locally Cached`,colorVar:`--token-local`}];var o=xb(),s=N(o),c=N(s),l=F(N(c),2),u=N(l);let d;var f=F(u,2),p=N(f,!0);E(f),E(l),E(c),Gy(F(c,2),{ariaLabel:`Live token throughput granularity`,get options(){return ob},get value(){return vb.granularity},onchange:e=>vb.setGranularity(e)}),E(s);var m=F(s,2);U(m,21,()=>a,e=>e.metric,(e,t)=>{var n=yb(),i=N(n),a=F(i,2),o=N(a,!0);E(a);var s=F(a,2),c=N(s,!0);E(s),E(n),I(e=>{Vi(i,`background: var(${L(t).colorVar??``})`),V(o,L(t).label),V(c,e)},[()=>pb(L(r),L(t).metric)]),B(e,n)}),E(m);var h=F(m,2),g=N(h);{let e=k(()=>hb(L(r),vb.granularity));Hy(g,{get ariaLabel(){return L(e)},build:()=>gb(qy(),i(),L(n),vb.granularity)})}var _=F(g,2),v=e=>{B(e,bb())},y=k(()=>!fb(L(r)));H(_,e=>{L(y)&&e(v)}),E(h),E(o),I(e=>{d=W(u,1,`live-dot`,null,d,{"is-streaming":vb.active}),V(p,e)},[()=>mb(vb.granularity)]),B(e,o),O()}function Cb(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 wb(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 Tb(e,t,n){let r=Number(e&&e.total_requests||0);return(Number.isFinite(r)?r:0)+wb(t,n)}function Eb(e,t,n){let r=wb(t,n);return r<=0?``:_c(Tb(e,t,n)-r)+` to providers + `+_c(r)+` from cache`}function Db(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 Ob(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:`Regular`,tokens:a+s,colorVar:`--cache-meter-uncached`,note:s>0?`Includes `+_c(s)+` cache-write tokens`:``},{key:`prompt`,label:`Prompt cached`,tokens:o,colorVar:`--cache-meter-prompt`,note:`Provider prompt-cache reads`},{key:`local`,label:`Locally cached`,tokens:l,colorVar:`--cache-meter-local`,note:`Served from GoModel response cache`}]}function kb(e,t,n){return Ob(e,t,n).reduce((e,t)=>e+t.tokens,0)}function Ab(e,t,n){return kb(e,t,n)>0}function jb(e,t,n){let r=Ob(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 Mb(e,t,n){return jb(e,t,n).filter(e=>e.tokens>0)}function Nb(e){let t=[e.label+`: `+_c(e.tokens)+` input tokens (`+e.pct+`%)`];return e.note&&t.push(e.note),t.join(` -`)}function Pb(e){let t=(e||[]).map(e=>e.label+` `+e.pct+`%`);return`Cache breakdown of input tokens — `+(t.length?t.join(`, `):`no data`)}function Fb(e){return e.getUTCFullYear()+`-`+String(e.getUTCMonth()+1).padStart(2,`0`)+`-`+String(e.getUTCDate()).padStart(2,`0`)}function Ib(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=Fb(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 Lb(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 Rb(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 zb(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 Bb(e){return zb(e)?Math.round(Rb(e))+`%`:`—`}function Vb(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(`Input Tokens`,t.inputPaid,i(`var(--token-input)`),{fill:`origin`}),o(`Output Tokens`,t.output,i(`var(--token-output)`),{fill:`-1`}),o(`Prompt (Input) Cached`,t.prompt,i(`var(--token-prompt)`),{fill:`-1`,borderDash:[6,4]})];return r&&s.push(o(`Locally Cached`,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:Yy(e,{label:e=>e.dataset.label+`: `+e.parsed.y.toLocaleString(),footer:e=>{let t=0;return e.forEach(e=>{t+=Number(e.parsed.y)||0}),`Total: `+t.toLocaleString()}})},scales:{x:{stacked:!0,grid:{color:e.grid},border:{display:!1},ticks:{color:e.text,font:Jy(),maxRotation:0,autoSkip:!0,maxTicksLimit:10}},y:{stacked:!0,beginAtZero:!0,grid:{color:e.grid},border:{display:!1},ticks:ib(e)}}}}}function Hb(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 Ub=`gomodel_provider_status_details_expanded`,Wb=`gomodel_provider_card_expanded_overrides`,Gb=3e3,Kb=`https://gomodel.enterpilot.io/docs/providers/`,qb={anthropic:`anthropic`,azure:`azure`,bailian:`bailian`,bedrock:`bedrock`,"bedrock-mantle":`bedrock-mantle`,cohere:`cohere`,deepseek:`deepseek`,gemini:`gemini`,opencode_go:`opencode-go`,oracle:`oracle`,vertex:`vertex`,vllm:`vllm`,xiaomi:`xiaomi`};function Jb(){return{summary:{total:0,healthy:0,degraded:0,unhealthy:0,overall_status:`degraded`},providers:[]}}function Yb(e){let t={detailsExpanded:!1,cardOverrides:{}};try{if(e){let n=e.getItem(Ub);n===`true`||n===`false`?t.detailsExpanded=n===`true`:e.setItem(Ub,`false`);let r=JSON.parse(e.getItem(Wb)||`{}`);r&&typeof r==`object`&&!Array.isArray(r)&&(t.cardOverrides=r)}}catch{}return t}function Xb(e,t){if(e)try{e.setItem(Ub,t?`true`:`false`)}catch{}}function Zb(e,t){if(e)try{e.setItem(Wb,JSON.stringify(t))}catch{}}function Qb(e,t,n){let r=n&&n.name?String(n.name):``;return r&&Object.prototype.hasOwnProperty.call(e,r)?e[r]===!0:t}function $b(e){return`is-`+(String(e&&e.overall_status||`degraded`).trim()||`degraded`)}function ex(e){return`is-`+(String(e||`degraded`).trim()||`degraded`)}function tx(e){let t=e||{};return String(t.healthy||0)+`/`+String(t.total||0)}function nx(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 ax(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 ox(e,t){let n=ax(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 sx(e,t){let n=ax(e);return n?typeof t==`function`?t(n):String(n):``}function cx(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 lx(e){let t=String(e&&(e.type||e.config&&e.config.type)||``).trim().toLowerCase(),n=t?qb[t]:``;return n?Kb+n+`?utm_source=gomodel_dashboard`:``}function ux(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 dx(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 fx(e){let t=e&&e.config&&Array.isArray(e.config.models)?e.config.models.filter(Boolean):[];return t.length===0?`Automatic`:t.join(`, `)}function px(e){if(!e)return``;let t=[];return e.status_reason&&t.push(String(e.status_reason)),e.last_error&&t.push(`Last error: `+String(e.last_error)),t.join(` +`)}function Pb(e){let t=(e||[]).map(e=>e.label+` `+e.pct+`%`);return`Cache breakdown of input tokens — `+(t.length?t.join(`, `):`no data`)}function Fb(e){return e.getUTCFullYear()+`-`+String(e.getUTCMonth()+1).padStart(2,`0`)+`-`+String(e.getUTCDate()).padStart(2,`0`)}function Ib(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=Fb(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 Lb(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 Rb(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 zb(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 Bb(e){return zb(e)?Math.round(Rb(e))+`%`:`—`}function Vb(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(`Input Tokens`,t.inputPaid,i(`var(--token-input)`),{fill:`origin`}),o(`Output Tokens`,t.output,i(`var(--token-output)`),{fill:`-1`}),o(`Prompt (Input) Cached`,t.prompt,i(`var(--token-prompt)`),{fill:`-1`,borderDash:[6,4]})];return r&&s.push(o(`Locally Cached`,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:Yy(e,{label:e=>e.dataset.label+`: `+e.parsed.y.toLocaleString(),footer:e=>{let t=0;return e.forEach(e=>{t+=Number(e.parsed.y)||0}),`Total: `+t.toLocaleString()}})},scales:{x:{stacked:!0,grid:{color:e.grid},border:{display:!1},ticks:{color:e.text,font:Jy(),maxRotation:0,autoSkip:!0,maxTicksLimit:10}},y:{stacked:!0,beginAtZero:!0,grid:{color:e.grid},border:{display:!1},ticks:ib(e)}}}}}function Hb(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 Ub=`gomodel_provider_status_details_expanded`,Wb=`gomodel_provider_card_expanded_overrides`,Gb=3e3,Kb=`https://gomodel.enterpilot.io/docs/providers/`,qb={anthropic:`anthropic`,azure:`azure`,bailian:`bailian`,bedrock:`bedrock`,"bedrock-mantle":`bedrock-mantle`,cohere:`cohere`,deepseek:`deepseek`,gemini:`gemini`,opencode_go:`opencode-go`,oracle:`oracle`,sglang:`sglang`,vertex:`vertex`,vllm:`vllm`,xiaomi:`xiaomi`};function Jb(){return{summary:{total:0,healthy:0,degraded:0,unhealthy:0,overall_status:`degraded`},providers:[]}}function Yb(e){let t={detailsExpanded:!1,cardOverrides:{}};try{if(e){let n=e.getItem(Ub);n===`true`||n===`false`?t.detailsExpanded=n===`true`:e.setItem(Ub,`false`);let r=JSON.parse(e.getItem(Wb)||`{}`);r&&typeof r==`object`&&!Array.isArray(r)&&(t.cardOverrides=r)}}catch{}return t}function Xb(e,t){if(e)try{e.setItem(Ub,t?`true`:`false`)}catch{}}function Zb(e,t){if(e)try{e.setItem(Wb,JSON.stringify(t))}catch{}}function Qb(e,t,n){let r=n&&n.name?String(n.name):``;return r&&Object.prototype.hasOwnProperty.call(e,r)?e[r]===!0:t}function $b(e){return`is-`+(String(e&&e.overall_status||`degraded`).trim()||`degraded`)}function ex(e){return`is-`+(String(e||`degraded`).trim()||`degraded`)}function tx(e){let t=e||{};return String(t.healthy||0)+`/`+String(t.total||0)}function nx(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 ax(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 ox(e,t){let n=ax(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 sx(e,t){let n=ax(e);return n?typeof t==`function`?t(n):String(n):``}function cx(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 lx(e){let t=String(e&&(e.type||e.config&&e.config.type)||``).trim().toLowerCase(),n=t?qb[t]:``;return n?Kb+n+`?utm_source=gomodel_dashboard`:``}function ux(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 dx(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 fx(e){let t=e&&e.config&&Array.isArray(e.config.models)?e.config.models.filter(Boolean):[];return t.length===0?`Automatic`:t.join(`, `)}function px(e){if(!e)return``;let t=[];return e.status_reason&&t.push(String(e.status_reason)),e.last_error&&t.push(`Last error: `+String(e.last_error)),t.join(` `)}function mx(e){let t=e&&e.request_health;return t&&typeof t==`object`?t:null}function hx(e){let t=mx(e);return t?String(t.circuit_state||``).trim():``}function gx(e){let t=hx(e);return t?t.charAt(0).toUpperCase()+t.slice(1):``}function _x(e){let t=hx(e);return t===`open`?`is-unhealthy`:t===`half-open`?`is-degraded`:`is-healthy`}function vx(e){let t=mx(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 yx(e){let t=mx(e);return t&&Array.isArray(t.models)?t.models:[]}function bx(e){return e?String(Number(e.errors||0))+`/`+String(Number(e.requests||0))+` failed`:``}function xx(e){let t=e&&e.last_error;return!t||!t.message?``:(t.status_code?`HTTP `+String(t.status_code)+`: `:``)+t.message}function Sx(){return{name:``,slug:``,url:``,transport:`http`,description:``,enabled:!0,headers:[],allowed_tools:``,disallowed_tools:``,user_paths:``,tool_timeout_seconds:``}}function Cx(){return{server:``,status:``,instructions:``,tools:[],prompts:[],resources:[],templates:[]}}function wx(e){return String(e&&(e.slug||e.name)||``).trim()}function Tx(e){return String(e&&e.status||``).trim()||`connecting`}function Ex(e){switch(Tx(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 Dx(e,t){let n=Tx(e),r=String(e&&e.last_error||``).trim();return r&&n!==`connected`?r:n===`connected`&&e&&e.connected_at?`Connected since `+(typeof t==`function`?t:String)(e.connected_at):``}function Ox(e){return String(e&&e.transport||``)===`stdio`?`local command`:String(e&&e.url||``).trim()||`—`}function kx(e){let t=Number(e&&e.prompt_count||0),n=Number(e&&e.resource_count||0);return t+` prompts · `+n+` resources`}function Ax(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 jx(e){return String(e||``).split(` `).map(e=>e.trim()).filter(e=>e)}function Mx(e){return!e||typeof e!=`object`||Array.isArray(e)?[]:Object.keys(e).sort().map(t=>({name:t,value:String(e[t]||``)}))}function Nx(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 Px(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 Fx(e){return{name:String(e.name||``).trim(),slug:wx(e),url:String(e.url||``).trim(),transport:e.transport===`sse`?`sse`:`http`,description:String(e.description||``).trim(),enabled:e.enabled!==!1,headers:Mx(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 8c8786eb5..d57678b38 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/config_test.go b/internal/providers/config_test.go index 625451373..cc22f9c5e 100644 --- a/internal/providers/config_test.go +++ b/internal/providers/config_test.go @@ -54,6 +54,10 @@ var testDiscoveryConfigs = map[string]DiscoveryConfig{ DefaultBaseURL: "http://localhost:8000/v1", AllowAPIKeyless: true, }, + "sglang": { + DefaultBaseURL: "http://localhost:30000/v1", + AllowAPIKeyless: true, + }, "azure": { RequireBaseURL: true, SupportsAPIVersion: true, @@ -371,6 +375,15 @@ func TestFilterEmptyProviders_VLLMAllowsKeylessConfig(t *testing.T) { } } +func TestFilterEmptyProvidersSGLangAllowsKeylessConfig(t *testing.T) { + got := filterEmptyProviders(map[string]config.RawProviderConfig{ + "sglang": {Type: "sglang", BaseURL: "http://localhost:30000/v1"}, + }, testDiscoveryConfigs) + if _, exists := got["sglang"]; !exists { + t.Fatal("expected sglang to be kept without an API key") + } +} + func TestSkippedProviderNames_ListsDeclaredButUnresolved(t *testing.T) { declared := map[string]config.RawProviderConfig{ "openai": {Type: "openai", APIKey: "${OPENAI_API_KEY}"}, @@ -930,6 +943,32 @@ func TestApplyProviderEnvVars_DiscoversVLLMFromBaseURLWithoutAPIKey(t *testing.T } } +func TestApplyProviderEnvVarsDiscoversSGLangWithoutAPIKey(t *testing.T) { + t.Setenv("SGLANG_BASE_URL", "http://localhost:30000/v1") + + got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) + p, exists := got["sglang"] + if !exists { + t.Fatal("expected sglang to be discovered from SGLANG_BASE_URL") + } + if p.Type != "sglang" || p.BaseURL != "http://localhost:30000/v1" || p.APIKey != "" { + t.Fatalf("sglang config = %+v", p) + } +} + +func TestApplyProviderEnvVarsDiscoversSGLangFromAPIKeyWithDefaultBaseURL(t *testing.T) { + t.Setenv("SGLANG_API_KEY", "sglang-key") + + got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) + p, exists := got["sglang"] + if !exists { + t.Fatal("expected sglang to be discovered from SGLANG_API_KEY") + } + if p.APIKey != "sglang-key" || p.BaseURL != testDiscoveryConfigs["sglang"].DefaultBaseURL { + t.Fatalf("sglang config = %+v", p) + } +} + func TestApplyProviderEnvVars_DiscoversUnsuffixedAndSuffixedVLLMProvidersFromBaseURLs(t *testing.T) { t.Setenv("VLLM_BASE_URL", "http://localhost:8000/v1") t.Setenv("VLLM_TEST_BASE_URL", "http://localhost:8000/v1") diff --git a/internal/providers/sglang/passthrough_semantics.go b/internal/providers/sglang/passthrough_semantics.go new file mode 100644 index 000000000..246defce7 --- /dev/null +++ b/internal/providers/sglang/passthrough_semantics.go @@ -0,0 +1,10 @@ +package sglang + +import "github.com/enterpilot/gomodel/internal/providers" + +var passthroughSemanticEnricher = providers.NewSemanticEnricher("sglang", map[string]providers.PassthroughEndpointSemantics{ + "/chat/completions": {Operation: "sglang.chat_completions", AuditPath: "/v1/chat/completions"}, + "/responses": {Operation: "sglang.responses", AuditPath: "/v1/responses"}, + "/embeddings": {Operation: "sglang.embeddings", AuditPath: "/v1/embeddings"}, + "/completions": {Operation: "sglang.completions", AuditPath: "/v1/completions"}, +}) diff --git a/internal/providers/sglang/sglang.go b/internal/providers/sglang/sglang.go new file mode 100644 index 000000000..0b8bb0a25 --- /dev/null +++ b/internal/providers/sglang/sglang.go @@ -0,0 +1,181 @@ +// Package sglang provides SGLang OpenAI-compatible API integration for the LLM gateway. +package sglang + +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" +) + +const defaultBaseURL = "http://localhost:30000/v1" + +// Registration provides factory registration for the SGLang provider. +var Registration = providers.Registration{ + Type: "sglang", + New: New, + PassthroughSemanticEnricher: passthroughSemanticEnricher, + Discovery: providers.DiscoveryConfig{ + DefaultBaseURL: defaultBaseURL, + AllowAPIKeyless: true, + }, +} + +// Provider implements the OpenAI-compatible SGLang surface explicitly and +// keeps a root client for SGLang-native endpoints such as /generate. +type Provider struct { + compatible *openai.CompatibleProvider + rootClient *llmclient.Client +} + +var _ core.Provider = (*Provider)(nil) +var _ core.PassthroughProvider = (*Provider)(nil) + +// New creates a new SGLang provider. +func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { + baseURL := providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL) + keys := opts.Keyring(cfg.APIKey) + opts.Keys = keys + return &Provider{ + compatible: openai.NewCompatibleProvider(cfg.APIKey, opts, openai.CompatibleProviderConfig{ + ProviderName: "sglang", + BaseURL: baseURL, + SetHeaders: setHeaders, + }), + rootClient: llmclient.New(llmclient.Config{ + ProviderName: "sglang", + BaseURL: passthroughBaseURL(baseURL), + Retry: opts.Resilience.Retry, + Hooks: opts.Hooks, + CircuitBreaker: opts.Resilience.CircuitBreaker, + }, func(req *http.Request) { + setHeaders(req, keys.NextForContext(req.Context())) + }), + } +} + +// NewWithHTTPClient creates a new SGLang provider with a custom HTTP client. +// If httpClient is nil, http.DefaultClient is used. +func NewWithHTTPClient(apiKey, baseURL string, httpClient *http.Client, hooks llmclient.Hooks) *Provider { + resolvedBaseURL := providers.ResolveBaseURL(baseURL, defaultBaseURL) + rootClientCfg := llmclient.DefaultConfig("sglang", passthroughBaseURL(resolvedBaseURL)) + rootClientCfg.Hooks = hooks + return &Provider{ + compatible: openai.NewCompatibleProviderWithHTTPClient(apiKey, httpClient, hooks, openai.CompatibleProviderConfig{ + ProviderName: "sglang", + BaseURL: resolvedBaseURL, + SetHeaders: setHeaders, + }), + rootClient: llmclient.NewWithHTTPClient(httpClient, rootClientCfg, func(req *http.Request) { + setHeaders(req, apiKey) + }), + } +} + +// SetBaseURL updates both the OpenAI-compatible and native endpoint clients. +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 SGLang. +func (p *Provider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { + return p.compatible.ChatCompletion(ctx, req) +} + +// StreamChatCompletion streams a chat completion request from SGLang. +func (p *Provider) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { + return p.compatible.StreamChatCompletion(ctx, req) +} + +// ListModels retrieves the models served by SGLang. +func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) { + return p.compatible.ListModels(ctx) +} + +// Responses sends an OpenAI Responses API request to SGLang. +func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + return p.compatible.Responses(ctx, req) +} + +// StreamResponses streams an OpenAI Responses API request from SGLang. +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 SGLang. +func (p *Provider) Embeddings(ctx context.Context, req *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { + return p.compatible.Embeddings(ctx, req) +} + +// Passthrough routes opaque requests to either SGLang's /v1 API or its native root API. +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) { + return p.compatible.Passthrough(ctx, req) + } + + resp, err := p.rootClient.DoPassthrough(ctx, llmclient.Request{ + Method: req.Method, + Endpoint: endpoint, + 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 +} + +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) + endpoint, _, _ = strings.Cut(endpoint, "?") + if strings.HasPrefix(endpoint, "/v1/") { + return false + } + + v1Prefixes := []string{ + "/models", + "/chat/completions", + "/responses", + "/completions", + "/embeddings", + "/audio", + "/files", + "/batches", + } + for _, prefix := range v1Prefixes { + if endpoint == prefix || strings.HasPrefix(endpoint, prefix+"/") { + return true + } + } + return false +} diff --git a/internal/providers/sglang/sglang_test.go b/internal/providers/sglang/sglang_test.go new file mode 100644 index 000000000..3e1a10f70 --- /dev/null +++ b/internal/providers/sglang/sglang_test.go @@ -0,0 +1,277 @@ +package sglang + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" +) + +func TestChatCompletionUsesOptionalBearerAuthAndV1Endpoint(t *testing.T) { + for _, tt := range []struct { + name string + apiKey string + wantAuth string + }{ + {name: "with API key", apiKey: "sglang-key", wantAuth: "Bearer sglang-key"}, + {name: "without API key"}, + } { + t.Run(tt.name, func(t *testing.T) { + var gotPath, 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-sglang", + "created":1677652288, + "model":"HuggingFaceTB/SmolLM2-135M-Instruct", + "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: "HuggingFaceTB/SmolLM2-135M-Instruct", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if resp.Model != "HuggingFaceTB/SmolLM2-135M-Instruct" { + t.Fatalf("resp.Model = %q", 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 TestChatCompletionPreservesSGLangExtensionFields(t *testing.T) { + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"chatcmpl-sglang","model":"test","choices":[]}`)) + })) + defer server.Close() + + var req core.ChatRequest + if err := json.Unmarshal([]byte(`{ + "model":"test", + "messages":[{"role":"user","content":"hi"}], + "chat_template_kwargs":{"enable_thinking":false}, + "separate_reasoning":true + }`), &req); err != nil { + t.Fatalf("decode ChatRequest: %v", err) + } + + provider := NewWithHTTPClient("", server.URL+"/v1", server.Client(), llmclient.Hooks{}) + if _, err := provider.ChatCompletion(context.Background(), &req); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + + kwargs, ok := gotBody["chat_template_kwargs"].(map[string]any) + if !ok || kwargs["enable_thinking"] != false { + t.Fatalf("chat_template_kwargs = %#v", gotBody["chat_template_kwargs"]) + } + if gotBody["separate_reasoning"] != true { + t.Fatalf("separate_reasoning = %#v", gotBody["separate_reasoning"]) + } +} + +func TestOpenAICompatibleEndpoints(t *testing.T) { + tests := []struct { + name string + wantPath string + call func(*Provider) error + response string + }{ + { + name: "models", + wantPath: "/v1/models", + response: `{"object":"list","data":[]}`, + call: func(p *Provider) error { + _, err := p.ListModels(context.Background()) + return err + }, + }, + { + name: "responses", + wantPath: "/v1/responses", + response: `{"id":"resp-sglang","object":"response","status":"completed","model":"test","output":[]}`, + call: func(p *Provider) error { + _, err := p.Responses(context.Background(), &core.ResponsesRequest{Model: "test"}) + return err + }, + }, + { + name: "embeddings", + wantPath: "/v1/embeddings", + response: `{"object":"list","model":"test","data":[{"object":"embedding","embedding":[0.1],"index":0}],"usage":{"prompt_tokens":1,"total_tokens":1}}`, + call: func(p *Provider) error { + _, err := p.Embeddings(context.Background(), &core.EmbeddingRequest{Model: "test", Input: "hello"}) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(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(tt.response)) + })) + defer server.Close() + + provider := NewWithHTTPClient("", server.URL+"/v1", server.Client(), llmclient.Hooks{}) + if err := tt.call(provider); err != nil { + t.Fatalf("call error = %v", err) + } + if gotPath != tt.wantPath { + t.Fatalf("path = %q, want %q", gotPath, tt.wantPath) + } + }) + } +} + +func TestProviderExposesOnlyVerifiedOptionalInterfaces(t *testing.T) { + provider := NewWithHTTPClient("", "", nil, llmclient.Hooks{}) + + if _, ok := any(provider).(core.PassthroughProvider); !ok { + t.Fatal("sglang provider should implement passthrough provider") + } + if _, ok := any(provider).(core.NativeBatchProvider); ok { + t.Fatal("sglang provider should not implement native batch provider") + } + if _, ok := any(provider).(core.NativeFileProvider); ok { + t.Fatal("sglang provider should not implement native file provider") + } + if _, ok := any(provider).(core.NativeResponseLifecycleProvider); ok { + t.Fatal("sglang provider should not implement native response lifecycle provider") + } +} + +func TestPassthroughRoutesNativeAndOpenAIEndpoints(t *testing.T) { + tests := []struct { + name string + endpoint string + wantPath string + }{ + {name: "native generate", endpoint: "generate", wantPath: "/generate"}, + {name: "native health", endpoint: "health", wantPath: "/health"}, + {name: "explicit v1 rerank", endpoint: "v1/rerank", wantPath: "/v1/rerank"}, + {name: "OpenAI chat", endpoint: "chat/completions", wantPath: "/v1/chat/completions"}, + {name: "OpenAI models with query", endpoint: "models?limit=1", wantPath: "/v1/models"}, + {name: "OpenAI responses lifecycle", endpoint: "responses/resp-1", wantPath: "/v1/responses/resp-1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotPath, 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(`{}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("sglang-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 sglang-key" { + t.Fatalf("authorization = %q, want Bearer sglang-key", gotAuth) + } + }) + } +} + +func TestNewSharesKeyRotationWithNativePassthrough(t *testing.T) { + var gotAuth []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = append(gotAuth, r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[]}`)) + })) + defer server.Close() + + keys := providers.NewKeyring("key-one", "key-two") + provider := New(providers.ProviderConfig{ + Type: "sglang", + APIKey: "key-one", + BaseURL: server.URL + "/v1", + }, providers.ProviderOptions{Keys: keys}).(*Provider) + + if _, err := provider.ListModels(context.Background()); err != nil { + t.Fatalf("ListModels() error = %v", err) + } + resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ + Method: http.MethodGet, + Endpoint: "health", + }) + if err != nil { + t.Fatalf("Passthrough() error = %v", err) + } + defer resp.Body.Close() + + if len(gotAuth) != 2 || gotAuth[0] != "Bearer key-one" || gotAuth[1] != "Bearer key-two" { + t.Fatalf("authorization headers = %v", gotAuth) + } +} + +func TestSetBaseURLUpdatesOpenAIAndNativeClients(t *testing.T) { + var gotPaths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPaths = append(gotPaths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[]}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("", "http://127.0.0.1:1/v1", server.Client(), llmclient.Hooks{}) + provider.SetBaseURL(server.URL + "/v1") + if _, err := provider.ListModels(context.Background()); err != nil { + t.Fatalf("ListModels() error = %v", err) + } + resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ + Method: http.MethodGet, + Endpoint: "health", + }) + if err != nil { + t.Fatalf("Passthrough() error = %v", err) + } + defer resp.Body.Close() + + if len(gotPaths) != 2 || gotPaths[0] != "/v1/models" || gotPaths[1] != "/health" { + t.Fatalf("paths = %v, want [/v1/models /health]", gotPaths) + } +} diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 24692dc17..666042ed5 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -7131,7 +7131,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, openai, openrouter, vllm, zai") { + if !strings.Contains(rec.Body.String(), "anthropic, deepseek, kilo, 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 06714bba1..d63190a84 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", "vllm", "deepseek"} +var defaultEnabledPassthroughProviders = []string{"openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "deepseek"} func (h *Handler) setEnabledPassthroughProviders(providerTypes []string) { h.enabledPassthroughProviders = normalizeEnabledPassthroughProviders(providerTypes) diff --git a/internal/usage/cost.go b/internal/usage/cost.go index 0f5811006..c8b326844 100644 --- a/internal/usage/cost.go +++ b/internal/usage/cost.go @@ -76,7 +76,7 @@ var openAICompatibleTokenCostMappings = []tokenCostMapping{ // mappings. Providers not listed here fall back to // openAICompatibleTokenCostMappings (see tokenCostMappingsForProvider): every // other registered provider type (xiaomi, deepseek, zai, minimax, bailian, -// oracle, azure, vllm, ollama, opencode_go, …) speaks the OpenAI usage schema, +// oracle, azure, sglang, vllm, ollama, opencode_go, …) speaks the OpenAI usage schema, // so its cached/reasoning/audio token breakdowns must be priced the same way. // Only providers whose usage schema differs (anthropic, gemini) or that report // extra token types (xai) need an explicit entry. diff --git a/internal/usage/cost_test.go b/internal/usage/cost_test.go index 2bcd7176e..fadea1aa7 100644 --- a/internal/usage/cost_test.go +++ b/internal/usage/cost_test.go @@ -747,7 +747,7 @@ func TestCalculateGranularCost_OutputOnlyPricing(t *testing.T) { // TestCalculateGranularCost_OpenAICompatibleProvidersDefaultMappings is a // regression test for issue #435. Providers that speak the OpenAI usage schema // but are not explicitly listed in providerMappings (xiaomi, deepseek, zai, -// minimax, bailian, oracle, azure, vllm, ollama, opencode_go) must still apply +// minimax, bailian, oracle, azure, sglang, vllm, ollama, opencode_go) must still apply // the cached-input discount instead of billing cached tokens at the full input // rate. Previously these providers produced a much higher cost than "openai" // for the same response, over-charging cache-heavy workloads. @@ -768,7 +768,7 @@ func TestCalculateGranularCost_OpenAICompatibleProvidersDefaultMappings(t *testi for _, provider := range []string{ "xiaomi", "deepseek", "zai", "minimax", "bailian", - "oracle", "azure", "vllm", "ollama", "opencode_go", + "oracle", "azure", "sglang", "vllm", "ollama", "opencode_go", } { got := CalculateGranularCost(1_000_000, 200_000, rawData, provider, pricing) assertCostNear(t, provider+" InputCost", got.InputCost, *want.InputCost) diff --git a/run/providers.go b/run/providers.go index 93f9af6be..2a4e2ea20 100644 --- a/run/providers.go +++ b/run/providers.go @@ -23,6 +23,7 @@ import ( "github.com/enterpilot/gomodel/internal/providers/opencodego" "github.com/enterpilot/gomodel/internal/providers/openrouter" "github.com/enterpilot/gomodel/internal/providers/oracle" + "github.com/enterpilot/gomodel/internal/providers/sglang" "github.com/enterpilot/gomodel/internal/providers/vertex" "github.com/enterpilot/gomodel/internal/providers/vllm" "github.com/enterpilot/gomodel/internal/providers/xai" @@ -59,6 +60,7 @@ func defaultProviderFactory(cfg *config.Config) *providers.ProviderFactory { factory.Add(minimax.Registration) factory.Add(ollama.Registration) factory.Add(opencodego.Registration) + factory.Add(sglang.Registration) factory.Add(vllm.Registration) factory.Add(xai.Registration) factory.Add(xiaomi.Registration) diff --git a/run/providers_test.go b/run/providers_test.go index a53984b5c..c8e137556 100644 --- a/run/providers_test.go +++ b/run/providers_test.go @@ -46,6 +46,12 @@ func TestDefaultProviderFactoryCredentialForms(t *testing.T) { fields: []string{"api_keys", "base_url", "session_sticky_keys", "models"}, required: nil, }, + { + // SGLang supports both unauthenticated and --api-key deployments. + providerType: "sglang", + fields: []string{"api_keys", "base_url", "session_sticky_keys", "models"}, + required: nil, + }, { // Authenticates through the AWS SDK credential chain, never a key. providerType: "bedrock", @@ -142,7 +148,7 @@ func TestDefaultProviderFactoryRegistersAllProviderTypes(t *testing.T) { expected := []string{ "anthropic", "azure", "bailian", "bedrock", "bedrock-mantle", "cohere", "deepseek", "fireworks", "gemini", "groq", "kilo", "kimicode", "meta", "minimax", "ollama", "openai", "opencode_go", - "openrouter", "oracle", "vertex", "vllm", "xai", "xiaomi", "zai", + "openrouter", "oracle", "sglang", "vertex", "vllm", "xai", "xiaomi", "zai", } for _, metricsEnabled := range []bool{false, true} { diff --git a/web/dashboard/src/pages/overview/providersLogic.js b/web/dashboard/src/pages/overview/providersLogic.js index edeedfb3e..4fce397d6 100644 --- a/web/dashboard/src/pages/overview/providersLogic.js +++ b/web/dashboard/src/pages/overview/providersLogic.js @@ -24,6 +24,7 @@ const PROVIDER_DOC_SLUGS = { gemini: "gemini", opencode_go: "opencode-go", oracle: "oracle", + sglang: "sglang", vertex: "vertex", vllm: "vllm", xiaomi: "xiaomi", diff --git a/web/dashboard/tests/overview-providers.test.js b/web/dashboard/tests/overview-providers.test.js index 355cee5a1..3059a9e5d 100644 --- a/web/dashboard/tests/overview-providers.test.js +++ b/web/dashboard/tests/overview-providers.test.js @@ -157,6 +157,10 @@ test("providerDocUrl links provider types with docs and stays empty otherwise", providerDocUrl({ name: "gemini", type: "GEMINI" }), "https://gomodel.enterpilot.io/docs/providers/gemini?utm_source=gomodel_dashboard", ); + assert.equal( + providerDocUrl({ type: "sglang" }), + "https://gomodel.enterpilot.io/docs/providers/sglang?utm_source=gomodel_dashboard", + ); // Types without a provider-specific doc → no link (no icon). assert.equal(providerDocUrl({ type: "openai" }), ""); assert.equal(providerDocUrl({ type: "ollama" }), ""); From 4b205e56738669df8118817f70f2181e01b74a14 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 8 Aug 2026 18:59:28 +0200 Subject: [PATCH 2/2] fix(sglang): correct passthrough routing --- .env.template | 6 +++ CLAUDE.md | 4 +- internal/providers/config_test.go | 56 +++++++++++++++--------- internal/providers/sglang/sglang.go | 2 + internal/providers/sglang/sglang_test.go | 47 ++++++++++++++++++++ run/providers_test.go | 11 +++-- 6 files changed, 99 insertions(+), 27 deletions(-) diff --git a/.env.template b/.env.template index 495303270..66b06a0dd 100644 --- a/.env.template +++ b/.env.template @@ -539,6 +539,12 @@ # Set base URL to enable (default: http://localhost:8000/v1) # VLLM_BASE_URL=http://localhost:8000/v1 +# SGLang (OpenAI-compatible server) +# SGLANG_API_KEY is optional; set it only if launch_server uses --api-key. +# SGLANG_API_KEY=token-abc123 +# Set base URL to enable (default: http://localhost:30000/v1) +# SGLANG_BASE_URL=http://localhost:30000/v1 + # LM Studio (local, OpenAI-compatible server) # LM Studio speaks the OpenAI API (/v1/chat/completions, /v1/embeddings) and has # NO native Ollama API. Attach it to the openai type via a suffix so it gets a diff --git a/CLAUDE.md b/CLAUDE.md index c77815192..04ff7aa97 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 — rate limit counters, session affinity pins, live log buffers — resets as it would on restart. `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,zai,vllm: Comma-separated list of enabled passthrough providers) + - `ENABLED_PASSTHROUGH_PROVIDERS` (openai,anthropic,openrouter,kilo,zai,sglang,vllm,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. @@ -136,7 +136,7 @@ Full reference: `.env.template` and `config/config.yaml` - **Resilience:** Configured via `config/config.yaml` - global `resilience.retry.*` and `resilience.circuit_breaker.*` defaults with optional per-provider overrides under `providers..resilience.retry.*` and `providers..resilience.circuit_breaker.*`. Retry defaults: `max_retries` (3), `initial_backoff` (1s), `max_backoff` (30s), `backoff_factor` (2.0), `jitter_factor` (0.1). Circuit breaker defaults: `failure_threshold` (5), `success_threshold` (2), `timeout` (30s). Breaker state is per-process and exported as the `gomodel_circuit_breaker_state` gauge when metrics are enabled. The dashboard's provider status also folds in real-traffic request health: each provider's runtime row carries `request_health` (last observed breaker state plus per-model request/error counts over a 10-minute sliding window; a model with ≥3 errors and a ≥50% error rate is flagged). An open breaker turns the provider card's status pill to "Circuit Open" (unhealthy), a half-open breaker or a flagged model degrades an otherwise healthy provider ("Recovering"/"Degraded"), and the expanded card details list recent per-model traffic with the latest error. Signals only worsen the discovery-based status, never improve it; tracking is in-memory per instance, and providers with no recent requests show discovery-based status only. - **Metrics:** `METRICS_ENABLED` (false), `METRICS_ENDPOINT` (/metrics) - **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`. Keyless (Ollama, SGLang, vLLM) and non-API-key providers (Vertex, Bedrock) are unaffected. +- **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), `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) - **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/internal/providers/config_test.go b/internal/providers/config_test.go index cc22f9c5e..953cd8883 100644 --- a/internal/providers/config_test.go +++ b/internal/providers/config_test.go @@ -943,29 +943,43 @@ func TestApplyProviderEnvVars_DiscoversVLLMFromBaseURLWithoutAPIKey(t *testing.T } } -func TestApplyProviderEnvVarsDiscoversSGLangWithoutAPIKey(t *testing.T) { - t.Setenv("SGLANG_BASE_URL", "http://localhost:30000/v1") - - got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) - p, exists := got["sglang"] - if !exists { - t.Fatal("expected sglang to be discovered from SGLANG_BASE_URL") - } - if p.Type != "sglang" || p.BaseURL != "http://localhost:30000/v1" || p.APIKey != "" { - t.Fatalf("sglang config = %+v", p) +func TestApplyProviderEnvVars_DiscoversSGLang(t *testing.T) { + tests := []struct { + name string + apiKey string + baseURL string + wantAPIKey string + wantBaseURL string + }{ + { + name: "base URL without API key", + baseURL: "http://localhost:30000/v1", + wantBaseURL: "http://localhost:30000/v1", + }, + { + name: "API key with default base URL", + apiKey: "sglang-key", + wantAPIKey: "sglang-key", + wantBaseURL: testDiscoveryConfigs["sglang"].DefaultBaseURL, + }, } -} - -func TestApplyProviderEnvVarsDiscoversSGLangFromAPIKeyWithDefaultBaseURL(t *testing.T) { - t.Setenv("SGLANG_API_KEY", "sglang-key") - got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) - p, exists := got["sglang"] - if !exists { - t.Fatal("expected sglang to be discovered from SGLANG_API_KEY") - } - if p.APIKey != "sglang-key" || p.BaseURL != testDiscoveryConfigs["sglang"].DefaultBaseURL { - t.Fatalf("sglang config = %+v", p) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set both variables so ambient provider configuration cannot leak + // between the keyless and authenticated discovery cases. + t.Setenv("SGLANG_API_KEY", tt.apiKey) + t.Setenv("SGLANG_BASE_URL", tt.baseURL) + + got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) + p, exists := got["sglang"] + if !exists { + t.Fatal("expected sglang to be discovered") + } + if p.Type != "sglang" || p.APIKey != tt.wantAPIKey || p.BaseURL != tt.wantBaseURL { + t.Fatalf("sglang config = %+v, want api_key=%q base_url=%q", p, tt.wantAPIKey, tt.wantBaseURL) + } + }) } } diff --git a/internal/providers/sglang/sglang.go b/internal/providers/sglang/sglang.go index 0b8bb0a25..51f3fe3e0 100644 --- a/internal/providers/sglang/sglang.go +++ b/internal/providers/sglang/sglang.go @@ -168,6 +168,8 @@ func usesV1PassthroughBase(endpoint string) bool { "/responses", "/completions", "/embeddings", + "/rerank", + "/tokenize", "/audio", "/files", "/batches", diff --git a/internal/providers/sglang/sglang_test.go b/internal/providers/sglang/sglang_test.go index 3e1a10f70..acc0be83c 100644 --- a/internal/providers/sglang/sglang_test.go +++ b/internal/providers/sglang/sglang_test.go @@ -151,6 +151,51 @@ func TestOpenAICompatibleEndpoints(t *testing.T) { } } +func TestOpenAICompatibleStreamingEndpoints(t *testing.T) { + tests := []struct { + name string + wantPath string + call func(*Provider) (io.ReadCloser, error) + }{ + { + name: "chat completions", + wantPath: "/v1/chat/completions", + call: func(p *Provider) (io.ReadCloser, error) { + return p.StreamChatCompletion(context.Background(), &core.ChatRequest{Model: "test"}) + }, + }, + { + name: "responses", + wantPath: "/v1/responses", + call: func(p *Provider) (io.ReadCloser, error) { + return p.StreamResponses(context.Background(), &core.ResponsesRequest{Model: "test"}) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(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", "text/event-stream") + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + + provider := NewWithHTTPClient("", server.URL+"/v1", server.Client(), llmclient.Hooks{}) + body, err := tt.call(provider) + if err != nil { + t.Fatalf("streaming call error = %v", err) + } + defer body.Close() + if gotPath != tt.wantPath { + t.Fatalf("path = %q, want %q", gotPath, tt.wantPath) + } + }) + } +} + func TestProviderExposesOnlyVerifiedOptionalInterfaces(t *testing.T) { provider := NewWithHTTPClient("", "", nil, llmclient.Hooks{}) @@ -177,6 +222,8 @@ func TestPassthroughRoutesNativeAndOpenAIEndpoints(t *testing.T) { {name: "native generate", endpoint: "generate", wantPath: "/generate"}, {name: "native health", endpoint: "health", wantPath: "/health"}, {name: "explicit v1 rerank", endpoint: "v1/rerank", wantPath: "/v1/rerank"}, + {name: "normalized v1 rerank alias", endpoint: "rerank", wantPath: "/v1/rerank"}, + {name: "normalized v1 tokenize alias", endpoint: "tokenize", wantPath: "/v1/tokenize"}, {name: "OpenAI chat", endpoint: "chat/completions", wantPath: "/v1/chat/completions"}, {name: "OpenAI models with query", endpoint: "models?limit=1", wantPath: "/v1/models"}, {name: "OpenAI responses lifecycle", endpoint: "responses/resp-1", wantPath: "/v1/responses/resp-1"}, diff --git a/run/providers_test.go b/run/providers_test.go index c8e137556..c7ff5a674 100644 --- a/run/providers_test.go +++ b/run/providers_test.go @@ -109,12 +109,15 @@ func TestDefaultProviderFactoryCredentialForms(t *testing.T) { if !slices.Equal(names, tt.fields) { t.Errorf("fields = %v, want %v", names, tt.fields) } - for _, name := range tt.required { - field, found := schema.Field(name) - if !found || !field.Required { - t.Errorf("%s.Required = %v (present=%v), want true", name, field.Required, found) + var requiredNames []string + for _, field := range schema.Fields { + if field.Required { + requiredNames = append(requiredNames, field.Name) } } + if !slices.Equal(requiredNames, tt.required) { + t.Errorf("required = %v, want %v", requiredNames, tt.required) + } for _, name := range tt.absent { if schema.Accepts(name) { t.Errorf("Accepts(%s) = true, want false for provider type %q", name, tt.providerType)