From 153ee1502c18e4d01ece9192657ce2e4dcaa5a42 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 8 Aug 2026 20:19:31 +0200 Subject: [PATCH 1/7] feat(providers): add Chutes AI support --- config/config.example.yaml | 5 + docs/advanced/configuration.mdx | 1 + docs/providers/overview.mdx | 5 + internal/providers/chutes/chutes.go | 97 +++++++++++++ internal/providers/chutes/chutes_test.go | 167 +++++++++++++++++++++++ internal/providers/chutes/models.go | 134 ++++++++++++++++++ internal/providers/config_test.go | 23 ++++ run/providers.go | 2 + run/providers_test.go | 2 +- 9 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 internal/providers/chutes/chutes.go create mode 100644 internal/providers/chutes/chutes_test.go create mode 100644 internal/providers/chutes/models.go diff --git a/config/config.example.yaml b/config/config.example.yaml index dc687d5a6..d43412956 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -380,6 +380,11 @@ providers: # models: # - id: "accounts/fireworks/models/gpt-oss-120b" + chutes: + type: chutes + api_key: "${CHUTES_API_KEY}" + # base_url defaults to "https://llm.chutes.ai/v1" + meta: type: meta api_key: "..." diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 339ccc57a..b5c35aa47 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -409,6 +409,7 @@ export GEMINI_API_KEY="..." # Registers "gemini" provider export DEEPSEEK_API_KEY="..." # Registers "deepseek" provider export XAI_API_KEY="..." # Registers "xai" provider export GROQ_API_KEY="gsk_..." # Registers "groq" provider +export CHUTES_API_KEY="cpk_..." # Registers "chutes" provider export OPENROUTER_API_KEY="sk-or-..." # Registers "openrouter" provider export KILO_API_KEY="..." # Registers "kilo" provider export ZAI_API_KEY="..." # Registers "zai" provider diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index 49e62a391..50e32aad6 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -48,6 +48,7 @@ support, not every individual model capability exposed by an upstream provider. | DeepSeek | `DEEPSEEK_API_KEY` | `deepseek-v4-pro` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | [DeepSeek](/providers/deepseek) | | Groq | `GROQ_API_KEY` | `llama-3.3-70b-versatile` | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | — | | Fireworks AI | `FIREWORKS_API_KEY` (`FIREWORKS_BASE_URL` optional) | `accounts/fireworks/models/gpt-oss-120b` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | — | +| Chutes AI | `CHUTES_API_KEY` (`CHUTES_BASE_URL` optional) | `Qwen/Qwen3-32B-TEE` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | — | | Meta (Muse Spark) | `META_API_KEY` (`META_BASE_URL` optional) | `muse-spark-1.1` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | — | | OpenRouter | `OPENROUTER_API_KEY` | `google/gemini-2.5-flash` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | | Kilo AI | `KILO_API_KEY` (`KILO_BASE_URL` optional) | `anthropic/claude-sonnet-4.5` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | — | @@ -90,6 +91,10 @@ support, not every individual model capability exposed by an upstream provider. - **Fireworks AI** — model IDs are account-scoped paths such as `accounts/fireworks/models/gpt-oss-120b`; use them verbatim in requests and in `FIREWORKS_MODELS`. +- **Chutes AI** — defaults to `https://llm.chutes.ai/v1` and discovers its + current model IDs, context limits, capabilities, and pricing from the live + catalog. GoModel translates `/v1/responses` requests to chat completions; + Chutes' shared LLM endpoint does not expose embeddings. - **Meta (Muse Spark)** — the Meta Model API is OpenAI-compatible; set `META_API_KEY` and route to `muse-spark-1.1`. Muse Spark models are not in the upstream model catalog yet, so declare `context_window` and `pricing` diff --git a/internal/providers/chutes/chutes.go b/internal/providers/chutes/chutes.go new file mode 100644 index 000000000..dc172c80b --- /dev/null +++ b/internal/providers/chutes/chutes.go @@ -0,0 +1,97 @@ +// Package chutes provides Chutes AI integration for the LLM gateway. +package chutes + +import ( + "context" + "io" + "net/http" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" + "github.com/enterpilot/gomodel/internal/providers/openai" +) + +const defaultBaseURL = "https://llm.chutes.ai/v1" + +// Registration provides factory registration for the Chutes AI provider. +var Registration = providers.Registration{ + Type: "chutes", + New: New, + Discovery: providers.DiscoveryConfig{ + DefaultBaseURL: defaultBaseURL, + }, +} + +// Provider implements Chutes' OpenAI-compatible chat surface. Chutes does not +// expose native Responses or embeddings endpoints, so Responses requests are +// translated through chat completions and embeddings fail locally. +type Provider struct { + compat *openai.CompatibleProvider +} + +var _ core.Provider = (*Provider)(nil) +var _ core.PassthroughProvider = (*Provider)(nil) + +// New creates a new Chutes AI provider. +func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { + return &Provider{compat: openai.NewCompatibleProvider(cfg.APIKey, opts, compatibleConfig( + providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL), + ))} +} + +// NewWithHTTPClient creates a new Chutes AI provider with a custom HTTP client. +// If httpClient is nil, http.DefaultClient is used. +func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, hooks llmclient.Hooks) *Provider { + return &Provider{compat: openai.NewCompatibleProviderWithHTTPClient(apiKey, httpClient, hooks, compatibleConfig( + providers.ResolveBaseURL(baseURL, defaultBaseURL), + ))} +} + +func compatibleConfig(baseURL string) openai.CompatibleProviderConfig { + return openai.CompatibleProviderConfig{ + ProviderName: "chutes", + BaseURL: baseURL, + SetHeaders: setHeaders, + } +} + +func setHeaders(req *http.Request, apiKey string) { + providers.SetAuthHeaders(req, apiKey, providers.AuthHeaderConfig{AuthScheme: "Bearer "}) +} + +// SetBaseURL changes the Chutes API base URL. +func (p *Provider) SetBaseURL(baseURL string) { + p.compat.SetBaseURL(baseURL) +} + +// ChatCompletion sends a chat completion request to Chutes. +func (p *Provider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { + return p.compat.ChatCompletion(ctx, req) +} + +// StreamChatCompletion sends a streaming chat completion request to Chutes. +func (p *Provider) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { + return p.compat.StreamChatCompletion(ctx, req) +} + +// Responses translates an OpenAI Responses request through Chutes chat completions. +func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + return providers.ResponsesViaChat(ctx, p, req) +} + +// StreamResponses translates a streaming Responses request through Chutes chat completions. +func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { + return providers.StreamResponsesViaChat(ctx, p, req, "chutes") +} + +// Embeddings returns an error because the shared Chutes LLM endpoint does not +// expose an OpenAI-compatible embeddings route. +func (p *Provider) Embeddings(_ context.Context, _ *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { + return nil, core.NewInvalidRequestError("chutes does not support embeddings", nil) +} + +// Passthrough forwards an opaque request to Chutes. +func (p *Provider) Passthrough(ctx context.Context, req *core.PassthroughRequest) (*core.PassthroughResponse, error) { + return p.compat.Passthrough(ctx, req) +} diff --git a/internal/providers/chutes/chutes_test.go b/internal/providers/chutes/chutes_test.go new file mode 100644 index 000000000..63376f4ac --- /dev/null +++ b/internal/providers/chutes/chutes_test.go @@ -0,0 +1,167 @@ +package chutes + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" +) + +func TestChatCompletion_UsesBearerAuthAndChatEndpoint(t *testing.T) { + var gotPath, gotAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-chutes", + "created":1677652288, + "model":"Qwen/Qwen3-32B-TEE", + "choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4} + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("cpk_test", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "Qwen/Qwen3-32B-TEE", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotPath != "/chat/completions" { + t.Fatalf("path = %q, want /chat/completions", gotPath) + } + if gotAuth != "Bearer cpk_test" { + t.Fatalf("authorization = %q, want Bearer cpk_test", gotAuth) + } + if resp.Model != "Qwen/Qwen3-32B-TEE" || resp.Usage.TotalTokens != 4 { + t.Fatalf("response = %+v, want model and usage preserved", resp) + } +} + +func TestResponses_TranslatesToChatCompletions(t *testing.T) { + var gotPath string + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + http.Error(w, "decode error", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-chutes", + "created":1677652288, + "model":"Qwen/Qwen3-32B-TEE", + "choices":[{"index":0,"message":{"role":"assistant","content":"translated"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5} + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("cpk_test", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ + Model: "Qwen/Qwen3-32B-TEE", + Input: "hi", + }) + if err != nil { + t.Fatalf("Responses() error = %v", err) + } + if gotPath != "/chat/completions" { + t.Fatalf("path = %q, want /chat/completions", gotPath) + } + if gotBody["model"] != "Qwen/Qwen3-32B-TEE" { + t.Fatalf("request model = %#v, want Qwen/Qwen3-32B-TEE", gotBody["model"]) + } + if resp.Object != "response" || resp.Status != "completed" { + t.Fatalf("response metadata = object %q status %q, want response/completed", resp.Object, resp.Status) + } +} + +func TestListModels_PreservesChutesMetadata(t *testing.T) { + var gotPath, gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "object":"list", + "data":[{ + "id":"Qwen/Qwen3.5-397B-A17B-TEE", + "owned_by":"sglang", + "created":1677652288, + "context_length":262144, + "max_output_length":65536, + "input_modalities":["text","image"], + "supported_features":["json_mode","tools","structured_outputs","reasoning"], + "confidential_compute":true, + "pricing":{"prompt":0.45,"completion":3.0,"input_cache_read":0.045} + }] + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("cpk_test", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if gotPath != "/models" || gotAuth != "Bearer cpk_test" { + t.Fatalf("request path/auth = %q/%q, want /models/Bearer cpk_test", gotPath, gotAuth) + } + if len(resp.Data) != 1 { + t.Fatalf("len(resp.Data) = %d, want 1", len(resp.Data)) + } + model := resp.Data[0] + if model.Object != "model" { + t.Fatalf("model.Object = %q, want model", model.Object) + } + if model.Metadata == nil || model.Metadata.ContextWindow == nil || *model.Metadata.ContextWindow != 262144 { + t.Fatalf("model context metadata = %+v, want 262144", model.Metadata) + } + if model.Metadata.MaxOutputTokens == nil || *model.Metadata.MaxOutputTokens != 65536 { + t.Fatalf("max output tokens = %+v, want 65536", model.Metadata.MaxOutputTokens) + } + if !model.Metadata.Capabilities["tools"] || !model.Metadata.Capabilities["vision"] || !model.Metadata.Capabilities["confidential_compute"] { + t.Fatalf("capabilities = %v, want tools, vision, and confidential_compute", model.Metadata.Capabilities) + } + pricing := model.Metadata.Pricing + if pricing == nil || pricing.Currency != "USD" || pricing.InputPerMtok == nil || *pricing.InputPerMtok != 0.45 || + pricing.OutputPerMtok == nil || *pricing.OutputPerMtok != 3.0 || + pricing.CachedInputPerMtok == nil || *pricing.CachedInputPerMtok != 0.045 { + t.Fatalf("pricing = %+v, want Chutes per-MTok USD pricing", pricing) + } +} + +func TestEmbeddings_ReturnsUnsupportedError(t *testing.T) { + provider := NewWithHTTPClient("cpk_test", "", nil, llmclient.Hooks{}) + if _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{}); err == nil { + t.Fatal("Embeddings() error = nil, want unsupported error") + } +} + +func TestProvider_DoesNotExposeUnsupportedOptionalInterfaces(t *testing.T) { + provider := NewWithHTTPClient("cpk_test", "", nil, llmclient.Hooks{}) + + if _, ok := any(provider).(core.NativeBatchProvider); ok { + t.Fatal("chutes provider should not implement native batch provider") + } + if _, ok := any(provider).(core.NativeFileProvider); ok { + t.Fatal("chutes provider should not implement native file provider") + } + if _, ok := any(provider).(core.NativeResponseLifecycleProvider); ok { + t.Fatal("chutes provider should not implement native response lifecycle provider") + } + if _, ok := any(provider).(core.AudioProvider); ok { + t.Fatal("chutes provider should not implement audio provider") + } +} diff --git a/internal/providers/chutes/models.go b/internal/providers/chutes/models.go new file mode 100644 index 000000000..2904e746e --- /dev/null +++ b/internal/providers/chutes/models.go @@ -0,0 +1,134 @@ +package chutes + +import ( + "context" + "net/http" + "strings" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" +) + +type modelsResponse struct { + Object string `json:"object"` + Data []modelInfo `json:"data"` +} + +type modelInfo struct { + ID string `json:"id"` + Object string `json:"object"` + OwnedBy string `json:"owned_by"` + Created int64 `json:"created"` + ContextLength int `json:"context_length"` + MaxOutputLength int `json:"max_output_length"` + InputModalities []string `json:"input_modalities"` + SupportedFeatures []string `json:"supported_features"` + ConfidentialCompute bool `json:"confidential_compute"` + Pricing *modelPricing `json:"pricing"` +} + +type modelPricing struct { + Prompt *float64 `json:"prompt"` + Completion *float64 `json:"completion"` + InputCacheRead *float64 `json:"input_cache_read"` +} + +// ListModels returns Chutes' live model catalog while retaining the context, +// feature, and per-million-token pricing data included in its response. +func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) { + var upstream modelsResponse + if err := p.compat.Do(ctx, llmclient.Request{ + Method: http.MethodGet, + Endpoint: "/models", + }, &upstream); err != nil { + return nil, err + } + + result := &core.ModelsResponse{Object: strings.TrimSpace(upstream.Object)} + if result.Object == "" { + result.Object = "list" + } + result.Data = make([]core.Model, 0, len(upstream.Data)) + for _, model := range upstream.Data { + if strings.TrimSpace(model.ID) == "" { + continue + } + result.Data = append(result.Data, model.toCore()) + } + return result, nil +} + +func (m modelInfo) toCore() core.Model { + object := strings.TrimSpace(m.Object) + if object == "" { + object = "model" + } + + modes := []string{"chat", "responses"} + metadata := &core.ModelMetadata{ + Modes: modes, + Categories: core.CategoriesForModes(modes), + Capabilities: modelCapabilities(m), + Pricing: m.Pricing.toCore(), + } + if m.ContextLength > 0 { + metadata.ContextWindow = new(m.ContextLength) + } + if m.MaxOutputLength > 0 { + metadata.MaxOutputTokens = new(m.MaxOutputLength) + } + + return core.Model{ + ID: strings.TrimSpace(m.ID), + Object: object, + OwnedBy: strings.TrimSpace(m.OwnedBy), + Created: m.Created, + Metadata: metadata, + } +} + +func modelCapabilities(m modelInfo) map[string]bool { + capabilities := make(map[string]bool, len(m.SupportedFeatures)+3) + for _, feature := range m.SupportedFeatures { + if normalized := strings.ToLower(strings.TrimSpace(feature)); normalized != "" { + capabilities[normalized] = true + } + } + for _, modality := range m.InputModalities { + switch strings.ToLower(strings.TrimSpace(modality)) { + case "image": + capabilities["vision"] = true + case "video": + capabilities["video"] = true + case "audio": + capabilities["audio"] = true + } + } + if m.ConfidentialCompute { + capabilities["confidential_compute"] = true + } + if len(capabilities) == 0 { + return nil + } + return capabilities +} + +func (p *modelPricing) toCore() *core.ModelPricing { + if p == nil || (p.Prompt == nil && p.Completion == nil && p.InputCacheRead == nil) { + return nil + } + return &core.ModelPricing{ + Currency: "USD", + InputPerMtok: cloneFloat(p.Prompt), + OutputPerMtok: cloneFloat(p.Completion), + CachedInputPerMtok: cloneFloat(p.InputCacheRead), + } +} + +func cloneFloat(value *float64) *float64 { + if value == nil { + return nil + } + cloned := *value + return &cloned +} diff --git a/internal/providers/config_test.go b/internal/providers/config_test.go index 9f4cea48d..63db6714d 100644 --- a/internal/providers/config_test.go +++ b/internal/providers/config_test.go @@ -35,6 +35,9 @@ var testDiscoveryConfigs = map[string]DiscoveryConfig{ "deepseek": { DefaultBaseURL: "https://api.deepseek.com", }, + "chutes": { + DefaultBaseURL: "https://llm.chutes.ai/v1", + }, "xai": { DefaultBaseURL: "https://api.x.ai/v1", }, @@ -744,6 +747,26 @@ func TestApplyProviderEnvVars_DiscoversDeepSeekFromAPIKey(t *testing.T) { } } +func TestApplyProviderEnvVars_DiscoversChutesFromAPIKey(t *testing.T) { + t.Setenv("CHUTES_API_KEY", "cpk_test") + + got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) + + p, exists := got["chutes"] + if !exists { + t.Fatal("expected chutes to be discovered from env var") + } + if p.APIKey != "cpk_test" { + t.Errorf("APIKey = %q, want cpk_test", p.APIKey) + } + if p.Type != "chutes" { + t.Errorf("Type = %q, want chutes", p.Type) + } + if p.BaseURL != testDiscoveryConfigs["chutes"].DefaultBaseURL { + t.Errorf("BaseURL = %q, want %q", p.BaseURL, testDiscoveryConfigs["chutes"].DefaultBaseURL) + } +} + func TestApplyProviderEnvVars_DiscoversZAIFromAPIKey(t *testing.T) { t.Setenv("ZAI_API_KEY", "zai-key") diff --git a/run/providers.go b/run/providers.go index b0331baf3..d76388477 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/chutes" "github.com/enterpilot/gomodel/internal/providers/cohere" "github.com/enterpilot/gomodel/internal/providers/deepseek" "github.com/enterpilot/gomodel/internal/providers/fireworks" @@ -49,6 +50,7 @@ func defaultProviderFactory(cfg *config.Config) *providers.ProviderFactory { factory.Add(anthropic.Registration) factory.Add(bedrock.Registration) factory.Add(bedrockmantle.Registration) + factory.Add(chutes.Registration) factory.Add(cohere.Registration) factory.Add(deepseek.Registration) factory.Add(fireworks.Registration) diff --git a/run/providers_test.go b/run/providers_test.go index 4aa00429b..285038e83 100644 --- a/run/providers_test.go +++ b/run/providers_test.go @@ -155,7 +155,7 @@ var credentialPayloadFields = []string{ func TestDefaultProviderFactoryRegistersAllProviderTypes(t *testing.T) { expected := []string{ - "anthropic", "azure", "bailian", "bedrock", "bedrock-mantle", "cohere", "deepseek", "fireworks", + "anthropic", "azure", "bailian", "bedrock", "bedrock-mantle", "chutes", "cohere", "deepseek", "fireworks", "gemini", "groq", "kilo", "kimicode", "llmd", "meta", "minimax", "ollama", "openai", "opencode_go", "openrouter", "oracle", "sglang", "vertex", "vllm", "xai", "xiaomi", "zai", } From 829102ac308f2fdb9ff070c7a75c4e3a462bb493 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 8 Aug 2026 20:33:24 +0200 Subject: [PATCH 2/7] fix(chutes): address provider review feedback --- config/config.example.yaml | 3 +- docs/advanced/configuration.mdx | 3 +- internal/providers/chutes/chutes.go | 2 + internal/providers/chutes/chutes_test.go | 118 ++++++++++++++++++++++- internal/providers/chutes/models.go | 9 +- 5 files changed, 128 insertions(+), 7 deletions(-) diff --git a/config/config.example.yaml b/config/config.example.yaml index d43412956..8142a5d38 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -383,7 +383,8 @@ providers: chutes: type: chutes api_key: "${CHUTES_API_KEY}" - # base_url defaults to "https://llm.chutes.ai/v1" + # base_url defaults to "https://llm.chutes.ai/v1". + # Set base_url when using a different compatible endpoint. meta: type: meta diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index b5c35aa47..e148ef840 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -276,6 +276,7 @@ Set these to automatically register providers. No YAML configuration required. | `ZAI_API_KEY` | Z.ai | | `XAI_API_KEY` | xAI (Grok) | | `GROQ_API_KEY` | Groq | +| `CHUTES_API_KEY` | Chutes AI (`CHUTES_BASE_URL` optional) | | `AZURE_API_KEY` | Azure OpenAI (`AZURE_BASE_URL` also required) | | `ORACLE_API_KEY` | Oracle GenAI (`ORACLE_BASE_URL` also required) | | `OLLAMA_BASE_URL` | Ollama (no API key needed) | @@ -283,7 +284,7 @@ Set these to automatically register providers. No YAML configuration required. | `VLLM_BASE_URL` | vLLM (no API key needed unless upstream requires) | | `LLMD_BASE_URL` | llm-d Router/EPP (no API key needed unless its Gateway requires one) | -Most providers can use a custom base URL via `_BASE_URL` (for example `OPENAI_BASE_URL`). DeepSeek defaults to `https://api.deepseek.com`; set `DEEPSEEK_BASE_URL` only for a compatible proxy or alternate DeepSeek endpoint. OpenRouter defaults to `https://openrouter.ai/api/v1` and can be overridden with `OPENROUTER_BASE_URL`. Kilo AI defaults to `https://api.kilo.ai/api/gateway` and can be overridden with `KILO_BASE_URL`. Z.ai defaults to `https://api.z.ai/api/paas/v4`; set `ZAI_BASE_URL=https://api.z.ai/api/coding/paas/v4` for the GLM Coding Plan endpoint. SGLang defaults to `http://localhost:30000/v1` when `SGLANG_API_KEY` is set, but keyless deployments should set `SGLANG_BASE_URL` explicitly to register the provider. vLLM follows the same pattern at `http://localhost:8000/v1`. llm-d has no universal endpoint, so `LLMD_BASE_URL` is always required; `LLMD_API_KEY` is optional. Azure uses `AZURE_BASE_URL` for its deployment base URL and accepts an optional `AZURE_API_VERSION` override; otherwise it defaults to `2024-10-21`. Oracle requires `ORACLE_BASE_URL` because its OpenAI-compatible endpoint is region-specific. +Most providers can use a custom base URL via `_BASE_URL` (for example `OPENAI_BASE_URL`). Chutes AI defaults to `https://llm.chutes.ai/v1` and can be overridden with `CHUTES_BASE_URL`. DeepSeek defaults to `https://api.deepseek.com`; set `DEEPSEEK_BASE_URL` only for a compatible proxy or alternate DeepSeek endpoint. OpenRouter defaults to `https://openrouter.ai/api/v1` and can be overridden with `OPENROUTER_BASE_URL`. Kilo AI defaults to `https://api.kilo.ai/api/gateway` and can be overridden with `KILO_BASE_URL`. Z.ai defaults to `https://api.z.ai/api/paas/v4`; set `ZAI_BASE_URL=https://api.z.ai/api/coding/paas/v4` for the GLM Coding Plan endpoint. SGLang defaults to `http://localhost:30000/v1` when `SGLANG_API_KEY` is set, but keyless deployments should set `SGLANG_BASE_URL` explicitly to register the provider. vLLM follows the same pattern at `http://localhost:8000/v1`. llm-d has no universal endpoint, so `LLMD_BASE_URL` is always required; `LLMD_API_KEY` is optional. Azure uses `AZURE_BASE_URL` for its deployment base URL and accepts an optional `AZURE_API_VERSION` override; otherwise it defaults to `2024-10-21`. Oracle requires `ORACLE_BASE_URL` because its OpenAI-compatible endpoint is region-specific. Every provider type also accepts a comma-separated configured model list via `_MODELS`, for example `OPENROUTER_MODELS`, `ORACLE_MODELS`, diff --git a/internal/providers/chutes/chutes.go b/internal/providers/chutes/chutes.go index dc172c80b..60701d0c6 100644 --- a/internal/providers/chutes/chutes.go +++ b/internal/providers/chutes/chutes.go @@ -48,6 +48,7 @@ func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, h ))} } +// compatibleConfig returns the shared OpenAI-compatible transport settings for Chutes. func compatibleConfig(baseURL string) openai.CompatibleProviderConfig { return openai.CompatibleProviderConfig{ ProviderName: "chutes", @@ -56,6 +57,7 @@ func compatibleConfig(baseURL string) openai.CompatibleProviderConfig { } } +// setHeaders applies Chutes' bearer-token authentication. func setHeaders(req *http.Request, apiKey string) { providers.SetAuthHeaders(req, apiKey, providers.AuthHeaderConfig{AuthScheme: "Bearer "}) } diff --git a/internal/providers/chutes/chutes_test.go b/internal/providers/chutes/chutes_test.go index 63376f4ac..3654a4fb0 100644 --- a/internal/providers/chutes/chutes_test.go +++ b/internal/providers/chutes/chutes_test.go @@ -3,8 +3,10 @@ package chutes import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/enterpilot/gomodel/internal/core" @@ -47,6 +49,117 @@ func TestChatCompletion_UsesBearerAuthAndChatEndpoint(t *testing.T) { } } +func TestStreamChatCompletion_UsesBearerAuthAndChatEndpoint(t *testing.T) { + var gotPath, gotAuth string + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + http.Error(w, "decode error", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-chutes\",\"object\":\"chat.completion.chunk\",\"choices\":[]}\n\ndata: [DONE]\n\n") + })) + defer server.Close() + + provider := NewWithHTTPClient("cpk_test", server.URL, server.Client(), llmclient.Hooks{}) + stream, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{ + Model: "Qwen/Qwen3-32B-TEE", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatalf("StreamChatCompletion() error = %v", err) + } + defer stream.Close() + + body, err := io.ReadAll(stream) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if gotPath != "/chat/completions" || gotAuth != "Bearer cpk_test" { + t.Fatalf("request path/auth = %q/%q, want /chat/completions/Bearer cpk_test", gotPath, gotAuth) + } + if gotBody["model"] != "Qwen/Qwen3-32B-TEE" || gotBody["stream"] != true { + t.Fatalf("stream request body = %#v", gotBody) + } + if !strings.Contains(string(body), "data: [DONE]") { + t.Fatalf("stream body = %q, want SSE terminator", body) + } +} + +func TestChatCompletion_ReturnsUpstreamError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"error":{"message":"rate limited","type":"rate_limit_error"}}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("cpk_test", server.URL, server.Client(), llmclient.Hooks{}) + _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "Qwen/Qwen3-32B-TEE", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }) + if err == nil { + t.Fatal("ChatCompletion() error = nil, want upstream error") + } + gatewayErr, ok := err.(*core.GatewayError) + if !ok { + t.Fatalf("error type = %T, want *core.GatewayError", err) + } + if gatewayErr.StatusCode != http.StatusTooManyRequests || gatewayErr.Type != core.ErrorTypeRateLimit { + t.Fatalf("gateway error = %+v, want 429 rate_limit_error", gatewayErr) + } +} + +func TestPassthrough_ForwardsOpaqueRequest(t *testing.T) { + var gotURI, gotAuth, gotBeta, gotBody string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotURI = r.URL.RequestURI() + gotAuth = r.Header.Get("Authorization") + gotBeta = r.Header.Get("X-Chutes-Beta") + body, _ := io.ReadAll(r.Body) + gotBody = string(body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"accepted":true}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("cpk_test", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ + Method: http.MethodPost, + Endpoint: "chat/completions?trace=true", + Body: io.NopCloser(strings.NewReader(`{"model":"Qwen/Qwen3-32B-TEE"}`)), + Headers: http.Header{ + "Content-Type": {"application/json"}, + "X-Chutes-Beta": {"test"}, + }, + }) + if err != nil { + t.Fatalf("Passthrough() error = %v", err) + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if gotURI != "/chat/completions?trace=true" || gotAuth != "Bearer cpk_test" { + t.Fatalf("request URI/auth = %q/%q", gotURI, gotAuth) + } + if gotBeta != "test" || gotBody != `{"model":"Qwen/Qwen3-32B-TEE"}` { + t.Fatalf("request beta/body = %q/%q", gotBeta, gotBody) + } + if resp.StatusCode != http.StatusAccepted || string(responseBody) != `{"accepted":true}` { + t.Fatalf("response status/body = %d/%q", resp.StatusCode, responseBody) + } +} + func TestResponses_TranslatesToChatCompletions(t *testing.T) { var gotPath string var gotBody map[string]any @@ -94,7 +207,7 @@ func TestListModels_PreservesChutesMetadata(t *testing.T) { gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ - "object":"list", + "object":"chutes-model-catalog", "data":[{ "id":"Qwen/Qwen3.5-397B-A17B-TEE", "owned_by":"sglang", @@ -121,6 +234,9 @@ func TestListModels_PreservesChutesMetadata(t *testing.T) { if len(resp.Data) != 1 { t.Fatalf("len(resp.Data) = %d, want 1", len(resp.Data)) } + if resp.Object != "list" { + t.Fatalf("resp.Object = %q, want list", resp.Object) + } model := resp.Data[0] if model.Object != "model" { t.Fatalf("model.Object = %q, want model", model.Object) diff --git a/internal/providers/chutes/models.go b/internal/providers/chutes/models.go index 2904e746e..aec4684e0 100644 --- a/internal/providers/chutes/models.go +++ b/internal/providers/chutes/models.go @@ -44,10 +44,7 @@ func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) return nil, err } - result := &core.ModelsResponse{Object: strings.TrimSpace(upstream.Object)} - if result.Object == "" { - result.Object = "list" - } + result := &core.ModelsResponse{Object: "list"} result.Data = make([]core.Model, 0, len(upstream.Data)) for _, model := range upstream.Data { if strings.TrimSpace(model.ID) == "" { @@ -58,6 +55,7 @@ func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) return result, nil } +// toCore normalizes a Chutes catalog entry into GoModel's provider-neutral model shape. func (m modelInfo) toCore() core.Model { object := strings.TrimSpace(m.Object) if object == "" { @@ -87,6 +85,7 @@ func (m modelInfo) toCore() core.Model { } } +// modelCapabilities maps Chutes features and input modalities to GoModel capabilities. func modelCapabilities(m modelInfo) map[string]bool { capabilities := make(map[string]bool, len(m.SupportedFeatures)+3) for _, feature := range m.SupportedFeatures { @@ -113,6 +112,7 @@ func modelCapabilities(m modelInfo) map[string]bool { return capabilities } +// toCore converts Chutes' per-million-token prices to GoModel pricing metadata. func (p *modelPricing) toCore() *core.ModelPricing { if p == nil || (p.Prompt == nil && p.Completion == nil && p.InputCacheRead == nil) { return nil @@ -125,6 +125,7 @@ func (p *modelPricing) toCore() *core.ModelPricing { } } +// cloneFloat copies optional prices so the normalized model does not retain upstream pointers. func cloneFloat(value *float64) *float64 { if value == nil { return nil From a7c34ff246f6e5543026c225fbc01452b2fcbf91 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 8 Aug 2026 21:00:46 +0200 Subject: [PATCH 3/7] test(chutes): cover provider integration branches --- internal/providers/chutes/chutes_test.go | 100 +++++++++------- internal/providers/chutes/models_test.go | 143 +++++++++++++++++++++++ 2 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 internal/providers/chutes/models_test.go diff --git a/internal/providers/chutes/chutes_test.go b/internal/providers/chutes/chutes_test.go index 3654a4fb0..533fc8df9 100644 --- a/internal/providers/chutes/chutes_test.go +++ b/internal/providers/chutes/chutes_test.go @@ -11,8 +11,41 @@ import ( "github.com/enterpilot/gomodel/internal/core" "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" ) +func TestNew_ConstructsRegisteredProvider(t *testing.T) { + provider, ok := New(providers.ProviderConfig{ + APIKey: "cpk_test", + BaseURL: "https://chutes.example/v1", + }, providers.ProviderOptions{}).(*Provider) + if !ok || provider.compat == nil { + t.Fatalf("New() = %T, want initialized *Provider", provider) + } + if Registration.Discovery.DefaultBaseURL != defaultBaseURL { + t.Fatalf("registration base URL = %q, want %q", Registration.Discovery.DefaultBaseURL, defaultBaseURL) + } +} + +func TestSetBaseURL_ChangesRequestTarget(t *testing.T) { + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[]}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("cpk_test", "https://unused.example/v1", server.Client(), llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + if _, err := provider.ListModels(context.Background()); err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if gotPath != "/models" { + t.Fatalf("path = %q, want /models", gotPath) + } +} + func TestChatCompletion_UsesBearerAuthAndChatEndpoint(t *testing.T) { var gotPath, gotAuth string @@ -200,61 +233,44 @@ func TestResponses_TranslatesToChatCompletions(t *testing.T) { } } -func TestListModels_PreservesChutesMetadata(t *testing.T) { +func TestStreamResponses_TranslatesToChatCompletions(t *testing.T) { var gotPath, gotAuth string + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotAuth = r.Header.Get("Authorization") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "object":"chutes-model-catalog", - "data":[{ - "id":"Qwen/Qwen3.5-397B-A17B-TEE", - "owned_by":"sglang", - "created":1677652288, - "context_length":262144, - "max_output_length":65536, - "input_modalities":["text","image"], - "supported_features":["json_mode","tools","structured_outputs","reasoning"], - "confidential_compute":true, - "pricing":{"prompt":0.45,"completion":3.0,"input_cache_read":0.045} - }] - }`)) + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + http.Error(w, "decode error", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-chutes\",\"object\":\"chat.completion.chunk\",\"created\":1677652288,\"model\":\"Qwen/Qwen3-32B-TEE\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: [DONE]\n\n") })) defer server.Close() provider := NewWithHTTPClient("cpk_test", server.URL, server.Client(), llmclient.Hooks{}) - resp, err := provider.ListModels(context.Background()) + stream, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ + Model: "Qwen/Qwen3-32B-TEE", + Input: "hi", + }) if err != nil { - t.Fatalf("ListModels() error = %v", err) - } - if gotPath != "/models" || gotAuth != "Bearer cpk_test" { - t.Fatalf("request path/auth = %q/%q, want /models/Bearer cpk_test", gotPath, gotAuth) - } - if len(resp.Data) != 1 { - t.Fatalf("len(resp.Data) = %d, want 1", len(resp.Data)) + t.Fatalf("StreamResponses() error = %v", err) } - if resp.Object != "list" { - t.Fatalf("resp.Object = %q, want list", resp.Object) - } - model := resp.Data[0] - if model.Object != "model" { - t.Fatalf("model.Object = %q, want model", model.Object) - } - if model.Metadata == nil || model.Metadata.ContextWindow == nil || *model.Metadata.ContextWindow != 262144 { - t.Fatalf("model context metadata = %+v, want 262144", model.Metadata) + defer stream.Close() + + body, err := io.ReadAll(stream) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) } - if model.Metadata.MaxOutputTokens == nil || *model.Metadata.MaxOutputTokens != 65536 { - t.Fatalf("max output tokens = %+v, want 65536", model.Metadata.MaxOutputTokens) + if gotPath != "/chat/completions" || gotAuth != "Bearer cpk_test" { + t.Fatalf("request path/auth = %q/%q", gotPath, gotAuth) } - if !model.Metadata.Capabilities["tools"] || !model.Metadata.Capabilities["vision"] || !model.Metadata.Capabilities["confidential_compute"] { - t.Fatalf("capabilities = %v, want tools, vision, and confidential_compute", model.Metadata.Capabilities) + if gotBody["model"] != "Qwen/Qwen3-32B-TEE" || gotBody["stream"] != true { + t.Fatalf("stream request body = %#v", gotBody) } - pricing := model.Metadata.Pricing - if pricing == nil || pricing.Currency != "USD" || pricing.InputPerMtok == nil || *pricing.InputPerMtok != 0.45 || - pricing.OutputPerMtok == nil || *pricing.OutputPerMtok != 3.0 || - pricing.CachedInputPerMtok == nil || *pricing.CachedInputPerMtok != 0.045 { - t.Fatalf("pricing = %+v, want Chutes per-MTok USD pricing", pricing) + if raw := string(body); !strings.Contains(raw, "response.output_text.delta") || !strings.Contains(raw, "data: [DONE]") { + t.Fatalf("converted stream missing Responses events or done marker: %s", raw) } } diff --git a/internal/providers/chutes/models_test.go b/internal/providers/chutes/models_test.go new file mode 100644 index 000000000..fb269eee5 --- /dev/null +++ b/internal/providers/chutes/models_test.go @@ -0,0 +1,143 @@ +package chutes + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" +) + +func TestListModels_PreservesChutesMetadata(t *testing.T) { + var gotPath, gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "object":"chutes-model-catalog", + "data":[{ + "id":"Qwen/Qwen3.5-397B-A17B-TEE", + "owned_by":"sglang", + "created":1677652288, + "context_length":262144, + "max_output_length":65536, + "input_modalities":["text","image"], + "supported_features":["json_mode","tools","structured_outputs","reasoning"], + "confidential_compute":true, + "pricing":{"prompt":0.45,"completion":3.0,"input_cache_read":0.045} + }] + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("cpk_test", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if gotPath != "/models" || gotAuth != "Bearer cpk_test" { + t.Fatalf("request path/auth = %q/%q, want /models/Bearer cpk_test", gotPath, gotAuth) + } + if len(resp.Data) != 1 { + t.Fatalf("len(resp.Data) = %d, want 1", len(resp.Data)) + } + if resp.Object != "list" { + t.Fatalf("resp.Object = %q, want list", resp.Object) + } + model := resp.Data[0] + if model.Object != "model" { + t.Fatalf("model.Object = %q, want model", model.Object) + } + if model.Metadata == nil || model.Metadata.ContextWindow == nil || *model.Metadata.ContextWindow != 262144 { + t.Fatalf("model context metadata = %+v, want 262144", model.Metadata) + } + if model.Metadata.MaxOutputTokens == nil || *model.Metadata.MaxOutputTokens != 65536 { + t.Fatalf("max output tokens = %+v, want 65536", model.Metadata.MaxOutputTokens) + } + if !model.Metadata.Capabilities["tools"] || !model.Metadata.Capabilities["vision"] || !model.Metadata.Capabilities["confidential_compute"] { + t.Fatalf("capabilities = %v, want tools, vision, and confidential_compute", model.Metadata.Capabilities) + } + pricing := model.Metadata.Pricing + if pricing == nil || pricing.Currency != "USD" || pricing.InputPerMtok == nil || *pricing.InputPerMtok != 0.45 || + pricing.OutputPerMtok == nil || *pricing.OutputPerMtok != 3.0 || + pricing.CachedInputPerMtok == nil || *pricing.CachedInputPerMtok != 0.045 { + t.Fatalf("pricing = %+v, want Chutes per-MTok USD pricing", pricing) + } +} + +func TestListModels_FiltersBlankIDsAndKeepsMinimalModels(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data":[ + {"id":" "}, + {"id":" minimal-model ","object":"model","owned_by":" chutes ","pricing":{}} + ] + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("cpk_test", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(resp.Data) != 1 { + t.Fatalf("models = %+v, want one non-blank model", resp.Data) + } + model := resp.Data[0] + if model.ID != "minimal-model" || model.Object != "model" || model.OwnedBy != "chutes" { + t.Fatalf("model identity = %+v, want trimmed minimal model", model) + } + if model.Metadata.ContextWindow != nil || model.Metadata.MaxOutputTokens != nil || + model.Metadata.Capabilities != nil || model.Metadata.Pricing != nil { + t.Fatalf("optional metadata = %+v, want omitted zero values", model.Metadata) + } +} + +func TestListModels_ReturnsUpstreamError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":{"message":"catalog unavailable"}}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("cpk_test", server.URL, server.Client(), llmclient.Hooks{}) + _, err := provider.ListModels(context.Background()) + if err == nil { + t.Fatal("ListModels() error = nil, want upstream error") + } + gatewayErr, ok := err.(*core.GatewayError) + if !ok || gatewayErr.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("error = %#v, want 503 *core.GatewayError", err) + } +} + +func TestModelCapabilities_MapsOptionalModalities(t *testing.T) { + capabilities := modelCapabilities(modelInfo{ + SupportedFeatures: []string{" JSON_Mode ", " "}, + InputModalities: []string{"audio", "video", "unknown"}, + }) + if !capabilities["json_mode"] || !capabilities["audio"] || !capabilities["video"] { + t.Fatalf("capabilities = %v, want normalized json_mode, audio, and video", capabilities) + } +} + +func TestModelPricing_HandlesNilAndPartialPrices(t *testing.T) { + var absent *modelPricing + if got := absent.toCore(); got != nil { + t.Fatalf("nil pricing = %+v, want nil", got) + } + + prompt := 0.25 + got := (&modelPricing{Prompt: &prompt}).toCore() + if got == nil || got.InputPerMtok == nil || *got.InputPerMtok != prompt { + t.Fatalf("partial pricing = %+v, want input price", got) + } + if got.OutputPerMtok != nil || got.CachedInputPerMtok != nil { + t.Fatalf("partial pricing = %+v, want absent optional prices", got) + } +} From d0dc94346849ecdcee815bb4e85cb6cd4099e428 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 8 Aug 2026 21:56:28 +0200 Subject: [PATCH 4/7] test(chutes): strengthen request contract assertions --- internal/providers/chutes/chutes_test.go | 36 +++++++++++++++--------- internal/providers/chutes/models_test.go | 7 +++-- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/internal/providers/chutes/chutes_test.go b/internal/providers/chutes/chutes_test.go index 533fc8df9..5e0416d85 100644 --- a/internal/providers/chutes/chutes_test.go +++ b/internal/providers/chutes/chutes_test.go @@ -28,8 +28,9 @@ func TestNew_ConstructsRegisteredProvider(t *testing.T) { } func TestSetBaseURL_ChangesRequestTarget(t *testing.T) { - var gotPath string + var gotMethod, gotPath string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method gotPath = r.URL.Path w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"object":"list","data":[]}`)) @@ -41,8 +42,8 @@ func TestSetBaseURL_ChangesRequestTarget(t *testing.T) { if _, err := provider.ListModels(context.Background()); err != nil { t.Fatalf("ListModels() error = %v", err) } - if gotPath != "/models" { - t.Fatalf("path = %q, want /models", gotPath) + if gotMethod != http.MethodGet || gotPath != "/models" { + t.Fatalf("method/path = %q/%q, want GET /models", gotMethod, gotPath) } } @@ -84,7 +85,10 @@ func TestChatCompletion_UsesBearerAuthAndChatEndpoint(t *testing.T) { func TestStreamChatCompletion_UsesBearerAuthAndChatEndpoint(t *testing.T) { var gotPath, gotAuth string - var gotBody map[string]any + var gotBody struct { + Model string `json:"model"` + Stream bool `json:"stream"` + } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path @@ -115,7 +119,7 @@ func TestStreamChatCompletion_UsesBearerAuthAndChatEndpoint(t *testing.T) { if gotPath != "/chat/completions" || gotAuth != "Bearer cpk_test" { t.Fatalf("request path/auth = %q/%q, want /chat/completions/Bearer cpk_test", gotPath, gotAuth) } - if gotBody["model"] != "Qwen/Qwen3-32B-TEE" || gotBody["stream"] != true { + if gotBody.Model != "Qwen/Qwen3-32B-TEE" || !gotBody.Stream { t.Fatalf("stream request body = %#v", gotBody) } if !strings.Contains(string(body), "data: [DONE]") { @@ -195,7 +199,9 @@ func TestPassthrough_ForwardsOpaqueRequest(t *testing.T) { func TestResponses_TranslatesToChatCompletions(t *testing.T) { var gotPath string - var gotBody map[string]any + var gotBody struct { + Model string `json:"model"` + } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path @@ -225,8 +231,8 @@ func TestResponses_TranslatesToChatCompletions(t *testing.T) { if gotPath != "/chat/completions" { t.Fatalf("path = %q, want /chat/completions", gotPath) } - if gotBody["model"] != "Qwen/Qwen3-32B-TEE" { - t.Fatalf("request model = %#v, want Qwen/Qwen3-32B-TEE", gotBody["model"]) + if gotBody.Model != "Qwen/Qwen3-32B-TEE" { + t.Fatalf("request model = %q, want Qwen/Qwen3-32B-TEE", gotBody.Model) } if resp.Object != "response" || resp.Status != "completed" { t.Fatalf("response metadata = object %q status %q, want response/completed", resp.Object, resp.Status) @@ -234,10 +240,14 @@ func TestResponses_TranslatesToChatCompletions(t *testing.T) { } func TestStreamResponses_TranslatesToChatCompletions(t *testing.T) { - var gotPath, gotAuth string - var gotBody map[string]any + var gotMethod, gotPath, gotAuth string + var gotBody struct { + Model string `json:"model"` + Stream bool `json:"stream"` + } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method gotPath = r.URL.Path gotAuth = r.Header.Get("Authorization") if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { @@ -263,10 +273,10 @@ func TestStreamResponses_TranslatesToChatCompletions(t *testing.T) { if err != nil { t.Fatalf("ReadAll() error = %v", err) } - if gotPath != "/chat/completions" || gotAuth != "Bearer cpk_test" { - t.Fatalf("request path/auth = %q/%q", gotPath, gotAuth) + if gotMethod != http.MethodPost || gotPath != "/chat/completions" || gotAuth != "Bearer cpk_test" { + t.Fatalf("request method/path/auth = %q/%q/%q", gotMethod, gotPath, gotAuth) } - if gotBody["model"] != "Qwen/Qwen3-32B-TEE" || gotBody["stream"] != true { + if gotBody.Model != "Qwen/Qwen3-32B-TEE" || !gotBody.Stream { t.Fatalf("stream request body = %#v", gotBody) } if raw := string(body); !strings.Contains(raw, "response.output_text.delta") || !strings.Contains(raw, "data: [DONE]") { diff --git a/internal/providers/chutes/models_test.go b/internal/providers/chutes/models_test.go index fb269eee5..1904df1fd 100644 --- a/internal/providers/chutes/models_test.go +++ b/internal/providers/chutes/models_test.go @@ -11,8 +11,9 @@ import ( ) func TestListModels_PreservesChutesMetadata(t *testing.T) { - var gotPath, gotAuth string + var gotMethod, gotPath, gotAuth string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method gotPath = r.URL.Path gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") @@ -38,8 +39,8 @@ func TestListModels_PreservesChutesMetadata(t *testing.T) { if err != nil { t.Fatalf("ListModels() error = %v", err) } - if gotPath != "/models" || gotAuth != "Bearer cpk_test" { - t.Fatalf("request path/auth = %q/%q, want /models/Bearer cpk_test", gotPath, gotAuth) + if gotMethod != http.MethodGet || gotPath != "/models" || gotAuth != "Bearer cpk_test" { + t.Fatalf("request method/path/auth = %q/%q/%q, want GET /models/Bearer cpk_test", gotMethod, gotPath, gotAuth) } if len(resp.Data) != 1 { t.Fatalf("len(resp.Data) = %d, want 1", len(resp.Data)) From a5a661da33b2e9fc46701c2601229bf2f8346e7e Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 8 Aug 2026 22:42:41 +0200 Subject: [PATCH 5/7] feat(chutes): enable passthrough by default --- config/config.example.yaml | 2 +- config/config.go | 1 + config/config_test.go | 4 ++-- config/server.go | 2 +- docs/features/passthrough-api.mdx | 4 ++-- internal/server/handlers_test.go | 31 +++++++++++++++++++++++++- internal/server/passthrough_support.go | 2 +- 7 files changed, 38 insertions(+), 8 deletions(-) diff --git a/config/config.example.yaml b/config/config.example.yaml index 8142a5d38..8133ba427 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -13,7 +13,7 @@ server: enable_passthrough_routes: true # expose /p/{provider}/{endpoint} passthrough routes allow_passthrough_v1_alias: true # allow /p/{provider}/v1/... while keeping /p/{provider}/... canonical user_path_header: "X-GoModel-User-Path" # env: USER_PATH_HEADER; inbound header used for user_path scoping - enabled_passthrough_providers: ["openai", "anthropic", "cohere", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek", "bailian"] # providers enabled on /p/{provider}/... + enabled_passthrough_providers: ["openai", "anthropic", "chutes", "cohere", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek", "bailian"] # providers enabled on /p/{provider}/... realtime_enabled: true # env: REALTIME_ENABLED; expose /v1/realtime websocket and /p/{provider}/v1/realtime upgrades (OpenAI only) pid_file: "data/gomodel.pid" # env: PID_FILE; where the running gateway records its process id so `gomodel --reload` can find it. Set per instance when several gateways share a host; empty writes no pid file and disables --reload; changing it needs a restart, not a reload diff --git a/config/config.go b/config/config.go index b4d0b6e45..fc0d8f82b 100644 --- a/config/config.go +++ b/config/config.go @@ -97,6 +97,7 @@ func buildDefaultConfig() *Config { EnabledPassthroughProviders: []string{ "openai", "anthropic", + "chutes", "openrouter", "kilo", "zai", diff --git a/config/config_test.go b/config/config_test.go index 1182bc288..044d31b51 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -126,7 +126,7 @@ func TestBuildDefaultConfig(t *testing.T) { if !cfg.Server.AllowPassthroughV1Alias { t.Error("expected Server.AllowPassthroughV1Alias=true") } - if got, want := cfg.Server.EnabledPassthroughProviders, []string{"openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"}; !reflect.DeepEqual(got, want) { + if got, want := cfg.Server.EnabledPassthroughProviders, []string{"openai", "anthropic", "chutes", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"}; !reflect.DeepEqual(got, want) { t.Errorf("expected Server.EnabledPassthroughProviders=%v, got %v", want, got) } if cfg.Models.ConfiguredProviderModelsMode != ConfiguredProviderModelsModeFallback { @@ -1203,7 +1203,7 @@ func TestLoad_ConfigExample_UsesNestedModelCacheSettings(t *testing.T) { t.Fatalf("expected Cache.Model.Redis to be nil in example config, got %+v", result.Config.Cache.Model.Redis) } gotProviders := result.Config.Server.EnabledPassthroughProviders - wantProviders := []string{"openai", "anthropic", "cohere", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek", "bailian"} + wantProviders := []string{"openai", "anthropic", "chutes", "cohere", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek", "bailian"} if !reflect.DeepEqual(gotProviders, wantProviders) { t.Fatalf("Server.EnabledPassthroughProviders = %v, want %v", gotProviders, wantProviders) } diff --git a/config/server.go b/config/server.go index e0af13d47..bf9f657a0 100644 --- a/config/server.go +++ b/config/server.go @@ -39,7 +39,7 @@ type ServerConfig struct { UserPathHeader string `yaml:"user_path_header" env:"USER_PATH_HEADER"` // EnabledPassthroughProviders lists the provider types enabled on // /p/{provider}/... passthrough routes. Default: - // ["openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"]. + // ["openai", "anthropic", "chutes", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"]. EnabledPassthroughProviders []string `yaml:"enabled_passthrough_providers" env:"ENABLED_PASSTHROUGH_PROVIDERS"` // RealtimeEnabled exposes the realtime (speech-to-speech) websocket endpoint // at /v1/realtime and the /p/{provider}/v1/realtime passthrough upgrade. diff --git a/docs/features/passthrough-api.mdx b/docs/features/passthrough-api.mdx index 2f6e985d2..c1deb825a 100644 --- a/docs/features/passthrough-api.mdx +++ b/docs/features/passthrough-api.mdx @@ -131,7 +131,7 @@ from passthrough requests before forwarding them upstream. Passthrough is intentionally narrow while the API is in beta. -- `openai`, `anthropic`, `openrouter`, `kilo`, `zai`, `sglang`, `vllm`, `llmd`, and `deepseek` are enabled by +- `openai`, `anthropic`, `chutes`, `openrouter`, `kilo`, `zai`, `sglang`, `vllm`, `llmd`, and `deepseek` are enabled by default. - GoModel does not translate passthrough request bodies or response bodies. - Provider-native error bodies and status codes are proxied instead of converted @@ -146,7 +146,7 @@ Passthrough routes are enabled by default: ```env ENABLE_PASSTHROUGH_ROUTES=true ALLOW_PASSTHROUGH_V1_ALIAS=true -ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,openrouter,kilo,zai,sglang,vllm,llmd,deepseek +ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,chutes,openrouter,kilo,zai,sglang,vllm,llmd,deepseek ``` Set `ENABLED_PASSTHROUGH_PROVIDERS` to the provider types you want to expose. diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 1b9335efb..26bc32961 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -7178,11 +7178,40 @@ func TestProviderPassthrough_RejectsUnsupportedProvider(t *testing.T) { if !strings.Contains(rec.Body.String(), `provider passthrough for \"groq\" is not enabled`) { t.Fatalf("unexpected error body: %s", rec.Body.String()) } - if !strings.Contains(rec.Body.String(), "anthropic, deepseek, kilo, llmd, openai, openrouter, sglang, vllm, zai") { + if !strings.Contains(rec.Body.String(), "anthropic, chutes, deepseek, kilo, llmd, openai, openrouter, sglang, vllm, zai") { t.Fatalf("unexpected error body: %s", rec.Body.String()) } } +func TestProviderPassthrough_DefaultAllowsChutes(t *testing.T) { + provider := &mockProvider{ + passthroughResponse: &core.PassthroughResponse{ + StatusCode: http.StatusOK, + Headers: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), + }, + } + + e := echo.New() + handler := NewHandler(provider, nil, nil, nil) + e.POST("/p/:provider/*", handler.ProviderPassthrough) + + req := httptest.NewRequest(http.MethodPost, "/p/chutes/chat/completions", strings.NewReader(`{"model":"Qwen/Qwen3-32B-TEE"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + if provider.lastPassthroughProvider != "chutes" { + t.Fatalf("providerType = %q, want chutes", provider.lastPassthroughProvider) + } + if provider.lastPassthroughReq == nil || provider.lastPassthroughReq.Endpoint != "chat/completions" { + t.Fatalf("passthrough request = %+v, want chat/completions endpoint", provider.lastPassthroughReq) + } +} + func TestProviderPassthrough_UsesConfiguredSupportedProviders(t *testing.T) { provider := &mockProvider{ passthroughResponse: &core.PassthroughResponse{ diff --git a/internal/server/passthrough_support.go b/internal/server/passthrough_support.go index 5bce2f747..1bdf0bb5a 100644 --- a/internal/server/passthrough_support.go +++ b/internal/server/passthrough_support.go @@ -16,7 +16,7 @@ import ( "github.com/enterpilot/gomodel/internal/usage" ) -var defaultEnabledPassthroughProviders = []string{"openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"} +var defaultEnabledPassthroughProviders = []string{"openai", "anthropic", "chutes", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"} const llmdDroppedReasonHeader = "X-Llm-D-Request-Dropped-Reason" From 9d421be3c795d133a07cf77146661f6606905494 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 8 Aug 2026 23:16:07 +0200 Subject: [PATCH 6/7] fix(chutes): require passthrough opt-in --- config/config.example.yaml | 2 +- config/config.go | 1 - config/config_test.go | 4 ++-- config/server.go | 2 +- docs/features/passthrough-api.mdx | 8 ++++++-- docs/providers/overview.mdx | 4 +++- internal/server/handlers_test.go | 18 +++++++++++++++--- internal/server/passthrough_support.go | 2 +- 8 files changed, 29 insertions(+), 12 deletions(-) diff --git a/config/config.example.yaml b/config/config.example.yaml index 8133ba427..8142a5d38 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", "chutes", "cohere", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek", "bailian"] # providers enabled on /p/{provider}/... + enabled_passthrough_providers: ["openai", "anthropic", "cohere", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek", "bailian"] # providers enabled on /p/{provider}/... realtime_enabled: true # env: REALTIME_ENABLED; expose /v1/realtime websocket and /p/{provider}/v1/realtime upgrades (OpenAI only) pid_file: "data/gomodel.pid" # env: PID_FILE; where the running gateway records its process id so `gomodel --reload` can find it. Set per instance when several gateways share a host; empty writes no pid file and disables --reload; changing it needs a restart, not a reload diff --git a/config/config.go b/config/config.go index fc0d8f82b..b4d0b6e45 100644 --- a/config/config.go +++ b/config/config.go @@ -97,7 +97,6 @@ func buildDefaultConfig() *Config { EnabledPassthroughProviders: []string{ "openai", "anthropic", - "chutes", "openrouter", "kilo", "zai", diff --git a/config/config_test.go b/config/config_test.go index 044d31b51..1182bc288 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -126,7 +126,7 @@ func TestBuildDefaultConfig(t *testing.T) { if !cfg.Server.AllowPassthroughV1Alias { t.Error("expected Server.AllowPassthroughV1Alias=true") } - if got, want := cfg.Server.EnabledPassthroughProviders, []string{"openai", "anthropic", "chutes", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"}; !reflect.DeepEqual(got, want) { + if got, want := cfg.Server.EnabledPassthroughProviders, []string{"openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"}; !reflect.DeepEqual(got, want) { t.Errorf("expected Server.EnabledPassthroughProviders=%v, got %v", want, got) } if cfg.Models.ConfiguredProviderModelsMode != ConfiguredProviderModelsModeFallback { @@ -1203,7 +1203,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", "chutes", "cohere", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek", "bailian"} + wantProviders := []string{"openai", "anthropic", "cohere", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek", "bailian"} if !reflect.DeepEqual(gotProviders, wantProviders) { t.Fatalf("Server.EnabledPassthroughProviders = %v, want %v", gotProviders, wantProviders) } diff --git a/config/server.go b/config/server.go index bf9f657a0..e0af13d47 100644 --- a/config/server.go +++ b/config/server.go @@ -39,7 +39,7 @@ type ServerConfig struct { UserPathHeader string `yaml:"user_path_header" env:"USER_PATH_HEADER"` // EnabledPassthroughProviders lists the provider types enabled on // /p/{provider}/... passthrough routes. Default: - // ["openai", "anthropic", "chutes", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"]. + // ["openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"]. EnabledPassthroughProviders []string `yaml:"enabled_passthrough_providers" env:"ENABLED_PASSTHROUGH_PROVIDERS"` // RealtimeEnabled exposes the realtime (speech-to-speech) websocket endpoint // at /v1/realtime and the /p/{provider}/v1/realtime passthrough upgrade. diff --git a/docs/features/passthrough-api.mdx b/docs/features/passthrough-api.mdx index c1deb825a..6e59b89fc 100644 --- a/docs/features/passthrough-api.mdx +++ b/docs/features/passthrough-api.mdx @@ -131,8 +131,12 @@ from passthrough requests before forwarding them upstream. Passthrough is intentionally narrow while the API is in beta. -- `openai`, `anthropic`, `chutes`, `openrouter`, `kilo`, `zai`, `sglang`, `vllm`, `llmd`, and `deepseek` are enabled by +- `openai`, `anthropic`, `openrouter`, `kilo`, `zai`, `sglang`, `vllm`, `llmd`, and `deepseek` are enabled by default. +- Chutes supports passthrough but requires explicit operator opt-in because + passthrough can forward provider-native routes that do not identify a model. + Add `chutes` to `ENABLED_PASSTHROUGH_PROVIDERS` only when you intend to expose + that surface. - GoModel does not translate passthrough request bodies or response bodies. - Provider-native error bodies and status codes are proxied instead of converted into OpenAI-compatible responses. @@ -146,7 +150,7 @@ Passthrough routes are enabled by default: ```env ENABLE_PASSTHROUGH_ROUTES=true ALLOW_PASSTHROUGH_V1_ALIAS=true -ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,chutes,openrouter,kilo,zai,sglang,vllm,llmd,deepseek +ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,openrouter,kilo,zai,sglang,vllm,llmd,deepseek ``` Set `ENABLED_PASSTHROUGH_PROVIDERS` to the provider types you want to expose. diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index 50e32aad6..bce882554 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -94,7 +94,9 @@ support, not every individual model capability exposed by an upstream provider. - **Chutes AI** — defaults to `https://llm.chutes.ai/v1` and discovers its current model IDs, context limits, capabilities, and pricing from the live catalog. GoModel translates `/v1/responses` requests to chat completions; - Chutes' shared LLM endpoint does not expose embeddings. + Chutes' shared LLM endpoint does not expose embeddings. Passthrough support + requires explicit operator opt-in by adding `chutes` to + `ENABLED_PASSTHROUGH_PROVIDERS`. - **Meta (Muse Spark)** — the Meta Model API is OpenAI-compatible; set `META_API_KEY` and route to `muse-spark-1.1`. Muse Spark models are not in the upstream model catalog yet, so declare `context_window` and `pricing` diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 26bc32961..ba9d92673 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -7178,12 +7178,12 @@ 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, chutes, deepseek, kilo, llmd, openai, openrouter, sglang, vllm, zai") { + if !strings.Contains(rec.Body.String(), "anthropic, deepseek, kilo, llmd, openai, openrouter, sglang, vllm, zai") { t.Fatalf("unexpected error body: %s", rec.Body.String()) } } -func TestProviderPassthrough_DefaultAllowsChutes(t *testing.T) { +func TestProviderPassthrough_ChutesRequiresExplicitOptIn(t *testing.T) { provider := &mockProvider{ passthroughResponse: &core.PassthroughResponse{ StatusCode: http.StatusOK, @@ -7196,13 +7196,25 @@ func TestProviderPassthrough_DefaultAllowsChutes(t *testing.T) { handler := NewHandler(provider, nil, nil, nil) e.POST("/p/:provider/*", handler.ProviderPassthrough) + blockedReq := httptest.NewRequest(http.MethodPost, "/p/chutes/provider-native/admin/keys", strings.NewReader(`{}`)) + blockedRec := httptest.NewRecorder() + e.ServeHTTP(blockedRec, blockedReq) + + if blockedRec.Code != http.StatusBadRequest { + t.Fatalf("default status = %d, want 400: %s", blockedRec.Code, blockedRec.Body.String()) + } + if provider.lastPassthroughReq != nil { + t.Fatal("default Chutes passthrough reached provider, want rejection before forwarding") + } + + handler.setEnabledPassthroughProviders([]string{"chutes"}) req := httptest.NewRequest(http.MethodPost, "/p/chutes/chat/completions", strings.NewReader(`{"model":"Qwen/Qwen3-32B-TEE"}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() e.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + t.Fatalf("opt-in status = %d, want 200: %s", rec.Code, rec.Body.String()) } if provider.lastPassthroughProvider != "chutes" { t.Fatalf("providerType = %q, want chutes", provider.lastPassthroughProvider) diff --git a/internal/server/passthrough_support.go b/internal/server/passthrough_support.go index 1bdf0bb5a..5bce2f747 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", "chutes", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"} +var defaultEnabledPassthroughProviders = []string{"openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"} const llmdDroppedReasonHeader = "X-Llm-D-Request-Dropped-Reason" From 85c5bcbf96415201faf2e7d5ea154c5424131d6c Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sun, 9 Aug 2026 21:18:36 +0200 Subject: [PATCH 7/7] test(dashboard): cover all provider types --- run/providers_test.go | 24 ++++++++++++++++++++ web/dashboard/tests/providers-config.test.js | 14 ++++++++++++ 2 files changed, 38 insertions(+) diff --git a/run/providers_test.go b/run/providers_test.go index 285038e83..3d3c27eec 100644 --- a/run/providers_test.go +++ b/run/providers_test.go @@ -21,6 +21,7 @@ func TestDefaultProviderFactoryCredentialForms(t *testing.T) { tests := []struct { providerType string + defaultURL string fields []string // exact, in display order required []string absent []string @@ -29,10 +30,19 @@ func TestDefaultProviderFactoryCredentialForms(t *testing.T) { { // The plain shape every API-key provider derives. providerType: "openai", + defaultURL: "https://api.openai.com/v1", fields: []string{"api_keys", "base_url", "session_sticky_keys", "models"}, required: []string{"api_keys"}, absent: []string{"api_version", "vertex_project"}, }, + { + // Newly registered API-key providers use the same schema feed as + // every other type, so the dashboard can offer them immediately. + providerType: "chutes", + defaultURL: "https://llm.chutes.ai/v1", + fields: []string{"api_keys", "base_url", "session_sticky_keys", "models"}, + required: []string{"api_keys"}, + }, { // A deployment URL is the provider, so it is required, and Azure // is the one type that takes an API version. @@ -107,6 +117,9 @@ func TestDefaultProviderFactoryCredentialForms(t *testing.T) { if !ok { t.Fatalf("no credential schema for provider type %q", tt.providerType) } + if tt.defaultURL != "" && schema.DefaultBaseURL != tt.defaultURL { + t.Errorf("DefaultBaseURL = %q, want %q", schema.DefaultBaseURL, tt.defaultURL) + } var names []string for _, field := range schema.Fields { @@ -171,5 +184,16 @@ func TestDefaultProviderFactoryRegistersAllProviderTypes(t *testing.T) { if !slices.Equal(got, expected) { t.Errorf("metrics=%v: registered types = %v, want %v", metricsEnabled, got, expected) } + + // CredentialSchemas is the source for + // GET /admin/provider-credentials/types, which drives the dashboard's + // Add Provider selector. Keep it in exact lockstep with construction. + dashboardTypes := make([]string, 0, len(expected)) + for _, schema := range factory.CredentialSchemas() { + dashboardTypes = append(dashboardTypes, schema.Type) + } + if !slices.Equal(dashboardTypes, expected) { + t.Errorf("metrics=%v: dashboard provider types = %v, want %v", metricsEnabled, dashboardTypes, expected) + } } } diff --git a/web/dashboard/tests/providers-config.test.js b/web/dashboard/tests/providers-config.test.js index 3de1499f2..1075ed563 100644 --- a/web/dashboard/tests/providers-config.test.js +++ b/web/dashboard/tests/providers-config.test.js @@ -527,6 +527,20 @@ test("providerCredentialTypeOptions always includes the current selection", () = assert.deepEqual(providerCredentialTypeOptions(null, " "), []); }); +test("providerCredentialTypeOptions lists every server-supplied provider type", () => { + const recentProviderSchemas = ["chutes", "cohere", "llmd", "sglang"].map((type) => ({ + type, + fields: [], + })); + + assert.deepEqual(providerCredentialTypeOptions(recentProviderSchemas, ""), [ + "chutes", + "cohere", + "llmd", + "sglang", + ]); +}); + test("splitCommaList trims and drops empties", () => { assert.deepEqual( splitCommaList(" gpt-4o, gpt-4o-mini ,,"),