feat: add custom Extra Headers support for model configurations

Allows users to configure custom HTTP headers per-model via the UI
or directly in config.json. These headers are injected into the HTTP
requests sent to OpenAI-compatible provider endpoints.

- Add `ExtraHeaders` to `ModelConfig` and handle it in models API payload.
- Modify `openai_compat.Provider` to accept and inject `extraHeaders`.
- Add "Extra Headers" JSON input in web frontend's model forms.
- Update README.md with tutorial for configuring custom headers.

Co-authored-by: TanLuong <28281768+TanLuong@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-03-25 04:44:58 +00:00
parent 6b503ca745
commit 21c603de9a
10 changed files with 128 additions and 7 deletions

View file

@ -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. > \* 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.
<details>
<summary><b>Custom Headers (Web UI & config.json)</b></summary>
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"
}
}
]
}
```
</details>
<details> <details>
<summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary> <summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary>

View file

@ -854,8 +854,9 @@ type ModelConfig struct {
RPM int `json:"rpm,omitempty"` // Requests per minute limit 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") MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
RequestTimeout int `json:"request_timeout,omitempty"` RequestTimeout int `json:"request_timeout,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive 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 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 // from security
secModelName string secModelName string
@ -2045,6 +2046,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
RequestTimeout: m.RequestTimeout, RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel, ThinkingLevel: m.ThinkingLevel,
ExtraBody: m.ExtraBody, ExtraBody: m.ExtraBody,
ExtraHeaders: m.ExtraHeaders,
apiKeys: []string{keys[0]}, apiKeys: []string{keys[0]},
} }

View file

@ -99,6 +99,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.MaxTokensField, cfg.MaxTokensField,
cfg.RequestTimeout, cfg.RequestTimeout,
cfg.ExtraBody, cfg.ExtraBody,
cfg.ExtraHeaders,
), modelID, nil ), modelID, nil
case "azure", "azure-openai": case "azure", "azure-openai":
@ -193,6 +194,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.MaxTokensField, cfg.MaxTokensField,
cfg.RequestTimeout, cfg.RequestTimeout,
cfg.ExtraBody, cfg.ExtraBody,
cfg.ExtraHeaders,
), modelID, nil ), modelID, nil
case "minimax": case "minimax":
@ -218,6 +220,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.MaxTokensField, cfg.MaxTokensField,
cfg.RequestTimeout, cfg.RequestTimeout,
extraBody, extraBody,
cfg.ExtraHeaders,
), modelID, nil ), modelID, nil
case "anthropic": case "anthropic":
@ -244,6 +247,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.MaxTokensField, cfg.MaxTokensField,
cfg.RequestTimeout, cfg.RequestTimeout,
cfg.ExtraBody, cfg.ExtraBody,
cfg.ExtraHeaders,
), modelID, nil ), modelID, nil
case "anthropic-messages": case "anthropic-messages":

View file

@ -24,13 +24,14 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
} }
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField 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( func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
apiKey, apiBase, proxy, maxTokensField string, apiKey, apiBase, proxy, maxTokensField string,
requestTimeoutSeconds int, requestTimeoutSeconds int,
extraBody map[string]any, extraBody map[string]any,
extraHeaders map[string]string,
) *HTTPProvider { ) *HTTPProvider {
return &HTTPProvider{ return &HTTPProvider{
delegate: openai_compat.NewProvider( delegate: openai_compat.NewProvider(
@ -40,6 +41,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
openai_compat.WithMaxTokensField(maxTokensField), openai_compat.WithMaxTokensField(maxTokensField),
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
openai_compat.WithExtraBody(extraBody), openai_compat.WithExtraBody(extraBody),
openai_compat.WithExtraHeaders(extraHeaders),
), ),
} }
} }

View file

