diff --git a/README.md b/README.md index a60637a7c..fb21021e2 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,43 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use > \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile. +
+Custom Headers (Web UI & config.json) + +You can pass custom HTTP headers to your model API calls (useful for custom gateways, proxies, or specific provider features). + +**In the Web UI:** +1. Go to the **Models** page and click **Add Model** or edit an existing one. +2. Open the **Advanced** section. +3. In the **Extra Headers** field, enter your custom headers in valid JSON format, for example: + ```json + { + "X-My-Header": "value", + "Authorization": "Bearer my-custom-token" + } + ``` +4. Save the model. + +**Directly in `config.json`:** +Add the `extra_headers` field to your model configuration: +```json +{ + "model_list": [ + { + "model_name": "custom-header-model", + "model": "openai/gpt-4o", + "api_base": "https://api.myproxy.com/v1", + "api_key": "sk-123", + "extra_headers": { + "X-My-Header": "value" + } + } + ] +} +``` + +
+
Local deployment (Ollama, vLLM, etc.) diff --git a/pkg/config/config.go b/pkg/config/config.go index 56eda1637..7822fe544 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -854,8 +854,9 @@ type ModelConfig struct { RPM int `json:"rpm,omitempty"` // Requests per minute limit MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") RequestTimeout int `json:"request_timeout,omitempty"` - ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive - ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body + ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive + ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body + ExtraHeaders map[string]string `json:"extra_headers,omitempty"` // Additional headers to inject into request // from security secModelName string @@ -2045,6 +2046,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, ExtraBody: m.ExtraBody, + ExtraHeaders: m.ExtraHeaders, apiKeys: []string{keys[0]}, } diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 62d7eb2f0..d0edb0d01 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -99,6 +99,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.MaxTokensField, cfg.RequestTimeout, cfg.ExtraBody, + cfg.ExtraHeaders, ), modelID, nil case "azure", "azure-openai": @@ -193,6 +194,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.MaxTokensField, cfg.RequestTimeout, cfg.ExtraBody, + cfg.ExtraHeaders, ), modelID, nil case "minimax": @@ -218,6 +220,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.MaxTokensField, cfg.RequestTimeout, extraBody, + cfg.ExtraHeaders, ), modelID, nil case "anthropic": @@ -244,6 +247,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.MaxTokensField, cfg.RequestTimeout, cfg.ExtraBody, + cfg.ExtraHeaders, ), modelID, nil case "anthropic-messages": diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index f2ff52f1d..f3a2034a2 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -24,13 +24,14 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { } func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider { - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil) + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil, nil) } func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( apiKey, apiBase, proxy, maxTokensField string, requestTimeoutSeconds int, extraBody map[string]any, + extraHeaders map[string]string, ) *HTTPProvider { return &HTTPProvider{ delegate: openai_compat.NewProvider( @@ -40,6 +41,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( openai_compat.WithMaxTokensField(maxTokensField), openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), openai_compat.WithExtraBody(extraBody), + openai_compat.WithExtraHeaders(extraHeaders), ), } } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 90bc683b8..c7429213e 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -35,7 +35,8 @@ type Provider struct { apiBase string maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) httpClient *http.Client - extraBody map[string]any // Additional fields to inject into request body + extraBody map[string]any // Additional fields to inject into request body + extraHeaders map[string]string // Additional headers to inject into request } type Option func(*Provider) @@ -62,6 +63,12 @@ func WithExtraBody(extraBody map[string]any) Option { } } +func WithExtraHeaders(extraHeaders map[string]string) Option { + return func(p *Provider) { + p.extraHeaders = extraHeaders + } +} + func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { p := &Provider{ apiKey: apiKey, @@ -183,6 +190,9 @@ func (p *Provider) Chat( if p.apiKey != "" { req.Header.Set("Authorization", "Bearer "+p.apiKey) } + for k, v := range p.extraHeaders { + req.Header.Set(k, v) + } resp, err := p.httpClient.Do(req) if err != nil { @@ -229,6 +239,9 @@ func (p *Provider) ChatStream( if p.apiKey != "" { req.Header.Set("Authorization", "Bearer "+p.apiKey) } + for k, v := range p.extraHeaders { + req.Header.Set(k, v) + } // Use a client without Timeout for streaming — the http.Client.Timeout covers // the entire request lifecycle including body reads, which would kill long streams. diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 48babd8cd..867288f98 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -37,8 +37,9 @@ type modelResponse struct { RPM int `json:"rpm,omitempty"` MaxTokensField string `json:"max_tokens_field,omitempty"` RequestTimeout int `json:"request_timeout,omitempty"` - ThinkingLevel string `json:"thinking_level,omitempty"` - ExtraBody map[string]any `json:"extra_body,omitempty"` + ThinkingLevel string `json:"thinking_level,omitempty"` + ExtraBody map[string]any `json:"extra_body,omitempty"` + ExtraHeaders map[string]string `json:"extra_headers,omitempty"` // Meta Configured bool `json:"configured"` IsDefault bool `json:"is_default"` @@ -84,6 +85,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, ExtraBody: m.ExtraBody, + ExtraHeaders: m.ExtraHeaders, Configured: configured[i], IsDefault: m.ModelName == defaultModel, }) @@ -205,6 +207,9 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { if mc.ExtraBody == nil { mc.ExtraBody = cfg.ModelList[idx].ExtraBody } + if mc.ExtraHeaders == nil { + mc.ExtraHeaders = cfg.ModelList[idx].ExtraHeaders + } cfg.ModelList[idx] = &mc.ModelConfig diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index 2fd042593..58f8e8673 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -18,6 +18,7 @@ export interface ModelInfo { request_timeout?: number thinking_level?: string extra_body?: Record + extra_headers?: Record // Meta configured: boolean is_default: boolean diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index c760bc672..8fa0c3ceb 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -34,6 +34,7 @@ interface AddForm { maxTokensField: string requestTimeout: string thinkingLevel: string + extraHeaders: string } const EMPTY_ADD_FORM: AddForm = { @@ -49,6 +50,7 @@ const EMPTY_ADD_FORM: AddForm = { maxTokensField: "", requestTimeout: "", thinkingLevel: "", + extraHeaders: "", } interface AddModelSheetProps { @@ -95,6 +97,16 @@ export function AddModelSheet({ errors.modelName = t("models.add.errorDuplicateModelName") } if (!form.model.trim()) errors.model = t("models.add.errorRequired") + if (form.extraHeaders.trim()) { + try { + const parsed = JSON.parse(form.extraHeaders.trim()) + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + errors.extraHeaders = "Must be a valid JSON object" + } + } catch { + errors.extraHeaders = "Invalid JSON format" + } + } setFieldErrors(errors) return Object.keys(errors).length === 0 } @@ -129,6 +141,7 @@ export function AddModelSheet({ ? Number(form.requestTimeout) : undefined, thinking_level: form.thinkingLevel.trim() || undefined, + extra_headers: form.extraHeaders.trim() ? JSON.parse(form.extraHeaders.trim()) : undefined, }) if (setAsDefault) { await setDefaultModel(modelName) @@ -305,6 +318,21 @@ export function AddModelSheet({ placeholder="max_completion_tokens" /> + + + + {fieldErrors.extraHeaders && ( +

{fieldErrors.extraHeaders}

+ )} +
{serverError && ( diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index 237991a9f..4e90dc9a5 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -32,6 +32,7 @@ interface EditForm { maxTokensField: string requestTimeout: string thinkingLevel: string + extraHeaders: string } interface EditModelSheetProps { @@ -59,6 +60,7 @@ export function EditModelSheet({ maxTokensField: "", requestTimeout: "", thinkingLevel: "", + extraHeaders: "", }) const [saving, setSaving] = useState(false) const [setAsDefault, setSetAsDefault] = useState(false) @@ -79,6 +81,7 @@ export function EditModelSheet({ ? String(model.request_timeout) : "", thinkingLevel: model.thinking_level ?? "", + extraHeaders: model.extra_headers ? JSON.stringify(model.extra_headers) : "", }) setSetAsDefault(model.is_default) setError("") @@ -91,6 +94,18 @@ export function EditModelSheet({ const handleSave = async () => { if (!model) return + if (form.extraHeaders.trim()) { + try { + const parsed = JSON.parse(form.extraHeaders.trim()) + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + setError("Extra headers must be a valid JSON object") + return + } + } catch { + setError("Extra headers must be valid JSON format") + return + } + } setSaving(true) setError("") try { @@ -109,6 +124,7 @@ export function EditModelSheet({ ? Number(form.requestTimeout) : undefined, thinking_level: form.thinkingLevel || undefined, + extra_headers: form.extraHeaders.trim() ? JSON.parse(form.extraHeaders.trim()) : undefined, }) if (setAsDefault && !model.is_default) { await setDefaultModel(model.model_name) @@ -273,6 +289,17 @@ export function EditModelSheet({ placeholder="max_completion_tokens" /> + + + + {error && ( diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 68105fcea..fc196194e 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -208,7 +208,9 @@ "thinkingLevel": "Thinking Level", "thinkingLevelHint": "Extended thinking budget: off, low, medium, high, xhigh, adaptive.", "maxTokensField": "Max Tokens Field", - "maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens." + "maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens.", + "extraHeaders": "Extra Headers", + "extraHeadersHint": "Custom HTTP headers in JSON format, e.g. {\"X-My-Header\": \"value\"}" }, "edit": { "title": "Configure {{name}}",