diff --git a/.env.template b/.env.template index cd4ca3760..6b78476d1 100644 --- a/.env.template +++ b/.env.template @@ -48,8 +48,9 @@ # Allow optional /p/{provider}/v1/... passthrough aliases while keeping /p/{provider}/... canonical (default: true) # ALLOW_PASSTHROUGH_V1_ALIAS=true -# Comma-separated list of provider types enabled for /p/{provider}/... passthrough (default: openai,anthropic,openrouter,zai,vllm) -# ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,openrouter,zai,vllm +# Comma-separated list of provider types enabled for /p/{provider}/... passthrough (default: openai,anthropic,openrouter,kilo,zai,vllm,deepseek) +# Cohere native passthrough is opt-in; add cohere when those routes are needed. +# ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,cohere,openrouter,kilo,zai,vllm,deepseek # Enable the realtime (speech-to-speech) endpoints (default: true): the /v1/realtime # websocket (and /p/{provider}/v1/realtime passthrough upgrade), the WebRTC SDP @@ -402,6 +403,10 @@ # longer outputs (e.g. Sonnet 4.6 / Opus 4.7). # ANTHROPIC_DEFAULT_MAX_TOKENS=4096 +# Cohere +# COHERE_API_KEY=... +# COHERE_BASE_URL=https://api.cohere.com + # Google Gemini # GEMINI_API_KEY=... # Use Gemini's native generateContent API for chat/responses (default: true). diff --git a/README.md b/README.md index 95e38dad2..97b87325c 100644 --- a/README.md +++ b/README.md @@ -154,11 +154,11 @@ const client = new Anthropic({ ## Supported LLM Providers -GoModel supports OpenAI, Anthropic, 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 all -OpenAI-compatible providers. +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 +all OpenAI-compatible providers. See the [Providers Overview](https://gomodel.enterpilot.io/docs/providers/overview?utm_source=readme) for the full per-provider feature matrix (chat, `/responses`, embeddings, files, batches, diff --git a/config/config.example.yaml b/config/config.example.yaml index 8e7afe486..779e07887 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", "openrouter", "kilo", "zai", "vllm", "deepseek", "bailian"] # providers enabled on /p/{provider}/... + enabled_passthrough_providers: ["openai", "anthropic", "cohere", "openrouter", "kilo", "zai", "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) models: @@ -311,6 +311,10 @@ providers: type: anthropic api_key: "sk-ant-..." + cohere: + type: cohere + api_key: "${COHERE_API_KEY}" + bailian: type: bailian api_key: "${BAILIAN_API_KEY}" diff --git a/config/config_test.go b/config/config_test.go index 72434adca..25a6975a9 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -19,6 +19,7 @@ func clearProviderEnvVars(t *testing.T) { for _, key := range []string{ "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_MODELS", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODELS", + "COHERE_API_KEY", "COHERE_BASE_URL", "COHERE_MODELS", "GEMINI_API_KEY", "GEMINI_BASE_URL", "GEMINI_MODELS", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL", "DEEPSEEK_MODELS", "XAI_API_KEY", "XAI_BASE_URL", "XAI_MODELS", @@ -1131,7 +1132,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", "openrouter", "kilo", "zai", "vllm", "deepseek", "bailian"} + wantProviders := []string{"openai", "anthropic", "cohere", "openrouter", "kilo", "zai", "vllm", "deepseek", "bailian"} if !reflect.DeepEqual(gotProviders, wantProviders) { t.Fatalf("Server.EnabledPassthroughProviders = %v, want %v", gotProviders, wantProviders) } diff --git a/docs/about/roadmap.mdx b/docs/about/roadmap.mdx index 9e3326753..890ba0702 100644 --- a/docs/about/roadmap.mdx +++ b/docs/about/roadmap.mdx @@ -17,7 +17,7 @@ icon: "list-todo" ## Roadmap to 0.2.0 -- [ ] Broader provider support, including Cohere, Command A, and Operational +- [ ] Broader provider support beyond the current catalog - [ ] Workflows rework with better UI/UX and a more flexible design - [ ] Guardrails hardening: better UI, simpler architecture, easier custom guardrails, and response-side guardrails applied before output reaches the client - [ ] Provider-native passthrough for all providers, beyond the current beta coverage diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 154f6c0aa..d4ab68863 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -243,6 +243,7 @@ Set these to automatically register providers. No YAML configuration required. | -------------------- | -------------------------------------------------- | | `OPENAI_API_KEY` | OpenAI | | `ANTHROPIC_API_KEY` | Anthropic | +| `COHERE_API_KEY` | Cohere | | `GEMINI_API_KEY` | Google Gemini | | `DEEPSEEK_API_KEY` | DeepSeek | | `OPENROUTER_API_KEY` | OpenRouter | @@ -376,6 +377,7 @@ The simplest way to add providers. GoModel checks for well-known API key environ ```bash export OPENAI_API_KEY="sk-..." # Registers "openai" provider export ANTHROPIC_API_KEY="sk-ant-..." # Registers "anthropic" provider +export COHERE_API_KEY="..." # Registers "cohere" provider export GEMINI_API_KEY="..." # Registers "gemini" provider export DEEPSEEK_API_KEY="..." # Registers "deepseek" provider export XAI_API_KEY="..." # Registers "xai" provider diff --git a/docs/docs.json b/docs/docs.json index 99658d1dd..d3f21981e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -139,6 +139,7 @@ "providers/overview", "providers/key-rotation", "providers/anthropic", + "providers/cohere", "providers/gemini", "providers/deepseek", "providers/xai", diff --git a/docs/providers/cohere.mdx b/docs/providers/cohere.mdx new file mode 100644 index 000000000..35752e202 --- /dev/null +++ b/docs/providers/cohere.mdx @@ -0,0 +1,117 @@ +--- +title: "Cohere" +description: "Configure Cohere in GoModel and use Command chat, tool calling, reasoning, image input, audio transcription, and Embed models through OpenAI-compatible APIs." +icon: "comment" +--- + +GoModel translates OpenAI-compatible Chat Completions, Responses, Embeddings, +and Audio Transcription requests to Cohere's native v2 API. Model +discovery uses Cohere's model catalog and exposes chat, embedding, and +transcription models through `/v1/models`. + +## Configure + +```bash +COHERE_API_KEY=... +# COHERE_BASE_URL=https://api.cohere.com # optional override +``` + +Or in `config.yaml`: + +```yaml +providers: + cohere: + type: cohere + api_key: "${COHERE_API_KEY}" +``` + +Multiple keys use the standard `COHERE_API_KEY_2`, +`COHERE_API_KEY_3`, ... rotation convention. You can restrict or supplement +model discovery with `COHERE_MODELS`. + +## Chat and Responses + +Use a Cohere Command model with either OpenAI-compatible endpoint: + +```bash +curl http://localhost:8080/v1/chat/completions \ + -H "Authorization: Bearer $GOMODEL_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "command-a-plus-05-2026", + "messages": [{"role": "user", "content": "Explain vector search briefly"}] + }' +``` + +Chat streaming, system and developer messages, image URL parts, JSON response +formats, tool definitions, tool calls, and tool results are translated to the +Cohere v2 format. `/v1/responses` uses the same chat adapter. + +Cohere reasoning models enable thinking by default. To control Cohere's native +reasoning behavior, include its `thinking` object as an extension: + +```json +{ + "model": "command-a-reasoning-08-2025", + "messages": [{"role": "user", "content": "Solve this carefully..."}], + "thinking": {"type": "enabled", "token_budget": 4000} +} +``` + +Thinking output is returned as the OpenAI-compatible `reasoning_content` +extension in both normal and streaming responses. + +## Audio transcription + +Cohere audio uses a dedicated speech-to-text model and endpoint; Command chat +models do not accept audio content mixed into Chat Completions or Responses. +Transcribe audio through the OpenAI-compatible endpoint: + +```bash +curl http://localhost:8080/v1/audio/transcriptions \ + -H "Authorization: Bearer $GOMODEL_API_KEY" \ + -F "model=cohere/cohere-transcribe-03-2026" \ + -F "language=en" \ + -F "response_format=json" \ + -F "file=@recording.wav" +``` + +The Cohere transcription API requires `language` and returns JSON. GoModel +accepts the OpenAI-compatible `response_format=json` field but omits it from the +native request because JSON is Cohere's fixed response format. Cohere does not +support text-to-speech, prompt hints, timestamp granularities, or streaming +transcription. + +## Embeddings + +GoModel supplies Cohere's required embedding fields while retaining the +OpenAI request shape: + +```bash +curl http://localhost:8080/v1/embeddings \ + -H "Authorization: Bearer $GOMODEL_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "embed-v4.0", + "input": ["first document", "second document"], + "dimensions": 1024, + "input_type": "search_document" + }' +``` + +`input_type` defaults to `search_document`. Set it to `search_query`, +`classification`, or `clustering` when that better describes the input. +`encoding_format` supports `float` (the default) and `base64`. Cohere-native +`truncate`, `max_tokens`, and `priority` fields are also passed through. + +## Native passthrough + +Add `cohere` to `server.enabled_passthrough_providers`, then use paths such as +`/p/cohere/v2/chat` or `/p/cohere/v2/rerank` to call Cohere-native APIs that do +not have an OpenAI-compatible GoModel surface. + +See Cohere's [Chat API](https://docs.cohere.com/reference/chat), +[Embed API](https://docs.cohere.com/reference/embed), +[Audio Transcription API](https://docs.cohere.com/reference/create-audio-transcription), +and [reasoning guide](https://docs.cohere.com/docs/reasoning) for native fields +and model-specific capabilities. diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index 1f88bb494..0c64591b2 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -41,6 +41,7 @@ support, not every individual model capability exposed by an upstream provider. | -------- | ---------- | ------------- | :--: | :----------: | :---: | :---: | :-----: | :------: | ----- | | OpenAI | `OPENAI_API_KEY` | `gpt-5.5` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | | Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-20250514` | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | [Anthropic](/providers/anthropic) | +| Cohere | `COHERE_API_KEY` | `command-a-plus-05-2026` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [Cohere](/providers/cohere) | | Google Gemini | `GEMINI_API_KEY` | `gemini-2.5-flash` | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | [Google Gemini](/providers/gemini) | | Google Vertex AI | `VERTEX_PROJECT` + `VERTEX_LOCATION` + GCP credentials | `google/gemini-2.5-flash` | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | [Google Vertex AI](/providers/vertex) | | DeepSeek | `DEEPSEEK_API_KEY` | `deepseek-v4-pro` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | [DeepSeek](/providers/deepseek) | diff --git a/docs/snippets/provider-credentials-note.mdx b/docs/snippets/provider-credentials-note.mdx index f8c249c0a..c8d23a578 100644 --- a/docs/snippets/provider-credentials-note.mdx +++ b/docs/snippets/provider-credentials-note.mdx @@ -1,6 +1,6 @@ Set at least one provider credential or base URL - (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, + (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `COHERE_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `XAI_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY`, `KILO_API_KEY`, `ZAI_API_KEY`, `AZURE_API_KEY` + `AZURE_BASE_URL`, `ORACLE_API_KEY` + `ORACLE_BASE_URL`, diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 2680a40ee..885bba2d9 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: gomodel -description: High-performance AI gateway for multiple LLM providers (OpenAI, Anthropic, Gemini, DeepSeek, Groq, Kilo AI, Z.ai, xAI) +description: High-performance AI gateway for multiple LLM providers (OpenAI, Anthropic, Cohere, Gemini, DeepSeek, Groq, Kilo AI, Z.ai, xAI) type: application version: 0.1.0 appVersion: "1.0.0" @@ -11,6 +11,7 @@ keywords: - gateway - openai - anthropic + - cohere - gemini - groq - xai diff --git a/helm/README.md b/helm/README.md index 548ff86a7..c18de6110 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, 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). ## Prerequisites @@ -58,6 +58,8 @@ helm install gomodel ./helm \ | `providers.existingSecret` | Existing secret for API keys | `""` | | `providers.openai.enabled` | Enable OpenAI | `false` | | `providers.anthropic.enabled` | Enable Anthropic | `false` | +| `providers.cohere.enabled` | Enable Cohere | `false` | +| `providers.cohere.baseUrl` | Optional Cohere base URL mapped to `COHERE_BASE_URL` | `""` | | `providers.gemini.enabled` | Enable Gemini | `false` | | `providers.gemini.useNativeApi` | Use Gemini native generateContent for chat/responses; set false for Gemini OpenAI compatibility | `true` | | `providers.groq.enabled` | Enable Groq | `false` | @@ -92,6 +94,7 @@ type: Opaque stringData: OPENAI_API_KEY: "sk-..." ANTHROPIC_API_KEY: "sk-ant-..." + COHERE_API_KEY: "..." GEMINI_API_KEY: "..." ZAI_API_KEY: "..." KILO_API_KEY: "..." diff --git a/helm/templates/NOTES.txt b/helm/templates/NOTES.txt index 2c6127768..c651ebeef 100644 --- a/helm/templates/NOTES.txt +++ b/helm/templates/NOTES.txt @@ -36,6 +36,7 @@ To get the application URL: {{- $enabledProviders := list }} {{- if or .Values.providers.openai.enabled .Values.providers.openai.apiKey }}{{ $enabledProviders = append $enabledProviders "openai" }}{{ end }} {{- if or .Values.providers.anthropic.enabled .Values.providers.anthropic.apiKey }}{{ $enabledProviders = append $enabledProviders "anthropic" }}{{ end }} +{{- if or .Values.providers.cohere.enabled .Values.providers.cohere.apiKey }}{{ $enabledProviders = append $enabledProviders "cohere" }}{{ end }} {{- if or .Values.providers.gemini.enabled .Values.providers.gemini.apiKey }}{{ $enabledProviders = append $enabledProviders "gemini" }}{{ end }} {{- if or .Values.providers.groq.enabled .Values.providers.groq.apiKey }}{{ $enabledProviders = append $enabledProviders "groq" }}{{ end }} {{- if or .Values.providers.xai.enabled .Values.providers.xai.apiKey }}{{ $enabledProviders = append $enabledProviders "xai" }}{{ end }} diff --git a/helm/values.schema.json b/helm/values.schema.json index 5f0e386b6..296099638 100644 --- a/helm/values.schema.json +++ b/helm/values.schema.json @@ -141,6 +141,17 @@ } } }, + { + "properties": { + "providers": { + "properties": { + "cohere": { + "properties": { "apiKey": { "minLength": 1 } } + } + } + } + } + }, { "properties": { "providers": { @@ -261,6 +272,14 @@ "baseUrl": { "type": "string" } } }, + "cohere": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "apiKey": { "type": "string" }, + "baseUrl": { "type": "string" } + } + }, "gemini": { "type": "object", "properties": { diff --git a/helm/values.yaml b/helm/values.yaml index ba2c1a9b6..f06099f46 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: OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, XAI_API_KEY, ZAI_API_KEY, KILO_API_KEY, ORACLE_API_KEY, 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, or VLLM_API_KEY existingSecret: "" openai: @@ -61,6 +61,14 @@ providers: # -- Optional: Override Anthropic base URL baseUrl: "" + cohere: + # -- Enable Cohere provider (auto-enabled if apiKey is set) + enabled: false + # -- Cohere API key (ignored if providers.existingSecret is set) + apiKey: "" + # -- Optional: Override Cohere base URL + baseUrl: "" + gemini: # -- Enable Google Gemini provider (auto-enabled if apiKey is set) enabled: false diff --git a/internal/admin/dashboard/static/dist/assets/index-DmWIREH6.js b/internal/admin/dashboard/static/dist/assets/index-BeTR3jY3.js similarity index 99% rename from internal/admin/dashboard/static/dist/assets/index-DmWIREH6.js rename to internal/admin/dashboard/static/dist/assets/index-BeTR3jY3.js index 7f1bb68a8..47ada5565 100644 --- a/internal/admin/dashboard/static/dist/assets/index-DmWIREH6.js +++ b/internal/admin/dashboard/static/dist/assets/index-BeTR3jY3.js @@ -5,7 +5,7 @@ var e=Object.defineProperty,t=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],en `)>-1?e.split(` `):e}function _q(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 vq(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=iV(t.bodyFont),l=iV(t.titleFont),u=iV(t.footerFont),d=a.length,f=i.length,p=r.length,m=rV(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,rz(e.title,y),n.font=c.string,rz(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,rz(r,e=>{rz(e.before,y),rz(e.lines,y),rz(e.after,y)}),v=0,n.font=u.string,rz(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function yq(e,t){let{y:n,height:r}=t;return ne.height-r/2?`bottom`:`center`}function bq(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 xq(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`),bq(l,e,t,n)&&(l=`center`),l}function Sq(e,t,n){let r=n.yAlign||t.yAlign||yq(e,n);return{xAlign:n.xAlign||t.xAlign||xq(e,t,n,r),yAlign:r}}function Cq(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function wq(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:t===`bottom`?r-=i+n:r-=i/2,r}function Tq(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}=nV(o),m=Cq(t,s),h=wq(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:Gz(m,0,r.width-t.width),y:Gz(h,0,r.height-t.height)}}function Eq(e,t,n){let r=rV(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function Dq(e){return hq([],gq(e))}function Oq(e,t,n){return sV(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function kq(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var Aq={beforeTitle:KR,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=kq(n,e);hq(t.before,gq(jq(i,`beforeLabel`,this,e))),hq(t.lines,jq(i,`label`,this,e)),hq(t.after,gq(jq(i,`afterLabel`,this,e))),r.push(t)}),r}getAfterBody(e,t){return Dq(jq(t.callbacks,`afterBody`,this,e))}getFooter(e,t){let{callbacks:n}=t,r=jq(n,`beforeFooter`,this,e),i=jq(n,`footer`,this,e),a=jq(n,`afterFooter`,this,e),o=[];return o=hq(o,gq(r)),o=hq(o,gq(i)),o=hq(o,gq(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))),rz(o,t=>{let n=kq(e.callbacks,t);r.push(jq(n,`labelColor`,this,t)),i.push(jq(n,`labelPointStyle`,this,t)),a.push(jq(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=mq[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=vq(this,n),o=Object.assign({},e,t),s=Sq(this.chart,n,o),c=Tq(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}=nV(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=aH(n.rtl,this.x,this.width);for(e.x=Eq(this,n.titleAlign,n),t.textAlign=c.textAlign(n.titleAlign),t.textBaseline=`middle`,a=iV(n.titleFont),o=n.titleSpacing,t.fillStyle=n.titleColor,t.font=a.string,s=0;se!==0)?(e.beginPath(),e.fillStyle=i.multiKeyBackground,YB(e,{x:t,y:p,w:c,h:s,radius:o}),e.fill(),e.stroke(),e.fillStyle=a.backgroundColor,e.beginPath(),YB(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=iV(n.bodyFont),d=u.lineHeight,f=0,p=aH(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=Eq(this,h,n),t.fillStyle=n.bodyColor,rz(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=mq[e.position].call(this,this._active,this._eventPosition);if(!n)return;let a=this._size=vq(this,e),o=Object.assign({},n,this._size),s=Sq(t,e,o),c=Tq(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=rV(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),oH(e,t.textDirection),i.y+=a.top,this.drawTitle(i,e,t),this.drawBody(i,e,t),this.drawFooter(i,e,t),sH(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=!iz(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||!iz(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=mq[i.position].call(this,e,t);return a!==!1&&(n!==a.x||r!==a.y)}},Nq=Object.freeze({__proto__:null,Colors:gK,Decimation:SK,Filler:$K,Legend:cq,SubTitle:pq,Title:dq,Tooltip:{id:`tooltip`,_element:Mq,positioners:mq,afterInit(e,t,n){n&&(e.tooltip=new Mq({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:Aq},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`]}}),Pq=(e,t,n,r)=>(typeof t==`string`?(n=e.push(t)-1,r.unshift({index:n,label:t})):isNaN(t)&&(n=null),n);function Fq(e,t,n,r){let i=e.indexOf(t);return i===-1?Pq(e,t,n,r):i===e.lastIndexOf(t)?i:n}var Iq=(e,t)=>e===null?null:Gz(Math.round(e),0,t);function Lq(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 zq(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=!JR(a),_=!JR(o),v=!JR(c),y=(h-m)/(u+1),b=jz((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=jz(w*b/p/f)*f),JR(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&&Fz((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=Az(w,Math.round(w),b/1e3)?Math.round(w):Math.ceil(w));let T=Math.max(zz(b),zz(S));x=10**(JR(s)?T:s),S=Math.round(S*x)/x,C=Math.round(C*x)/x;let E=0;for(g&&(d&&S!==a?(n.push({value:a}),So)break;n.push({value:e})}return _&&d&&C!==o?n.length&&Az(n[n.length-1].value,o,Bq(o,y,e))?n[n.length-1].value=o:n.push({value:o}):(!_||C===o)&&n.push({value:C}),n}function Bq(e,t,{horizontal:n,minRotation:r}){let i=Lz(r),a=(n?Math.sin(i):Math.cos(i))||.001,o=.75*t*(``+e).length;return Math.min(t/a,o)}var Vq=class extends FW{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 JR(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=kz(r),t=kz(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=zq({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`&&Iz(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 CB(e,this.chart.options.locale,this.options.ticks.format)}},Hq=class extends Vq{static id=`linear`;static defaults={ticks:{callback:EB.formatters.numeric}};determineDataLimits(){let{min:e,max:t}=this.getMinMax(!0);this.min=ZR(e)?e:0,this.max=ZR(t)?t:1,this.handleTickRangeOptions()}computeTickLimit(){let e=this.isHorizontal(),t=e?this.width:this.height,n=Lz(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}},Uq=e=>Math.floor(Oz(e)),Wq=(e,t)=>10**(Uq(e)+t);function Gq(e){return e/10**Uq(e)==1}function Kq(e,t,n){let r=10**n,i=Math.floor(e/r);return Math.ceil(t/r)-i}function qq(e,t){let n=Uq(t-e);for(;Kq(e,t,n)>10;)n++;for(;Kq(e,t,n)<10;)n--;return Math.min(n,Uq(e))}function Jq(e,{min:t,max:n}){t=QR(e.min,t);let r=[],i=Uq(t),a=qq(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=QR(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=QR(e.max,f);return r.push({value:p,major:Gq(p),significand:d}),r}var Yq=class extends FW{static id=`logarithmic`;static defaults={ticks:{callback:EB.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=Vq.prototype.parse.apply(this,[e,t]);if(n===0){this._zero=!0;return}return ZR(n)&&n>0?n:null}determineDataLimits(){let{min:e,max:t}=this.getMinMax(!0);this.min=ZR(e)?Math.max(0,e):null,this.max=ZR(t)?Math.max(0,t):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!ZR(this._userMin)&&(this.min=e===Wq(this.min,0)?Wq(this.min,-1):Wq(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(Wq(n,-1)),a(Wq(r,1)))),n<=0&&i(Wq(r,-1)),r<=0&&a(Wq(n,1)),this.min=n,this.max=r}buildTicks(){let e=this.options,t=Jq({min:this._userMin,max:this._userMax},this);return e.bounds===`ticks`&&Iz(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`:CB(e,this.chart.options.locale,this.options.ticks.format)}configure(){let e=this.min;super.configure(),this._startValue=Oz(e),this._valueRange=Oz(this.max)-Oz(e)}getPixelForValue(e){return(e===void 0||e===0)&&(e=this.min),e===null||isNaN(e)?NaN:this.getPixelForDecimal(e===this.min?0:(Oz(e)-this._startValue)/this._valueRange)}getValueForPixel(e){let t=this.getDecimalForPixel(e);return 10**(this._startValue+t*this._valueRange)}};function Xq(e){let t=e.ticks;if(t.display&&e.display){let e=rV(t.backdropPadding);return $R(t.font&&t.font.size,MB.font.size)+e.height}return 0}function Zq(e,t,n){return n=YR(n)?n:[n],{w:FB(e,t.string,n),h:n.length*t.lineHeight}}function Qq(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 $q(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?bz/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 tJ(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(Rz(Uz(c.angle+Tz))),u=oJ(c.y,s.h,l),d=iJ(l),f=aJ(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 nJ(e,t){if(!t)return!0;let{left:n,top:r,right:i,bottom:a}=e;return!(BB({x:n,y:r},t)||BB({x:n,y:a},t)||BB({x:i,y:r},t)||BB({x:i,y:a},t))}function rJ(e,t,n){let r=[],i=e._pointLabels.length,a=e.options,{centerPointLabels:o,display:s}=a.pointLabels,c={extra:Xq(a)/2,additionalAngle:o?bz/i:0},l;for(let a=0;a270||n<90)&&(e-=t),e}function sJ(e,t,n){let{left:r,top:i,right:a,bottom:o}=n,{backdropColor:s}=t;if(!JR(s)){let n=nV(t.borderRadius),c=rV(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(),YB(e,{x:l,y:u,w:d,h:f,radius:n}),e.fill()):e.fillRect(l,u,d,f)}}function cJ(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));sJ(n,a,t);let o=iV(a.font),{x:s,y:c,textAlign:l}=t;JB(n,e._pointLabels[i],s,c+o.lineHeight/2,o,{color:a.color,textAlign:l,textBaseline:`middle`})}}function lJ(e,t,n,r){let{ctx:i}=e;if(n)i.arc(e.xCenter,e.yCenter,t,0,xz);else{let n=e.getPointPosition(0,t);i.moveTo(n.x,n.y);for(let a=1;a{let n=nz(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?$q(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=xz/(this._pointLabels.length||1),n=this.options.startAngle||0;return Uz(e*t+Lz(n))}getDistanceFromCenterForValue(e){if(JR(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(JR(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);uJ(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=iV(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=rV(s.backdropPadding);e.fillRect(-a/2-t.left,-i-c.size/2-t.top,a+t.width,c.size+t.height)}JB(e,r.label,0,-i,c,{color:s.color,strokeColor:s.textStrokeColor,strokeWidth:s.textStrokeWidth})}),e.restore()}drawTitle(){}},pJ={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}},mJ=Object.keys(pJ);function hJ(e,t){return e-t}function gJ(e,t){if(JR(t))return null;let n=e._adapter,{parser:r,round:i,isoWeekday:a}=e._parseOpts,o=t;return typeof r==`function`&&(o=r(o)),ZR(o)||(o=typeof r==`string`?n.parse(o,r):n.parse(o)),o===null?null:(i&&(o=i===`week`&&(Pz(a)||a===!0)?n.startOf(o,`isoWeek`,a):n.startOf(o,i)),+o)}function _J(e,t,n,r){let i=mJ.length;for(let a=mJ.indexOf(e);a=mJ.indexOf(n);a--){let n=mJ[a];if(pJ[n].common&&e._adapter.diff(i,r,n)>=t-1)return n}return mJ[n?mJ.indexOf(n):0]}function yJ(e){for(let t=mJ.indexOf(e)+1,n=mJ.length;t=t?n[r]:n[i];e[a]=!0}}function xJ(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 SJ(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=Gz(t,0,a),n=Gz(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||_J(i.minUnit,t,n,this._getLabelCapacity(t)),o=$R(r.ticks.stepSize,1),s=a===`week`&&i.isoWeekday,c=Pz(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 nz(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}=Yz(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}=Yz(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 TJ=class extends CJ{static id=`timeseries`;static defaults=CJ.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=wJ(t,this.min),this._tableRange=wJ(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(wJ(this._table,e)-this._minPos)/this._tableRange}getValueForPixel(e){let t=this._offsets,n=this.getDecimalForPixel(e)/t.factor-t.end;return wJ(this._table,n*this._tableRange+this._minPos,!0)}},EJ=[_U,iK,Nq,Object.freeze({__proto__:null,CategoryScale:Rq,LinearScale:Hq,LogarithmicScale:Yq,RadialLinearScale:fJ,TimeScale:CJ,TimeSeriesScale:TJ})];CG.register(...EJ);var DJ=CG,OJ=R(``);function kJ(e,t){O(t,!0);let n=da(t,`class`,3,``),r=da(t,`ariaLabel`,3,``),i=j(null),a=null;An(()=>{if(II.tick,!I(i)||typeof t.build!=`function`)return;let e=t.build();if(!e){a&&=(a.destroy(),null);return}return a&&=(a.destroy(),null),a=new DJ(I(i).getContext(`2d`),e),()=>{a&&=(a.destroy(),null)}});var o=OJ();ca(o,e=>M(i,e),()=>I(i)),F(()=>{H(o,1,ki(n())),W(o,`aria-label`,r())}),z(e,o),k()}var AJ=R(``),jJ=R(`
`);function MJ(e,t){O(t,!0);let n=da(t,`options`,19,()=>[]),r=da(t,`ariaLabel`,3,``),i=da(t,`class`,3,``);var a=jJ();si(a,21,n,e=>e.value,(e,n)=>{var r=AJ();let i;var a=N(r,!0);D(r),F(()=>{i=H(r,1,`segmented-btn svelte-92fh5i`,null,i,{active:t.value===I(n).value}),W(r,`aria-pressed`,t.value===I(n).value),B(a,I(n).label)}),L(`click`,r,()=>t.onchange?.(I(n).value)),z(e,r)}),D(a),F(()=>{H(a,1,`segmented-control ${i()??``}`,`svelte-92fh5i`),W(a,`aria-label`,r())}),z(e,a),k()}Br([`click`]);function NJ(e){return getComputedStyle(document.documentElement).getPropertyValue(e).trim()}function PJ(){return{grid:NJ(`--chart-grid`),text:NJ(`--chart-text`),dayMarker:NJ(`--chart-day-marker`),tooltipBg:NJ(`--chart-tooltip-bg`),tooltipBorder:NJ(`--chart-tooltip-border`),tooltipText:NJ(`--chart-tooltip-text`)}}function FJ(e){return e==null||e===void 0?`-`:e.toLocaleString()}function IJ(e){if(e==null)return`---`;let t=Number(e);return Number.isFinite(t)?t>0&&t<1e-4?`<$0.0001`:`$`+t.toFixed(4).replace(/(\.\d{2}\d*?)0+$/,`$1`):`---`}function LJ(e){return e==null||e===void 0?`—`:`$`+e.toFixed(2)}function RJ(e){return e==null||e===void 0?`—`:e<.01?`$`+e.toFixed(6):`$`+e.toFixed(4)}function zJ(e){if(e==null||e===``)return`-`;let t=Number(e);if(!Number.isFinite(t))return`-`;let n=Math.abs(t),r=[{threshold:1e9,suffix:`B`},{threshold:1e6,suffix:`M`},{threshold:1e3,suffix:`K`}];for(let e=0;e=i.threshold){let n=t/i.threshold;return Math.abs(Number(n.toFixed(1)))>=1e3&&e>0&&(i=r[e-1],n=t/i.threshold),n.toFixed(1).replace(/\.0$/,``)+i.suffix}}return String(t)}function BJ(e,t){let n=t==null||t===``?NaN:Number(t),r=Number.isFinite(n)?FJ(n):`-`;return String(e||`Tokens`)+`: `+r}function VJ(e){if(!e)return`-`;let t=new Date(e);return Number.isNaN(t.getTime())?`-`:t.getUTCFullYear()+`-`+String(t.getUTCMonth()+1).padStart(2,`0`)+`-`+String(t.getUTCDate()).padStart(2,`0`)}function HJ(e){if(!e)return`-`;let t=new Date(e);return Number.isNaN(t.getTime())?`-`:t.getUTCFullYear()+`-`+String(t.getUTCMonth()+1).padStart(2,`0`)+`-`+String(t.getUTCDate()).padStart(2,`0`)+` `+String(t.getUTCHours()).padStart(2,`0`)+`:`+String(t.getUTCMinutes()).padStart(2,`0`)+`:`+String(t.getUTCSeconds()).padStart(2,`0`)+` UTC`}function UJ(e){return String(e&&e.provider||``).trim()}function WJ(e){return String(e&&e.provider_name||``).trim()||UJ(e)}function GJ(e,t){let n=String(t||``).trim();if(!n)return`-`;let r=WJ(e);return!r||n===r||n.startsWith(r+`/`)?n:r+`/`+n}function KJ(e){return GJ(e,e&&e.model)}function qJ(e){return GJ(e,e&&e.resolved_model)}function JJ(e){let t=String(e&&(e.requested_model||e.model)||``).trim();if(!e)return t;let n=String(e.data&&e.data.failover&&e.data.failover.target_model||``).trim();if(n&&n!==t)return t+` ⮕ `+n;if(e.alias_used&&e.resolved_model){let n=qJ(e);if(n&&n!==`-`&&n!==t)return t+` ⮕ `+n}return t}function YJ(){return{size:11,family:`'SF Mono', Menlo, Consolas, monospace`}}function XJ(e,t){return{backgroundColor:e.tooltipBg,borderColor:e.tooltipBorder,borderWidth:1,titleColor:e.tooltipText,bodyColor:e.tooltipText,callbacks:t}}function ZJ(e){return{color:e.text,font:YJ(),callback:e=>zJ(e)}}function QJ(){return[`#c2845a`,`#7a9e7e`,`#d4a574`,`#b8a98e`,`#8b9e6b`,`#7d8a97`,`#c47a5a`,`#6b8e6b`,`#a09486`,`#9b7ea4`,`#c49a6c`]}function $J(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 eY={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}},tY=[{value:`seconds`,label:`Seconds`},{value:`minutes`,label:`Minutes`},{value:`hours`,label:`Hours`},{value:`days`,label:`Days`}];function nY(){return{input:0,output:0,prompt:0,local:0}}function rY(e){return String(e).padStart(2,`0`)}function iY(e){let t=Number(e);return Number.isFinite(t)&&t>0?t:0}function aY(e,t){if(!Number.isFinite(t))return``;let n=new Date(t);switch(e){case`seconds`:return rY(n.getHours())+`:`+rY(n.getMinutes())+`:`+rY(n.getSeconds());case`minutes`:return rY(n.getHours())+`:`+rY(n.getMinutes());case`hours`:return rY(n.getHours())+`:00`;default:return rY(n.getMonth()+1)+`-`+rY(n.getDate())}}function oY(e,t){let n=[],r=[],i={input:[],output:[],prompt:[],local:[]},a=nY();for(let o of e||[]){let e=Date.parse(o&&o.start),s=iY(o&&o.input_tokens),c=iY(o&&o.output_tokens),l=iY(o&&o.prompt_cached_tokens),u=iY(o&&o.locally_cached_tokens);n.push(aY(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 sY(e){let t=e||nY();return t.input+t.output+t.prompt+t.local>0}function cY(e,t){return zJ(Math.max(0,Math.round(e&&e[t]||0)))}function lY(e){return(eY[e]||eY.minutes).windowLabel}function uY(e,t){return`Live token throughput, `+lY(t).toLowerCase()+`. Input `+cY(e,`input`)+`, output `+cY(e,`output`)+`, prompt cached `+cY(e,`prompt`)+`, locally cached `+cY(e,`local`)+` tokens.`}function dY(e,t,n,r){let i=e=>zJ(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 fY=900,pY=6,mY=new class{#e=j(`minutes`);get granularity(){return I(this.#e)}set granularity(e){M(this.#e,e,!0)}#t=j(ln([]));get buckets(){return I(this.#t)}set buckets(e){M(this.#t,e,!0)}#n=j(!1);get active(){return I(this.#n)}set active(e){M(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){!eY[e]||e===this.granularity||(this.granularity=e,this.buckets=[],this.#l(),this.fetch())}#l(){this.#r&&=(clearInterval(this.#r),null);let e=eY[this.granularity]||eY.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()},fY))}async fetch(){if(!this.active||this.#c)return;this.#c=!0;let e=this.granularity;try{let t=await tL(`/admin/usage/throughput?granularity=`+(eY[e]||eY.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(iL(e))return;console.error(`Failed to fetch token throughput:`,e)}finally{this.#c=!1,this.active&&this.granularity!==e&&this.fetch()}}async#u(){await oL.ensureLoaded(),this.active&&oL.liveLogsVisible()&&(typeof ReadableStream>`u`||(this.#s&&this.#s.abort(),this.#s=new AbortController,this.#d(this.#s)))}async#d(e){try{let t=await $I(`/admin/live/logs?types=usage`,{signal:e.signal});if(!t.ok||!t.body||typeof t.body.getReader!=`function`){this.#m();return}this.#o=0,await this.#f(t.body.getReader()),this.#m()}catch(e){if(iL(e))return;console.error(`Live usage stream failed:`,e),this.#m()}}async#f(e){let t=new TextDecoder,n=``;for(;;){let r=await e.read();if(r.done)break;n+=t.decode(r.value,{stream:!0});let i;for(;i=n.match(/\r?\n\r?\n/);){let e=i.index,t=n.slice(0,e);n=n.slice(e+i[0].length),this.#p(t)}}n+=t.decode(),n.trim()&&this.#p(n)}#p(e){let t=String(e||``).split(/\r?\n/),n=[];for(let e of t)e.indexOf(`data:`)===0&&n.push(e.slice(5).trimStart());if(n.length===0)return;let r;try{r=JSON.parse(n.join(` `))}catch{return}if(!r||typeof r!=`object`)return;let i=String(r.type||``).trim();i.indexOf(`usage.`)===0&&this.noteUsageEvent(i)}#m(){if(!this.active||this.#a)return;let e=Math.min(this.#o+1,pY);this.#o=e;let t=Math.min(3e4,500*2**(e-1));this.#a=setTimeout(()=>{this.#a=null,this.#u()},t)}},hY=R(`
`),gY=R(`
Waiting for live requests…
`),_Y=R(`

