Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ models:
- name: my-model
backend: http://192.168.1.10:8000/v1
# api_key: backend-key # if your backend requires auth
# auth_header_name: Authorization # optional override for where api_key is sent
# auth_scheme: bearer # bearer | raw; defaults depend on header/backend type
# model: actual-model-name # if the backend expects a different model name
# timeout: 300 # request timeout in seconds (default: 300)
# context_window: 131072 # set manually if auto-detect fails (default: auto)
Expand All @@ -41,6 +43,13 @@ models:
# backend: https://api.openai.com/v1
# api_key: sk-your-openai-key

# OpenAI-compatible backend with raw custom auth header
# - name: custom-gateway
# backend: https://example.com/openai/v1
# api_key: your-key
# auth_header_name: x-provider-api-key
# auth_scheme: raw

# Third-party API with model name rewriting
# - name: glm-4.5
# backend: https://api.z.ai/api/coding/paas/v4
Expand Down
41 changes: 40 additions & 1 deletion docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ models:
- name: MiniMax-M2.5
backend: http://192.168.100.10:8000/v1
api_key: your-backend-key
auth_header_name: Authorization # optional
auth_scheme: bearer # optional
model: internal-model-name # optional
timeout: 300 # optional
type: openai # optional
Expand All @@ -33,7 +35,9 @@ models:
|---|---|---|---|
| `name` | yes | — | Model name clients use in requests |
| `backend` | yes | — | Upstream base URL (see [Backend URL routing](#backend-url-routing)) |
| `api_key` | no | — | Token sent upstream (`Bearer` for OpenAI, `x-api-key` for Anthropic) |
| `api_key` | no | — | Upstream API key value |
| `auth_header_name` | no | backend-specific default | Header used when sending `api_key` upstream |
| `auth_scheme` | no | backend-specific default | How `api_key` is encoded: `bearer` or `raw` |
| `model` | no | same as `name` | Model name sent to the backend (for rewriting) |
| `timeout` | no | `300` | Request timeout in seconds |
| `type` | no | `"openai"` | Backend protocol: `"openai"`, `"anthropic"`, or `"bedrock"` (see [Bedrock backends](#bedrock-backends)) |
Expand All @@ -42,6 +46,41 @@ models:
| `context_window` | no | `0` | Max context tokens. `0` = auto-detect from backend at startup |
| `defaults` | no | — | Default sampling parameters (see below) |

### Upstream auth behavior

By default, the proxy sends `api_key` using the backend's normal auth style:

- OpenAI-compatible: `Authorization: Bearer <api_key>`
- Anthropic: `x-api-key: <api_key>`
- Bedrock API-key mode: `Authorization: Bearer <api_key>`

Override that behavior per model with `auth_header_name` and `auth_scheme`.

`auth_scheme` values:

- `bearer`: sends `Header-Name: Bearer <api_key>`
- `raw`: sends `Header-Name: <api_key>`

Example: default bearer auth

```yaml
models:
- name: gpt-4.1
backend: https://api.openai.com/v1
api_key: sk-your-key
```

Example: raw custom header auth

```yaml
models:
- name: custom-gateway
backend: https://example.com/openai/v1
api_key: your-key
auth_header_name: x-provider-api-key
auth_scheme: raw
```

### Per-model sampling defaults

The `defaults` block sets sampling parameters that are injected when the client doesn't send its own. Coding assistants (Claude Code, Codex, Cursor, etc.) send their own parameters and are unaffected.
Expand Down
83 changes: 70 additions & 13 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
Expand Down Expand Up @@ -39,6 +40,9 @@ const (
BackendAnthropic = "anthropic"
BackendBedrock = "bedrock"

AuthSchemeBearer = "bearer"
AuthSchemeRaw = "raw"

ResponsesModeAuto = "" // default: probe backend, cache result
ResponsesModeNative = "native" // always passthrough
ResponsesModeTranslate = "translate" // always translate to Chat Completions
Expand All @@ -63,19 +67,21 @@ type SamplingDefaults struct {

type ModelConfig struct {
Name string `yaml:"name"`
Backend string `yaml:"backend"` // upstream URL e.g. http://192.168.100.10:8000/v1
APIKey string `yaml:"api_key"` // key to send to the backend (if required)
Model string `yaml:"model"` // model name to send to the backend (if different from Name)
Timeout int `yaml:"timeout"` // request timeout in seconds (default 300)
Type string `yaml:"type"` // backend type: "" or "openai" (default), "anthropic"
ResponsesMode string `yaml:"responses_mode"` // "auto" (default), "native", or "translate"
MessagesMode string `yaml:"messages_mode"` // "auto" (default), "native", or "translate"
ContextWindow int `yaml:"context_window"` // max context tokens (0 = auto-detect from backend)
SupportsVision bool `yaml:"supports_vision"` // model handles images natively
SupportsAudio bool `yaml:"supports_audio"` // model handles audio (transcription or audio input)
ForcePipeline bool `yaml:"force_pipeline"` // run pipeline even on native backends
Processors *ProcessorsConfig `yaml:"processors"` // per-model processor overrides (nil = use global)
Defaults *SamplingDefaults `yaml:"defaults"` // default sampling parameters (nil = use backend defaults)
Backend string `yaml:"backend"` // upstream URL e.g. http://192.168.100.10:8000/v1
APIKey string `yaml:"api_key"` // key to send to the backend (if required)
AuthHeaderName string `yaml:"auth_header_name"` // optional upstream auth header name override
AuthScheme string `yaml:"auth_scheme"` // optional upstream auth scheme override: bearer or raw
Model string `yaml:"model"` // model name to send to the backend (if different from Name)
Timeout int `yaml:"timeout"` // request timeout in seconds (default 300)
Type string `yaml:"type"` // backend type: "" or "openai" (default), "anthropic"
ResponsesMode string `yaml:"responses_mode"` // "auto" (default), "native", or "translate"
MessagesMode string `yaml:"messages_mode"` // "auto" (default), "native", or "translate"
ContextWindow int `yaml:"context_window"` // max context tokens (0 = auto-detect from backend)
SupportsVision bool `yaml:"supports_vision"` // model handles images natively
SupportsAudio bool `yaml:"supports_audio"` // model handles audio (transcription or audio input)
ForcePipeline bool `yaml:"force_pipeline"` // run pipeline even on native backends
Processors *ProcessorsConfig `yaml:"processors"` // per-model processor overrides (nil = use global)
Defaults *SamplingDefaults `yaml:"defaults"` // default sampling parameters (nil = use backend defaults)

// AWS Bedrock fields (only used when type: "bedrock").
// If api_key is set, it is sent as a Bedrock API key bearer token and the
Expand Down Expand Up @@ -367,6 +373,18 @@ func validateConfig(cfg *Config) error {
return fmt.Errorf("model %q has unknown messages_mode %q (must be %q, %q, or omitted)", m.Name, m.MessagesMode, MessagesModeNative, MessagesModeTranslate)
}

if m.AuthHeaderName != "" {
if http.CanonicalHeaderKey(m.AuthHeaderName) == "" {
return fmt.Errorf("model %q has invalid auth_header_name", m.Name)
}
}

switch m.AuthScheme {
case "", AuthSchemeBearer, AuthSchemeRaw:
default:
return fmt.Errorf("model %q has unknown auth_scheme %q (must be %q, %q, or omitted)", m.Name, m.AuthScheme, AuthSchemeBearer, AuthSchemeRaw)
}

if d := m.Defaults; d != nil && d.ReasoningEffort != nil {
switch *d.ReasoningEffort {
case "low", "medium", "high":
Expand Down Expand Up @@ -494,6 +512,45 @@ func validateConfig(cfg *Config) error {
return nil
}

// UpstreamAuthConfig returns the effective upstream auth header name and scheme.
func (m ModelConfig) UpstreamAuthConfig() (headerName, scheme string) {
headerName = m.AuthHeaderName
scheme = m.AuthScheme

if headerName == "" {
if m.Type == BackendAnthropic {
headerName = "X-Api-Key"
} else {
headerName = "Authorization"
}
}

if scheme == "" {
if headerName == "Authorization" {
scheme = AuthSchemeBearer
} else {
scheme = AuthSchemeRaw
}
}

return headerName, scheme
}

// ApplyUpstreamAuthHeaders writes the configured upstream auth header for a model.
func ApplyUpstreamAuthHeaders(req *http.Request, m ModelConfig) {
if m.APIKey == "" {
return
}

headerName, scheme := m.UpstreamAuthConfig()
switch scheme {
case AuthSchemeRaw:
req.Header.Set(headerName, m.APIKey)
default:
req.Header.Set(headerName, "Bearer "+m.APIKey)
}
}

// ApplySamplingDefaults injects default sampling parameters into a Chat Completions
// request map. Only sets values that are not already present in the request.
// This allows per-model defaults to be applied without overriding explicit user values.
Expand Down
41 changes: 41 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,47 @@ func TestValidateConfig_ValidAnthropicType(t *testing.T) {
}
}

func TestValidateConfig_ValidCustomAuthFields(t *testing.T) {
cfg := validConfig()
cfg.Models[0].AuthHeaderName = "X-Custom-Key"
cfg.Models[0].AuthScheme = AuthSchemeRaw
if err := validateConfig(cfg); err != nil {
t.Fatalf("expected no error for custom auth fields, got: %v", err)
}
}

func TestValidateConfig_InvalidAuthScheme(t *testing.T) {
cfg := validConfig()
cfg.Models[0].AuthScheme = "bogus"
err := validateConfig(cfg)
if err == nil || !strings.Contains(err.Error(), "unknown auth_scheme") {
t.Fatalf("expected unknown auth_scheme error, got: %v", err)
}
}

func TestModelConfig_UpstreamAuthConfigDefaults(t *testing.T) {
tests := []struct {
name string
model ModelConfig
wantHeader string
wantScheme string
}{
{name: "openai", model: ModelConfig{Type: BackendOpenAI}, wantHeader: "Authorization", wantScheme: AuthSchemeBearer},
{name: "default-openai", model: ModelConfig{}, wantHeader: "Authorization", wantScheme: AuthSchemeBearer},
{name: "anthropic", model: ModelConfig{Type: BackendAnthropic}, wantHeader: "X-Api-Key", wantScheme: AuthSchemeRaw},
{name: "custom-header-defaults-raw", model: ModelConfig{AuthHeaderName: "X-Litellm-Api-Key"}, wantHeader: "X-Litellm-Api-Key", wantScheme: AuthSchemeRaw},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotHeader, gotScheme := tt.model.UpstreamAuthConfig()
if gotHeader != tt.wantHeader || gotScheme != tt.wantScheme {
t.Fatalf("got (%q, %q), want (%q, %q)", gotHeader, gotScheme, tt.wantHeader, tt.wantScheme)
}
})
}
}

func TestValidateConfig_ValidOpenAIType(t *testing.T) {
cfg := validConfig()
cfg.Models[0].Type = BackendOpenAI
Expand Down
46 changes: 20 additions & 26 deletions internal/config/contextwindow.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,31 +22,31 @@ func DetectContextWindows(cs *ConfigStore) {

for i := range cfg.Models {
m := &cfg.Models[i]
go detectOne(client, cs, m.Name, m.Backend, m.Model, m.APIKey, m.Type, m.ContextWindow)
go detectOne(client, cs, m.Name, *m, m.ContextWindow)
}
}

// detectOne runs backend detection for a single model. Detection wins when it
// returns a positive value (the live backend is more authoritative than a
// hand-edited config). The configured value is the fallback: if detection
// errors or returns 0, whatever was in config.yaml is left in place.
func detectOne(client *http.Client, cs *ConfigStore, name, backend, modelID, apiKey, backendType string, configured int) {
func detectOne(client *http.Client, cs *ConfigStore, name string, model ModelConfig, configured int) {
var ctxWindow int
var err error

switch backendType {
switch model.Type {
case BackendAnthropic:
ctxWindow, err = detectAnthropic(client, backend, modelID, apiKey)
ctxWindow, err = detectAnthropic(client, model)
case BackendBedrock:
// Bedrock has no API endpoint that returns model context window;
// GetFoundationModel reports modality/region but not max tokens,
// and Mantle's OpenAI-compatible /v1/models omits it too. Fall
// back to a lookup table of well-known model-ID prefixes. On a
// miss we leave detection empty and let the configured value
// stand.
ctxWindow = lookupBedrockContextWindow(modelID)
ctxWindow = lookupBedrockContextWindow(model.Model)
default:
ctxWindow, err = detectOpenAI(client, backend, modelID, apiKey)
ctxWindow, err = detectOpenAI(client, model)
}

if err != nil {
Expand All @@ -55,7 +55,7 @@ func detectOne(client *http.Client, cs *ConfigStore, name, backend, modelID, api
"model", name, "configured", configured, "error", err)
} else {
slog.Warn("failed to detect context window",
"model", name, "backend", backend, "error", err)
"model", name, "backend", model.Backend, "error", err)
}
return
}
Expand All @@ -65,7 +65,7 @@ func detectOne(client *http.Client, cs *ConfigStore, name, backend, modelID, api
"model", name, "configured", configured)
} else {
slog.Debug("backend did not report context window; set context_window in config",
"model", name, "backend", backend)
"model", name, "backend", model.Backend)
}
return
}
Expand Down Expand Up @@ -96,12 +96,12 @@ func detectOne(client *http.Client, cs *ConfigStore, name, backend, modelID, api

// detectOpenAI queries GET /models on an OpenAI-compatible backend and
// extracts max_model_len from the matching model entry.
func detectOpenAI(client *http.Client, backend, modelID, apiKey string) (int, error) {
base := strings.TrimRight(backend, "/")
func detectOpenAI(client *http.Client, model ModelConfig) (int, error) {
base := strings.TrimRight(model.Backend, "/")

// Try llama.cpp /props endpoint first — it reports actual runtime n_ctx
// (respects --ctx-size), unlike /models which reports n_ctx_train.
if ctxWindow := detectLlamaCppProps(client, base, apiKey); ctxWindow > 0 {
if ctxWindow := detectLlamaCppProps(client, model, base); ctxWindow > 0 {
return ctxWindow, nil
}

Expand All @@ -112,9 +112,7 @@ func detectOpenAI(client *http.Client, backend, modelID, apiKey string) (int, er
if err != nil {
return 0, err
}
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
ApplyUpstreamAuthHeaders(req, model)

resp, err := client.Do(req)
if err != nil {
Expand Down Expand Up @@ -146,7 +144,7 @@ func detectOpenAI(client *http.Client, backend, modelID, apiKey string) (int, er
}

for _, m := range result.Data {
if m.ID == modelID {
if m.ID == model.Model {
if m.MaxModelLen > 0 {
return m.MaxModelLen, nil
}
Expand All @@ -166,12 +164,12 @@ func detectOpenAI(client *http.Client, backend, modelID, apiKey string) (int, er
}
}

return 0, fmt.Errorf("model %q not found or no context window reported", modelID)
return 0, fmt.Errorf("model %q not found or no context window reported", model.Model)
}

// detectLlamaCppProps queries the llama.cpp /props endpoint to get the actual
// runtime context size (n_ctx) which respects --ctx-size configuration.
func detectLlamaCppProps(client *http.Client, base, apiKey string) int {
func detectLlamaCppProps(client *http.Client, model ModelConfig, base string) int {
// Strip /v1 suffix if present to get the server root.
propsBase := strings.TrimSuffix(base, "/v1")
propsURL := propsBase + "/props"
Expand All @@ -180,9 +178,7 @@ func detectLlamaCppProps(client *http.Client, base, apiKey string) int {
if err != nil {
return 0
}
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
ApplyUpstreamAuthHeaders(req, model)

resp, err := client.Do(req)
if err != nil {
Expand Down Expand Up @@ -307,17 +303,15 @@ var bedrockContextWindows = map[string]int{

// detectAnthropic queries GET /v1/models/{model_id} on an Anthropic backend
// and extracts max_input_tokens.
func detectAnthropic(client *http.Client, backend, modelID, apiKey string) (int, error) {
base := strings.TrimRight(backend, "/")
modelURL := base + "/v1/models/" + modelID
func detectAnthropic(client *http.Client, model ModelConfig) (int, error) {
base := strings.TrimRight(model.Backend, "/")
modelURL := base + "/v1/models/" + model.Model

req, err := http.NewRequest(http.MethodGet, modelURL, nil)
if err != nil {
return 0, err
}
if apiKey != "" {
req.Header.Set("X-Api-Key", apiKey)
}
ApplyUpstreamAuthHeaders(req, model)
req.Header.Set("Anthropic-Version", "2023-06-01")

resp, err := client.Do(req)
Expand Down
Loading