From e5f681c1aa755565f55a584ca3698acf1531d565 Mon Sep 17 00:00:00 2001 From: weselben Date: Mon, 17 Aug 2026 16:59:46 +0000 Subject: [PATCH 1/9] feat(providers): add hetzner experimental provider Mirror the kimicode pattern: wrap the shared openai.ChatCompatible adapter behind a thin Registration/New/NewWithHTTPClient surface. Hetzner exposes chat completions, model listing, and passthrough via OpenAI-compat at https://inference.hetzner.com/api/v1. No embeddings endpoint is documented upstream; the embedded adapter advertises the capability, but embedding requests will fail at the provider. --- internal/providers/hetzner/hetzner.go | 62 +++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 internal/providers/hetzner/hetzner.go diff --git a/internal/providers/hetzner/hetzner.go b/internal/providers/hetzner/hetzner.go new file mode 100644 index 000000000..f65d95c09 --- /dev/null +++ b/internal/providers/hetzner/hetzner.go @@ -0,0 +1,62 @@ +// Package hetzner provides Hetzner Inference API integration for the LLM gateway. +// +// The "hetzner" provider routes to Hetzner's experimental OpenAI-compatible +// inference endpoint, so all transport goes through the shared chat-centric +// adapter and model IDs are forwarded unchanged. +// +// Note: Hetzner declares this inference API as experimental. Breaking changes +// may ship without notice and there is no SLA. Hetzner does not document an +// embeddings endpoint; chat completions, model listing, and passthrough are +// the supported surfaces. +package hetzner + +import ( + "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://inference.hetzner.com/api/v1" + +// Registration provides factory registration for the Hetzner provider. +var Registration = providers.Registration{ + Type: "hetzner", + New: New, + Discovery: providers.DiscoveryConfig{ + DefaultBaseURL: defaultBaseURL, + }, +} + +// Provider implements the core.Provider interface for Hetzner. Hetzner is +// OpenAI-compatible, so all transport goes through the shared chat-centric +// adapter: chat completions, model listing, and passthrough are exposed via +// the embedded *openai.ChatCompatible. Hetzner documents no embeddings +// endpoint, so embedding requests fail upstream. +type Provider struct { + *openai.ChatCompatible +} + +var _ core.Provider = (*Provider)(nil) + +// New creates a new Hetzner provider. +func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { + return &Provider{openai.NewChatCompatible(cfg.APIKey, opts, openai.CompatibleProviderConfig{ + ProviderName: "hetzner", + BaseURL: providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL), + })} +} + +// NewWithHTTPClient creates a new Hetzner provider with a custom HTTP client. +// If httpClient is nil, http.DefaultClient is used. +// +// The signature is intentionally stable and matches every other chat-compatible +// provider on main: (apiKey, baseURL, httpClient, hooks). +func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, hooks llmclient.Hooks) *Provider { + return &Provider{openai.NewChatCompatibleWithHTTPClient(apiKey, httpClient, hooks, openai.CompatibleProviderConfig{ + ProviderName: "hetzner", + BaseURL: providers.ResolveBaseURL(baseURL, defaultBaseURL), + })} +} From 132297fcbf2a365cf906820f577b6fe1f6ef3eb7 Mon Sep 17 00:00:00 2001 From: weselben Date: Mon, 17 Aug 2026 17:03:59 +0000 Subject: [PATCH 2/9] feat(run): register hetzner provider in factory Wire hetzner.Registration into defaultProviderFactory and assert it is registered and instantiable. Add hetzner to the expected provider type list kept in lockstep with the dashboard's Add Provider selector. --- run/lifecycle_test.go | 18 ++++++++++++++++++ run/providers.go | 2 ++ run/providers_test.go | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/run/lifecycle_test.go b/run/lifecycle_test.go index a787faf63..f92b6ab7a 100644 --- a/run/lifecycle_test.go +++ b/run/lifecycle_test.go @@ -257,3 +257,21 @@ func TestMain_KimicodeProviderRegistration(t *testing.T) { t.Fatal("factory.Create(kimicode) returned nil provider") } } + +func TestMain_HetznerProviderRegistration(t *testing.T) { + factory := defaultProviderFactory(&config.Config{}) + + registered := factory.RegisteredTypes() + found := slices.Contains(registered, "hetzner") + if !found { + t.Fatalf("hetzner not in RegisteredTypes() = %v", registered) + } + + provider, err := factory.Create(providers.ProviderConfig{Type: "hetzner", APIKey: "test"}) + if err != nil { + t.Fatalf("factory.Create(hetzner) error = %v, want nil", err) + } + if provider == nil { + t.Fatal("factory.Create(hetzner) returned nil provider") + } +} diff --git a/run/providers.go b/run/providers.go index b1cad50dd..9354813c7 100644 --- a/run/providers.go +++ b/run/providers.go @@ -16,6 +16,7 @@ import ( "github.com/enterpilot/gomodel/internal/providers/fireworks" "github.com/enterpilot/gomodel/internal/providers/gemini" "github.com/enterpilot/gomodel/internal/providers/groq" + "github.com/enterpilot/gomodel/internal/providers/hetzner" "github.com/enterpilot/gomodel/internal/providers/kilo" "github.com/enterpilot/gomodel/internal/providers/kimicode" "github.com/enterpilot/gomodel/internal/providers/llamacpp" @@ -60,6 +61,7 @@ func defaultProviderFactory(cfg *config.Config) *providers.ProviderFactory { factory.Add(gemini.Registration) factory.Add(vertex.Registration) factory.Add(groq.Registration) + factory.Add(hetzner.Registration) factory.Add(kilo.Registration) factory.Add(kimicode.Registration) factory.Add(llamacpp.Registration) diff --git a/run/providers_test.go b/run/providers_test.go index 112069f82..963b10035 100644 --- a/run/providers_test.go +++ b/run/providers_test.go @@ -176,7 +176,7 @@ var credentialPayloadFields = []string{ func TestDefaultProviderFactoryRegistersAllProviderTypes(t *testing.T) { expected := []string{ "anthropic", "azure", "bailian", "bedrock", "bedrock-mantle", "chutes", "cohere", "deepseek", "elevenlabs", - "fireworks", "gemini", "groq", "kilo", "kimicode", "llamacpp", "llmd", "meta", "minimax", "ollama", "openai", "opencode_go", +"fireworks", "gemini", "groq", "hetzner", "kilo", "kimicode", "llamacpp", "llmd", "meta", "minimax", "ollama", "openai", "opencode_go", "openrouter", "oracle", "sglang", "vertex", "vllm", "xai", "xiaomi", "zai", } From bca0b60f5633c2f552a5cb90f720b7dc305e45ee Mon Sep 17 00:00:00 2001 From: weselben Date: Mon, 17 Aug 2026 17:05:06 +0000 Subject: [PATCH 3/9] test(providers): add hetzner to config parser test fixtures Add hetzner entry to testDiscoveryConfigs and a focused test that applyProviderEnvVars discovers the type and resolves its default base URL. .env.template and config.example.yaml are out of scope while Hetzner's API is experimental. --- internal/providers/config_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/providers/config_test.go b/internal/providers/config_test.go index 17bdd2cf2..6f065ec7b 100644 --- a/internal/providers/config_test.go +++ b/internal/providers/config_test.go @@ -85,6 +85,9 @@ var testDiscoveryConfigs = map[string]DiscoveryConfig{ "kimicode": { DefaultBaseURL: "https://api.kimi.com/coding/v1", }, + "hetzner": { + DefaultBaseURL: "https://inference.hetzner.com/api/v1", + }, } // --- buildProviderConfig --- @@ -1897,3 +1900,26 @@ func TestResolveProviders_NoProvidersNoEnvVars(t *testing.T) { t.Errorf("expected empty filtered raw, got %d entries", len(filteredRaw)) } } + +func TestBuildProviderConfig_Hetzner_ResolvesBaseURL(t *testing.T) { + t.Setenv("HETZNER_API_KEY", "hetzner-test-key") + + raw := map[string]config.RawProviderConfig{ + "hetzner": {Type: "hetzner", APIKey: "hetzner-test-key"}, + } + got := applyProviderEnvVars(raw, testDiscoveryConfigs) + + p, exists := got["hetzner"] + if !exists { + t.Fatal("hetzner not discovered by config parser") + } + if p.Type != "hetzner" { + t.Errorf("Type = %q, want hetzner", p.Type) + } + if p.APIKey != "hetzner-test-key" { + t.Errorf("APIKey = %q, want hetzner-test-key", p.APIKey) + } + if p.BaseURL != testDiscoveryConfigs["hetzner"].DefaultBaseURL { + t.Errorf("BaseURL = %q, want %q", p.BaseURL, testDiscoveryConfigs["hetzner"].DefaultBaseURL) + } +} From a5b86c9898a3e1d158bd976ed826e62a578dc570 Mon Sep 17 00:00:00 2001 From: weselben Date: Mon, 17 Aug 2026 17:07:27 +0000 Subject: [PATCH 4/9] docs(providers): add hetzner provider guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New hetzner.mdx leads with the experimental warning and documents configuration, runtime model discovery, rate limits (429, windows change during the experiment), and free-while-experimental pricing. Overview table gains a hetzner row and a provider note; docs.json gets the nav entry. No model list or limit table is hardcoded — both moved during the experimental period. --- docs/docs.json | 1 + docs/providers/hetzner.mdx | 82 +++++++++++++++++++++++++++++++++++++ docs/providers/overview.mdx | 8 ++++ 3 files changed, 91 insertions(+) create mode 100644 docs/providers/hetzner.mdx diff --git a/docs/docs.json b/docs/docs.json index bc847b7e7..f348e58c6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -181,6 +181,7 @@ "providers/llmd", "providers/multiple-ollama", "providers/kimicode", + "providers/hetzner", { "group": "Cloud Platforms", "icon": "cloud", diff --git a/docs/providers/hetzner.mdx b/docs/providers/hetzner.mdx new file mode 100644 index 000000000..42ba058a6 --- /dev/null +++ b/docs/providers/hetzner.mdx @@ -0,0 +1,82 @@ +--- +title: "Hetzner" +description: "Configure Hetzner's experimental OpenAI-compatible inference API in GoModel." +icon: "server" +keywords: ["Hetzner", "experimental", "inference", "provider setup"] +--- + + + **Experimental**: Hetzner declares this inference API as experimental. Expect breaking + changes, no SLA, and no availability guarantees. Do not use it for production workloads. + Hetzner may change models, limits, or the endpoint itself without notice while the + experiment runs. + + +Hetzner Inference is an OpenAI-compatible REST API served at +`https://inference.hetzner.com/api/v1`. GoModel routes chat, model listing, and +passthrough requests through the shared OpenAI adapter. The `/v1/responses` endpoint is +translated through chat completions. Files, batches, and embeddings are not supported — +Hetzner exposes no `/v1/embeddings` endpoint. + +## Configure + +Create an API token in the [Hetzner Experiments console](https://experiments.hetzner.com/inference) +and set: + +```bash +HETZNER_API_KEY=... +``` + +Or in `config.yaml`: + +```yaml +providers: + hetzner: + type: hetzner + base_url: "https://inference.hetzner.com/api/v1" + api_key: "${HETZNER_API_KEY}" +``` + +You can also override the base URL and model list with: + +```bash +HETZNER_BASE_URL=https://inference.hetzner.com/api/v1 +HETZNER_MODELS=Qwen/Qwen3.6-35B-A3B-FP8 +``` + +## Models + +The model catalogue changes while the experiment runs. Query the live list instead of +relying on documentation snapshots: + +```bash +curl -s https://inference.hetzner.com/api/v1/models \ + -H "Authorization: Bearer $HETZNER_API_KEY" +``` + +GoModel also exposes this list through its own `/v1/models` endpoint once the provider +is configured. Vision-capable models accept OpenAI-standard `image_url` content parts +unchanged. + +## Rate limits + +Hetzner enforces per-key rate limits on input tokens, output tokens, and request count. +Exceeding any limit returns HTTP 429. The exact windows changed during the experimental +period, so check the +[official inference docs](https://docs.hetzner.com/general/company-and-policy/experiments/inference/) +for the current values. Prefer conservative retry settings: + +```yaml +providers: + hetzner: + type: hetzner + api_key: "${HETZNER_API_KEY}" + retries: 2 +``` + +## Pricing + +The API is free of charge while it remains in experimental status. Hetzner states it will +notify users by email before billing begins. GoModel's usage-cost tracking reports `cost` +as zero for Hetzner requests until upstream pricing exists, so `cost` load-balancing cannot +rank this provider by price. diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index 6f541c5e9..bf8fa21ce 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -60,6 +60,7 @@ support, not every individual model capability exposed by an upstream provider. | ElevenLabs (voice only) | `ELEVENLABS_API_KEY` (`ELEVENLABS_BASE_URL` optional) | `eleven_multilingual_v2` | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | [ElevenLabs](/providers/elevenlabs) | | OpenCode Go | `OPENCODE_GO_API_KEY` (`OPENCODE_GO_BASE_URL` optional) | `glm-5.1` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | [OpenCode Go](/providers/opencode-go) | | Kimi Code | `KIMICODE_API_KEY` | `kimi-for-coding` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [Kimi Code](/providers/kimicode) | +| Hetzner (experimental) | `HETZNER_API_KEY` (`HETZNER_BASE_URL` optional) | `Qwen/Qwen3.6-35B-A3B-FP8` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | [Hetzner](/providers/hetzner) | | Azure OpenAI | `AZURE_API_KEY` + `AZURE_BASE_URL` (`AZURE_API_VERSION` optional) | `gpt-5` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | [Azure OpenAI](/providers/azure) | | Oracle GenAI | `ORACLE_API_KEY` + `ORACLE_BASE_URL` | `openai.gpt-oss-120b` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | [Oracle GenAI](/providers/oracle) | | Ollama | `OLLAMA_BASE_URL` | `llama3.2` | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | [Ollama](/providers/multiple-ollama) | @@ -130,6 +131,13 @@ support, not every individual model capability exposed by an upstream provider. constrained by a rolling 5-hour window. Usage-cost tracking reports zero for Kimi Code, so `cost` load-balancing cannot price it; prefer conservative retry strategies. +- **Hetzner (experimental)** — Hetzner declares the inference API experimental: + expect breaking changes and no SLA. The model catalogue and rate-limit windows + change while the experiment runs; query the live `/v1/models` endpoint and the + official docs instead of relying on snapshots. Free while experimental, so + usage-cost tracking reports zero and `cost` load-balancing cannot price it. + No embeddings endpoint; chat, `/v1/responses` (via chat), model listing, and + passthrough only. - **Configured model lists** — available for every provider with `_MODELS`, for example `OPENROUTER_MODELS=openai/gpt-oss-120b,anthropic/claude-sonnet-4` or From e54d52fcd03a77a8373a606076b583f86545392c Mon Sep 17 00:00:00 2001 From: weselben Date: Mon, 17 Aug 2026 17:09:36 +0000 Subject: [PATCH 5/9] test(providers): hetzner unit tests, 100% statement coverage Mirror the kilo test depth (test-to-impl ratio ~4x) with no golden JSON and no live API calls. Cover registration shape, both constructors (nil HTTP client + zero hooks paths), Bearer auth on chat and stream, model ID passthrough, /v1/models list, embeddings upstream-failure path, and the optional interface guard matching kilo. --- internal/providers/hetzner/hetzner_test.go | 266 +++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 internal/providers/hetzner/hetzner_test.go diff --git a/internal/providers/hetzner/hetzner_test.go b/internal/providers/hetzner/hetzner_test.go new file mode 100644 index 000000000..ea31f6c4a --- /dev/null +++ b/internal/providers/hetzner/hetzner_test.go @@ -0,0 +1,266 @@ +package hetzner + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" +) + +// TestNew_ReturnsProvider asserts that New returns a non-nil *Provider whose embedded +// ChatCompatible is non-nil. Matches kimicode's surface. +func TestNew_ReturnsProvider(t *testing.T) { + provider := New(providers.ProviderConfig{APIKey: "test-api-key"}, providers.ProviderOptions{}) + + if provider == nil { + t.Fatal("provider should not be nil") + } + + concrete, ok := provider.(*Provider) + if !ok { + t.Fatalf("New() returned %T, want *hetzner.Provider", provider) + } + if concrete.ChatCompatible == nil { + t.Error("embedded ChatCompatible should not be nil") + } +} + +// TestNewWithHTTPClient_ReturnsProvider asserts the explicit HTTP-client constructor +// returns a valid Provider with a non-nil ChatCompatible. +func TestNewWithHTTPClient_ReturnsProvider(t *testing.T) { + provider := NewWithHTTPClient("test-api-key", "http://example.invalid", &http.Client{}, llmclient.Hooks{}) + + if provider == nil { + t.Fatal("provider should not be nil") + } + if provider.ChatCompatible == nil { + t.Error("embedded ChatCompatible should not be nil") + } +} + +// TestNewWithHTTPClient_NilHTTPClientDoesNotPanic asserts that passing nil for the +// HTTP client falls back to http.DefaultClient without panicking. +func TestNewWithHTTPClient_NilHTTPClientDoesNotPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("NewWithHTTPClient(nil, ...) panicked: %v", r) + } + }() + provider := NewWithHTTPClient("test-api-key", "http://example.invalid", nil, llmclient.Hooks{}) + if provider == nil { + t.Fatal("provider should not be nil") + } +} + +// TestNewWithHTTPClient_ZeroHooksDoesNotPanic asserts that the hooks argument can be +// an empty struct (no hooks registered) without panicking. +func TestNewWithHTTPClient_ZeroHooksDoesNotPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("NewWithHTTPClient(..., llmclient.Hooks{}) panicked: %v", r) + } + }() + provider := NewWithHTTPClient("test-api-key", "http://example.invalid", &http.Client{}, llmclient.Hooks{}) + if provider == nil { + t.Fatal("provider should not be nil") + } +} + +// TestRegistration_TypeAndDiscovery asserts the Registration struct exposes the +// expected type, New function, and default base URL. +func TestRegistration_TypeAndDiscovery(t *testing.T) { + if Registration.Type != "hetzner" { + t.Errorf("Registration.Type = %q, want %q", Registration.Type, "hetzner") + } + if Registration.New == nil { + t.Error("Registration.New should not be nil") + } + if Registration.Discovery.DefaultBaseURL == "" { + t.Error("Registration.Discovery.DefaultBaseURL should not be empty") + } + want := "https://inference.hetzner.com/api/v1" + if Registration.Discovery.DefaultBaseURL != want { + t.Errorf("Registration.Discovery.DefaultBaseURL = %q, want %q", Registration.Discovery.DefaultBaseURL, want) + } +} + +// TestProvider_ImplementsCoreProvider is a compile-time check that *Provider +// satisfies the core.Provider interface used by the factory. +func TestProvider_ImplementsCoreProvider(t *testing.T) { + var _ core.Provider = (*Provider)(nil) +} + +// TestChatCompletion_UsesBearerAuthAndForwardsModel asserts that ChatCompletion +// posts to /chat/completions with the Bearer header and forwards the requested +// model unchanged. +func TestChatCompletion_UsesBearerAuthAndForwardsModel(t *testing.T) { + var gotPath string + var 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", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-hetzner", + "created":1677652288, + "model":"Qwen/Qwen3.6-35B-A3B-FP8", + "choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6} + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("hetzner-key", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "Qwen/Qwen3.6-35B-A3B-FP8", + 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 hetzner-key" { + t.Fatalf("authorization = %q, want Bearer hetzner-key", gotAuth) + } + if gotBody["model"] != "Qwen/Qwen3.6-35B-A3B-FP8" { + t.Fatalf("request model = %#v, want Qwen/Qwen3.6-35B-A3B-FP8", gotBody["model"]) + } + if resp.Model != "Qwen/Qwen3.6-35B-A3B-FP8" { + t.Fatalf("response model = %q, want Qwen/Qwen3.6-35B-A3B-FP8", resp.Model) + } + if len(resp.Choices) != 1 || resp.Choices[0].Message.Content != "hello" { + t.Fatalf("unexpected response: %+v", resp) + } +} + +// TestStreamChatCompletion_UsesSSE asserts that streaming requests go to +// /chat/completions with the Bearer header, set stream=true, and return SSE data +// the adapter normalizes. +func TestStreamChatCompletion_UsesSSE(t *testing.T) { + var gotPath string + var 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-hetzner\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n") + })) + defer server.Close() + + provider := NewWithHTTPClient("hetzner-key", server.URL, server.Client(), llmclient.Hooks{}) + stream, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{ + Model: "Qwen/Qwen3.6-35B-A3B-FP8", + 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" { + t.Fatalf("path = %q, want /chat/completions", gotPath) + } + if gotAuth != "Bearer hetzner-key" { + t.Fatalf("authorization = %q, want Bearer hetzner-key", gotAuth) + } + if gotBody["model"] != "Qwen/Qwen3.6-35B-A3B-FP8" || 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) + } +} + +// TestListModels_ForwardsToModelsEndpoint asserts that ListModels calls +// /v1/models and returns the parsed model list unchanged. +func TestListModels_ForwardsToModelsEndpoint(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":[{"id":"Qwen/Qwen3.6-35B-A3B-FP8","object":"model","owned_by":"alibaba"}]}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("hetzner-key", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if gotPath != "/models" { + t.Fatalf("path = %q, want /models", gotPath) + } + if len(resp.Data) != 1 || resp.Data[0].ID != "Qwen/Qwen3.6-35B-A3B-FP8" { + t.Fatalf("models = %+v, want one hetzner model", resp.Data) + } +} + +// TestEmbeddings_ForwardsToUpstreamWhichReturnsError documents that the embedded +// ChatCompatible forwards embedding requests to /v1/embeddings even though Hetzner +// documents no embeddings endpoint. The call is expected to fail at the upstream; +// GoModel does not currently override Embeddings to return a clearer error. +func TestEmbeddings_ForwardsToUpstreamWhichReturnsError(t *testing.T) { + var gotPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + provider := NewWithHTTPClient("hetzner-key", server.URL, server.Client(), llmclient.Hooks{}) + _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{ + Model: "anything", + Input: "hi", + }) + if err == nil { + t.Fatal("Embeddings() error = nil, want error (Hetzner has no embeddings endpoint)") + } + if gotPath != "/embeddings" { + t.Errorf("path = %q, want /embeddings (proves request was forwarded)", gotPath) + } +} + +// TestProvider_DoesNotExposeOptionalOpenAICompatibleInterfaces mirrors the kilo +// guard: hetzner wraps *ChatCompatible which does not satisfy the optional native +// interfaces. If Hetzner ever gains native batch/file/audio support, the test +// fails and the implementation must add explicit method overrides to remove +// capabilities it cannot honour upstream. +func TestProvider_DoesNotExposeOptionalOpenAICompatibleInterfaces(t *testing.T) { + provider := NewWithHTTPClient("hetzner-key", "", nil, llmclient.Hooks{}) + + if _, ok := any(provider).(core.NativeBatchProvider); ok { + t.Fatal("hetzner provider should not implement native batch provider") + } + if _, ok := any(provider).(core.NativeFileProvider); ok { + t.Fatal("hetzner provider should not implement native file provider") + } + if _, ok := any(provider).(core.AudioProvider); ok { + t.Fatal("hetzner provider should not implement audio provider") + } +} \ No newline at end of file From 30e5052565bbc2c517cd46f55db222f06a1504a1 Mon Sep 17 00:00:00 2001 From: weselben Date: Mon, 17 Aug 2026 17:29:08 +0000 Subject: [PATCH 6/9] fix(providers): hetzner embeddings typed error, test newline Review findings from PR #14: - override Embeddings to return a typed "not supported" error instead of forwarding to the absent upstream /v1/embeddings (kilo precedent) - add missing trailing newline to hetzner_test.go - update hetzner.mdx to document the typed error --- docs/providers/hetzner.mdx | 3 +- internal/providers/hetzner/hetzner.go | 8 +++++- internal/providers/hetzner/hetzner_test.go | 33 ++++++---------------- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/docs/providers/hetzner.mdx b/docs/providers/hetzner.mdx index 42ba058a6..95c39a20f 100644 --- a/docs/providers/hetzner.mdx +++ b/docs/providers/hetzner.mdx @@ -16,7 +16,8 @@ Hetzner Inference is an OpenAI-compatible REST API served at `https://inference.hetzner.com/api/v1`. GoModel routes chat, model listing, and passthrough requests through the shared OpenAI adapter. The `/v1/responses` endpoint is translated through chat completions. Files, batches, and embeddings are not supported — -Hetzner exposes no `/v1/embeddings` endpoint. +Hetzner exposes no `/v1/embeddings` endpoint. Embedding requests fail fast with a typed +"not supported" error; no upstream call is made. ## Configure diff --git a/internal/providers/hetzner/hetzner.go b/internal/providers/hetzner/hetzner.go index f65d95c09..36f26bf3f 100644 --- a/internal/providers/hetzner/hetzner.go +++ b/internal/providers/hetzner/hetzner.go @@ -11,6 +11,7 @@ package hetzner import ( + "context" "net/http" "github.com/enterpilot/gomodel/internal/core" @@ -34,7 +35,7 @@ var Registration = providers.Registration{ // OpenAI-compatible, so all transport goes through the shared chat-centric // adapter: chat completions, model listing, and passthrough are exposed via // the embedded *openai.ChatCompatible. Hetzner documents no embeddings -// endpoint, so embedding requests fail upstream. +// endpoint, so Embeddings is overridden to fail fast with a typed error. type Provider struct { *openai.ChatCompatible } @@ -60,3 +61,8 @@ func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, h BaseURL: providers.ResolveBaseURL(baseURL, defaultBaseURL), })} } + +// Embeddings returns an error because Hetzner does not expose an embeddings endpoint. +func (p *Provider) Embeddings(_ context.Context, _ *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { + return nil, core.NewInvalidRequestError("hetzner does not support embeddings", nil) +} diff --git a/internal/providers/hetzner/hetzner_test.go b/internal/providers/hetzner/hetzner_test.go index ea31f6c4a..c39f5082f 100644 --- a/internal/providers/hetzner/hetzner_test.go +++ b/internal/providers/hetzner/hetzner_test.go @@ -220,29 +220,14 @@ func TestListModels_ForwardsToModelsEndpoint(t *testing.T) { } } -// TestEmbeddings_ForwardsToUpstreamWhichReturnsError documents that the embedded -// ChatCompatible forwards embedding requests to /v1/embeddings even though Hetzner -// documents no embeddings endpoint. The call is expected to fail at the upstream; -// GoModel does not currently override Embeddings to return a clearer error. -func TestEmbeddings_ForwardsToUpstreamWhichReturnsError(t *testing.T) { - var gotPath string - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - http.Error(w, "not found", http.StatusNotFound) - })) - defer server.Close() - - provider := NewWithHTTPClient("hetzner-key", server.URL, server.Client(), llmclient.Hooks{}) - _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{ - Model: "anything", - Input: "hi", - }) - if err == nil { - t.Fatal("Embeddings() error = nil, want error (Hetzner has no embeddings endpoint)") - } - if gotPath != "/embeddings" { - t.Errorf("path = %q, want /embeddings (proves request was forwarded)", gotPath) +// TestEmbeddings_ReturnsUnsupportedError asserts that Embeddings returns a typed +// "not supported" error without calling upstream — Hetzner documents no embeddings +// endpoint, so the provider overrides the embedded adapter to fail fast. +func TestEmbeddings_ReturnsUnsupportedError(t *testing.T) { + provider := NewWithHTTPClient("hetzner-key", "", nil, llmclient.Hooks{}) + _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{Model: "any"}) + if err == nil || !strings.Contains(err.Error(), "hetzner does not support embeddings") { + t.Fatalf("Embeddings() error = %v, want unsupported error", err) } } @@ -263,4 +248,4 @@ func TestProvider_DoesNotExposeOptionalOpenAICompatibleInterfaces(t *testing.T) if _, ok := any(provider).(core.AudioProvider); ok { t.Fatal("hetzner provider should not implement audio provider") } -} \ No newline at end of file +} From 695f1db61496e216d887fc06c238666dc499cf01 Mon Sep 17 00:00:00 2001 From: weselben Date: Mon, 17 Aug 2026 21:26:28 +0000 Subject: [PATCH 7/9] fix(providers): review findings round 2 Address feedback from the second pr-review loop: - run/providers_test.go: restore tabs lost during rebase conflict resolution (gofmt violation caught by the pre-commit hook) - hetzner_test.go: harden TestEmbeddings_ReturnsUnsupportedError to assert zero upstream requests via httptest; a regression that forwards embeddings upstream fails deterministically instead of hitting the network - hetzner_test.go: add TestResponses_TranslatesToChatCompletions so the "serves /v1/responses via chat" doc claim is exercised by a test - hetzner.mdx: add a Note that the example model ID comes from the official Hetzner docs and may differ at read time (experimental catalogue) --- docs/providers/hetzner.mdx | 7 +++ internal/providers/hetzner/hetzner_test.go | 61 +++++++++++++++++++++- run/providers_test.go | 2 +- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/docs/providers/hetzner.mdx b/docs/providers/hetzner.mdx index 95c39a20f..a22fe85b9 100644 --- a/docs/providers/hetzner.mdx +++ b/docs/providers/hetzner.mdx @@ -45,6 +45,13 @@ HETZNER_BASE_URL=https://inference.hetzner.com/api/v1 HETZNER_MODELS=Qwen/Qwen3.6-35B-A3B-FP8 ``` + + The model ID above is the example from the + [official Hetzner inference docs](https://docs.hetzner.com/general/company-and-policy/experiments/inference/) + (checked 2026-08-17). The catalogue is experimental and changes; confirm the current + IDs with `GET /v1/models` before you copy the example. + + ## Models The model catalogue changes while the experiment runs. Query the live list instead of diff --git a/internal/providers/hetzner/hetzner_test.go b/internal/providers/hetzner/hetzner_test.go index c39f5082f..913e12570 100644 --- a/internal/providers/hetzner/hetzner_test.go +++ b/internal/providers/hetzner/hetzner_test.go @@ -222,13 +222,25 @@ func TestListModels_ForwardsToModelsEndpoint(t *testing.T) { // TestEmbeddings_ReturnsUnsupportedError asserts that Embeddings returns a typed // "not supported" error without calling upstream — Hetzner documents no embeddings -// endpoint, so the provider overrides the embedded adapter to fail fast. +// endpoint, so the provider overrides the embedded adapter to fail fast. The +// httptest server asserts zero requests: a regression that forwards embeddings +// upstream fails this test deterministically instead of hitting the network. func TestEmbeddings_ReturnsUnsupportedError(t *testing.T) { - provider := NewWithHTTPClient("hetzner-key", "", nil, llmclient.Hooks{}) + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + http.Error(w, "should not be called", http.StatusInternalServerError) + })) + defer server.Close() + + provider := NewWithHTTPClient("hetzner-key", server.URL, server.Client(), llmclient.Hooks{}) _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{Model: "any"}) if err == nil || !strings.Contains(err.Error(), "hetzner does not support embeddings") { t.Fatalf("Embeddings() error = %v, want unsupported error", err) } + if requests != 0 { + t.Fatalf("upstream received %d requests, want 0 (embeddings must not be forwarded)", requests) + } } // TestProvider_DoesNotExposeOptionalOpenAICompatibleInterfaces mirrors the kilo @@ -249,3 +261,48 @@ func TestProvider_DoesNotExposeOptionalOpenAICompatibleInterfaces(t *testing.T) t.Fatal("hetzner provider should not implement audio provider") } } + +// TestResponses_TranslatesToChatCompletions asserts that a Responses API request is +// translated to a chat-completions call (the doc claims /v1/responses is served via +// chat translation; this test keeps that claim honest). +func TestResponses_TranslatesToChatCompletions(t *testing.T) { + var gotPath string + var gotBody struct { + Model string `json:"model"` + } + + 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-hetzner", + "created":1677652288, + "model":"Qwen/Qwen3.6-35B-A3B-FP8", + "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("hetzner-key", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ + Model: "Qwen/Qwen3.6-35B-A3B-FP8", + 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.6-35B-A3B-FP8" { + t.Fatalf("request model = %q, want Qwen/Qwen3.6-35B-A3B-FP8", gotBody.Model) + } + if resp.Object != "response" || resp.Status != "completed" { + t.Fatalf("response metadata = object %q status %q, want response/completed", resp.Object, resp.Status) + } +} diff --git a/run/providers_test.go b/run/providers_test.go index 963b10035..f39ba6bcc 100644 --- a/run/providers_test.go +++ b/run/providers_test.go @@ -176,7 +176,7 @@ var credentialPayloadFields = []string{ func TestDefaultProviderFactoryRegistersAllProviderTypes(t *testing.T) { expected := []string{ "anthropic", "azure", "bailian", "bedrock", "bedrock-mantle", "chutes", "cohere", "deepseek", "elevenlabs", -"fireworks", "gemini", "groq", "hetzner", "kilo", "kimicode", "llamacpp", "llmd", "meta", "minimax", "ollama", "openai", "opencode_go", + "fireworks", "gemini", "groq", "hetzner", "kilo", "kimicode", "llamacpp", "llmd", "meta", "minimax", "ollama", "openai", "opencode_go", "openrouter", "oracle", "sglang", "vertex", "vllm", "xai", "xiaomi", "zai", } From 7bde1931ef459a8c6ba0253be822d87603798fc9 Mon Sep 17 00:00:00 2001 From: weselben Date: Mon, 17 Aug 2026 21:31:12 +0000 Subject: [PATCH 8/9] docs(providers): document hetzner model-ID provenance and passthrough caveat Move the two thread-answered round-2 findings into the docs so downstream review bots on the upstream mirror do not re-raise them: - overview.mdx provider note: name the example model ID's source (official Hetzner docs, 2026-08-17) and mark the passthrough check as adapter capability with unverified upstream tolerance - hetzner.mdx: add a passthrough Note stating the forwarder is generic and arbitrary paths may 404/405 while the API is experimental --- docs/providers/hetzner.mdx | 7 +++++++ docs/providers/overview.mdx | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/providers/hetzner.mdx b/docs/providers/hetzner.mdx index a22fe85b9..6a455149a 100644 --- a/docs/providers/hetzner.mdx +++ b/docs/providers/hetzner.mdx @@ -19,6 +19,13 @@ translated through chat completions. Files, batches, and embeddings are not supp Hetzner exposes no `/v1/embeddings` endpoint. Embedding requests fail fast with a typed "not supported" error; no upstream call is made. + + Passthrough is a generic forwarder: it sends any path you give it to Hetzner + unchanged. Hetzner's tolerance for arbitrary upstream paths is unverified while the + API is experimental — expect HTTP 404 or 405 for paths outside `/v1/models`, + `/v1/completions`, and `/v1/chat/completions`. + + ## Configure Create an API token in the [Hetzner Experiments console](https://experiments.hetzner.com/inference) diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index bf8fa21ce..98bc729b6 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -134,10 +134,14 @@ support, not every individual model capability exposed by an upstream provider. - **Hetzner (experimental)** — Hetzner declares the inference API experimental: expect breaking changes and no SLA. The model catalogue and rate-limit windows change while the experiment runs; query the live `/v1/models` endpoint and the - official docs instead of relying on snapshots. Free while experimental, so + official docs instead of relying on snapshots. The example model ID in the table + above (`Qwen/Qwen3.6-35B-A3B-FP8`) is the entry from the official Hetzner docs as + of 2026-08-17 and may differ at read time. Free while experimental, so usage-cost tracking reports zero and `cost` load-balancing cannot price it. No embeddings endpoint; chat, `/v1/responses` (via chat), model listing, and - passthrough only. + passthrough only. Passthrough is a generic forwarder — the ✅ marks adapter + capability; Hetzner's tolerance for arbitrary upstream paths is unverified + while the API is experimental. - **Configured model lists** — available for every provider with `_MODELS`, for example `OPENROUTER_MODELS=openai/gpt-oss-120b,anthropic/claude-sonnet-4` or From e0fd2935fea1f633edac27bf865f179e6d025ec1 Mon Sep 17 00:00:00 2001 From: weselben Date: Mon, 17 Aug 2026 22:15:42 +0000 Subject: [PATCH 9/9] fix(providers): review-bot findings on PR #701 Three findings from upstream PR review bots, addressed in source: - docs/providers/hetzner.mdx: drop the undocumented request-count limit claim (CodeRabbit verified only token-based limits are documented); state the actual 3M/60k per 60s and 500M/5M per 24h windows - internal/providers/hetzner/hetzner_test.go: harden TestEmbeddings_ReturnsUnsupportedError with errors.As against *core.GatewayError so a plain error with the same text would fail the typed-contract assertion (CodeRabbit) - internal/server/passthrough_support.go: add hetzner to the default ENABLED_PASSTHROUGH_PROVIDERS allowlist (greptile P1: provider matrix marked \xE2\x9C\x85 but default-configured gateway returned 400 on /p/hetzner/...) - .env.template + docs/providers/overview.mdx + docs/providers/hetzner.mdx: document the default-allowlist inclusion - internal/server/handlers_test.go: update the rejection-message expectation to include hetzner in the sorted allowlist - internal/server/passthrough_support_test.go: add an assertion that the default allowlist contains hetzner (regression guard) --- .env.template | 4 ++-- docs/providers/hetzner.mdx | 13 ++++++++----- docs/providers/overview.mdx | 4 +++- internal/providers/hetzner/hetzner_test.go | 21 +++++++++++++++++++-- internal/server/handlers_test.go | 2 +- internal/server/passthrough_support.go | 2 +- internal/server/passthrough_support_test.go | 17 +++++++++++++++++ 7 files changed, 51 insertions(+), 12 deletions(-) diff --git a/.env.template b/.env.template index ee8fb6ddb..9f7c53e3f 100644 --- a/.env.template +++ b/.env.template @@ -68,9 +68,9 @@ # Allow optional /p/{provider}/v1/... passthrough aliases while keeping /p/{provider}/... canonical (default: true) # ALLOW_PASSTHROUGH_V1_ALIAS=true -# Comma-separated list of provider types enabled for /p/{provider}/... passthrough (default: openai,anthropic,openrouter,kilo,zai,sglang,vllm,llamacpp,llmd,deepseek) +# Comma-separated list of provider types enabled for /p/{provider}/... passthrough (default: openai,anthropic,openrouter,kilo,zai,sglang,vllm,llamacpp,llmd,deepseek,hetzner) # Cohere native passthrough is opt-in; add cohere when those routes are needed. -# ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,cohere,openrouter,kilo,zai,sglang,vllm,llamacpp,llmd,deepseek +# ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,cohere,openrouter,kilo,zai,sglang,vllm,llamacpp,llmd,deepseek,hetzner # Enable the realtime (speech-to-speech) endpoints (default: true): the /v1/realtime # websocket (and /p/{provider}/v1/realtime passthrough upgrade), the WebRTC SDP diff --git a/docs/providers/hetzner.mdx b/docs/providers/hetzner.mdx index 6a455149a..25f3f46c4 100644 --- a/docs/providers/hetzner.mdx +++ b/docs/providers/hetzner.mdx @@ -23,7 +23,9 @@ Hetzner exposes no `/v1/embeddings` endpoint. Embedding requests fail fast with Passthrough is a generic forwarder: it sends any path you give it to Hetzner unchanged. Hetzner's tolerance for arbitrary upstream paths is unverified while the API is experimental — expect HTTP 404 or 405 for paths outside `/v1/models`, - `/v1/completions`, and `/v1/chat/completions`. + `/v1/completions`, and `/v1/chat/completions`. `hetzner` is in the default + `ENABLED_PASSTHROUGH_PROVIDERS` allowlist, so `/p/hetzner/...` routes work + without operator opt-in. ## Configure @@ -75,11 +77,12 @@ unchanged. ## Rate limits -Hetzner enforces per-key rate limits on input tokens, output tokens, and request count. -Exceeding any limit returns HTTP 429. The exact windows changed during the experimental -period, so check the +Hetzner enforces per-key rate limits on input tokens and output tokens. +Exceeding either limit returns HTTP 429. The documented windows are 3M input tokens / +60k output tokens per 60s and 500M input / 5M output per 24h. The exact values change +while the experiment runs, so check the [official inference docs](https://docs.hetzner.com/general/company-and-policy/experiments/inference/) -for the current values. Prefer conservative retry settings: +for the current numbers. Prefer conservative retry settings: ```yaml providers: diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index 98bc729b6..7c776240f 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -141,7 +141,9 @@ support, not every individual model capability exposed by an upstream provider. No embeddings endpoint; chat, `/v1/responses` (via chat), model listing, and passthrough only. Passthrough is a generic forwarder — the ✅ marks adapter capability; Hetzner's tolerance for arbitrary upstream paths is unverified - while the API is experimental. + while the API is experimental. `hetzner` is included in the default + `ENABLED_PASSTHROUGH_PROVIDERS` allowlist so `/p/hetzner/...` routes work + without operator opt-in; remove it from the list to gate them. - **Configured model lists** — available for every provider with `_MODELS`, for example `OPENROUTER_MODELS=openai/gpt-oss-120b,anthropic/claude-sonnet-4` or diff --git a/internal/providers/hetzner/hetzner_test.go b/internal/providers/hetzner/hetzner_test.go index 913e12570..aa927e18f 100644 --- a/internal/providers/hetzner/hetzner_test.go +++ b/internal/providers/hetzner/hetzner_test.go @@ -3,6 +3,7 @@ package hetzner import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -225,6 +226,8 @@ func TestListModels_ForwardsToModelsEndpoint(t *testing.T) { // endpoint, so the provider overrides the embedded adapter to fail fast. The // httptest server asserts zero requests: a regression that forwards embeddings // upstream fails this test deterministically instead of hitting the network. +// The typed contract is asserted via errors.As against *core.GatewayError so the +// test would fail on a plain error with the same text. func TestEmbeddings_ReturnsUnsupportedError(t *testing.T) { var requests int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -235,8 +238,22 @@ func TestEmbeddings_ReturnsUnsupportedError(t *testing.T) { provider := NewWithHTTPClient("hetzner-key", server.URL, server.Client(), llmclient.Hooks{}) _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{Model: "any"}) - if err == nil || !strings.Contains(err.Error(), "hetzner does not support embeddings") { - t.Fatalf("Embeddings() error = %v, want unsupported error", err) + if err == nil { + t.Fatal("Embeddings() error = nil, want typed unsupported error") + } + + var gwErr *core.GatewayError + if !errors.As(err, &gwErr) { + t.Fatalf("Embeddings() error type = %T, want *core.GatewayError", err) + } + if gwErr.Type != core.ErrorTypeInvalidRequest { + t.Errorf("error Type = %v, want %v", gwErr.Type, core.ErrorTypeInvalidRequest) + } + if gwErr.StatusCode != http.StatusBadRequest { + t.Errorf("error StatusCode = %v, want %v", gwErr.StatusCode, http.StatusBadRequest) + } + if !strings.Contains(err.Error(), "hetzner does not support embeddings") { + t.Errorf("Embeddings() error = %v, want message containing \"hetzner does not support embeddings\"", err) } if requests != 0 { t.Fatalf("upstream received %d requests, want 0 (embeddings must not be forwarded)", requests) diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index aeef05124..55b3117ec 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -7212,7 +7212,7 @@ func TestProviderPassthrough_RejectsUnsupportedProvider(t *testing.T) { if !strings.Contains(rec.Body.String(), `provider passthrough for \"groq\" is not enabled`) { t.Fatalf("unexpected error body: %s", rec.Body.String()) } - if !strings.Contains(rec.Body.String(), "anthropic, deepseek, kilo, llamacpp, llmd, openai, openrouter, sglang, vllm, zai") { + if !strings.Contains(rec.Body.String(), "anthropic, deepseek, hetzner, kilo, llamacpp, llmd, openai, openrouter, sglang, vllm, zai") { t.Fatalf("unexpected error body: %s", rec.Body.String()) } } diff --git a/internal/server/passthrough_support.go b/internal/server/passthrough_support.go index 4e7af290d..2dd9cd80f 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", "llamacpp", "llmd", "deepseek"} +var defaultEnabledPassthroughProviders = []string{"openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "llamacpp", "llmd", "deepseek", "hetzner"} const llmdDroppedReasonHeader = "X-Llm-D-Request-Dropped-Reason" diff --git a/internal/server/passthrough_support_test.go b/internal/server/passthrough_support_test.go index b391acc66..a8c2f00e8 100644 --- a/internal/server/passthrough_support_test.go +++ b/internal/server/passthrough_support_test.go @@ -26,3 +26,20 @@ func TestBuildPassthroughHeadersSkipsConfiguredUserPathHeader(t *testing.T) { t.Fatalf("OpenAI-Beta = %q, want responses=v1", value) } } + +// TestDefaultEnabledPassthroughProvidersIncludesHetzner asserts that the default +// allowlist contains hetzner — the provider matrix marks hetzner passthrough ✅, +// and the default handler must not reject those requests before contacting the +// upstream. Caught by greptile P1 on PR #701. +func TestDefaultEnabledPassthroughProvidersIncludesHetzner(t *testing.T) { + found := false + for _, p := range defaultEnabledPassthroughProviders { + if p == "hetzner" { + found = true + break + } + } + if !found { + t.Fatalf("defaultEnabledPassthroughProviders = %v, want hetzner included", defaultEnabledPassthroughProviders) + } +}