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/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..25f3f46c4
--- /dev/null
+++ b/docs/providers/hetzner.mdx
@@ -0,0 +1,100 @@
+---
+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. 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`. `hetzner` is in the default
+ `ENABLED_PASSTHROUGH_PROVIDERS` allowlist, so `/p/hetzner/...` routes work
+ without operator opt-in.
+
+
+## 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
+```
+
+
+ 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
+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 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 numbers. 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..7c776240f 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,19 @@ 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. 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 is a generic forwarder — the ✅ marks adapter
+ capability; Hetzner's tolerance for arbitrary upstream paths is unverified
+ 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/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)
+ }
+}
diff --git a/internal/providers/hetzner/hetzner.go b/internal/providers/hetzner/hetzner.go
new file mode 100644
index 000000000..36f26bf3f
--- /dev/null
+++ b/internal/providers/hetzner/hetzner.go
@@ -0,0 +1,68 @@
+// 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 (
+ "context"
+ "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 Embeddings is overridden to fail fast with a typed error.
+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),
+ })}
+}
+
+// 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
new file mode 100644
index 000000000..aa927e18f
--- /dev/null
+++ b/internal/providers/hetzner/hetzner_test.go
@@ -0,0 +1,325 @@
+package hetzner
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "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_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. 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) {
+ 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 {
+ 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)
+ }
+}
+
+// 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")
+ }
+}
+
+// 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/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)
+ }
+}
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..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", "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",
}