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", }