Live Token Throughput

`);function vY(e,t){O(t,!0);let n=A(()=>oY(mY.buckets,mY.granularity)),r=A(()=>I(n).totals);function i(){return{input:$J(`var(--token-input)`),output:$J(`var(--token-output)`),prompt:$J(`var(--token-prompt)`),local:$J(`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=_Y(),s=N(o),c=N(s),l=P(N(c),2),u=N(l);let d;var f=P(u,2),p=N(f,!0);D(f),D(l),D(c),MJ(P(c,2),{ariaLabel:`Live token throughput granularity`,get options(){return tY},get value(){return mY.granularity},onchange:e=>mY.setGranularity(e)}),D(s);var m=P(s,2);si(m,21,()=>a,e=>e.metric,(e,t)=>{var n=hY(),i=N(n),a=P(i,2),o=N(a,!0);D(a);var s=P(a,2),c=N(s,!0);D(s),D(n),F(e=>{Ii(i,`background: var(${I(t).colorVar??``})`),B(o,I(t).label),B(c,e)},[()=>cY(I(r),I(t).metric)]),z(e,n)}),D(m);var h=P(m,2),g=N(h);{let e=A(()=>uY(I(r),mY.granularity));kJ(g,{get ariaLabel(){return I(e)},build:()=>dY(PJ(),i(),I(n),mY.granularity)})}var _=P(g,2),v=e=>{z(e,gY())},y=A(()=>!sY(I(r)));V(_,e=>{I(y)&&e(v)}),D(h),D(o),F(e=>{d=H(u,1,`live-dot`,null,d,{"is-streaming":mY.active}),B(p,e)},[()=>lY(mY.granularity)]),z(e,o),k()}function yY(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 bY(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 xY(e,t,n){let r=Number(e&&e.total_requests||0);return(Number.isFinite(r)?r:0)+bY(t,n)}function SY(e,t,n){let r=bY(t,n);return r<=0?``:FJ(xY(e,t,n)-r)+` to providers + `+FJ(r)+` from cache`}function CY(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 wY(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 `+FJ(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 TY(e,t,n){return wY(e,t,n).reduce((e,t)=>e+t.tokens,0)}function EY(e,t,n){return TY(e,t,n)>0}function DY(e,t,n){let r=wY(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 OY(e,t,n){return DY(e,t,n).filter(e=>e.tokens>0)}function kY(e){let t=[e.label+`: `+FJ(e.tokens)+` input tokens (`+e.pct+`%)`];return e.note&&t.push(e.note),t.join(` -`)}function AY(e){let t=(e||[]).map(e=>e.label+` `+e.pct+`%`);return`Cache breakdown of input tokens — `+(t.length?t.join(`, `):`no data`)}function jY(e){return e.getUTCFullYear()+`-`+String(e.getUTCMonth()+1).padStart(2,`0`)+`-`+String(e.getUTCDate()).padStart(2,`0`)}function MY(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=jY(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 NY(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 PY(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 FY(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 IY(e){return FY(e)?Math.round(PY(e))+`%`:`—`}function LY(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:XJ(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:YJ(),maxRotation:0,autoSkip:!0,maxTicksLimit:10}},y:{stacked:!0,beginAtZero:!0,grid:{color:e.grid},border:{display:!1},ticks:ZJ(e)}}}}}function RY(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 zY=`gomodel_provider_status_details_expanded`,BY=`gomodel_provider_card_expanded_overrides`,VY=3e3,HY=`https://gomodel.enterpilot.io/docs/providers/`,UY={anthropic:`anthropic`,azure:`azure`,bailian:`bailian`,bedrock:`bedrock`,"bedrock-mantle":`bedrock-mantle`,deepseek:`deepseek`,gemini:`gemini`,opencode_go:`opencode-go`,oracle:`oracle`,vertex:`vertex`,vllm:`vllm`,xiaomi:`xiaomi`};function WY(){return{summary:{total:0,healthy:0,degraded:0,unhealthy:0,overall_status:`degraded`},providers:[]}}function GY(e){let t={detailsExpanded:!1,cardOverrides:{}};try{if(e){let n=e.getItem(zY);n===`true`||n===`false`?t.detailsExpanded=n===`true`:e.setItem(zY,`false`);let r=JSON.parse(e.getItem(BY)||`{}`);r&&typeof r==`object`&&!Array.isArray(r)&&(t.cardOverrides=r)}}catch{}return t}function KY(e,t){if(e)try{e.setItem(zY,t?`true`:`false`)}catch{}}function qY(e,t){if(e)try{e.setItem(BY,JSON.stringify(t))}catch{}}function JY(e,t,n){let r=n&&n.name?String(n.name):``;return r&&Object.prototype.hasOwnProperty.call(e,r)?e[r]===!0:t}function YY(e){return`is-`+(String(e&&e.overall_status||`degraded`).trim()||`degraded`)}function XY(e){return`is-`+(String(e||`degraded`).trim()||`degraded`)}function ZY(e){let t=e||{};return String(t.healthy||0)+`/`+String(t.total||0)}function QY(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 tX(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 nX(e,t){let n=tX(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 rX(e,t){let n=tX(e);return n?typeof t==`function`?t(n):String(n):``}function iX(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 aX(e){let t=String(e&&(e.type||e.config&&e.config.type)||``).trim().toLowerCase(),n=t?UY[t]:``;return n?HY+n+`?utm_source=gomodel_dashboard`:``}function oX(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 sX(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 cX(e){let t=e&&e.config&&Array.isArray(e.config.models)?e.config.models.filter(Boolean):[];return t.length===0?`Automatic`:t.join(`, `)}function lX(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 AY(e){let t=(e||[]).map(e=>e.label+` `+e.pct+`%`);return`Cache breakdown of input tokens — `+(t.length?t.join(`, `):`no data`)}function jY(e){return e.getUTCFullYear()+`-`+String(e.getUTCMonth()+1).padStart(2,`0`)+`-`+String(e.getUTCDate()).padStart(2,`0`)}function MY(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=jY(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 NY(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 PY(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 FY(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 IY(e){return FY(e)?Math.round(PY(e))+`%`:`—`}function LY(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:XJ(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:YJ(),maxRotation:0,autoSkip:!0,maxTicksLimit:10}},y:{stacked:!0,beginAtZero:!0,grid:{color:e.grid},border:{display:!1},ticks:ZJ(e)}}}}}function RY(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 zY=`gomodel_provider_status_details_expanded`,BY=`gomodel_provider_card_expanded_overrides`,VY=3e3,HY=`https://gomodel.enterpilot.io/docs/providers/`,UY={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 WY(){return{summary:{total:0,healthy:0,degraded:0,unhealthy:0,overall_status:`degraded`},providers:[]}}function GY(e){let t={detailsExpanded:!1,cardOverrides:{}};try{if(e){let n=e.getItem(zY);n===`true`||n===`false`?t.detailsExpanded=n===`true`:e.setItem(zY,`false`);let r=JSON.parse(e.getItem(BY)||`{}`);r&&typeof r==`object`&&!Array.isArray(r)&&(t.cardOverrides=r)}}catch{}return t}function KY(e,t){if(e)try{e.setItem(zY,t?`true`:`false`)}catch{}}function qY(e,t){if(e)try{e.setItem(BY,JSON.stringify(t))}catch{}}function JY(e,t,n){let r=n&&n.name?String(n.name):``;return r&&Object.prototype.hasOwnProperty.call(e,r)?e[r]===!0:t}function YY(e){return`is-`+(String(e&&e.overall_status||`degraded`).trim()||`degraded`)}function XY(e){return`is-`+(String(e||`degraded`).trim()||`degraded`)}function ZY(e){let t=e||{};return String(t.healthy||0)+`/`+String(t.total||0)}function QY(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 tX(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 nX(e,t){let n=tX(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 rX(e,t){let n=tX(e);return n?typeof t==`function`?t(n):String(n):``}function iX(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 aX(e){let t=String(e&&(e.type||e.config&&e.config.type)||``).trim().toLowerCase(),n=t?UY[t]:``;return n?HY+n+`?utm_source=gomodel_dashboard`:``}function oX(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 sX(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 cX(e){let t=e&&e.config&&Array.isArray(e.config.models)?e.config.models.filter(Boolean):[];return t.length===0?`Automatic`:t.join(`, `)}function lX(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 uX(e){let t=e&&e.request_health;return t&&typeof t==`object`?t:null}function dX(e){let t=uX(e);return t?String(t.circuit_state||``).trim():``}function fX(e){let t=dX(e);return t?t.charAt(0).toUpperCase()+t.slice(1):``}function pX(e){let t=dX(e);return t===`open`?`is-unhealthy`:t===`half-open`?`is-degraded`:`is-healthy`}function mX(e){let t=uX(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 hX(e){let t=uX(e);return t&&Array.isArray(t.models)?t.models:[]}function gX(e){return e?String(Number(e.errors||0))+`/`+String(Number(e.requests||0))+` failed`:``}function _X(e){let t=e&&e.last_error;return!t||!t.message?``:(t.status_code?`HTTP `+String(t.status_code)+`: `:``)+t.message}function vX(e){return String(e&&e.status||``).trim()||`connecting`}function yX(e){return(e||[]).length}function bX(e){return(e||[]).filter(e=>vX(e)===`connected`).length}function xX(e){return(e||[]).filter(e=>e&&e.enabled!==!1&&vX(e)===`degraded`).length}function SX(e,t){return!!e&&yX(t)>0}function CX(e){return String(bX(e))+`/`+String(yX(e))}function wX(e){return xX(e)>0?`is-degraded`:`is-healthy`}function TX(e){let t=xX(e);if(t>0)return String(t)+` server`+(t===1?``:`s`)+` need`+(t===1?`s`:``)+` attention`;let n=yX(e),r=bX(e);return n>0&&r===n?`All MCP servers connected`:String(r)+` of `+String(n)+` server`+(n===1?``:`s`)+` connected`}function EX(){return{interval:`day`,buckets:[],summary:{requests:0},provider_latency:[]}}function DX(e){let t=e&&typeof e==`object`?e:{};return{interval:t.interval===`hour`?`hour`:`day`,buckets:Array.isArray(t.buckets)?t.buckets:[],summary:t.summary&&typeof t.summary==`object`?t.summary:{requests:0},provider_latency:Array.isArray(t.provider_latency)?t.provider_latency:[]}}function OX(e){return Number(e&&e.summary&&e.summary.requests||0)>0}function kX(e){return(e&&Array.isArray(e.provider_latency)?e.provider_latency:[]).length>0}function AX(e){let t=e&&e.summary?e.summary.success_rate:null;return t==null?`—`:(Math.round(Number(t)*1e3)/10).toFixed(1)+`%`}function jX(e,t){return Number(e&&e.summary&&e.summary[t]||0)}function MX(e){let t=Number(e);return Number.isFinite(t)?t>=6e4?(t/6e4).toFixed(1)+` min`:t>=1e3?(t/1e3).toFixed(2)+` s`:Math.round(t)+` ms`:`-`}function NX(e){let t=e&&e.summary?e.summary.avg_duration_ms:null;return t==null?`—`:MX(Number(t))}function PX(e,t){try{let n={};return new Intl.DateTimeFormat(`en-US`,{timeZone:t,year:`numeric`,month:`short`,day:`numeric`,hour:`2-digit`,hourCycle:`h23`}).formatToParts(e).forEach(e=>{n[e.type]=e.value}),{year:n.year,month:n.month,day:n.day,hour:Number(n.hour)}}catch{return{year:String(e.getFullYear()),month:[`Jan`,`Feb`,`Mar`,`Apr`,`May`,`Jun`,`Jul`,`Aug`,`Sep`,`Oct`,`Nov`,`Dec`][e.getMonth()],day:String(e.getDate()),hour:e.getHours()}}}function FX(e,t,n){let r=new Date(e.start);if(Number.isNaN(r.getTime()))return String(e.start||``);let i=PX(r,n),a=i.month+` `+i.day;return t!==`hour`||i.hour===0?a:String(i.hour).padStart(2,`0`)+`:00`}function IX(e,t,n,r){let i=new Date(e.start);if(Number.isNaN(i.getTime()))return String(e.start||``);if(t===`hour`)return r(e.start);let a=PX(i,n);return a.month+` `+a.day+`, `+a.year}function LX(e){return{ok:e(`var(--success)`),clientError:e(`var(--warning)`),serverError:e(`var(--danger)`),other:e(`color-mix(in srgb, var(--text-muted) 55%, transparent)`)}}function RX(e,t,n={}){let r=n.interval===`hour`?`hour`:`day`,i=n.zone,a=n.resolve||(e=>e),o=n.formatTimestamp||(e=>String(e)),s=t.map(e=>FX(e,r,i)),c=LX(a),l=a(`var(--bg-surface)`),u=e=>Number(e)||0,d=(e,t,n)=>({label:e,data:t,backgroundColor:n,borderColor:l,borderWidth:1,borderSkipped:!1,borderRadius:2,maxBarThickness:28}),f=[d(`2xx`,t.map(e=>u(e.status_2xx)),c.ok),d(`4xx`,t.map(e=>u(e.status_4xx)),c.clientError),d(`5xx`,t.map(e=>u(e.status_5xx)),c.serverError)];return t.some(e=>u(e.status_other)>0)&&f.push(d(`Other`,t.map(e=>u(e.status_other)),c.other)),{type:`bar`,data:{labels:s,datasets:f},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:0},interaction:{mode:`index`,intersect:!1},plugins:{legend:{labels:{color:e.text,font:{size:12}}},tooltip:XJ(e,{title:e=>e.length?IX(t[e[0].dataIndex],r,i,o):``,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:{display:!1},border:{display:!1},ticks:{color:e.text,font:YJ(),maxRotation:0,autoSkip:!0,maxTicksLimit:12}},y:{stacked:!0,beginAtZero:!0,grid:{color:e.grid},border:{display:!1},ticks:{color:e.text,font:YJ(),precision:0,callback:e=>zJ(e)}}}}}}function zX(e=QJ()){let t={};return function(n){return n in t||(t[n]=e[Object.keys(t).length%e.length]),t[n]}}function BX(e,t,n,r={}){let i=r.interval===`hour`?`hour`:`day`,a=r.zone,o=r.formatTimestamp||(e=>String(e)),s=r.providerColor||zX();return{type:`line`,data:{labels:t.map(e=>FX(e,i,a)),datasets:n.map(e=>({label:e.provider,data:(e.avg_duration_ms||[]).map(e=>e==null?null:Number(e)),borderColor:s(e.provider),backgroundColor:s(e.provider),fill:!1,tension:.3,borderWidth:2,pointRadius:0,pointHoverRadius:4,spanGaps:i===`hour`&&2}))},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:0},interaction:{mode:`index`,intersect:!1},plugins:{legend:{labels:{color:e.text,font:{size:12}}},tooltip:XJ(e,{title:e=>e.length?IX(t[e[0].dataIndex],i,a,o):``,label:e=>{let t=(n[e.datasetIndex]&&n[e.datasetIndex].requests||[])[e.dataIndex],r=Number(t)||0;return e.dataset.label+`: `+MX(e.parsed.y)+(r>0?` (`+r.toLocaleString()+` req)`:``)}})},scales:{x:{grid:{color:e.grid},border:{display:!1},ticks:{color:e.text,font:YJ(),maxRotation:0,autoSkip:!0,maxTicksLimit:12}},y:{beginAtZero:!0,grid:{color:e.grid},border:{display:!1},ticks:{color:e.text,font:YJ(),callback:e=>MX(e)}}}}}}function VX(){try{return typeof localStorage>`u`?null:localStorage}catch{return null}}var HX=class{#e=j(ln(WY()));get status(){return I(this.#e)}set status(e){M(this.#e,e,!0)}#t=j(!1);get loading(){return I(this.#t)}set loading(e){M(this.#t,e,!0)}#n=j(!1);get loadedOnce(){return I(this.#n)}set loadedOnce(e){M(this.#n,e,!0)}#r=j(!1);get detailsExpanded(){return I(this.#r)}set detailsExpanded(e){M(this.#r,e,!0)}#i=j(ln({}));get cardOverrides(){return I(this.#i)}set cardOverrides(e){M(this.#i,e,!0)}#a=null;#o=null;#s=!1;initPreferences(){if(this.#s)return;this.#s=!0;let e=GY(VX());this.detailsExpanded=e.detailsExpanded,this.cardOverrides=e.cardOverrides}cardExpanded(e){return JY(this.cardOverrides,this.detailsExpanded,e)}toggleCard(e){let t=e&&e.name?String(e.name):``;if(!t)return;let n={...this.cardOverrides};n[t]=!this.cardExpanded(e),this.cardOverrides=n,qY(VX(),this.cardOverrides)}toggleDetails(){this.detailsExpanded=!this.detailsExpanded,this.cardOverrides={},KY(VX(),this.detailsExpanded),qY(VX(),this.cardOverrides)}detailsToggleLabel(){return this.detailsExpanded?`Show Details`:`Hide Details`}async fetch(){this.initPreferences(),this.#a&&this.#a.abort();let e=new AbortController;this.#a=e,this.loading=!0;try{let t=await tL(`/admin/providers/status`,{label:`provider status`,signal:e.signal});if(t.stale||e.signal.aborted)return;if(!t.ok){this.status=WY(),this.#l();return}let n=t.data&&typeof t.data==`object`?t.data:WY();n.summary||=WY().summary,Array.isArray(n.providers)||(n.providers=[]),this.status=n,this.#c()}catch(e){if(iL(e))return;console.error(`Failed to fetch provider status:`,e),this.status=WY(),this.#l()}finally{this.#a===e&&(this.#a=null,this.loading=!1,this.loadedOnce=!0)}}#c(){this.#l(),eX(this.status.providers)&&(this.#o=setTimeout(()=>{this.#o=null,this.fetch()},VY))}#l(){this.#o&&=(clearTimeout(this.#o),null)}stopPolling(){this.#l()}},UX=class{#e=j(ln(EX()));get stats(){return I(this.#e)}set stats(e){M(this.#e,e,!0)}#t=j(!1);get loading(){return I(this.#t)}set loading(e){M(this.#t,e,!0)}#n=0;async fetch(){let e=++this.#n;this.loading=!0;try{let t=await tL(`/admin/audit/stats?`+VL.queryStr(),{label:`audit stats`});if(t.stale||e!==this.#n)return;if(!t.ok){this.stats=EX();return}this.stats=DX(t.data)}catch(t){if(console.error(`Failed to fetch audit stats:`,t),e!==this.#n)return;this.stats=EX()}finally{e===this.#n&&(this.loading=!1)}}},WX=class{#e=j(ln([]));get servers(){return I(this.#e)}set servers(e){M(this.#e,e,!0)}#t=j(!1);get available(){return I(this.#t)}set available(e){M(this.#t,e,!0)}#n=j(!1);get loading(){return I(this.#n)}set loading(e){M(this.#n,e,!0)}async fetch(){if(await oL.ensureLoaded(),!oL.mcpVisible()){this.available=!1,this.servers=[];return}this.loading=!0;try{let e=await tL(`/admin/mcp-servers`,{label:`mcp servers`});if(e.stale)return;if(e.status===503||e.status===404){this.available=!1,this.servers=[];return}if(this.available=!0,!e.ok){this.servers=[];return}this.servers=Array.isArray(e.data)?e.data:[]}catch(e){console.error(`Failed to fetch MCP servers:`,e),this.servers=[]}finally{this.loading=!1}}},GX=class{#e=j(ln([]));get data(){return I(this.#e)}set data(e){M(this.#e,e,!0)}#t=j(`tokens`);get mode(){return I(this.#t)}set mode(e){M(this.#t,e,!0)}#n=j(!1);get loading(){return I(this.#n)}set loading(e){M(this.#n,e,!0)}#r=null;async fetch(){this.#r&&this.#r.abort();let e=new AbortController;this.#r=e,this.loading=!0;try{let t=await tL(`/admin/usage/daily?days=365&interval=daily`,{label:`calendar`,signal:e.signal});if(t.stale||e.signal.aborted)return;if(!t.ok){this.data=[];return}this.data=Array.isArray(t.data)?t.data:[]}catch(e){if(iL(e))return;console.error(`Failed to fetch calendar data:`,e),this.data=[]}finally{this.#r===e&&(this.#r=null,this.loading=!1)}}},KX=new HX,qX=new UX,JX=new WX,YX=new GX,XX=R(`
Cache Hits
`),ZX=R(`
Local Cache
i + o =
`),QX=R(``),$X=R(` `),eZ=R(`
Provider Status
`),tZ=R(`
MCP Servers
`),nZ=R(`
Tokens
i + o =
Total Requests
Estimated Cost
Prompt Cache Rate
`);function rZ(e,t){O(t,!0);let n=A(()=>WL.summary),r=A(()=>WL.cacheOverview),i=A(()=>WL.cacheAnalyticsEnabled()),a=A(()=>KX.status.summary);function o(){let e=document.getElementById(`provider-status-section`);e&&(e.scrollIntoView({behavior:`smooth`,block:`start`}),e.focus({preventScroll:!0}))}var s=nZ(),c=N(s),l=P(N(c),2),u=N(l),d=N(u),f=N(d,!0);D(d),Ve(),D(u);var p=P(u,4),m=N(p),h=N(m,!0);D(m),Ve(),D(p);var g=P(p,4),_=N(g,!0);D(g),D(l),D(c);var v=P(c,2),y=P(N(v),2),b=N(y,!0);D(y),D(v);var x=P(v,2),S=e=>{var t=XX(),n=P(N(t),2),i=N(n,!0);D(n),D(t),F(e=>B(i,e),[()=>FJ(I(r).summary.total_hits)]),z(e,t)};V(x,e=>{I(i)&&e(S)});var C=P(x,2),w=P(N(C),2),T=N(w,!0);D(w),D(C);var E=P(C,2),ee=e=>{var t=ZX(),n=P(N(t),2),i=N(n),a=N(i),o=N(a,!0);D(a),Ve(),D(i);var s=P(i,4),c=N(s),l=N(c,!0);D(c),Ve(),D(s);var u=P(s,4),d=N(u,!0);D(u),D(n),D(t),F((e,t,n,r,a,c)=>{W(i,`title`,e),B(o,t),W(s,`title`,n),B(l,r),W(u,`title`,a),B(d,c)},[()=>BJ(`Input tokens`,I(r).summary.total_input_tokens),()=>zJ(I(r).summary.total_input_tokens),()=>BJ(`Output tokens`,I(r).summary.total_output_tokens),()=>zJ(I(r).summary.total_output_tokens),()=>BJ(`Total tokens`,CY(I(r))),()=>zJ(CY(I(r)))]),z(e,t)};V(E,e=>{I(i)&&e(ee)});var te=P(E,2),ne=P(N(te),2),re=N(ne);kJ(re,{build:()=>RY(PY(I(n)),$J(`var(--token-prompt)`),$J(`var(--bg-surface-hover)`))});var ie=P(re,2),ae=N(ie,!0);D(ie),D(ne),D(te);var oe=P(te,2),se=e=>{var t=eZ(),n=P(N(t),2),r=N(n,!0);D(n);var i=P(n,2),s=e=>{var t=QX(),n=N(t,!0);D(t),F(e=>B(n,e),[()=>$Y(I(a))]),L(`click`,t,o),z(e,t)},c=A(()=>QY(I(a))),l=e=>{var t=$X(),n=N(t,!0);D(t),F(e=>B(n,e),[()=>$Y(I(a))]),z(e,t)};V(i,e=>{I(c)?e(s):e(l,-1)}),D(t),F((e,n)=>{H(t,1,`card provider-status-flag provider-status-overview-card ${e??``}`,`svelte-6tr9cf`),B(r,n)},[()=>YY(I(a)),()=>ZY(I(a))]),z(e,t)};V(oe,e=>{I(a).total>0&&e(se)});var ce=P(oe,2),le=e=>{var t=tZ(),n=P(N(t),2),r=N(n,!0);D(n);var i=P(n,2),a=N(i,!0);D(i),D(t),F((e,n,i)=>{H(t,1,`card provider-status-flag mcp-servers-flag ${e??``}`,`svelte-6tr9cf`),B(r,n),B(a,i)},[()=>wX(JX.servers),()=>CX(JX.servers),()=>TX(JX.servers)]),L(`click`,i,()=>FI.navigate(`mcp-servers`)),z(e,t)},ue=A(()=>SX(JX.available,JX.servers));V(ce,e=>{I(ue)&&e(le)}),D(s),F((e,t,n,r,i,a,o,s,c,l,d)=>{W(u,`title`,e),B(f,t),W(p,`title`,n),B(h,r),W(g,`title`,i),B(_,a),W(y,`title`,o),B(b,s),B(T,c),W(ne,`aria-label`,l),B(ae,d)},[()=>BJ(`Input tokens`,I(n).total_input_tokens),()=>zJ(I(n).total_input_tokens),()=>BJ(`Output tokens`,I(n).total_output_tokens),()=>zJ(I(n).total_output_tokens),()=>BJ(`Total tokens`,yY(I(n))),()=>zJ(yY(I(n))),()=>SY(I(n),I(r),I(i)),()=>FJ(xY(I(n),I(r),I(i))),()=>IJ(I(n).total_cost),()=>`Prompt cache rate `+IY(I(n)),()=>IY(I(n))]),z(e,s),k()}Br([`click`]);var iZ=R(` `),aZ=R(`
`),oZ=R(`No usage in the selected period yet`),sZ=R(`
`),cZ=R(`

Tokens

Share of input tokens over the selected period
`);function lZ(e,t){O(t,!0);let n=A(()=>WL.cacheAnalyticsEnabled()),r=A(()=>DY(WL.summary,WL.cacheOverview,I(n))),i=A(()=>OY(WL.summary,WL.cacheOverview,I(n))),a=A(()=>EY(WL.summary,WL.cacheOverview,I(n)));var o=cZ(),s=P(N(o),2);let c;var l=N(s);si(l,17,()=>I(i),e=>e.key,(e,t)=>{var n=aZ(),r=N(n),i=e=>{var n=iZ(),r=N(n);D(n),F(()=>B(r,`${I(t).pct??``}%`)),z(e,n)};V(r,e=>{I(t).pct>=8&&e(i)}),D(n),F(e=>{Ii(n,`width: ${I(t).pct??``}%; background: var(${I(t).colorVar??``})`),W(n,`title`,e)},[()=>kY(I(t))]),z(e,n)});var u=P(l,2),d=e=>{z(e,oZ())};V(u,e=>{I(a)||e(d)}),D(s);var f=P(s,2);si(f,21,()=>I(r),e=>e.key,(e,t)=>{var n=sZ(),r=N(n),i=P(r,2),a=N(i,!0);D(i);var o=P(i,2),s=N(o);D(o);var c=P(o,2),l=N(c,!0);D(c),D(n),F((e,i)=>{W(n,`title`,e),Ii(r,`background: var(${I(t).colorVar??``})`),B(a,I(t).label),B(s,`${I(t).pct??``}%`),B(l,i)},[()=>kY(I(t)),()=>FJ(I(t).tokens)]),z(e,n)}),D(f),D(o),F(e=>{c=H(s,1,`cache-meter-bar svelte-1yzecxj`,null,c,{"is-empty":!I(a)}),W(s,`aria-label`,e)},[()=>AY(I(i))]),z(e,o),k()}var uZ=R(``);function dZ(e,t){let n=da(t,`size`,3,16),r=da(t,`label`,3,`Loading`),i=da(t,`class`,3,``);var a=uZ();F(()=>{H(a,1,`spinner ${i()??``}`,`svelte-b54l9o`),Ii(a,`--spinner-size: ${n()??``}px`),W(a,`aria-label`,r())}),z(e,a)}var fZ=R(`
`),pZ=R(`
No data
`),mZ=R(`

`);function hZ(e,t){O(t,!0);let n=[`daily`,`weekly`,`monthly`,`yearly`];function r(e){VL.interval=e,t.onintervalchange?.()}function i(){let e=WL.daily;if(e.length===0)return null;let t=VL.rangeStart(),n=VL.rangeEnd(),r=NY(MY(e,VL.interval,t,n),MY(Array.isArray(WL.cacheOverview.daily)?WL.cacheOverview.daily:[],VL.interval,t,n));return LY(PJ(),r,{cacheEnabled:WL.cacheAnalyticsEnabled(),resolve:$J})}var a=mZ(),o=N(a),s=N(o),c=N(s,!0);D(s);var l=P(s,2);{let e=A(()=>n.map(e=>({value:e,label:e.charAt(0).toUpperCase()+e.slice(1)})));MJ(l,{ariaLabel:`Usage chart interval`,get options(){return I(e)},get value(){return VL.interval},onchange:r})}D(o);var u=P(o,2),d=N(u);kJ(d,{build:i});var f=P(d,2),p=e=>{var t=fZ();dZ(N(t),{size:24,label:`Loading usage`}),D(t),z(e,t)},m=e=>{z(e,pZ())};V(f,e=>{WL.daily.length===0&&WL.loading?e(p):WL.daily.length===0&&!q.authError&&e(m,1)}),D(u),D(a),F(e=>B(c,e),[()=>VL.chartTitle()]),z(e,a),k()}var gZ=10,_Z=.7;function vZ(e){return String(e).padStart(2,`0`)}function yZ(e){if(!e)return null;let t=/^(\d{4})-(\d{2})-(\d{2})$/.exec(e);return t?new Date(Date.UTC(Number(t[1]),Number(t[2])-1,Number(t[3]))):null}function bZ(e){return!e||typeof e.getTime!=`function`||Number.isNaN(e.getTime())?``:e.getUTCFullYear()+`-`+vZ(e.getUTCMonth()+1)+`-`+vZ(e.getUTCDate())}function xZ(e,t){let n=yZ(e);return n?(n.setUTCDate(n.getUTCDate()+t),bZ(n)):``}function SZ(e,t){if(e<=0||t<=0)return 0;let n=(e/t)**+_Z,r=Math.ceil(n*gZ);return r<1?1:r>gZ?gZ:r}function CZ(){let e=[];for(let t=0;t<=gZ;t++)e.push(t);return e}function wZ(e,t,n){let r={};(e||[]).forEach(e=>{r[e.date]=e});let i=yZ(xZ(n,-364)),a=i.getUTCDay();i.setUTCDate(i.getUTCDate()-a);let o=[];for(let e=new Date(i);bZ(e)<=n;e.setUTCDate(e.getUTCDate()+1)){let n=bZ(e),i=r[n],a=0;i&&(a=t===`costs`?i.total_cost==null?0:i.total_cost:i.total_tokens||0),o.push({dateStr:n,value:a,level:0,empty:!1})}let s=0;for(let e=0;es&&(s=o[e].value);for(let e=0;e0){for(;l.length<7;)l.push({dateStr:``,value:0,level:0,empty:!0});c.push(l)}return c}function TZ(e){let t=yZ(xZ(e,-364)),n=t.getUTCDay();t.setUTCDate(t.getUTCDate()-n);let r=[`Jan`,`Feb`,`Mar`,`Apr`,`May`,`Jun`,`Jul`,`Aug`,`Sep`,`Oct`,`Nov`,`Dec`],i=[],a={},o=0;for(let n=new Date(t);bZ(n)<=e;n.setUTCDate(n.getUTCDate()+7),o++){let t=null;if(o===0)t=new Date(n);else for(let r=0;r<7;r++){let i=new Date(n);if(i.setUTCDate(n.getUTCDate()+r),bZ(i)>e)break;if(i.getUTCDate()===1){t=i;break}}if(!t)continue;let s=t.getUTCFullYear()+`-`+t.getUTCMonth();a[s]||(i.push({label:r[t.getUTCMonth()],col:o,key:s}),a[s]=!0)}for(let e=0;e `),kZ=R(`
`),AZ=R(`
`),jZ=R(`
`),MZ=R(`
`),NZ=R(`

Activity

Mon Wed Fri
`,1);function PZ(e,t){O(t,!0);let n=j(ln({show:!1,x:0,y:0,text:``})),r=A(()=>ZI.currentDateKey()),i=A(()=>wZ(YX.data,YX.mode,I(r))),a=A(()=>TZ(I(r)));function o(e,t){t.empty||M(n,{show:!0,x:e.clientX,y:e.clientY,text:DZ(t,YX.mode)},!0)}function s(){M(n,{show:!1,x:0,y:0,text:``},!0)}var c=NZ(),l=bn(c),u=N(l),d=P(N(u),2),f=e=>{dZ(e,{size:14,label:`Loading activity`})};V(d,e=>{YX.loading&&YX.data.length===0&&e(f)}),MJ(P(d,2),{ariaLabel:`Activity calendar mode`,options:[{value:`tokens`,label:`Tokens`},{value:`costs`,label:`Costs`}],get value(){return YX.mode},onchange:e=>YX.mode=e}),D(u);var p=P(u,2),m=P(N(p),2),h=N(m);si(h,21,()=>I(a),e=>e.key,(e,t)=>{var n=OZ(),r=N(n,!0);D(n),F(()=>{Ii(n,`grid-column: ${I(t).col+1} / span ${I(t).span??``}`),B(r,I(t).label)}),z(e,n)}),D(h);var g=P(h,2);si(g,21,()=>I(i),ri,(e,t,n)=>{var r=AZ();si(r,23,()=>I(t),(e,t)=>n+`-`+t,(e,t)=>{var n=kZ();F(()=>H(n,1,`contribution-calendar-cell ${I(t).empty?`empty`:`level-`+I(t).level}`,`svelte-3hfxuq`)),zr(`mouseenter`,n,e=>o(e,I(t))),zr(`mouseleave`,n,s),z(e,n)}),D(r),z(e,r)}),D(g),D(m),D(p);var _=P(p,2),v=N(_),y=N(v),b=N(y,!0);D(y),D(v);var x=P(v,2);si(P(N(x),2),16,CZ,e=>e,(e,t)=>{var n=jZ();F(()=>H(n,1,`contribution-calendar-cell level-${t??``}`,`svelte-3hfxuq`)),z(e,n)}),Ve(2),D(x),D(_),D(l);var S=P(l,2),C=e=>{var t=MZ(),r=N(t,!0);D(t),F(()=>{Ii(t,`left: ${I(n).x??``}px; top: ${I(n).y-40}px`),B(r,I(n).text)}),z(e,t)};V(S,e=>{I(n).show&&e(C)}),F(e=>B(b,e),[()=>EZ(YX.data,YX.mode)]),z(e,c),k()}var FZ=R(``),IZ=R(`

`),LZ=R(`
`);function RZ(e,t){O(t,!0);let n=da(t,`label`,3,`help`),r=da(t,`text`,3,``),i=da(t,`open`,15,!1),a=da(t,`external`,3,!1),o=A(()=>!!r()||!!t.help||a());var s=LZ(),c=N(s),l=N(c);mi(l,()=>t.title??m);var u=P(l,2),d=e=>{var r=FZ();let a;F(()=>{a=H(r,1,`inline-help-toggle svelte-y40or3`,null,a,{"is-open":i()}),W(r,`aria-label`,(i()?`Hide `:`Show `)+n()),W(r,`aria-expanded`,i()),W(r,`aria-controls`,t.copyId)}),L(`click`,r,()=>i(!i())),z(e,r)};V(u,e=>{I(o)&&e(d)}),mi(P(u,2),()=>t.extra??m),D(c);var f=P(c,2),p=e=>{var n=IZ(),i=N(n),a=e=>{var n=Xr();mi(bn(n),()=>t.help),z(e,n)},o=e=>{var t=Yr();F(()=>B(t,r())),z(e,t)};V(i,e=>{t.help?e(a):e(o,-1)}),D(n),F(()=>W(n,`id`,t.copyId)),z(e,n)};V(f,e=>{i()&&I(o)&&!a()&&e(p)}),D(s),z(e,s),k()}Br([`click`]);var zZ=R(`

Provider Latency

`),BZ=R(`
Avg
`),VZ=R(`

Requests by Status

Success 2xx 4xx 5xx
`,1);function HZ(e,t){O(t,!0);let n=zX(),r=A(()=>qX.stats);function i(){return{interval:I(r).interval,zone:ZI.effectiveTimezone(),resolve:$J,formatTimestamp:e=>ZI.formatTimestamp(e)}}var a=Xr(),o=bn(a),s=e=>{var t=VZ(),a=bn(t),o=N(a),s=P(N(o),2),c=N(s),l=P(N(c),2),u=N(l,!0);D(l),D(c);var d=P(c,2),f=P(N(d),4),p=N(f,!0);D(f),D(d);var m=P(d,2),h=P(N(m),4),g=N(h,!0);D(h),D(m);var _=P(m,2),v=P(N(_),4),y=N(v,!0);D(v),D(_),D(s),D(o);var b=P(o,2);kJ(N(b),{build:()=>RX(PJ(),I(r).buckets,i())}),D(b),D(a);var x=P(a,2),S=e=>{var t=BZ(),a=N(t),o=N(a);RZ(o,{copyId:`audit-latency-help-copy`,label:`provider latency help`,text:`Average duration of successful requests as measured at the gateway, per provider. Local cache hits and failed requests are excluded; streamed responses count until the stream completes.`,title:e=>{z(e,zZ())},$$slots:{title:!0}});var s=P(o,2),c=N(s),l=P(N(c),2),u=N(l,!0);D(l),D(c),D(s),D(a);var d=P(a,2);kJ(N(d),{build:()=>BX(PJ(),I(r).buckets,I(r).provider_latency,{...i(),providerColor:n})}),D(d),D(t),F(e=>B(u,e),[()=>NX(I(r))]),z(e,t)},C=A(()=>kX(I(r)));V(x,e=>{I(C)&&e(S)}),F((e,t,n,r)=>{B(u,e),B(p,t),B(g,n),B(y,r)},[()=>AX(I(r)),()=>FJ(jX(I(r),`status_2xx`)),()=>FJ(jX(I(r),`status_4xx`)),()=>FJ(jX(I(r),`status_5xx`))]),z(e,t)},c=A(()=>OX(I(r)));V(o,e=>{I(c)&&e(s)}),z(e,a),k()}var UZ=R(` `),WZ=R(``),GZ=R(`

`),KZ=R(`
Breaker State
`),qZ=R(`
`),JZ=R(`
Models (Recent Traffic)
`),YZ=R(`
Recent Requests
`),XZ=R(`
Base URL
`),ZZ=R(`
API Version
`),QZ=R(`

Models Available
Last Checked

Configured Models
Retry
Circuit Breaker
`);function $Z(e,t){O(t,!0);let n=A(()=>KX.cardExpanded(t.provider)),r=e=>ZI.formatTimestamp(e);var i=QZ(),a=N(i),o=N(a),s=N(o),c=N(s),l=N(c,!0);D(c);var u=P(c,2),d=e=>{var n=UZ(),r=N(n);D(n),F(e=>B(r,`(${e??``})`),[()=>iX(t.provider)]),z(e,n)},f=A(()=>iX(t.provider));V(u,e=>{I(f)&&e(d)});var p=P(u,2),m=e=>{var n=WZ();F((e,t,r)=>{W(n,`href`,e),W(n,`aria-label`,t),W(n,`title`,r)},[()=>aX(t.provider),()=>`View `+(iX(t.provider)||t.provider.name)+` provider docs`,()=>`View `+(iX(t.provider)||t.provider.name)+` provider docs`]),z(e,n)},h=A(()=>aX(t.provider));V(p,e=>{I(h)&&e(m)}),D(s),D(o);var g=P(o,2),_=N(g,!0);D(g),D(a);var v=P(a,2),y=N(v),b=P(N(y),2),x=N(b,!0);D(b),D(y);var S=P(y,2),C=P(N(S),2),w=N(C,!0);D(C),D(S),D(v);var T=P(v,2);let E;var ee=N(T),te=N(ee),ne=N(te,!0);D(te);var re=P(te,2),ie=e=>{var n=GZ(),r=N(n,!0);D(n),F(()=>B(r,t.provider.last_error)),z(e,n)};V(re,e=>{t.provider.last_error&&e(ie)});var ae=P(re,2),oe=e=>{var n=YZ(),r=N(n),i=P(N(r),2),a=N(i,!0);D(i),D(r);var o=P(r,2),s=e=>{var n=KZ(),r=P(N(n),2),i=N(r),a=N(i,!0);D(i),D(r),D(n),F((e,t)=>{H(i,1,`provider-status-health-state ${e??``}`,`svelte-nopjmh`),B(a,t)},[()=>pX(t.provider),()=>fX(t.provider)]),z(e,n)},c=A(()=>dX(t.provider));V(o,e=>{I(c)&&e(s)});var l=P(o,2),u=e=>{var n=JZ(),r=P(N(n),2);si(r,21,()=>hX(t.provider),e=>e.model,(e,t)=>{var n=qZ();let r;var i=N(n),a=N(i,!0);D(i);var o=P(i,2),s=N(o,!0);D(o),D(n),F((e,i)=>{r=H(n,1,`provider-status-health-model svelte-nopjmh`,null,r,{"is-flagged":I(t).flagged}),W(n,`title`,e),B(a,I(t).model),B(s,i)},[()=>_X(I(t)),()=>gX(I(t))]),z(e,n)}),D(r),D(n),z(e,n)},d=A(()=>hX(t.provider).length>0);V(l,e=>{I(d)&&e(u)}),D(n),F(e=>B(a,e),[()=>mX(t.provider)]),z(e,n)},se=A(()=>uX(t.provider));V(ae,e=>{I(se)&&e(oe)});var ce=P(ae,2),le=N(ce),ue=e=>{var n=XZ(),r=P(N(n),2),i=N(r,!0);D(r),D(n),F(()=>B(i,t.provider.config.base_url)),z(e,n)};V(le,e=>{t.provider.config?.base_url&&e(ue)});var de=P(le,2),fe=e=>{var n=ZZ(),r=P(N(n),2),i=N(r,!0);D(r),D(n),F(()=>B(i,t.provider.config.api_version)),z(e,n)};V(de,e=>{t.provider.config?.api_version&&e(fe)});var pe=P(de,2),me=P(N(pe),2),he=N(me,!0);D(me),D(pe);var ge=P(pe,2),_e=P(N(ge),2),ve=N(_e,!0);D(_e),D(ge);var ye=P(ge,2),be=P(N(ye),2),xe=N(be,!0);D(be),D(ye),D(ce),D(ee),D(T);var Se=P(T,2);let Ce;D(i),F((e,r,i,a,o,s,c,u)=>{B(l,t.provider.name),H(g,1,`provider-status-pill ${e??``}`,`svelte-nopjmh`),W(g,`title`,r),B(_,t.provider.status_label),B(x,i),W(C,`title`,a),B(w,o),E=H(T,1,`provider-status-details svelte-nopjmh`,null,E,{"is-expanded":I(n),"is-collapsed":!I(n)}),W(T,`aria-hidden`,!I(n)),B(ne,t.provider.status_reason),B(he,s),B(ve,c),B(xe,u),Ce=H(Se,1,`provider-status-card-toggle svelte-nopjmh`,null,Ce,{"is-expanded":I(n)}),W(Se,`aria-expanded`,I(n)),W(Se,`aria-label`,(I(n)?`Collapse `:`Expand `)+t.provider.name+` details`),W(Se,`title`,I(n)?`Collapse details`:`Expand details`)},[()=>XY(t.provider.status),()=>lX(t.provider),()=>FJ(t.provider.runtime?.discovered_model_count),()=>rX(t.provider,r),()=>nX(t.provider,r),()=>cX(t.provider),()=>oX(t.provider),()=>sX(t.provider)]),L(`click`,Se,()=>KX.toggleCard(t.provider)),z(e,i),k()}Br([`click`]);var eQ=R(`

Providers Overview

`),tQ=R(`
`);function nQ(e,t){O(t,!0);let n=A(()=>KX.status.providers);var r=Xr(),i=bn(r),a=e=>{var t=eQ(),r=N(t),i=P(N(r),2),a=N(i),o=N(a,!0);D(a);var s=P(a,2);let c;D(i),D(r);var l=P(r,2);si(l,21,()=>I(n),e=>e.name,(e,t)=>{$Z(e,{get provider(){return I(t)}})}),D(l),D(t),F((e,t)=>{W(i,`aria-checked`,KX.detailsExpanded),W(i,`title`,e),B(o,t),c=H(s,1,`provider-status-toggle-track svelte-1kx3uw4`,null,c,{"is-active":KX.detailsExpanded})},[()=>KX.detailsToggleLabel(),()=>KX.detailsToggleLabel()]),L(`click`,i,()=>KX.toggleDetails()),z(e,t)},o=e=>{var t=tQ();dZ(N(t),{size:18,label:`Loading provider status`}),D(t),z(e,t)};V(i,e=>{I(n).length>0?e(a):KX.loading&&!KX.loadedOnce&&e(o,1)}),z(e,r),k()}Br([`click`]);var rQ=R(`
`);function iQ(e,t){O(t,!0);function n(){WL.fetchUsage(),WL.fetchCacheOverview(``),qX.fetch(),KX.fetch(),JX.fetch(),YX.fetch()}function r(){WL.fetchUsage(),WL.fetchCacheOverview(``),qX.fetch()}function i(){r(),YX.fetch()}An(()=>{if(q.refreshTick,FI.page===`overview`)return Er(()=>{n(),mY.start()}),()=>{mY.stop(),KX.stopPolling()}});var a=rQ(),o=N(a);vY(o,{});var s=P(o,4);eR(N(s),{onchange:i}),D(s);var c=P(s,2);zL(c,{});var l=P(c,2);rZ(l,{});var u=P(l,2);lZ(u,{});var d=P(u,2);hZ(d,{onintervalchange:r});var f=P(d,2);PZ(f,{});var p=P(f,2);HZ(p,{}),nQ(P(p,2),{}),D(a),z(e,a),k()}var aQ=`/admin/live/logs?types=audit,usage`;function oQ(e){let t=aQ,n=Number(e||0);return Number.isFinite(n)&&n>0&&(t+=`&cursor=`+encodeURIComponent(String(n))),t}function sQ(){return{async consumeLiveLogsBody(e){let t=new TextDecoder,n=``;for(;;){let r=await e.read();if(r.done)break;n+=t.decode(r.value,{stream:!0});let i;for(;i=n.match(/\r?\n\r?\n/);){let e=i.index,t=n.slice(0,e);n=n.slice(e+i[0].length),this.handleLiveLogsFrame(t)}}n+=t.decode(),n.trim()&&this.handleLiveLogsFrame(n)},handleLiveLogsFrame(e){let t=String(e||``).split(/\r?\n/),n=[];for(let e of t)e.indexOf(`data:`)===0&&n.push(e.slice(5).trimStart());if(n.length===0)return;let r;try{r=JSON.parse(n.join(` `))}catch{return}this.applyLiveLogEvent(r)},applyLiveLogEvent(e){if(!e||typeof e!=`object`)return;let t=Number(e.seq||0);Number.isFinite(t)&&t>this.liveLogsLastSeq&&(this.liveLogsLastSeq=t);let n=String(e.type||``).trim();if(n!==`heartbeat`){if(n===`reset`){this.reloadLiveLogSources();return}if(n===`audit.removed`){this.removeLiveAuditEntry(e.data);return}if(n.indexOf(`audit.`)===0){this.mergeLiveAuditEntry(e.data||{},n);return}n.indexOf(`usage.`)===0&&(this.mergeLiveUsageEntry(e.data||{},n),typeof this.noteLiveTokenUsage==`function`&&this.noteLiveTokenUsage(n))}},reloadLiveLogSources(){typeof this.fetchUsage==`function`&&this.fetchUsage(),this.page===`audit-logs`&&typeof this.fetchAuditLog==`function`&&this.fetchAuditLog(!0)},auditLiveInsertAllowed(){return this.auditLog&&this.auditLog.offset===0&&!this.auditSearch&&!this.auditMethod&&!this.auditStatusCode&&!this.auditStream&&!this.customStartDate&&!this.customEndDate},usageLiveInsertAllowed(){return this.usageLog&&this.usageLog.offset===0&&!this.usageLogSearch&&!this.usageFilterModel&&!this.usageFilterProvider&&!this.usageFilterLabel&&!this.usageFilterUserPath},mergeLiveAuditEntry(e,t){if(!e||typeof e!=`object`)return;let n=String(e.id||e.request_id||``).trim();if(!n)return;let r=this.auditLog&&Array.isArray(this.auditLog.entries)?this.auditLog.entries:[],i=r.findIndex(t=>String(t.id||``).trim()===n||e.request_id&&String(t.request_id||``).trim()===String(e.request_id).trim()),a=i>=0&&r[i]||{};if(t===`audit.detail`){let t={...e,_detail_loaded:!0,_response_partial:!1};if(i>=0){let e=this.mergeLiveAuditPatch(a,t);return r.splice(i,1,e),this.auditLog.entries=[...r],this.notifyLiveConversation(e),e}return this.auditLiveInsertAllowed()?(this.auditLog.entries=[this.mergeLiveAuditUsagePatch(t),...r].slice(0,this.auditLog.limit||25),this.auditLog.total=Number(this.auditLog.total||0)+1,this.auditLog.entries[0]):void 0}let o=this.liveAuditStateAfter(a._live_state,t),s=this.liveAuditEventFlushed(a._live_state)||this.liveAuditEventFlushed(o),c={...e,_live:!0,_live_state:o,_audit_flushed:s};if(s?c._live_pending=!1:c._live_pending=!0,t===`audit.stream`?c._response_partial=!0:this.liveAuditStateSettled(t)&&(c._response_partial=!1),i>=0){let e=this.mergeLiveAuditPatch(a,c);return r.splice(i,1,e),this.auditLog.entries=[...r],this.fetchExpandedAuditDetailIfReady(e),this.notifyLiveConversation(e),e}if(!this.auditLiveInsertAllowed())return;this.auditLog.entries=[this.mergeLiveAuditUsagePatch(c),...r].slice(0,this.auditLog.limit||25),this.auditLog.total=Number(this.auditLog.total||0)+1;let l=this.auditLog.entries[0];return this.fetchExpandedAuditDetailIfReady(l),this.notifyLiveConversation(l),l},mergeLiveAuditPatch(e,t){let n={...e,...t};return t.data===void 0&&e.data!==void 0?n.data=e.data:e.data&&t.data&&typeof e.data==`object`&&typeof t.data==`object`&&!Array.isArray(e.data)&&!Array.isArray(t.data)&&(n.data={...e.data,...t.data}),this.mergeLiveAuditUsagePatch(n)},mergeLiveAuditUsagePatch(e){let t=this.liveUsageEntryForAudit(e);if(!t)return e;let n=this.auditEntryWithLiveUsage(e,t);return this.removeSkippedLiveUsage(t),n},liveUsageEntryForAudit(e){let t=String(e&&e.request_id||``).trim();return t&&((this.usageLog&&Array.isArray(this.usageLog.entries)?this.usageLog.entries:[]).find(e=>String(e&&e.request_id||``).trim()===t)||this.skippedLiveUsageByRequestId&&this.skippedLiveUsageByRequestId[t])||null},notifyLiveConversation(e){e&&typeof this.refreshLiveConversation==`function`&&this.refreshLiveConversation(e)},fetchExpandedAuditDetailIfReady(e){!e||!this.isAuditEntryExpanded||!this.isAuditEntryExpanded(e)||String(e._live_state||``).trim()!==`audit.flushed`&&!e._audit_flushed||typeof this.fetchAuditEntryDetail==`function`&&this.fetchAuditEntryDetail(e)},liveAuditStateRank(e){switch(String(e||``).trim()){case`audit.started`:return 10;case`audit.updated`:case`audit.stream`:return 20;case`audit.completed`:return 30;case`audit.failed`:case`audit.flushed`:case`audit.detail`:return 40;default:return 0}},liveAuditStateAfter(e,t){let n=String(e||``).trim(),r=String(t||``).trim();return this.liveAuditStateRank(n)>this.liveAuditStateRank(r)?n:r},liveAuditStateSettled(e){return this.liveAuditStateRank(e)>=this.liveAuditStateRank(`audit.completed`)},liveAuditEventFlushed(e){let t=String(e||``).trim();return t===`audit.failed`||t===`audit.flushed`||t===`audit.detail`},removeLiveAuditEntry(e){if(!e||!this.auditLog||!Array.isArray(this.auditLog.entries))return;let t=String(e.id||``).trim(),n=String(e.request_id||``).trim();if(!t&&!n)return;let r=this.auditLog.entries.filter(e=>!(t&&String(e.id||``).trim()===t||n&&String(e.request_id||``).trim()===n)),i=this.auditLog.entries.length-r.length;i>0&&(this.auditLog.entries=r,this.auditLog.total=Math.max(0,Number(this.auditLog.total||0)-i))},mergeLiveUsageEntry(e,t){if(!e||typeof e!=`object`)return;e={...e,_live_state:t||e._live_state||`usage.completed`};let n=String(e.id||``).trim();if(!n)return;let r=this.usageLog&&Array.isArray(this.usageLog.entries)?this.usageLog.entries:[],i=r.findIndex(e=>String(e.id||``).trim()===n);if(i>=0){let t=r[i]||{},n=this.mergeLiveUsagePatch(t,e);if(this.applyLiveUsageToAudit(n),this.liveUsageShouldSkip(n)){r.splice(i,1),this.usageLog.entries=[...r],this.usageLog.total=Math.max(0,Number(this.usageLog.total||0)-1),this.storeSkippedLiveUsage(n);return}r.splice(i,1,n),this.usageLog.entries=[...r],this.removeSkippedLiveUsage(n);return}let a=this.mergeLiveUsagePatch(this.liveUsageSeedForEntry(e),e);if(this.applyLiveUsageToAudit(a),this.liveUsageShouldSkip(a)){this.storeSkippedLiveUsage(a);return}this.removeSkippedLiveUsage(a),this.usageLog.entries=[a,...r].slice(0,this.usageLog.limit||50),this.usageLog.total=Number(this.usageLog.total||0)+1},mergeLiveUsagePatch(e,t){e=e&&typeof e==`object`?e:{};let n=this.liveUsageStateAfter(e._live_state,t&&t._live_state),r=this.liveUsageEventFlushed(e)||this.liveUsageEventFlushed({...t,_live_state:n});return{...e,...t,_live:!0,_live_state:n||`usage.completed`,_live_pending:!r,_usage_flushed:r}},liveUsageShouldSkip(e){return!!(this.usageLogHideCached&&this.liveUsageEntryCached(e))||!this.usageLiveInsertAllowed()},liveUsageSeedForEntry(e){return this.skippedLiveUsageForEntry(e)||this.auditLiveUsageForEntry(e)},skippedLiveUsageForEntry(e){let t=String(e&&e.request_id||``).trim();return t&&this.skippedLiveUsageByRequestId?this.skippedLiveUsageByRequestId[t]:null},auditLiveUsageForEntry(e){let t=String(e&&e.request_id||``).trim();if(!t||!this.auditLog||!Array.isArray(this.auditLog.entries))return null;let n=this.auditLog.entries.find(e=>String(e&&e.request_id||``).trim()===t),r=n&&n.usage&&typeof n.usage==`object`&&!Array.isArray(n.usage)?n.usage:null;return r?{id:e&&e.id,request_id:t,entries:r.entries,input_tokens:r.input_tokens,uncached_input_tokens:r.uncached_input_tokens,cached_input_tokens:r.cached_input_tokens,cache_write_input_tokens:r.cache_write_input_tokens,output_tokens:r.output_tokens,total_tokens:r.total_tokens,cached_input_ratio:r.cached_input_ratio,estimated_cached_characters:r.estimated_cached_characters,_live_state:n._usage_live_state,_live_pending:n._usage_live_pending,_usage_flushed:n._usage_flushed}:null},storeSkippedLiveUsage(e){let t=String(e&&e.request_id||``).trim();t&&((!this.skippedLiveUsageByRequestId||typeof this.skippedLiveUsageByRequestId!=`object`||Array.isArray(this.skippedLiveUsageByRequestId))&&(this.skippedLiveUsageByRequestId={}),this.skippedLiveUsageByRequestId[t]=e)},removeSkippedLiveUsage(e){let t=String(e&&e.request_id||``).trim();t&&this.skippedLiveUsageByRequestId&&delete this.skippedLiveUsageByRequestId[t]},liveUsageEntryCached(e){let t=String(e&&e.cache_type||``).trim().toLowerCase();return t===`exact`||t===`semantic`||!!(e&&e.cache_hit)},liveUsageEventFlushed(e){let t=String(e&&e._live_state||``).trim();return!!(e&&e._usage_flushed)||t===`usage.failed`||t===`usage.flushed`},liveUsageStateRank(e){switch(String(e||``).trim()){case`usage.completed`:return 10;case`usage.failed`:case`usage.flushed`:return 20;default:return 0}},liveUsageStateAfter(e,t){let n=String(e||``).trim(),r=String(t||``).trim();return this.liveUsageStateRank(n)>this.liveUsageStateRank(r)?n:r},applyLiveUsageToAudit(e){let t=String(e&&e.request_id||``).trim();if(!t||!this.auditLog||!Array.isArray(this.auditLog.entries))return;let n=this.auditLog.entries.findIndex(e=>String(e.request_id||``).trim()===t);if(n<0)return;let r=this.auditLog.entries[n];this.auditLog.entries.splice(n,1,this.auditEntryWithLiveUsage(r,e)),this.auditLog.entries=[...this.auditLog.entries]},auditEntryWithLiveUsage(e,t){let n=this.liveUsageStateAfter(e._usage_live_state,t._live_state||`usage.completed`),r=this.liveUsageEventFlushed({_live_state:n,_usage_flushed:e._usage_flushed||t._usage_flushed});return{...e,usage:this.liveUsageSummary(t,e.usage),_usage_live_state:n||`usage.completed`,_usage_live_pending:!r,_usage_flushed:r}},liveUsageSummary(e,t){let n=t&&typeof t==`object`&&!Array.isArray(t)?t:{},r=this.liveNumber(e.input_tokens,this.liveNumber(n.input_tokens,0)),i=this.liveNumber(e.output_tokens,this.liveNumber(n.output_tokens,0)),a=this.liveNumber(e.uncached_input_tokens,this.liveNumber(n.uncached_input_tokens,0)),o=this.liveNumber(e.cached_input_tokens,this.liveNumber(n.cached_input_tokens,0)),s=this.liveNumber(e.cache_write_input_tokens,this.liveNumber(n.cache_write_input_tokens,0));r>0&&a+o+s===0&&(a=r);let c=a+o+s||r,l=c+i||this.liveNumber(e.total_tokens,this.liveNumber(n.total_tokens,0)),u=this.liveNumber(e.cached_input_ratio,this.liveNumber(n.cached_input_ratio,c>0?o/c:0));return{entries:Math.max(1,this.liveNumber(e.entries,this.liveNumber(n.entries,1))),input_tokens:c,uncached_input_tokens:a,cached_input_tokens:o,cache_write_input_tokens:s,output_tokens:i,total_tokens:l,cached_input_ratio:u,estimated_cached_characters:this.liveNumber(e.estimated_cached_characters,this.liveNumber(n.estimated_cached_characters,o*4))}},liveNumber(e,t){let n=Number(e);return Number.isFinite(n)?n:t},auditEntryShouldFetchDetail(e){return!e||e._detail_loading||e._detail_loaded||this.auditEntryLiveDetailPending(e)?!1:this.auditEntryNeedsPersistedLiveDetail(e)?!0:!this.auditEntryHasDetailData(e)},auditEntryLiveDetailPending(e){if(!e||!e._live)return!1;let t=String(e._live_state||``).trim();return t===`audit.failed`||!e._audit_flushed&&t!==`audit.flushed`&&t!==`audit.detail`},auditEntryNeedsPersistedLiveDetail(e){return!!(e&&e._live&&!e._detail_loaded)},auditEntryHasDetailData(e){let t=e&&e.data;return!t||typeof t!=`object`?!1:t.request_headers!==void 0||t.response_headers!==void 0||t.request_body!==void 0||t.response_body!==void 0||t.request_body_too_big_to_handle!==void 0||t.response_body_too_big_to_handle!==void 0||t.user_agent!==void 0||t.api_key_hash!==void 0||t.temperature!==void 0||t.max_tokens!==void 0||t.error_message!==void 0||t.error_code!==void 0},clearAuditDetailLoading(e){if(!e)return;let t=String(e.id||``).trim(),n=String(e.request_id||``).trim(),r=this.auditLog&&Array.isArray(this.auditLog.entries)?this.auditLog.entries:[],i=r.find(e=>t&&String(e.id||``).trim()===t?!0:!!(n&&String(e.request_id||``).trim()===n)),a=i||e;a._detail_loading=!1,i&&(this.auditLog.entries=[...r])}}}var cQ=class{#e=j(ln({entries:[],total:0,limit:25,offset:0}));get auditLog(){return I(this.#e)}set auditLog(e){M(this.#e,e,!0)}#t=j(ln({entries:[],total:0,limit:50,offset:0}));get usageLog(){return I(this.#t)}set usageLog(e){M(this.#t,e,!0)}#n=j(``);get auditSearch(){return I(this.#n)}set auditSearch(e){M(this.#n,e,!0)}#r=j(``);get auditMethod(){return I(this.#r)}set auditMethod(e){M(this.#r,e,!0)}#i=j(``);get auditStatusCode(){return I(this.#i)}set auditStatusCode(e){M(this.#i,e,!0)}#a=j(``);get auditStream(){return I(this.#a)}set auditStream(e){M(this.#a,e,!0)}#o=j(``);get usageLogSearch(){return I(this.#o)}set usageLogSearch(e){M(this.#o,e,!0)}#s=j(``);get usageFilterModel(){return I(this.#s)}set usageFilterModel(e){M(this.#s,e,!0)}#c=j(``);get usageFilterProvider(){return I(this.#c)}set usageFilterProvider(e){M(this.#c,e,!0)}#l=j(``);get usageFilterLabel(){return I(this.#l)}set usageFilterLabel(e){M(this.#l,e,!0)}#u=j(``);get usageFilterUserPath(){return I(this.#u)}set usageFilterUserPath(e){M(this.#u,e,!0)}#d=j(!1);get usageLogHideCached(){return I(this.#d)}set usageLogHideCached(e){M(this.#d,e,!0)}liveLogsLastSeq=0;liveLogsReconnectAttempts=0;liveLogsReconnectTimer=null;liveLogsController=null;skippedLiveUsageByRequestId=null;fetchUsage=null;fetchAuditLog=null;isAuditEntryExpanded=null;refreshLiveConversation=null;noteLiveTokenUsage=null;get page(){return FI.page}get customStartDate(){return VL.customStartDate}get customEndDate(){return VL.customEndDate}liveLogsEnabled(){return oL.liveLogsVisible()}async startLiveLogs(){typeof fetch!=`function`||typeof ReadableStream>`u`||(await oL.ensureLoaded(),this.liveLogsEnabled()&&(this.stopLiveLogs(),this.liveLogsController=typeof AbortController==`function`?new AbortController:null,this.readLiveLogsStream(this.liveLogsController)))}stopLiveLogs(){this.liveLogsReconnectTimer&&=(clearTimeout(this.liveLogsReconnectTimer),null),this.liveLogsController&&typeof this.liveLogsController.abort==`function`&&this.liveLogsController.abort(),this.liveLogsController=null}ensureLiveLogs(){this.liveLogsController||this.liveLogsReconnectTimer||this.startLiveLogs()}async readLiveLogsStream(e){let t={};e&&(t.signal=e.signal);let n=oQ(this.liveLogsLastSeq),r=q.generation;try{let e=await $I(n,t);if(e.status===401){if(q.handleUnauthorized(r),r{this.liveLogsReconnectTimer=null,this.startLiveLogs()},t)}async fetchAuditEntryDetail(e){if(!this.auditEntryShouldFetchDetail(e))return;let t=String(e.id||``).trim();if(!t)return;e._detail_loading=!0;let n=e;try{let e=await tL(`/admin/audit/detail?log_id=`+encodeURIComponent(t),{label:`audit detail`});if(e.stale||!e.ok)return;n=this.mergeLiveAuditEntry(e.data,`audit.detail`)||n}catch(e){console.error(`Failed to fetch audit detail:`,e)}finally{this.clearAuditDetailLoading(n)}}};Object.assign(cQ.prototype,sQ());var lQ=new cQ,uQ=null;Mn(()=>{An(()=>{let e=q.refreshTick;if(uQ===null){uQ=e;return}e!==uQ&&(uQ=e,Er(()=>{lQ.stopLiveLogs(),lQ.startLiveLogs()}))})});function dQ(){return{total_requests:0,total_input_tokens:0,total_output_tokens:0,total_tokens:0,uncached_input_tokens:0,cached_input_tokens:0,cache_write_input_tokens:0,total_input_cost:null,total_output_cost:null,total_cost:null,rewrite_tokens_saved:0,rewrite_cost_saved:null}}function fQ(){return{entries:[],total:0,limit:50,offset:0}}function pQ(e,t){let n=[[`model`,e&&e.model],[`provider`,e&&e.provider],[`label`,e&&e.label],[`user_path`,e&&e.user_path]],r=``;for(let[e,i]of n)!i||e===t||(r+=`&`+e+`=`+encodeURIComponent(i));return r}function mQ({limit:e,offset:t,hideCached:n,search:r}){let i=`&limit=`+e+`&offset=`+t;return i+=`&cache_mode=`+(n?`uncached`:`all`),r&&(i+=`&search=`+encodeURIComponent(r)),i}function hQ(e,t){let n=new Set(e||[]);return t&&n.add(t),[...n].sort()}function gQ(e,t){let n=Number(t&&t.total_requests||0)-Number(e&&e.total_requests||0);return Number.isFinite(n)&&n>0?n:0}function _Q(e,t,n){let r=n?e:t,i=Number(r&&r.total_requests||0);return Number.isFinite(i)?i:0}function vQ(e,t,n){let r=gQ(e,t);return r<=0?``:n?FJ(r)+` cached requests hidden`:FJ(Number(e&&e.total_requests||0))+` to providers + `+FJ(r)+` from cache`}function yQ(e){let t=e||{};return t.total_input_cost===null||t.total_input_cost===void 0?``:IJ(t.total_input_cost)+` input + `+IJ(t.total_output_cost)+` output`}function bQ(e){let t=Number(e&&e.rewrite_tokens_saved||0);return Number.isFinite(t)&&t>0?t:0}function xQ(e){return bQ(e)>0}function SQ(e){let t=e||{};return t.rewrite_cost_saved===void 0?null:t.rewrite_cost_saved}function CQ(e){let t=bQ(e);return t<=0?``:FJ(t)+` prompt tokens removed by request rewriters before reaching providers`}function wQ(e){return String(e&&e.cost_source||``).trim()}function TQ(e){let t=wQ(e);return t===`openrouter_credits`||t===`xai_cost_in_usd_ticks`}function EQ(e){switch(wQ(e)){case`openrouter_credits`:return`Costs from OpenRouter USD-based credits.`;case`xai_cost_in_usd_ticks`:return`Costs from xAI usage.cost_in_usd_ticks.`;default:return``}}function DQ(e){return String(e&&e.cache_type||``).trim().toLowerCase()}function OQ(e){let t=DQ(e);return t===`exact`||t===`semantic`}function kQ(e){let t=DQ(e);return t===`exact`?`Exact`:t===`semantic`?`Semantic`:`-`}function AQ(e,t){let n=t?String(t):``;return OQ(e)?n?`Saved by cache — not charged diff --git a/internal/admin/dashboard/static/dist/index.html b/internal/admin/dashboard/static/dist/index.html index 05b2eaf39..bfa9f3a88 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/core/errors.go b/internal/core/errors.go index 83a6207f5..798fe9344 100644 --- a/internal/core/errors.go +++ b/internal/core/errors.go @@ -279,29 +279,56 @@ type providerErrorDetails struct { func parseProviderErrorBody(body []byte) providerErrorDetails { var payload struct { - Error json.RawMessage `json:"error"` + Error json.RawMessage `json:"error"` + Message json.RawMessage `json:"message"` + Param json.RawMessage `json:"param"` + Code json.RawMessage `json:"code"` + ErrorType json.RawMessage `json:"error_type"` } - if err := json.Unmarshal(body, &payload); err != nil || len(payload.Error) == 0 { + if err := json.Unmarshal(body, &payload); err != nil { return providerErrorDetails{} } + details := providerErrorDetails{ + Message: jsonString(payload.Message), + Param: jsonString(payload.Param), + Code: jsonScalarString(payload.Code), + } + if details.Code == "" { + details.Code = jsonString(payload.ErrorType) + } + + if len(payload.Error) == 0 || bytes.Equal(bytes.TrimSpace(payload.Error), []byte("null")) { + return details + } + if message := jsonString(payload.Error); message != "" { - return providerErrorDetails{Message: message} + details.Message = message + return details } var errorFields map[string]json.RawMessage if err := json.Unmarshal(payload.Error, &errorFields); err != nil { - return providerErrorDetails{} + return details } - details := providerErrorDetails{ + nested := providerErrorDetails{ Message: jsonString(errorFields["message"]), Param: jsonString(errorFields["param"]), Code: jsonScalarString(errorFields["code"]), } - if raw := providerErrorMetadataRaw(errorFields["metadata"]); shouldPreferProviderRaw(details.Message, raw) { - details.Message = raw + if raw := providerErrorMetadataRaw(errorFields["metadata"]); shouldPreferProviderRaw(nested.Message, raw) { + nested.Message = raw + } + if nested.Message != "" { + details.Message = nested.Message + } + if nested.Param != "" { + details.Param = nested.Param + } + if nested.Code != "" { + details.Code = nested.Code } return details diff --git a/internal/core/errors_test.go b/internal/core/errors_test.go index f06aae135..ae6c2d113 100644 --- a/internal/core/errors_test.go +++ b/internal/core/errors_test.go @@ -276,14 +276,15 @@ func TestNewNotFoundError(t *testing.T) { func TestParseProviderError(t *testing.T) { tests := []struct { - name string - provider string - statusCode int - body []byte - expectedType ErrorType - expectedStatus int - expectedParam *string - expectedCode *string + name string + provider string + statusCode int + body []byte + expectedType ErrorType + expectedStatus int + expectedMessage string + expectedParam *string + expectedCode *string }{ { name: "401 unauthorized", @@ -341,6 +342,45 @@ func TestParseProviderError(t *testing.T) { expectedType: ErrorTypeProvider, expectedStatus: http.StatusInternalServerError, // Now preserves original 500 }, + { + name: "top-level Cohere error", + provider: "cohere", + statusCode: http.StatusUnprocessableEntity, + body: []byte(`{"error_type":"INVALID_TOOL_GENERATION","id":"request-id","message":"invalid tool generation"}`), + expectedType: ErrorTypeInvalidRequest, + expectedStatus: http.StatusUnprocessableEntity, + expectedMessage: "invalid tool generation", + expectedCode: new("INVALID_TOOL_GENERATION"), + }, + { + name: "nested Cohere error", + provider: "cohere", + statusCode: http.StatusBadRequest, + body: []byte(`{"error":{"message":"nested Cohere failure","code":"bad_request"}}`), + expectedType: ErrorTypeInvalidRequest, + expectedStatus: http.StatusBadRequest, + expectedMessage: "nested Cohere failure", + expectedCode: new("bad_request"), + }, + { + name: "scalar Cohere error", + provider: "cohere", + statusCode: http.StatusBadGateway, + body: []byte(`{"error":"scalar Cohere failure"}`), + expectedType: ErrorTypeProvider, + expectedStatus: http.StatusBadGateway, + expectedMessage: "scalar Cohere failure", + }, + { + name: "null Cohere error uses top-level message", + provider: "cohere", + statusCode: http.StatusUnprocessableEntity, + body: []byte(`{"error":null,"message":"top-level Cohere failure","error_type":"INVALID_REQUEST"}`), + expectedType: ErrorTypeInvalidRequest, + expectedStatus: http.StatusUnprocessableEntity, + expectedMessage: "top-level Cohere failure", + expectedCode: new("INVALID_REQUEST"), + }, { name: "json parse with message", provider: "openai", @@ -369,7 +409,9 @@ func TestParseProviderError(t *testing.T) { t.Errorf("Provider = %v, want %v", err.Provider, tt.provider) } - if err.Message == "" { + if tt.expectedMessage != "" && err.Message != tt.expectedMessage { + t.Errorf("Message = %q, want %q", err.Message, tt.expectedMessage) + } else if err.Message == "" { t.Error("Message should not be empty") } diff --git a/internal/providers/cohere/audio.go b/internal/providers/cohere/audio.go new file mode 100644 index 000000000..6c697b3a5 --- /dev/null +++ b/internal/providers/cohere/audio.go @@ -0,0 +1,114 @@ +package cohere + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "strings" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" +) + +// CreateSpeech reports that Cohere has no text-to-speech API. AudioProvider +// currently groups speech and transcription into one optional capability, so a +// transcription-only provider must still implement this method. +func (p *Provider) CreateSpeech(_ context.Context, _ *core.AudioSpeechRequest) (*core.AudioResponse, error) { + return nil, core.NewInvalidRequestError("cohere does not support text-to-speech", nil) +} + +// CreateTranscription translates the OpenAI-compatible multipart request to +// Cohere's native POST /v2/audio/transcriptions endpoint. +func (p *Provider) CreateTranscription(ctx context.Context, req *core.AudioTranscriptionRequest) (*core.AudioResponse, error) { + if req == nil { + return nil, core.NewInvalidRequestError("audio transcription request is required", nil) + } + if strings.TrimSpace(req.Language) == "" { + return nil, core.NewInvalidRequestError("language is required for Cohere transcription", nil) + } + if format := strings.ToLower(strings.TrimSpace(req.ResponseFormat)); format != "" && format != "json" { + return nil, core.NewInvalidRequestError("cohere transcription supports only the json response format", nil) + } + if strings.TrimSpace(req.Prompt) != "" { + return nil, core.NewInvalidRequestError("cohere transcription does not support prompt", nil) + } + for _, granularity := range req.TimestampGranularities { + if strings.TrimSpace(granularity) != "" { + return nil, core.NewInvalidRequestError("cohere transcription does not support timestamp_granularities", nil) + } + } + + content := req.FileReader + if content == nil && len(req.File) > 0 { + content = bytes.NewReader(req.File) + } + if content == nil { + return nil, core.NewInvalidRequestError("file is required", nil) + } + + body, contentType := cohereTranscriptionMultipart(req, content) + raw, err := p.client.DoRaw(ctx, llmclient.Request{ + Method: http.MethodPost, + Endpoint: "/v2/audio/transcriptions", + RawBodyReader: body, + Headers: http.Header{"Content-Type": {contentType}}, + }) + if err != nil { + return nil, err + } + + responseContentType := strings.TrimSpace(raw.ContentType) + if responseContentType == "" { + responseContentType = "application/json" + } + return &core.AudioResponse{ + ContentType: responseContentType, + Data: raw.Body, + }, nil +} + +// cohereTranscriptionMultipart streams form fields followed by the file. +// Cohere requires the file to be the final multipart field. +func cohereTranscriptionMultipart(req *core.AudioTranscriptionRequest, content io.Reader) (io.Reader, string) { + filename := strings.TrimSpace(req.Filename) + if filename == "" { + filename = "audio" + } + + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + go func() { + defer func() { _ = pw.Close() }() + + fields := [...][2]string{ + {"model", req.Model}, + {"language", req.Language}, + {"temperature", req.Temperature}, + } + for _, field := range fields { + if strings.TrimSpace(field[1]) == "" { + continue + } + if err := writer.WriteField(field[0], field[1]); err != nil { + _ = pw.CloseWithError(core.NewInvalidRequestError("failed to write "+field[0]+" field", err)) + return + } + } + + part, err := writer.CreateFormFile("file", filename) + if err != nil { + _ = pw.CloseWithError(core.NewInvalidRequestError("failed to create multipart file field", err)) + return + } + if _, err := io.Copy(part, content); err != nil { + _ = pw.CloseWithError(core.NewInvalidRequestError("failed to stream file content", err)) + return + } + if err := writer.Close(); err != nil { + _ = pw.CloseWithError(core.NewInvalidRequestError("failed to finalize multipart payload", err)) + } + }() + return pr, writer.FormDataContentType() +} diff --git a/internal/providers/cohere/audio_test.go b/internal/providers/cohere/audio_test.go new file mode 100644 index 000000000..1f2765e81 --- /dev/null +++ b/internal/providers/cohere/audio_test.go @@ -0,0 +1,131 @@ +package cohere + +import ( + "bytes" + "context" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" +) + +func TestCreateTranscriptionTranslatesMultipartRequest(t *testing.T) { + var ( + partNames []string + fields = map[string]string{} + filename string + audio []byte + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/audio/transcriptions" { + t.Errorf("path = %q, want /v2/audio/transcriptions", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("Authorization = %q", got) + } + + _, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("parse Content-Type: %v", err) + } + reader := multipart.NewReader(r.Body, params["boundary"]) + for { + part, nextErr := reader.NextPart() + if nextErr == io.EOF { + break + } + if nextErr != nil { + t.Fatalf("read multipart: %v", nextErr) + } + partNames = append(partNames, part.FormName()) + data, readErr := io.ReadAll(part) + if readErr != nil { + t.Fatalf("read part %q: %v", part.FormName(), readErr) + } + if part.FormName() == "file" { + filename = part.FileName() + audio = data + } else { + fields[part.FormName()] = string(data) + } + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = io.WriteString(w, `{"text":"GoModel routes requests reliably."}`) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-key", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.CreateTranscription(context.Background(), &core.AudioTranscriptionRequest{ + Model: "cohere-transcribe-03-2026", + Filename: "sample.wav", + FileReader: bytes.NewBufferString("wave-bytes"), + Language: "en", + ResponseFormat: "json", + Temperature: "0.2", + }) + if err != nil { + t.Fatalf("CreateTranscription() error = %v", err) + } + + wantPartNames := []string{"model", "language", "temperature", "file"} + if !reflect.DeepEqual(partNames, wantPartNames) { + t.Fatalf("multipart fields = %#v, want %#v", partNames, wantPartNames) + } + wantFields := map[string]string{ + "model": "cohere-transcribe-03-2026", + "language": "en", + "temperature": "0.2", + } + if !reflect.DeepEqual(fields, wantFields) { + t.Fatalf("multipart values = %#v, want %#v", fields, wantFields) + } + if filename != "sample.wav" || string(audio) != "wave-bytes" { + t.Fatalf("file = %q/%q", filename, audio) + } + if resp.ContentType != "application/json; charset=utf-8" { + t.Fatalf("ContentType = %q", resp.ContentType) + } + if string(resp.Data) != `{"text":"GoModel routes requests reliably."}` { + t.Fatalf("Data = %s", resp.Data) + } +} + +func TestCreateTranscriptionValidation(t *testing.T) { + provider := NewWithHTTPClient("key", "https://example.com", nil, llmclient.Hooks{}) + tests := []struct { + name string + req *core.AudioTranscriptionRequest + }{ + {name: "nil request"}, + {name: "language required", req: &core.AudioTranscriptionRequest{File: []byte("audio")}}, + {name: "file required", req: &core.AudioTranscriptionRequest{Language: "en"}}, + {name: "response format", req: &core.AudioTranscriptionRequest{ + Language: "en", File: []byte("audio"), ResponseFormat: "text", + }}, + {name: "prompt", req: &core.AudioTranscriptionRequest{ + Language: "en", File: []byte("audio"), Prompt: "names", + }}, + {name: "timestamps", req: &core.AudioTranscriptionRequest{ + Language: "en", File: []byte("audio"), TimestampGranularities: []string{"word"}, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := provider.CreateTranscription(context.Background(), tt.req) + assertInvalidRequest(t, err) + }) + } +} + +func TestCreateSpeechIsUnsupported(t *testing.T) { + provider := NewWithHTTPClient("key", "https://example.com", nil, llmclient.Hooks{}) + _, err := provider.CreateSpeech(context.Background(), &core.AudioSpeechRequest{}) + assertInvalidRequest(t, err) +} diff --git a/internal/providers/cohere/chat.go b/internal/providers/cohere/chat.go new file mode 100644 index 000000000..746d27a6b --- /dev/null +++ b/internal/providers/cohere/chat.go @@ -0,0 +1,531 @@ +package cohere + +import ( + "context" + "fmt" + "io" + "maps" + "net/http" + "strings" + "time" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" +) + +// ChatCompletion translates an OpenAI-compatible chat request to Cohere v2. +func (p *Provider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { + upstreamReq, err := toCohereChatRequest(req, false) + if err != nil { + return nil, err + } + var upstream chatResponse + if err := p.client.Do(ctx, llmclient.Request{ + Method: http.MethodPost, + Endpoint: "/v2/chat", + Body: upstreamReq, + }, &upstream); err != nil { + return nil, err + } + if err := cohereGenerationError(upstream.FinishReason, ""); err != nil { + return nil, err + } + return fromCohereChatResponse(&upstream, req.Model), nil +} + +// StreamChatCompletion translates Cohere's event stream to OpenAI chat chunks. +func (p *Provider) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { + upstreamReq, err := toCohereChatRequest(req, true) + if err != nil { + return nil, err + } + body, err := p.client.DoStream(ctx, llmclient.Request{ + Method: http.MethodPost, + Endpoint: "/v2/chat", + Body: upstreamReq, + }) + if err != nil { + return nil, err + } + includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage + return newChatStream(body, req.Model, includeUsage), nil +} + +func toCohereChatRequest(req *core.ChatRequest, stream bool) (*chatRequest, error) { + if req == nil { + return nil, core.NewInvalidRequestError("chat request is required", nil) + } + messages := make([]chatMessage, 0, len(req.Messages)) + for i, message := range req.Messages { + converted, err := toCohereMessage(message) + if err != nil { + return nil, core.NewInvalidRequestError(fmt.Sprintf("messages[%d]: %v", i, err), err) + } + messages = append(messages, converted) + } + + tools, choice, err := toCohereTools(req.Tools, req.ToolChoice) + if err != nil { + return nil, err + } + out := &chatRequest{ + Model: req.Model, + Messages: messages, + Tools: tools, + ToolChoice: choice, + MaxTokens: cohereMaxTokens(req), + Temperature: req.Temperature, + P: req.TopP, + Stream: stream, + } + if err := copyChatExtraFields(out, req.ExtraFields); err != nil { + return nil, err + } + return out, nil +} + +func cohereMaxTokens(req *core.ChatRequest) *int { + if req.MaxTokens != nil { + return req.MaxTokens + } + raw := req.ExtraFields.Lookup("max_completion_tokens") + var value int + if len(raw) > 0 && json.Unmarshal(raw, &value) == nil && value > 0 { + return &value + } + return nil +} + +func toCohereMessage(message core.Message) (chatMessage, error) { + role := strings.ToLower(strings.TrimSpace(message.Role)) + switch role { + case "developer": + role = "system" + case "system", "user", "assistant", "tool": + default: + role = "user" + } + + out := chatMessage{Role: role} + if role == "tool" { + if strings.TrimSpace(message.ToolCallID) == "" { + return chatMessage{}, fmt.Errorf("tool_call_id is required for tool messages") + } + out.ToolCallID = message.ToolCallID + out.Content = cohereToolResult(core.ExtractTextContent(message.Content)) + return out, nil + } + + content, err := toCohereContent(message, role) + if err != nil { + return chatMessage{}, err + } + if content == nil && (role == "system" || role == "user") { + content = "" + } + out.Content = content + if role == "assistant" { + out.ToolCalls = make([]toolCall, 0, len(message.ToolCalls)) + for _, call := range message.ToolCalls { + arguments := strings.TrimSpace(call.Function.Arguments) + if arguments == "" { + arguments = "{}" + } + if !json.Valid([]byte(arguments)) { + return chatMessage{}, fmt.Errorf("tool_call.function.arguments must be valid JSON") + } + out.ToolCalls = append(out.ToolCalls, toolCall{ + ID: call.ID, + Type: "function", + Function: toolFunction{ + Name: call.Function.Name, + Arguments: arguments, + }, + }) + } + } + return out, nil +} + +func toCohereContent(message core.Message, role string) (any, error) { + text := core.ExtractTextContent(message.Content) + parts, structured := core.NormalizeContentParts(message.Content) + reasoning := rawString(message.ExtraFields.Lookup("reasoning_content")) + + if structured && role == "system" { + for _, part := range parts { + if part.Type != "text" { + return nil, fmt.Errorf("system messages only support text content with Cohere") + } + } + return text, nil + } + + if !structured { + if role == "assistant" && reasoning != "" { + content := []contentPart{{Type: "thinking", Thinking: reasoning}} + if text != "" { + content = append(content, contentPart{Type: "text", Text: text}) + } + return content, nil + } + if text == "" { + return nil, nil + } + return text, nil + } + + content := make([]contentPart, 0, len(parts)+1) + if role == "assistant" && reasoning != "" { + content = append(content, contentPart{Type: "thinking", Thinking: reasoning}) + } + for _, part := range parts { + switch part.Type { + case "text": + content = append(content, contentPart{Type: "text", Text: part.Text}) + case "image_url": + if role != "user" { + return nil, fmt.Errorf("image content is only supported in user messages") + } + content = append(content, contentPart{ + Type: "image_url", + ImageURL: &imageURLContainer{ + URL: part.ImageURL.URL, + Detail: part.ImageURL.Detail, + }, + }) + default: + return nil, fmt.Errorf("content part type %q is not supported by Cohere", part.Type) + } + } + if len(content) == 0 { + return nil, nil + } + return content, nil +} + +func cohereToolResult(text string) string { + trimmed := strings.TrimSpace(text) + if trimmed != "" && json.Valid([]byte(trimmed)) && strings.HasPrefix(trimmed, "{") { + return trimmed + } + body, _ := json.Marshal(map[string]string{"result": text}) + return string(body) +} + +func toCohereTools(tools []map[string]any, toolChoice any) ([]map[string]any, string, error) { + converted := make([]map[string]any, 0, len(tools)) + for i, tool := range tools { + function, ok := tool["function"].(map[string]any) + if !ok { + return nil, "", core.NewInvalidRequestError(fmt.Sprintf("tools[%d].function must be an object", i), nil) + } + name, _ := function["name"].(string) + if strings.TrimSpace(name) == "" { + return nil, "", core.NewInvalidRequestError(fmt.Sprintf("tools[%d].function.name is required", i), nil) + } + clonedFunction := make(map[string]any, len(function)+1) + maps.Copy(clonedFunction, function) + if _, ok := clonedFunction["parameters"]; !ok { + clonedFunction["parameters"] = map[string]any{"type": "object", "properties": map[string]any{}} + } + converted = append(converted, map[string]any{"type": "function", "function": clonedFunction}) + } + + choice, selectedName, disableTools, err := cohereToolChoice(toolChoice) + if err != nil { + return nil, "", err + } + if disableTools { + return nil, "NONE", nil + } + if selectedName != "" { + for _, tool := range converted { + function := tool["function"].(map[string]any) + if function["name"] == selectedName { + return []map[string]any{tool}, "REQUIRED", nil + } + } + return nil, "", core.NewInvalidRequestError("tool_choice references an unknown tool", nil) + } + if choice != "" && len(converted) == 0 { + return nil, "", core.NewInvalidRequestError("tool_choice requires at least one tool", nil) + } + return converted, choice, nil +} + +func cohereToolChoice(value any) (choice, selectedName string, disableTools bool, err error) { + switch choiceValue := value.(type) { + case nil: + return "", "", false, nil + case string: + switch strings.ToLower(strings.TrimSpace(choiceValue)) { + case "", "auto": + return "", "", false, nil + case "required", "any": + return "REQUIRED", "", false, nil + case "none": + return "NONE", "", true, nil + default: + return "", "", false, core.NewInvalidRequestError("unsupported tool_choice value", nil) + } + case map[string]any: + choiceType, _ := choiceValue["type"].(string) + switch strings.ToLower(strings.TrimSpace(choiceType)) { + case "auto": + return "", "", false, nil + case "required", "any": + return "REQUIRED", "", false, nil + case "none": + return "NONE", "", true, nil + case "function": + function, _ := choiceValue["function"].(map[string]any) + name, _ := function["name"].(string) + if strings.TrimSpace(name) == "" { + return "", "", false, core.NewInvalidRequestError("tool_choice.function.name is required", nil) + } + return "", name, false, nil + default: + return "", "", false, core.NewInvalidRequestError("unsupported tool_choice type", nil) + } + default: + return "", "", false, core.NewInvalidRequestError("tool_choice must be a string or object", nil) + } +} + +func copyChatExtraFields(out *chatRequest, fields core.UnknownJSONFields) error { + out.StopSequences = stopSequences(fields.Lookup("stop")) + if len(out.StopSequences) == 0 { + out.StopSequences = fields.Lookup("stop_sequences") + } + out.Seed = fields.Lookup("seed") + out.FrequencyPenalty = fields.Lookup("frequency_penalty") + out.PresencePenalty = fields.Lookup("presence_penalty") + out.K = fields.Lookup("top_k") + if len(out.K) == 0 { + out.K = fields.Lookup("k") + } + out.Logprobs = fields.Lookup("logprobs") + responseFormat, err := cohereResponseFormat(fields.Lookup("response_format")) + if err != nil { + return err + } + out.ResponseFormat = responseFormat + out.SafetyMode = fields.Lookup("safety_mode") + out.Documents = fields.Lookup("documents") + out.CitationOptions = fields.Lookup("citation_options") + out.StrictTools = fields.Lookup("strict_tools") + out.Thinking = fields.Lookup("thinking") + out.Priority = fields.Lookup("priority") + return nil +} + +func cohereResponseFormat(raw json.RawMessage) (json.RawMessage, error) { + if len(raw) == 0 { + return nil, nil + } + + var format map[string]any + if err := json.Unmarshal(raw, &format); err != nil { + return nil, core.NewInvalidRequestError("response_format must be an object", err) + } + formatType, _ := format["type"].(string) + if strings.ToLower(strings.TrimSpace(formatType)) != "json_schema" { + return raw, nil + } + + openAISchema, ok := format["json_schema"].(map[string]any) + if !ok { + return nil, core.NewInvalidRequestError("response_format.json_schema must be an object", nil) + } + schema, ok := openAISchema["schema"] + if !ok { + // Be liberal with clients that put the schema directly under + // json_schema instead of using OpenAI's name/schema wrapper. + if _, directSchema := openAISchema["type"]; !directSchema { + return nil, core.NewInvalidRequestError("response_format.json_schema.schema is required", nil) + } + schema = openAISchema + } + + converted, err := json.Marshal(map[string]any{ + "type": "json_object", + "json_schema": schema, + }) + if err != nil { + return nil, core.NewInvalidRequestError("response_format.json_schema must be JSON-serializable", err) + } + return converted, nil +} + +func stopSequences(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return nil + } + var single string + if json.Unmarshal(raw, &single) == nil { + encoded, _ := json.Marshal([]string{single}) + return encoded + } + return raw +} + +func rawString(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var value string + _ = json.Unmarshal(raw, &value) + return value +} + +func fromCohereChatResponse(resp *chatResponse, requestedModel string) *core.ChatResponse { + var text, thinking strings.Builder + for _, part := range resp.Message.Content { + switch part.Type { + case "text": + appendBlock(&text, part.Text) + case "thinking": + appendBlock(&thinking, part.Thinking) + } + } + message := core.ResponseMessage{ + Role: "assistant", + Content: text.String(), + ToolCalls: fromCohereToolCalls(resp.Message.ToolCalls), + } + extra := make(map[string]json.RawMessage) + if thinking.Len() > 0 { + extra["reasoning_content"], _ = json.Marshal(thinking.String()) + } + if resp.Message.ToolPlan != "" { + extra["tool_plan"], _ = json.Marshal(resp.Message.ToolPlan) + } + if len(resp.Message.Citations) > 0 && string(resp.Message.Citations) != "null" { + extra["citations"] = core.CloneRawJSON(resp.Message.Citations) + } + if len(extra) > 0 { + message.ExtraFields = core.UnknownJSONFieldsFromMap(extra) + } + return &core.ChatResponse{ + ID: resp.ID, + Object: "chat.completion", + Model: requestedModel, + Provider: "cohere", + Created: time.Now().Unix(), + Choices: []core.Choice{{ + Index: 0, + Message: message, + FinishReason: normalizeFinishReason(resp.FinishReason), + }}, + Usage: toCoreUsage(resp.Usage), + } +} + +func appendBlock(builder *strings.Builder, value string) { + if value == "" { + return + } + if builder.Len() > 0 { + builder.WriteString("\n\n") + } + builder.WriteString(value) +} + +func fromCohereToolCalls(calls []toolCall) []core.ToolCall { + if len(calls) == 0 { + return nil + } + out := make([]core.ToolCall, 0, len(calls)) + for _, call := range calls { + arguments := call.Function.Arguments + if strings.TrimSpace(arguments) == "" { + arguments = "{}" + } + out = append(out, core.ToolCall{ + ID: call.ID, + Type: "function", + Function: core.FunctionCall{ + Name: call.Function.Name, + Arguments: arguments, + }, + }) + } + return out +} + +func normalizeFinishReason(reason string) string { + switch strings.ToUpper(strings.TrimSpace(reason)) { + case "MAX_TOKENS": + return "length" + case "TOOL_CALL": + return "tool_calls" + case "COMPLETE", "STOP_SEQUENCE", "": + return "stop" + default: + return "stop" + } +} + +func cohereGenerationError(reason, detail string) *core.GatewayError { + normalized := strings.ToUpper(strings.TrimSpace(reason)) + detail = strings.TrimSpace(detail) + if detail == "" && normalized != "ERROR" && normalized != "TIMEOUT" { + return nil + } + + status := http.StatusBadGateway + message := detail + switch normalized { + case "TIMEOUT": + status = http.StatusGatewayTimeout + if message == "" { + message = "Cohere generation timed out" + } + case "ERROR": + if message == "" { + message = "Cohere generation failed" + } + default: + if message == "" { + message = "Cohere generation failed" + } + } + return core.NewProviderError("cohere", status, message, nil) +} + +func toCoreUsage(value usage) core.Usage { + input := int(value.Tokens.InputTokens + value.Tokens.ImageTokens) + output := int(value.Tokens.OutputTokens) + if input == 0 && output == 0 { + input = int(value.BilledUnits.InputTokens) + output = int(value.BilledUnits.OutputTokens) + } + raw := map[string]any{} + if value.BilledUnits.InputTokens != 0 || + value.BilledUnits.OutputTokens != 0 || + value.BilledUnits.SearchUnits != 0 || + value.BilledUnits.Classifications != 0 { + raw["billed_units"] = value.BilledUnits + } + if value.CachedTokens != 0 { + raw["cached_tokens"] = value.CachedTokens + } + result := core.Usage{ + PromptTokens: input, + CompletionTokens: output, + TotalTokens: input + output, + } + if value.CachedTokens != 0 || value.Tokens.ImageTokens != 0 { + result.PromptTokensDetails = &core.PromptTokensDetails{ + CachedTokens: int(value.CachedTokens), + ImageTokens: int(value.Tokens.ImageTokens), + } + } + if len(raw) > 0 { + result.RawUsage = raw + } + return result +} diff --git a/internal/providers/cohere/cohere.go b/internal/providers/cohere/cohere.go new file mode 100644 index 000000000..5376e394e --- /dev/null +++ b/internal/providers/cohere/cohere.go @@ -0,0 +1,148 @@ +// Package cohere provides Cohere API v2 integration for the LLM gateway. +package cohere + +import ( + "context" + "io" + "net/http" + "strings" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" +) + +const defaultBaseURL = "https://api.cohere.com" + +// Registration provides factory registration for the Cohere provider. +var Registration = providers.Registration{ + Type: "cohere", + New: New, + Discovery: providers.DiscoveryConfig{ + DefaultBaseURL: defaultBaseURL, + }, +} + +// Provider implements Cohere's native v2 chat and embedding APIs. +type Provider struct { + client *llmclient.Client + keys *providers.Keyring +} + +var _ core.Provider = (*Provider)(nil) +var _ core.AudioProvider = (*Provider)(nil) +var _ core.PassthroughProvider = (*Provider)(nil) + +// New creates a Cohere provider using the shared resilience and observability settings. +func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { + p := &Provider{keys: opts.Keyring(cfg.APIKey)} + clientCfg := llmclient.Config{ + ProviderName: "cohere", + BaseURL: providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL), + Retry: opts.Resilience.Retry, + Hooks: opts.Hooks, + CircuitBreaker: opts.Resilience.CircuitBreaker, + } + p.client = llmclient.New(clientCfg, p.setHeaders) + return p +} + +// NewWithHTTPClient creates a Cohere provider with a custom HTTP client. +func NewWithHTTPClient(apiKey, baseURL string, httpClient *http.Client, hooks llmclient.Hooks) *Provider { + if httpClient == nil { + httpClient = http.DefaultClient + } + p := &Provider{keys: providers.NewKeyring(apiKey)} + cfg := llmclient.DefaultConfig("cohere", providers.ResolveBaseURL(baseURL, defaultBaseURL)) + cfg.Hooks = hooks + p.client = llmclient.NewWithHTTPClient(httpClient, cfg, p.setHeaders) + return p +} + +// SetBaseURL changes the Cohere API base URL. +func (p *Provider) SetBaseURL(baseURL string) { + p.client.SetBaseURL(baseURL) +} + +func (p *Provider) setHeaders(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+p.keys.Next()) + req.Header.Set("X-Client-Name", "GoModel") + if requestID := core.GetRequestID(req.Context()); requestID != "" { + req.Header.Set("X-Request-Id", requestID) + } +} + +// ListModels returns Cohere's model catalog in OpenAI-compatible form. +func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) { + var upstream modelsResponse + err := p.client.Do(ctx, llmclient.Request{ + Method: http.MethodGet, + Endpoint: "/v1/models?page_size=1000", + }, &upstream) + if err != nil { + return nil, err + } + + models := make([]core.Model, 0, len(upstream.Models)) + for _, model := range upstream.Models { + if strings.TrimSpace(model.Name) == "" || !supportedModel(model) { + continue + } + var metadata *core.ModelMetadata + if model.ContextLength > 0 { + contextWindow := int(model.ContextLength) + metadata = &core.ModelMetadata{ContextWindow: &contextWindow} + } + models = append(models, core.Model{ + ID: model.Name, + Object: "model", + OwnedBy: "cohere", + Metadata: metadata, + }) + } + return &core.ModelsResponse{Object: "list", Data: models}, nil +} + +func supportedModel(model modelInfo) bool { + if len(model.Endpoints) == 0 { + return true + } + for _, endpoint := range model.Endpoints { + switch strings.ToLower(strings.TrimSpace(endpoint)) { + case "chat", "embed", "transcriptions": + return true + } + } + return false +} + +// Responses translates the OpenAI Responses API through Cohere chat. +func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + return providers.ResponsesViaChat(ctx, p, req) +} + +// StreamResponses translates a streaming OpenAI Responses request through Cohere chat. +func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { + return providers.StreamResponsesViaChat(ctx, p, req, "cohere") +} + +// Passthrough forwards a Cohere-native request without typed translation. +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) + } + resp, err := p.client.DoPassthrough(ctx, llmclient.Request{ + Method: req.Method, + Endpoint: providers.PassthroughEndpoint(req.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 +} diff --git a/internal/providers/cohere/cohere_test.go b/internal/providers/cohere/cohere_test.go new file mode 100644 index 000000000..3a9b22e7a --- /dev/null +++ b/internal/providers/cohere/cohere_test.go @@ -0,0 +1,623 @@ +package cohere + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" +) + +func TestChatCompletionTranslatesRequestAndResponse(t *testing.T) { + var captured map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/chat" { + t.Errorf("path = %q, want /v2/chat", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("Authorization = %q", got) + } + if got := r.Header.Get("X-Client-Name"); got != "GoModel" { + t.Errorf("X-Client-Name = %q", got) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Errorf("decode request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ + "id":"cohere-id", + "finish_reason":"TOOL_CALL", + "message":{ + "role":"assistant", + "content":[ + {"type":"thinking","thinking":"check the weather"}, + {"type":"text","text":"I will check."} + ], + "tool_plan":"Use the weather tool.", + "tool_calls":[ + {"id":"call-2","type":"function","function":{"name":"weather","arguments":"{\"city\":\"Warsaw\"}"}} + ], + "citations":[{"start":0,"end":1,"text":"I"}] + }, + "usage":{ + "billed_units":{"input_tokens":10,"output_tokens":3}, + "tokens":{"input_tokens":12,"image_tokens":5,"output_tokens":4}, + "cached_tokens":2 + } + }`) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-key", server.URL, server.Client(), llmclient.Hooks{}) + req := &core.ChatRequest{ + Model: "command-a-03-2025", + Messages: []core.Message{ + {Role: "developer", Content: []core.ContentPart{{Type: "text", Text: "Be brief."}}}, + {Role: "user", Content: []core.ContentPart{ + {Type: "text", Text: "Weather?"}, + {Type: "image_url", ImageURL: &core.ImageURLContent{URL: "https://example.com/map.png", Detail: "low"}}, + }}, + { + Role: "assistant", + Content: "", + ToolCalls: []core.ToolCall{{ + ID: "call-1", + Type: "function", + Function: core.FunctionCall{ + Name: "weather", + Arguments: `{"city":"Warsaw"}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call-1", Content: "sunny"}, + }, + Tools: []map[string]any{{ + "type": "function", + "function": map[string]any{ + "name": "weather", + "description": "Get weather", + "parameters": map[string]any{"type": "object"}, + }, + }}, + ToolChoice: map[string]any{ + "type": "function", + "function": map[string]any{"name": "weather"}, + }, + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "stop": json.RawMessage(`"END"`), + "max_completion_tokens": json.RawMessage(`250`), + "response_format": json.RawMessage(`{ + "type":"json_schema", + "json_schema":{ + "name":"weather", + "strict":true, + "schema":{"type":"object","properties":{"summary":{"type":"string"}}} + } + }`), + "top_k": json.RawMessage(`20`), + "thinking": json.RawMessage(`{"type":"enabled","token_budget":500}`), + }), + } + + resp, err := provider.ChatCompletion(context.Background(), req) + if err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + + if captured["model"] != req.Model || captured["stream"] != false { + t.Fatalf("request model/stream = %#v/%#v", captured["model"], captured["stream"]) + } + messages := captured["messages"].([]any) + if messages[0].(map[string]any)["role"] != "system" { + t.Fatalf("developer role = %#v, want system", messages[0]) + } + if messages[0].(map[string]any)["content"] != "Be brief." { + t.Fatalf("developer content = %#v, want a Cohere system string", messages[0]) + } + userContent := messages[1].(map[string]any)["content"].([]any) + image := userContent[1].(map[string]any) + if image["type"] != "image_url" { + t.Fatalf("image content = %#v", image) + } + toolResult := messages[3].(map[string]any)["content"].(string) + if toolResult != `{"result":"sunny"}` { + t.Fatalf("tool result = %q", toolResult) + } + if captured["tool_choice"] != "REQUIRED" { + t.Fatalf("tool_choice = %#v", captured["tool_choice"]) + } + if captured["max_tokens"] != float64(250) { + t.Fatalf("max_tokens = %#v", captured["max_tokens"]) + } + if got := captured["stop_sequences"].([]any)[0]; got != "END" { + t.Fatalf("stop_sequences = %#v", captured["stop_sequences"]) + } + if captured["k"] != float64(20) { + t.Fatalf("k = %#v", captured["k"]) + } + responseFormat := captured["response_format"].(map[string]any) + if responseFormat["type"] != "json_object" { + t.Fatalf("response_format.type = %#v, want json_object", responseFormat["type"]) + } + jsonSchema, ok := responseFormat["json_schema"].(map[string]any) + if !ok || jsonSchema["type"] != "object" { + t.Fatalf("response_format.json_schema = %#v, want translated schema", responseFormat["json_schema"]) + } + if _, leaked := responseFormat["name"]; leaked { + t.Fatalf("response_format leaked OpenAI wrapper fields: %#v", responseFormat) + } + + if resp.ID != "cohere-id" || resp.Model != req.Model || resp.Provider != "cohere" { + t.Fatalf("response identity = %#v", resp) + } + if resp.Choices[0].FinishReason != "tool_calls" { + t.Fatalf("finish_reason = %q", resp.Choices[0].FinishReason) + } + if resp.Choices[0].Message.Content != "I will check." { + t.Fatalf("content = %#v", resp.Choices[0].Message.Content) + } + if got := rawString(resp.Choices[0].Message.ExtraFields.Lookup("reasoning_content")); got != "check the weather" { + t.Fatalf("reasoning_content = %q", got) + } + if len(resp.Choices[0].Message.ToolCalls) != 1 || + resp.Choices[0].Message.ToolCalls[0].Function.Arguments != `{"city":"Warsaw"}` { + t.Fatalf("tool calls = %#v", resp.Choices[0].Message.ToolCalls) + } + if resp.Usage.PromptTokens != 17 || resp.Usage.CompletionTokens != 4 || resp.Usage.TotalTokens != 21 { + t.Fatalf("usage = %#v", resp.Usage) + } + if resp.Usage.PromptTokensDetails == nil || + resp.Usage.PromptTokensDetails.CachedTokens != 2 || + resp.Usage.PromptTokensDetails.ImageTokens != 5 { + t.Fatalf("prompt token details = %#v", resp.Usage.PromptTokensDetails) + } +} + +func TestChatCompletionReturnsCohereGenerationFailures(t *testing.T) { + tests := []struct { + finishReason string + wantStatus int + wantMessage string + }{ + {finishReason: "ERROR", wantStatus: http.StatusBadGateway, wantMessage: "generation failed"}, + {finishReason: "TIMEOUT", wantStatus: http.StatusGatewayTimeout, wantMessage: "generation timed out"}, + } + + for _, tt := range tests { + t.Run(tt.finishReason, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{ + "id":"failed-generation", + "finish_reason":"`+tt.finishReason+`", + "message":{"role":"assistant","content":[]}, + "usage":{} + }`) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "command-a", + Messages: []core.Message{{Role: "user", Content: "hello"}}, + }) + if resp != nil { + t.Fatalf("ChatCompletion() response = %#v, want nil", resp) + } + gatewayErr, ok := err.(*core.GatewayError) + if !ok { + t.Fatalf("ChatCompletion() error = %#v, want GatewayError", err) + } + if gatewayErr.Type != core.ErrorTypeProvider || gatewayErr.StatusCode != tt.wantStatus { + t.Fatalf("ChatCompletion() error = %#v, want provider status %d", gatewayErr, tt.wantStatus) + } + if !strings.Contains(strings.ToLower(gatewayErr.Message), tt.wantMessage) { + t.Fatalf("ChatCompletion() message = %q, want %q", gatewayErr.Message, tt.wantMessage) + } + }) + } +} + +func TestStreamChatCompletionConvertsCohereEvents(t *testing.T) { + var streamValue any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + streamValue = body["stream"] + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, strings.Join([]string{ + `event: message-start`, + `data: {"type":"message-start","id":"stream-id","delta":{"message":{"role":"assistant","content":[],"tool_calls":[],"citations":[]}}}`, + ``, + `event: content-delta`, + `data: {"type":"content-delta","index":0,"delta":{"message":{"content":{"thinking":"consider"}}}}`, + ``, + `event: content-delta`, + `data: {"type":"content-delta","index":0,"delta":{"message":{"content":{"text":"Hello"}}}}`, + ``, + `event: tool-plan-delta`, + `data: {"type":"tool-plan-delta","delta":{"message":{"tool_plan":"Use lookup."}}}`, + ``, + `event: tool-call-start`, + `data: {"type":"tool-call-start","index":0,"delta":{"message":{"tool_calls":{"id":"call-1","type":"function","function":{"name":"lookup","arguments":""}}}}}`, + ``, + `event: tool-call-delta`, + `data: {"type":"tool-call-delta","index":0,"delta":{"message":{"tool_calls":{"function":{"arguments":"{\"q\":\"x\"}"}}}}}`, + ``, + `event: citation-start`, + `data: {"type":"citation-start","index":0,"delta":{"message":{"citations":{"start":0,"end":5,"text":"Hello","sources":[{"type":"document","id":"doc-1"}]}}}}`, + ``, + `event: citation-end`, + `data: {"type":"citation-end","index":0}`, + ``, + `event: message-end`, + `data: {"type":"message-end","delta":{"finish_reason":"TOOL_CALL","usage":{"tokens":{"input_tokens":8,"output_tokens":3},"cached_tokens":1}}}`, + ``, + }, "\n")) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + stream, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{ + Model: "command-a", + Messages: []core.Message{{Role: "user", Content: "hello"}}, + StreamOptions: &core.StreamOptions{IncludeUsage: true}, + }) + if err != nil { + t.Fatalf("StreamChatCompletion() error = %v", err) + } + defer stream.Close() + body, err := io.ReadAll(stream) + if err != nil { + t.Fatalf("read stream: %v", err) + } + output := string(body) + + if streamValue != true { + t.Fatalf("upstream stream = %#v", streamValue) + } + for _, want := range []string{ + `"id":"stream-id"`, + `"role":"assistant"`, + `"reasoning_content":"consider"`, + `"content":"Hello"`, + `"tool_plan":"Use lookup."`, + `"name":"lookup"`, + `"arguments":"{\"q\":\"x\"}"`, + `"citations":[{"start":0,"end":5,"text":"Hello","sources":[{"type":"document","id":"doc-1"}]}]`, + `"finish_reason":"tool_calls"`, + `"prompt_tokens":8`, + `"cached_tokens":1`, + "data: [DONE]", + } { + if !strings.Contains(output, want) { + t.Errorf("stream missing %q:\n%s", want, output) + } + } +} + +func TestStreamChatCompletionReturnsGenerationFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, strings.Join([]string{ + `data: {"type":"message-start","id":"stream-id","delta":{"message":{"role":"assistant"}}}`, + ``, + `data: {"type":"message-end","delta":{"finish_reason":"TIMEOUT"}}`, + ``, + }, "\n")) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + stream, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{ + Model: "command-a", + Messages: []core.Message{{Role: "user", Content: "hello"}}, + }) + if err != nil { + t.Fatalf("StreamChatCompletion() error = %v", err) + } + defer stream.Close() + + body, err := io.ReadAll(stream) + if err != nil { + t.Fatalf("read stream: %v", err) + } + output := string(body) + if !strings.Contains(output, `"type":"provider_error"`) || + !strings.Contains(output, `"message":"Cohere generation timed out"`) { + t.Fatalf("stream = %s, want provider timeout error", output) + } + if strings.Contains(output, `"finish_reason":"stop"`) { + t.Fatalf("stream fabricated a successful stop finish:\n%s", output) + } +} + +func TestStreamResponsesPropagatesGenerationFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, strings.Join([]string{ + `data: {"type":"message-start","id":"stream-id","delta":{"message":{"role":"assistant"}}}`, + ``, + `data: {"type":"message-end","delta":{"finish_reason":"ERROR","error":"capacity exhausted"}}`, + ``, + }, "\n")) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + stream, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ + Model: "command-a", + Input: "hello", + }) + if err != nil { + t.Fatalf("StreamResponses() error = %v", err) + } + defer stream.Close() + + body, err := io.ReadAll(stream) + if err != nil { + t.Fatalf("read stream: %v", err) + } + output := string(body) + if !strings.Contains(output, "event: response.failed") || + !strings.Contains(output, `"status":"failed"`) || + !strings.Contains(output, `"message":"capacity exhausted"`) { + t.Fatalf("Responses stream = %s, want response.failed", output) + } + if strings.Contains(output, "event: response.completed") { + t.Fatalf("Responses stream fabricated response.completed:\n%s", output) + } +} + +func TestStreamResponsesPropagatesAdapterFailures(t *testing.T) { + tests := []struct { + name string + body string + contentLengthExtra int + wantMessage string + }{ + { + name: "malformed event", + body: strings.Join([]string{ + `data: {"type":"message-start","id":"stream-id","delta":{"message":{"role":"assistant"}}}`, + ``, + `data: {not-json}`, + ``, + }, "\n"), + wantMessage: "failed to parse Cohere stream event", + }, + { + name: "upstream read failure", + body: strings.Join([]string{ + `data: {"type":"message-start","id":"stream-id","delta":{"message":{"role":"assistant"}}}`, + ``, + `data: {"type":"content-delta","delta":{"message":{"content":{"text":"partial"}}}}`, + ``, + }, "\n"), + contentLengthExtra: 100, + wantMessage: "failed to read Cohere stream", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + if tt.contentLengthExtra > 0 { + w.Header().Set("Content-Length", strconv.Itoa(len(tt.body)+tt.contentLengthExtra)) + } + _, _ = io.WriteString(w, tt.body) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + stream, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ + Model: "command-a", + Input: "hello", + }) + if err != nil { + t.Fatalf("StreamResponses() error = %v", err) + } + defer stream.Close() + + body, err := io.ReadAll(stream) + if err != nil { + t.Fatalf("read stream: %v", err) + } + output := string(body) + if !strings.Contains(output, "event: response.failed") || + !strings.Contains(output, `"status":"failed"`) || + !strings.Contains(output, `"message":"`+tt.wantMessage+`"`) || + !strings.Contains(output, "data: [DONE]") { + t.Fatalf("Responses stream = %s, want terminal failure %q", output, tt.wantMessage) + } + if strings.Contains(output, "event: response.completed") { + t.Fatalf("Responses stream fabricated response.completed:\n%s", output) + } + }) + } +} + +func TestStreamChatCompletionDoesNotTurnIncompleteStreamIntoSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"type\":\"content-delta\",\"delta\":{\"message\":{\"content\":{\"text\":\"partial\"}}}}\n\n") + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + stream, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{ + Model: "command-a", + Messages: []core.Message{{Role: "user", Content: "hello"}}, + }) + if err != nil { + t.Fatalf("StreamChatCompletion() error = %v", err) + } + defer stream.Close() + body, err := io.ReadAll(stream) + if err != nil { + t.Fatalf("read stream: %v", err) + } + output := string(body) + if !strings.Contains(output, `"message":"Cohere stream ended before message-end"`) { + t.Fatalf("stream = %s, want incomplete-stream error", output) + } + if strings.Contains(output, `"finish_reason":"stop"`) { + t.Fatalf("stream fabricated a successful stop finish:\n%s", output) + } + if !strings.Contains(output, "data: [DONE]") { + t.Fatalf("stream missing DONE marker:\n%s", output) + } +} + +func TestEmbeddingsTranslatesOpenAIShape(t *testing.T) { + var captured map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/embed" { + t.Errorf("path = %q, want /v2/embed", r.URL.Path) + } + _ = json.NewDecoder(r.Body).Decode(&captured) + _, _ = io.WriteString(w, `{ + "id":"embed-id", + "response_type":"embeddings_by_type", + "embeddings":{"float":[[0.1,0.2],[0.3,0.4]]}, + "meta":{"billed_units":{"input_tokens":7}} + }`) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + dimensions := 2 + resp, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{ + Model: "embed-v4.0", + Input: []any{"one", "two"}, + Dimensions: &dimensions, + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "truncate": json.RawMessage(`"END"`), + }), + }) + if err != nil { + t.Fatalf("Embeddings() error = %v", err) + } + + if captured["input_type"] != "search_document" { + t.Fatalf("input_type = %#v", captured["input_type"]) + } + if captured["output_dimension"] != float64(2) { + t.Fatalf("output_dimension = %#v", captured["output_dimension"]) + } + if captured["embedding_types"].([]any)[0] != "float" { + t.Fatalf("embedding_types = %#v", captured["embedding_types"]) + } + if captured["truncate"] != "END" { + t.Fatalf("truncate = %#v", captured["truncate"]) + } + if resp.Object != "list" || resp.Model != "embed-v4.0" || resp.Provider != "cohere" { + t.Fatalf("response = %#v", resp) + } + if len(resp.Data) != 2 || string(resp.Data[1].Embedding) != `[0.3,0.4]` { + t.Fatalf("embedding data = %#v", resp.Data) + } + if resp.Usage.PromptTokens != 7 || resp.Usage.TotalTokens != 7 { + t.Fatalf("usage = %#v", resp.Usage) + } +} + +func TestEmbeddingsSupportsBase64(t *testing.T) { + resp := fromCohereEmbedResponse(&embedResponse{ + Embeddings: embedVectors{Base64: []string{"AAAA", "BBBB"}}, + }, &core.EmbeddingRequest{Model: "embed-v4.0", EncodingFormat: "base64"}) + if got := string(resp.Data[1].Embedding); got != `"BBBB"` { + t.Fatalf("base64 embedding = %s", got) + } +} + +func TestListModelsFiltersUnsupportedEndpointsAndRotatesKeys(t *testing.T) { + var ( + mu sync.Mutex + headers []string + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" || r.URL.Query().Get("page_size") != "1000" { + t.Errorf("models URL = %s", r.URL.String()) + } + mu.Lock() + headers = append(headers, r.Header.Get("Authorization")) + mu.Unlock() + _, _ = io.WriteString(w, `{"models":[ + {"name":"command-a","endpoints":["chat"],"context_length":128000}, + {"name":"embed-v4.0","endpoints":["embed"]}, + {"name":"cohere-transcribe-03-2026","endpoints":["transcriptions"]}, + {"name":"rerank-v3.5","endpoints":["rerank"]}, + {"name":"legacy-unknown"} + ]}`) + })) + defer server.Close() + + provider := NewWithHTTPClient("first", server.URL, server.Client(), llmclient.Hooks{}) + provider.keys = providers.NewKeyring("first", "second") + for range 2 { + resp, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(resp.Data) != 4 { + t.Fatalf("models = %#v", resp.Data) + } + if resp.Data[0].Metadata == nil || resp.Data[0].Metadata.ContextWindow == nil || + *resp.Data[0].Metadata.ContextWindow != 128000 { + t.Fatalf("model metadata = %#v", resp.Data[0].Metadata) + } + } + if len(headers) != 2 || headers[0] != "Bearer first" || headers[1] != "Bearer second" { + t.Fatalf("Authorization headers = %#v", headers) + } +} + +func TestInvalidCohereRequestsReturnClientErrors(t *testing.T) { + _, err := toCohereChatRequest(&core.ChatRequest{ + Model: "command-a", + Messages: []core.Message{{Role: "user", Content: "hello"}}, + ToolChoice: "required", + }, false) + assertInvalidRequest(t, err) + + _, err = toCohereChatRequest(&core.ChatRequest{ + Model: "command-a", + Messages: []core.Message{{Role: "user", Content: "hello"}}, + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "response_format": json.RawMessage(`{"type":"json_schema","json_schema":{"name":"missing-schema"}}`), + }), + }, false) + assertInvalidRequest(t, err) + + _, err = toCohereEmbedRequest(&core.EmbeddingRequest{ + Model: "embed-v4.0", + Input: []any{"text", []any{1, 2}}, + }) + assertInvalidRequest(t, err) +} + +func TestProviderImplementsNativePassthrough(t *testing.T) { + provider := NewWithHTTPClient("key", "https://example.com", nil, llmclient.Hooks{}) + var _ core.PassthroughProvider = provider +} + +func assertInvalidRequest(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("error = nil, want invalid request") + } + gatewayErr, ok := err.(*core.GatewayError) + if !ok || gatewayErr.Type != core.ErrorTypeInvalidRequest { + t.Fatalf("error = %#v, want invalid request", err) + } +} diff --git a/internal/providers/cohere/embeddings.go b/internal/providers/cohere/embeddings.go new file mode 100644 index 000000000..fbe58a936 --- /dev/null +++ b/internal/providers/cohere/embeddings.go @@ -0,0 +1,116 @@ +package cohere + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" +) + +// Embeddings translates OpenAI-compatible embedding requests to Cohere v2. +func (p *Provider) Embeddings(ctx context.Context, req *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { + upstreamReq, err := toCohereEmbedRequest(req) + if err != nil { + return nil, err + } + var upstream embedResponse + if err := p.client.Do(ctx, llmclient.Request{ + Method: http.MethodPost, + Endpoint: "/v2/embed", + Body: upstreamReq, + }, &upstream); err != nil { + return nil, err + } + return fromCohereEmbedResponse(&upstream, req), nil +} + +func toCohereEmbedRequest(req *core.EmbeddingRequest) (*embedRequest, error) { + if req == nil { + return nil, core.NewInvalidRequestError("embedding request is required", nil) + } + texts, err := embeddingTexts(req.Input) + if err != nil { + return nil, err + } + encoding := strings.ToLower(strings.TrimSpace(req.EncodingFormat)) + if encoding == "" { + encoding = "float" + } + if encoding != "float" && encoding != "base64" { + return nil, core.NewInvalidRequestError("encoding_format must be float or base64", nil) + } + inputType := rawString(req.ExtraFields.Lookup("input_type")) + if inputType == "" { + inputType = "search_document" + } + out := &embedRequest{ + Texts: texts, + Model: req.Model, + InputType: inputType, + EmbeddingTypes: []string{encoding}, + OutputDimension: req.Dimensions, + MaxTokens: req.ExtraFields.Lookup("max_tokens"), + Truncate: req.ExtraFields.Lookup("truncate"), + Priority: req.ExtraFields.Lookup("priority"), + } + return out, nil +} + +func embeddingTexts(input any) ([]string, error) { + switch value := input.(type) { + case string: + return []string{value}, nil + case []string: + return append([]string(nil), value...), nil + case []any: + texts := make([]string, len(value)) + for i, item := range value { + text, ok := item.(string) + if !ok { + return nil, core.NewInvalidRequestError(fmt.Sprintf("input[%d] must be a string for Cohere embeddings", i), nil) + } + texts[i] = text + } + return texts, nil + default: + return nil, core.NewInvalidRequestError("input must be a string or array of strings for Cohere embeddings", nil) + } +} + +func fromCohereEmbedResponse(resp *embedResponse, req *core.EmbeddingRequest) *core.EmbeddingResponse { + base64Format := strings.EqualFold(strings.TrimSpace(req.EncodingFormat), "base64") + count := len(resp.Embeddings.Float) + if base64Format { + count = len(resp.Embeddings.Base64) + } + data := make([]core.EmbeddingData, 0, count) + for i := 0; i < count; i++ { + var encoded json.RawMessage + if base64Format { + encoded, _ = json.Marshal(resp.Embeddings.Base64[i]) + } else { + encoded, _ = json.Marshal(resp.Embeddings.Float[i]) + } + data = append(data, core.EmbeddingData{ + Object: "embedding", + Embedding: encoded, + Index: i, + }) + } + inputTokens := int(resp.Meta.BilledUnits.InputTokens) + return &core.EmbeddingResponse{ + Object: "list", + Data: data, + Model: req.Model, + Provider: "cohere", + Usage: core.EmbeddingUsage{ + PromptTokens: inputTokens, + TotalTokens: inputTokens, + }, + } +} diff --git a/internal/providers/cohere/stream.go b/internal/providers/cohere/stream.go new file mode 100644 index 000000000..7ca85aea9 --- /dev/null +++ b/internal/providers/cohere/stream.go @@ -0,0 +1,329 @@ +package cohere + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/internal/core" +) + +type chatStream struct { + reader *io.PipeReader + body io.ReadCloser +} + +func newChatStream(body io.ReadCloser, model string, includeUsage bool) io.ReadCloser { + reader, writer := io.Pipe() + stream := &chatStream{reader: reader, body: body} + go convertChatStream(body, writer, model, includeUsage) + return stream +} + +func (s *chatStream) Read(p []byte) (int, error) { + return s.reader.Read(p) +} + +func (s *chatStream) Close() error { + _ = s.reader.Close() + return s.body.Close() +} + +func convertChatStream(body io.ReadCloser, out *io.PipeWriter, model string, includeUsage bool) { + defer func() { _ = body.Close() }() + state := cohereStreamState{ + model: model, + includeUsage: includeUsage, + created: time.Now().Unix(), + } + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + var data strings.Builder + for scanner.Scan() { + line := strings.TrimRight(scanner.Text(), "\r") + if line == "" { + if err := state.consume(out, data.String()); err != nil { + closeStreamWithError(out, err) + return + } + data.Reset() + continue + } + if strings.HasPrefix(line, "data:") { + if data.Len() > 0 { + data.WriteByte('\n') + } + data.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } + if data.Len() > 0 { + if err := state.consume(out, data.String()); err != nil { + closeStreamWithError(out, err) + return + } + } + if err := scanner.Err(); err != nil { + closeStreamWithError(out, core.NewProviderError( + "cohere", 502, "failed to read Cohere stream", err, + )) + return + } + if !state.finished { + if err := writeStreamError(out, "Cohere stream ended before message-end"); err != nil { + _ = out.CloseWithError(err) + return + } + } + _, _ = io.WriteString(out, "data: [DONE]\n\n") + _ = out.Close() +} + +// closeStreamWithError converts adapter read and decoding failures into the +// same OpenAI-compatible error chunk used for upstream generation failures. +// Closing normally after [DONE] lets the Responses converter emit its terminal +// response.failed event instead of surfacing a raw pipe error to the client. +func closeStreamWithError(out *io.PipeWriter, err error) { + message := "Cohere stream failed" + var gatewayErr *core.GatewayError + if errors.As(err, &gatewayErr) && strings.TrimSpace(gatewayErr.Message) != "" { + message = gatewayErr.Message + } else if err != nil && strings.TrimSpace(err.Error()) != "" { + message = err.Error() + } + if writeErr := writeStreamError(out, message); writeErr != nil { + _ = out.CloseWithError(writeErr) + return + } + if _, writeErr := io.WriteString(out, "data: [DONE]\n\n"); writeErr != nil { + _ = out.CloseWithError(writeErr) + return + } + _ = out.Close() +} + +type cohereStreamState struct { + model string + includeUsage bool + created int64 + id string + roleSent bool + sawToolCalls bool + finished bool +} + +func (s *cohereStreamState) consume(out io.Writer, raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[DONE]" || s.finished { + return nil + } + var event streamEvent + if err := json.Unmarshal([]byte(raw), &event); err != nil { + return core.NewProviderError("cohere", 502, "failed to parse Cohere stream event", err) + } + if event.ID != "" { + s.id = event.ID + } + switch event.Type { + case "message-start": + return s.writeDelta(out, map[string]any{"role": "assistant"}) + case "content-delta": + content, err := decodeStreamContent(event.Delta.Message.Content) + if err != nil { + return err + } + delta := map[string]any{} + if content.Text != "" { + delta["content"] = content.Text + } + if content.Thinking != "" { + delta["reasoning_content"] = content.Thinking + } + return s.writeDelta(out, delta) + case "tool-plan-delta": + if event.Delta.Message.ToolPlan == "" { + return nil + } + return s.writeDelta(out, map[string]any{"tool_plan": event.Delta.Message.ToolPlan}) + case "tool-call-start": + call, err := decodeStreamToolCall(event.Delta.Message.ToolCalls) + if err != nil { + return err + } + if call == nil { + return nil + } + s.sawToolCalls = true + return s.writeDelta(out, map[string]any{"tool_calls": []map[string]any{{ + "index": event.Index, + "id": call.ID, + "type": "function", + "function": map[string]any{ + "name": call.Function.Name, + "arguments": call.Function.Arguments, + }, + }}}) + case "tool-call-delta": + call, err := decodeStreamToolCall(event.Delta.Message.ToolCalls) + if err != nil { + return err + } + if call == nil { + return nil + } + s.sawToolCalls = true + return s.writeDelta(out, map[string]any{"tool_calls": []map[string]any{{ + "index": event.Index, + "function": map[string]any{ + "arguments": call.Function.Arguments, + }, + }}}) + case "citation-start": + citations := bytes.TrimSpace(event.Delta.Message.Citations) + if len(citations) == 0 || bytes.Equal(citations, []byte("null")) { + return nil + } + if citations[0] == '[' { + return s.writeDelta(out, map[string]any{"citations": json.RawMessage(citations)}) + } + return s.writeDelta(out, map[string]any{"citations": []json.RawMessage{ + core.CloneRawJSON(citations), + }}) + case "citation-end": + // Cohere's citation-end event is only a boundary marker; all citation + // data is carried by the corresponding citation-start event. + return nil + case "message-end": + if failure := cohereGenerationError(event.Delta.FinishReason, event.Delta.Error); failure != nil { + s.finished = true + return writeStreamError(out, failure.Message) + } + finish := normalizeFinishReason(event.Delta.FinishReason) + if s.sawToolCalls { + finish = "tool_calls" + } + return s.writeFinish(out, finish, event.Delta.Usage) + default: + return nil + } +} + +func decodeStreamContent(raw json.RawMessage) (streamDeltaContent, error) { + if !streamObject(raw) { + return streamDeltaContent{}, nil + } + var content streamDeltaContent + if err := json.Unmarshal(raw, &content); err != nil { + return streamDeltaContent{}, core.NewProviderError( + "cohere", 502, "failed to parse Cohere stream content", err, + ) + } + return content, nil +} + +func decodeStreamToolCall(raw json.RawMessage) (*toolCall, error) { + if !streamObject(raw) { + return nil, nil + } + var call toolCall + if err := json.Unmarshal(raw, &call); err != nil { + return nil, core.NewProviderError( + "cohere", 502, "failed to parse Cohere stream tool call", err, + ) + } + return &call, nil +} + +func streamObject(raw json.RawMessage) bool { + raw = bytes.TrimSpace(raw) + return len(raw) > 0 && raw[0] == '{' +} + +func (s *cohereStreamState) writeDelta(out io.Writer, delta map[string]any) error { + if len(delta) == 0 { + return nil + } + if !s.roleSent { + if _, ok := delta["role"]; !ok { + delta["role"] = "assistant" + } + s.roleSent = true + } + return writeStreamChunk(out, s.chunk([]map[string]any{{ + "index": 0, + "delta": delta, + "finish_reason": nil, + }}, nil)) +} + +func (s *cohereStreamState) writeFinish(out io.Writer, finish string, value usage) error { + if s.finished { + return nil + } + s.finished = true + if err := writeStreamChunk(out, s.chunk([]map[string]any{{ + "index": 0, + "delta": map[string]any{}, + "finish_reason": finish, + }}, nil)); err != nil { + return err + } + if !s.includeUsage { + return nil + } + coreUsage := toCoreUsage(value) + usageMap := map[string]any{ + "prompt_tokens": coreUsage.PromptTokens, + "completion_tokens": coreUsage.CompletionTokens, + "total_tokens": coreUsage.TotalTokens, + } + if coreUsage.PromptTokensDetails != nil { + usageMap["prompt_tokens_details"] = coreUsage.PromptTokensDetails + } + if len(coreUsage.RawUsage) > 0 { + usageMap["raw_usage"] = coreUsage.RawUsage + } + return writeStreamChunk(out, s.chunk([]map[string]any{}, usageMap)) +} + +func (s *cohereStreamState) chunk(choices []map[string]any, usageValue map[string]any) map[string]any { + if s.id == "" { + s.id = "chatcmpl-cohere-" + strconv.FormatInt(s.created, 10) + } + chunk := map[string]any{ + "id": s.id, + "object": "chat.completion.chunk", + "created": s.created, + "model": s.model, + "provider": "cohere", + "choices": choices, + } + if usageValue != nil { + chunk["usage"] = usageValue + } + return chunk +} + +func writeStreamChunk(out io.Writer, chunk map[string]any) error { + body, err := json.Marshal(chunk) + if err != nil { + return err + } + _, err = fmt.Fprintf(out, "data: %s\n\n", body) + return err +} + +func writeStreamError(out io.Writer, message string) error { + return writeStreamChunk(out, map[string]any{"error": map[string]any{ + "type": core.ErrorTypeProvider, + "message": message, + "param": nil, + "code": nil, + }}) +} diff --git a/internal/providers/cohere/types.go b/internal/providers/cohere/types.go new file mode 100644 index 000000000..e015e210b --- /dev/null +++ b/internal/providers/cohere/types.go @@ -0,0 +1,157 @@ +package cohere + +import "github.com/goccy/go-json" + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Tools []map[string]any `json:"tools,omitempty"` + StrictTools json.RawMessage `json:"strict_tools,omitempty"` + Documents json.RawMessage `json:"documents,omitempty"` + CitationOptions json.RawMessage `json:"citation_options,omitempty"` + ResponseFormat json.RawMessage `json:"response_format,omitempty"` + SafetyMode json.RawMessage `json:"safety_mode,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + StopSequences json.RawMessage `json:"stop_sequences,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + Seed json.RawMessage `json:"seed,omitempty"` + FrequencyPenalty json.RawMessage `json:"frequency_penalty,omitempty"` + PresencePenalty json.RawMessage `json:"presence_penalty,omitempty"` + K json.RawMessage `json:"k,omitempty"` + P *float64 `json:"p,omitempty"` + Logprobs json.RawMessage `json:"logprobs,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` + Thinking json.RawMessage `json:"thinking,omitempty"` + Priority json.RawMessage `json:"priority,omitempty"` + Stream bool `json:"stream"` +} + +type chatMessage struct { + Role string `json:"role"` + Content any `json:"content,omitempty"` + ToolCalls []toolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type contentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` + ImageURL *imageURLContainer `json:"image_url,omitempty"` +} + +type imageURLContainer struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` +} + +type toolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function toolFunction `json:"function"` +} + +type toolFunction struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type chatResponse struct { + ID string `json:"id"` + FinishReason string `json:"finish_reason"` + Message assistantMessage `json:"message"` + Usage usage `json:"usage"` + Logprobs json.RawMessage `json:"logprobs,omitempty"` +} + +type assistantMessage struct { + Role string `json:"role"` + Content []contentPart `json:"content,omitempty"` + ToolCalls []toolCall `json:"tool_calls,omitempty"` + ToolPlan string `json:"tool_plan,omitempty"` + Citations json.RawMessage `json:"citations,omitempty"` +} + +type usage struct { + BilledUnits billedUnits `json:"billed_units"` + Tokens tokenUsage `json:"tokens"` + CachedTokens float64 `json:"cached_tokens,omitempty"` +} + +type billedUnits struct { + InputTokens float64 `json:"input_tokens,omitempty"` + OutputTokens float64 `json:"output_tokens,omitempty"` + SearchUnits float64 `json:"search_units,omitempty"` + Classifications float64 `json:"classifications,omitempty"` +} + +type tokenUsage struct { + InputTokens float64 `json:"input_tokens,omitempty"` + ImageTokens float64 `json:"image_tokens,omitempty"` + OutputTokens float64 `json:"output_tokens,omitempty"` +} + +type modelsResponse struct { + Models []modelInfo `json:"models"` +} + +type modelInfo struct { + Name string `json:"name"` + Endpoints []string `json:"endpoints,omitempty"` + ContextLength float64 `json:"context_length,omitempty"` +} + +type embedRequest struct { + Texts []string `json:"texts"` + Model string `json:"model"` + InputType string `json:"input_type"` + EmbeddingTypes []string `json:"embedding_types"` + OutputDimension *int `json:"output_dimension,omitempty"` + MaxTokens json.RawMessage `json:"max_tokens,omitempty"` + Truncate json.RawMessage `json:"truncate,omitempty"` + Priority json.RawMessage `json:"priority,omitempty"` +} + +type embedResponse struct { + ID string `json:"id"` + ResponseType string `json:"response_type,omitempty"` + Embeddings embedVectors `json:"embeddings"` + Meta embedMeta `json:"meta"` +} + +type embedVectors struct { + Float [][]float64 `json:"float,omitempty"` + Base64 []string `json:"base64,omitempty"` +} + +type embedMeta struct { + BilledUnits billedUnits `json:"billed_units"` +} + +type streamEvent struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Index int `json:"index,omitempty"` + Delta streamEventDelta `json:"delta"` +} + +type streamEventDelta struct { + Message streamDeltaMessage `json:"message"` + FinishReason string `json:"finish_reason,omitempty"` + Usage usage `json:"usage"` + Error string `json:"error,omitempty"` +} + +type streamDeltaMessage struct { + Role string `json:"role,omitempty"` + Content json.RawMessage `json:"content"` + ToolCalls json.RawMessage `json:"tool_calls,omitempty"` + ToolPlan string `json:"tool_plan,omitempty"` + Citations json.RawMessage `json:"citations,omitempty"` +} + +type streamDeltaContent struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` +} diff --git a/internal/providers/config_test.go b/internal/providers/config_test.go index 8357dccd4..b0d89bf34 100644 --- a/internal/providers/config_test.go +++ b/internal/providers/config_test.go @@ -25,6 +25,9 @@ var testDiscoveryConfigs = map[string]DiscoveryConfig{ "anthropic": { DefaultBaseURL: "https://api.anthropic.com/v1", }, + "cohere": { + DefaultBaseURL: "https://api.cohere.com", + }, "gemini": { DefaultBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", }, diff --git a/internal/providers/responses_converter.go b/internal/providers/responses_converter.go index c0927763e..5db16f81a 100644 --- a/internal/providers/responses_converter.go +++ b/internal/providers/responses_converter.go @@ -56,6 +56,7 @@ func NewOpenAIResponsesStreamConverter(reader io.ReadCloser, model, provider str // openAIStreamChunk is the subset of an OpenAI chat.completion.chunk the // converter consumes. Typed decoding avoids a map[string]any per chunk. type openAIStreamChunk struct { + Error json.RawMessage `json:"error"` Usage json.RawMessage `json:"usage"` Choices []struct { Delta struct { @@ -192,6 +193,10 @@ func (sc *OpenAIResponsesStreamConverter) processChunk(data []byte) { sc.processChunkTolerant(data) return } + if len(bytes.TrimSpace(chunk.Error)) > 0 && !bytes.Equal(bytes.TrimSpace(chunk.Error), []byte("null")) { + sc.appendFailedEvents(chunk.Error) + return + } // Capture usage if present and object-shaped (OpenAI sends it in the // final chunk); anything else must not leak into response.completed. @@ -222,6 +227,13 @@ func (sc *OpenAIResponsesStreamConverter) processChunkTolerant(data []byte) { if err := json.Unmarshal(data, &chunk); err != nil { return } + if streamErr, present := chunk["error"]; present && streamErr != nil { + raw, err := json.Marshal(streamErr) + if err == nil { + sc.appendFailedEvents(raw) + } + return + } if usage, ok := chunk["usage"].(map[string]any); ok { if raw, err := json.Marshal(usage); err == nil { @@ -338,6 +350,59 @@ func (sc *OpenAIResponsesStreamConverter) appendCompletedEvents() { sc.buffer.AppendString("\n\ndata: [DONE]\n\n") } +func (sc *OpenAIResponsesStreamConverter) appendFailedEvents(raw json.RawMessage) { + if sc.sentDone { + return + } + sc.sentDone = true + + var upstream struct { + Type string `json:"type"` + Message string `json:"message"` + Code json.RawMessage `json:"code"` + } + if err := json.Unmarshal(raw, &upstream); err != nil { + _ = json.Unmarshal(raw, &upstream.Message) + } + var code string + _ = json.Unmarshal(upstream.Code, &code) + if code == "" { + code = upstream.Type + } + if code == "" { + code = "provider_error" + } + if strings.TrimSpace(upstream.Message) == "" { + upstream.Message = "provider stream failed" + } + + responseData := map[string]any{ + "id": sc.responseID, + "object": "response", + "status": "failed", + "model": sc.model, + "provider": sc.provider, + "created_at": sc.createdAt, + "output": []map[string]any{}, + "error": map[string]any{ + "code": code, + "message": upstream.Message, + }, + } + failedEvent := map[string]any{ + "type": "response.failed", + "response": responseData, + } + jsonData, err := json.Marshal(failedEvent) + if err != nil { + slog.Error("failed to marshal response.failed event", "error", err, "response_id", sc.responseID) + return + } + sc.buffer.AppendString("event: response.failed\ndata: ") + sc.buffer.AppendBytes(jsonData) + sc.buffer.AppendString("\n\ndata: [DONE]\n\n") +} + func (sc *OpenAIResponsesStreamConverter) Read(p []byte) (n int, err error) { if sc.closed { return 0, io.EOF diff --git a/internal/providers/responses_converter_test.go b/internal/providers/responses_converter_test.go index 7ec442a12..ab66275b5 100644 --- a/internal/providers/responses_converter_test.go +++ b/internal/providers/responses_converter_test.go @@ -322,3 +322,73 @@ data: [DONE] } t.Fatal("expected response.completed event") } + +func TestOpenAIResponsesStreamConverter_PropagatesStreamError(t *testing.T) { + tests := []struct { + name string + errorChunk string + wantCode string + wantMessage string + }{ + { + name: "object error", + errorChunk: `{"error":{"type":"provider_error","message":"upstream generation timed out","param":null,"code":null}}`, + wantCode: "provider_error", + wantMessage: "upstream generation timed out", + }, + { + name: "scalar error", + errorChunk: `{"error":"capacity exhausted"}`, + wantCode: "provider_error", + wantMessage: "capacity exhausted", + }, + { + name: "tolerant fallback", + errorChunk: `{"error":{"message":"malformed companion field","code":"upstream_error"},"choices":"invalid"}`, + wantCode: "upstream_error", + wantMessage: "malformed companion field", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockStream := `data: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]} + +data: ` + tt.errorChunk + ` + +data: [DONE] +` + + converter := NewOpenAIResponsesStreamConverter( + io.NopCloser(strings.NewReader(mockStream)), + "test-model", + "cohere", + ) + raw, err := io.ReadAll(converter) + if err != nil { + t.Fatalf("failed to read from converter: %v", err) + } + + var failed map[string]any + for _, event := range parseTestSSEEvents(t, string(raw)) { + switch event.Name { + case "response.completed": + t.Fatalf("stream emitted response.completed after provider error:\n%s", raw) + case "response.failed": + failed, _ = event.Payload["response"].(map[string]any) + } + } + if failed == nil { + t.Fatalf("stream missing response.failed event:\n%s", raw) + } + if failed["status"] != "failed" || failed["provider"] != "cohere" { + t.Fatalf("response.failed response = %#v", failed) + } + responseErr, _ := failed["error"].(map[string]any) + if responseErr["code"] != tt.wantCode || + responseErr["message"] != tt.wantMessage { + t.Fatalf("response.failed error = %#v", responseErr) + } + }) + } +} diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 0c57b80fb..2493e7f56 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -7128,7 +7128,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, openai, openrouter, vllm, zai") { + if !strings.Contains(rec.Body.String(), "anthropic, deepseek, kilo, openai, openrouter, 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 1161403c1..b5eaed0c0 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", "zai", "vllm", "deepseek"} +var defaultEnabledPassthroughProviders = []string{"openai", "anthropic", "openrouter", "kilo", "zai", "vllm", "deepseek"} func (h *Handler) setEnabledPassthroughProviders(providerTypes []string) { h.enabledPassthroughProviders = normalizeEnabledPassthroughProviders(providerTypes) diff --git a/run/providers.go b/run/providers.go index 1a0f8b1cd..93f9af6be 100644 --- a/run/providers.go +++ b/run/providers.go @@ -9,6 +9,7 @@ import ( "github.com/enterpilot/gomodel/internal/providers/bailian" "github.com/enterpilot/gomodel/internal/providers/bedrock" "github.com/enterpilot/gomodel/internal/providers/bedrockmantle" + "github.com/enterpilot/gomodel/internal/providers/cohere" "github.com/enterpilot/gomodel/internal/providers/deepseek" "github.com/enterpilot/gomodel/internal/providers/fireworks" "github.com/enterpilot/gomodel/internal/providers/gemini" @@ -46,6 +47,7 @@ func defaultProviderFactory(cfg *config.Config) *providers.ProviderFactory { factory.Add(anthropic.Registration) factory.Add(bedrock.Registration) factory.Add(bedrockmantle.Registration) + factory.Add(cohere.Registration) factory.Add(deepseek.Registration) factory.Add(fireworks.Registration) factory.Add(gemini.Registration) diff --git a/run/providers_test.go b/run/providers_test.go index 27681afb5..2ab4f5f21 100644 --- a/run/providers_test.go +++ b/run/providers_test.go @@ -9,7 +9,7 @@ import ( func TestDefaultProviderFactoryRegistersAllProviderTypes(t *testing.T) { expected := []string{ - "anthropic", "azure", "bailian", "bedrock", "bedrock-mantle", "deepseek", "fireworks", + "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", } diff --git a/web/dashboard/src/pages/overview/providersLogic.js b/web/dashboard/src/pages/overview/providersLogic.js index 509b6013c..edeedfb3e 100644 --- a/web/dashboard/src/pages/overview/providersLogic.js +++ b/web/dashboard/src/pages/overview/providersLogic.js @@ -19,6 +19,7 @@ const PROVIDER_DOC_SLUGS = { bailian: "bailian", bedrock: "bedrock", "bedrock-mantle": "bedrock-mantle", + cohere: "cohere", deepseek: "deepseek", gemini: "gemini", opencode_go: "opencode-go", diff --git a/web/dashboard/tests/overview-providers.test.js b/web/dashboard/tests/overview-providers.test.js index b14324aa0..355cee5a1 100644 --- a/web/dashboard/tests/overview-providers.test.js +++ b/web/dashboard/tests/overview-providers.test.js @@ -143,6 +143,10 @@ test("providerDocUrl links provider types with docs and stays empty otherwise", providerDocUrl({ type: "bedrock-mantle" }), "https://gomodel.enterpilot.io/docs/providers/bedrock-mantle?utm_source=gomodel_dashboard", ); + assert.equal( + providerDocUrl({ type: "cohere" }), + "https://gomodel.enterpilot.io/docs/providers/cohere?utm_source=gomodel_dashboard", + ); // Type slug differs from the docs slug. assert.equal( providerDocUrl({ type: "opencode_go" }),