diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md index 0d4af719c..41b196b29 100644 --- a/docs/migration/model-list-migration.md +++ b/docs/migration/model-list-migration.md @@ -113,6 +113,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier` | `api_base` | No | API endpoint URL | | `api_key` | No* | API authentication key | | `proxy` | No | HTTP proxy URL | +| `headers` | No | Custom HTTP headers (`Authorization` and `Content-Type` are reserved and will be ignored) | | `auth_method` | No | Authentication method: `oauth`, `token` | | `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` | | `rpm` | No | Requests per minute limit | diff --git a/pkg/config/config.go b/pkg/config/config.go index 440fb31e4..7c5b9f706 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -464,7 +464,7 @@ type ModelConfig struct { APIBase string `json:"api_base,omitempty"` // API endpoint URL APIKey string `json:"api_key"` // API authentication key Proxy string `json:"proxy,omitempty"` // HTTP proxy URL - Headers map[string]string `json:"headers,omitempty"` // Custom HTTP headers + Headers map[string]string `json:"headers,omitempty"` // Custom HTTP headers (Authorization and Content-Type are reserved and will be ignored) // Special providers (CLI-based, OAuth, etc.) AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 09f8eeea6..38408c2ea 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -184,6 +184,9 @@ func (p *Provider) Chat( req.Header.Set("Authorization", "Bearer "+p.apiKey) } for key, value := range p.customHeaders { + if k := http.CanonicalHeaderKey(key); k == "Authorization" || k == "Content-Type" { + continue + } req.Header.Set(key, value) } @@ -301,7 +304,7 @@ func parseResponse(body []byte) (*LLMResponse, error) { type openaiMessage struct { Role string `json:"role"` Content string `json:"content"` - Reasoning string `json:"reasoning_content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` } @@ -315,7 +318,7 @@ func stripSystemParts(messages []Message) []openaiMessage { out[i] = openaiMessage{ Role: m.Role, Content: m.Content, - Reasoning: m.ReasoningContent, + ReasoningContent: m.ReasoningContent, ToolCalls: m.ToolCalls, ToolCallID: m.ToolCallID, } diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 7247fea3e..8a4aafb1b 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -361,3 +361,119 @@ func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) { t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) } } + +func TestProviderChat_SendsCustomHeaders(t *testing.T) { + var gotHeader string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("X-Custom-Key") + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "", WithCustomHeaders(map[string]string{ + "X-Custom-Key": "myvalue", + })) + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if gotHeader != "myvalue" { + t.Fatalf("X-Custom-Key = %q, want %q", gotHeader, "myvalue") + } +} + +func TestProviderChat_SkipsReservedHeaders(t *testing.T) { + var gotAuth, gotContentType string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotContentType = r.Header.Get("Content-Type") + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("mykey", server.URL, "", WithCustomHeaders(map[string]string{ + "Authorization": "Bearer OVERRIDE", + "Content-Type": "text/plain", + "X-Extra": "allowed", + })) + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if gotAuth != "Bearer mykey" { + t.Fatalf("Authorization = %q, want %q", gotAuth, "Bearer mykey") + } + if gotContentType != "application/json" { + t.Fatalf("Content-Type = %q, want %q", gotContentType, "application/json") + } +} + +func TestProviderChat_RoundTripsReasoningContent(t *testing.T) { + var reqBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat( + t.Context(), + []Message{ + {Role: "user", Content: "1+1=?"}, + {Role: "assistant", Content: "2", ReasoningContent: "let me think..."}, + {Role: "user", Content: "thanks"}, + }, + nil, + "gpt-4o", + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + msgs, ok := reqBody["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any") + } + assistantMsg, ok := msgs[1].(map[string]any) + if !ok { + t.Fatalf("messages[1] is not map[string]any") + } + if got := assistantMsg["reasoning_content"]; got != "let me think..." { + t.Fatalf("reasoning_content = %v, want %q", got, "let me think...") + } +}