@ -35,7 +35,8 @@ type Provider struct {
apiBase string apiBase string
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
httpClient *http.Client 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) 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 { func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
p := &Provider{ p := &Provider{
apiKey: apiKey, apiKey: apiKey,
@ -183,6 +190,9 @@ func (p *Provider) Chat(
if p.apiKey != "" { if p.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+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) resp, err := p.httpClient.Do(req)
if err != nil { if err != nil {
@ -229,6 +239,9 @@ func (p *Provider) ChatStream(
if p.apiKey != "" { if p.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+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 // Use a client without Timeout for streaming — the http.Client.Timeout covers
// the entire request lifecycle including body reads, which would kill long streams. // the entire request lifecycle including body reads, which would kill long streams.

View file

@ -37,8 +37,9 @@ type modelResponse struct {
RPM int `json:"rpm,omitempty"` RPM int `json:"rpm,omitempty"`
MaxTokensField string `json:"max_tokens_field,omitempty"` MaxTokensField string `json:"max_tokens_field,omitempty"`
RequestTimeout int `json:"request_timeout,omitempty"` RequestTimeout int `json:"request_timeout,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"` ThinkingLevel string `json:"thinking_level,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"` ExtraBody map[string]any `json:"extra_body,omitempty"`
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
// Meta // Meta
Configured bool `json:"configured"` Configured bool `json:"configured"`
IsDefault bool `json:"is_default"` IsDefault bool `json:"is_default"`
@ -84,6 +85,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
RequestTimeout: m.RequestTimeout, RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel, ThinkingLevel: m.ThinkingLevel,
ExtraBody: m.ExtraBody, ExtraBody: m.ExtraBody,
ExtraHeaders: m.ExtraHeaders,
Configured: configured[i], Configured: configured[i],
IsDefault: m.ModelName == defaultModel, IsDefault: m.ModelName == defaultModel,
}) })
@ -205,6 +207,9 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
if mc.ExtraBody == nil { if mc.ExtraBody == nil {
mc.ExtraBody = cfg.ModelList[idx].ExtraBody mc.ExtraBody = cfg.ModelList[idx].ExtraBody
} }
if mc.ExtraHeaders == nil {
mc.ExtraHeaders = cfg.ModelList[idx].ExtraHeaders
}
cfg.ModelList[idx] = &mc.ModelConfig cfg.ModelList[idx] = &mc.ModelConfig

View file

@ -18,6 +18,7 @@ export interface ModelInfo {
request_timeout?: number request_timeout?: number
thinking_level?: string thinking_level?: string
extra_body?: Record<string, unknown> extra_body?: Record<string, unknown>
extra_headers?: Record<string, string>
// Meta // Meta
configured: boolean configured: boolean
is_default: boolean is_default: boolean

View file

@ -34,6 +34,7 @@ interface AddForm {
maxTokensField: string maxTokensField: string
requestTimeout: string requestTimeout: string
thinkingLevel: string thinkingLevel: string
extraHeaders: string
} }
const EMPTY_ADD_FORM: AddForm = { const EMPTY_ADD_FORM: AddForm = {
@ -49,6 +50,7 @@ const EMPTY_ADD_FORM: AddForm = {
maxTokensField: "", maxTokensField: "",
requestTimeout: "", requestTimeout: "",
thinkingLevel: "", thinkingLevel: "",
extraHeaders: "",
} }
interface AddModelSheetProps { interface AddModelSheetProps {
@ -95,6 +97,16 @@ export function AddModelSheet({
errors.modelName = t("models.add.errorDuplicateModelName") errors.modelName = t("models.add.errorDuplicateModelName")
} }
if (!form.model.trim()) errors.model = t("models.add.errorRequired") 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) setFieldErrors(errors)
return Object.keys(errors).length === 0 return Object.keys(errors).length === 0
} }
@ -129,6 +141,7 @@ export function AddModelSheet({
? Number(form.requestTimeout) ? Number(form.requestTimeout)
: undefined, : undefined,
thinking_level: form.thinkingLevel.trim() || undefined, thinking_level: form.thinkingLevel.trim() || undefined,
extra_headers: form.extraHeaders.trim() ? JSON.parse(form.extraHeaders.trim()) : undefined,
}) })
if (setAsDefault) { if (setAsDefault) {
await setDefaultModel(modelName) await setDefaultModel(modelName)
@ -305,6 +318,21 @@ export function AddModelSheet({
placeholder="max_completion_tokens" placeholder="max_completion_tokens"
/> />
</Field> </Field>
<Field
label={t("models.field.extraHeaders")}
hint={t("models.field.extraHeadersHint")}
>
<Input
value={form.extraHeaders}
onChange={setField("extraHeaders")}
placeholder='{"X-My-Header": "value"}'
aria-invalid={!!fieldErrors.extraHeaders}
/>
{fieldErrors.extraHeaders && (
<p className="text-destructive text-xs">{fieldErrors.extraHeaders}</p>
)}
</Field>
</AdvancedSection> </AdvancedSection>
{serverError && ( {serverError && (

View file

@ -32,6 +32,7 @@ interface EditForm {
maxTokensField: string maxTokensField: string
requestTimeout: string requestTimeout: string
thinkingLevel: string thinkingLevel: string
extraHeaders: string
} }
interface EditModelSheetProps { interface EditModelSheetProps {
@ -59,6 +60,7 @@ export function EditModelSheet({
maxTokensField: "", maxTokensField: "",
requestTimeout: "", requestTimeout: "",
thinkingLevel: "", thinkingLevel: "",
extraHeaders: "",
}) })
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [setAsDefault, setSetAsDefault] = useState(false) const [setAsDefault, setSetAsDefault] = useState(false)
@ -79,6 +81,7 @@ export function EditModelSheet({
? String(model.request_timeout) ? String(model.request_timeout)
: "", : "",
thinkingLevel: model.thinking_level ?? "", thinkingLevel: model.thinking_level ?? "",
extraHeaders: model.extra_headers ? JSON.stringify(model.extra_headers) : "",
}) })
setSetAsDefault(model.is_default) setSetAsDefault(model.is_default)
setError("") setError("")
@ -91,6 +94,18 @@ export function EditModelSheet({
const handleSave = async () => { const handleSave = async () => {
if (!model) return 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) setSaving(true)
setError("") setError("")
try { try {
@ -109,6 +124,7 @@ export function EditModelSheet({
? Number(form.requestTimeout) ? Number(form.requestTimeout)
: undefined, : undefined,
thinking_level: form.thinkingLevel || undefined, thinking_level: form.thinkingLevel || undefined,
extra_headers: form.extraHeaders.trim() ? JSON.parse(form.extraHeaders.trim()) : undefined,
}) })
if (setAsDefault && !model.is_default) { if (setAsDefault && !model.is_default) {
await setDefaultModel(model.model_name) await setDefaultModel(model.model_name)
@ -273,6 +289,17 @@ export function EditModelSheet({
placeholder="max_completion_tokens" placeholder="max_completion_tokens"
/> />
</Field> </Field>
<Field
label={t("models.field.extraHeaders")}
hint={t("models.field.extraHeadersHint")}
>
<Input
value={form.extraHeaders}
onChange={setField("extraHeaders")}
placeholder='{"X-My-Header": "value"}'
/>
</Field>
</AdvancedSection> </AdvancedSection>
{error && ( {error && (

View file

@ -208,7 +208,9 @@
"thinkingLevel": "Thinking Level", "thinkingLevel": "Thinking Level",
"thinkingLevelHint": "Extended thinking budget: off, low, medium, high, xhigh, adaptive.", "thinkingLevelHint": "Extended thinking budget: off, low, medium, high, xhigh, adaptive.",
"maxTokensField": "Max Tokens Field", "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": { "edit": {
"title": "Configure {{name}}", "title": "Configure {{name}}",