From 37d6133ab9f3df9b660e4846faad165bb83ee1bb Mon Sep 17 00:00:00 2001 From: Eric Jacksch Date: Thu, 12 Mar 2026 20:02:56 -0400 Subject: [PATCH] feat(openai_compat): add strict_compat option to strip non-standard fields Some OpenAI-compatible providers (e.g. OpenRouter routing to strict backends) reject non-standard fields in the request body such as reasoning_content in messages and extra_content / thought_signature in tool calls. Add a per-model strict_compat: true config option that strips these fields before serialization. Implementation: - Add StrictCompat bool to config.ModelConfig - Add WithStrictCompat option to openai_compat.Provider - Refactor HTTPProvider constructors into a single NewHTTPProviderWithOptions using variadic openai_compat.Option, eliminating the growing list of named constructors - Thread StrictCompat through CreateProviderFromConfig via composed options Co-Authored-By: Claude Sonnet 4.6 --- pkg/config/config.go | 1 + pkg/providers/factory_provider.go | 41 +++--- pkg/providers/http_provider.go | 24 +--- pkg/providers/openai_compat/provider.go | 47 +++++-- pkg/providers/openai_compat/provider_test.go | 134 ++++++++++++++++++- 5 files changed, 189 insertions(+), 58 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 190341224..71c7fec8f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -608,6 +608,7 @@ 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"` + StrictCompat bool `json:"strict_compat,omitempty"` // Strip non-standard fields for strict OpenAI-compatible endpoints ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive } diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index b7567f9fc..235ec8e47 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -8,10 +8,12 @@ package providers import ( "fmt" "strings" + "time" "github.com/sipeed/picoclaw/pkg/config" anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages" "github.com/sipeed/picoclaw/pkg/providers/azure" + "github.com/sipeed/picoclaw/pkg/providers/openai_compat" ) // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. @@ -87,13 +89,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - cfg.APIKey, - apiBase, - cfg.Proxy, - cfg.MaxTokensField, - cfg.RequestTimeout, - ), modelID, nil + opts := []openai_compat.Option{ + openai_compat.WithMaxTokensField(cfg.MaxTokensField), + openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout) * time.Second), + openai_compat.WithStrictCompat(cfg.StrictCompat), + } + return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, opts...), modelID, nil case "azure", "azure-openai": // Azure OpenAI uses deployment-based URLs, api-key header auth, @@ -125,13 +126,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - cfg.APIKey, - apiBase, - cfg.Proxy, - cfg.MaxTokensField, - cfg.RequestTimeout, - ), modelID, nil + opts := []openai_compat.Option{ + openai_compat.WithMaxTokensField(cfg.MaxTokensField), + openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout) * time.Second), + openai_compat.WithStrictCompat(cfg.StrictCompat), + } + return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, opts...), modelID, nil case "anthropic": if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { @@ -150,13 +150,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if cfg.APIKey == "" { return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) } - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - cfg.APIKey, - apiBase, - cfg.Proxy, - cfg.MaxTokensField, - cfg.RequestTimeout, - ), modelID, nil + opts := []openai_compat.Option{ + openai_compat.WithMaxTokensField(cfg.MaxTokensField), + openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout) * time.Second), + openai_compat.WithStrictCompat(cfg.StrictCompat), + } + return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, opts...), modelID, nil case "anthropic-messages": // Anthropic Messages API with native format (HTTP-based, no SDK) diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 5c328f418..519993974 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -8,7 +8,6 @@ package providers import ( "context" - "time" "github.com/sipeed/picoclaw/pkg/providers/openai_compat" ) @@ -17,28 +16,9 @@ type HTTPProvider struct { delegate *openai_compat.Provider } -func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { +func NewHTTPProviderWithOptions(apiKey, apiBase, proxy string, opts ...openai_compat.Option) *HTTPProvider { return &HTTPProvider{ - delegate: openai_compat.NewProvider(apiKey, apiBase, proxy), - } -} - -func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider { - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0) -} - -func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - apiKey, apiBase, proxy, maxTokensField string, - requestTimeoutSeconds int, -) *HTTPProvider { - return &HTTPProvider{ - delegate: openai_compat.NewProvider( - apiKey, - apiBase, - proxy, - openai_compat.WithMaxTokensField(maxTokensField), - openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), - ), + delegate: openai_compat.NewProvider(apiKey, apiBase, proxy, opts...), } } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index f0d07c0f0..2c5ff9270 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -31,6 +31,7 @@ type Provider struct { apiKey string apiBase string maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) + strictCompat bool // Strip non-standard fields for strict OpenAI-compatible endpoints httpClient *http.Client } @@ -52,6 +53,12 @@ func WithRequestTimeout(timeout time.Duration) Option { } } +func WithStrictCompat(v bool) Option { + return func(p *Provider) { + p.strictCompat = v + } +} + func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { p := &Provider{ apiKey: apiKey, @@ -100,7 +107,7 @@ func (p *Provider) Chat( requestBody := map[string]any{ "model": model, - "messages": common.SerializeMessages(messages), + "messages": serializeMessages(messages, p.strictCompat), } if len(tools) > 0 { @@ -202,15 +209,37 @@ func msgContent(content string, toolCalls []ToolCall) *string { // - Strips SystemParts (unknown to third-party endpoints) // - Converts messages with Media to multipart content format (text + image_url parts) // - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages -func serializeMessages(messages []Message) []any { +// - When strictCompat is true, strips non-standard fields (reasoning_content, extra_content, +// thought_signature) that some strict OpenAI-compatible providers reject +func serializeMessages(messages []Message, strictCompat bool) []any { out := make([]any, 0, len(messages)) for _, m := range messages { + toolCalls := m.ToolCalls + reasoningContent := m.ReasoningContent + + if strictCompat { + reasoningContent = "" + if len(toolCalls) > 0 { + sanitized := make([]ToolCall, len(toolCalls)) + for i, tc := range toolCalls { + sanitized[i] = tc + sanitized[i].ExtraContent = nil + if tc.Function != nil { + fnCopy := *tc.Function + fnCopy.ThoughtSignature = "" + sanitized[i].Function = &fnCopy + } + } + toolCalls = sanitized + } + } + if len(m.Media) == 0 { out = append(out, openaiMessage{ Role: m.Role, - Content: msgContent(m.Content, m.ToolCalls), - ReasoningContent: m.ReasoningContent, - ToolCalls: m.ToolCalls, + Content: msgContent(m.Content, toolCalls), + ReasoningContent: reasoningContent, + ToolCalls: toolCalls, ToolCallID: m.ToolCallID, }) continue @@ -242,11 +271,11 @@ func serializeMessages(messages []Message) []any { if m.ToolCallID != "" { msg["tool_call_id"] = m.ToolCallID } - if len(m.ToolCalls) > 0 { - msg["tool_calls"] = m.ToolCalls + if len(toolCalls) > 0 { + msg["tool_calls"] = toolCalls } - if m.ReasoningContent != "" { - msg["reasoning_content"] = m.ReasoningContent + if reasoningContent != "" { + msg["reasoning_content"] = reasoningContent } out = append(out, msg) } diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 27f1fedda..2a30e1349 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -649,7 +649,7 @@ func TestSerializeMessages_PlainText(t *testing.T) { {Role: "user", Content: "hello"}, {Role: "assistant", Content: "hi", ReasoningContent: "thinking..."}, } - result := common.SerializeMessages(messages) + result := serializeMessages(messages, false) data, err := json.Marshal(result) if err != nil { @@ -671,7 +671,7 @@ func TestSerializeMessages_WithMedia(t *testing.T) { messages := []protocoltypes.Message{ {Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}}, } - result := common.SerializeMessages(messages) + result := serializeMessages(messages, false) data, _ := json.Marshal(result) var msgs []map[string]any @@ -704,7 +704,7 @@ func TestSerializeMessages_MediaWithToolCallID(t *testing.T) { messages := []protocoltypes.Message{ {Role: "tool", Content: "image result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"}, } - result := common.SerializeMessages(messages) + result := serializeMessages(messages, false) data, _ := json.Marshal(result) var msgs []map[string]any @@ -834,7 +834,7 @@ func TestSerializeMessages_OmitsContentWhenEmptyAndToolCallsPresent(t *testing.T }, }, } - result := serializeMessages(messages) + result := serializeMessages(messages, false) data, _ := json.Marshal(result) var msgs []map[string]any @@ -858,7 +858,7 @@ func TestSerializeMessages_IncludesContentWhenNonEmptyWithToolCalls(t *testing.T }, }, } - result := serializeMessages(messages) + result := serializeMessages(messages, false) data, _ := json.Marshal(result) var msgs []map[string]any @@ -879,7 +879,7 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) { }, }, } - result := common.SerializeMessages(messages) + result := serializeMessages(messages, false) data, _ := json.Marshal(result) raw := string(data) @@ -887,3 +887,125 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) { t.Fatal("system_parts should not appear in serialized output") } } + +func TestSerializeMessages_StrictCompat_StripsReasoningContent(t *testing.T) { + messages := []protocoltypes.Message{ + {Role: "user", Content: "What is 1+1?"}, + {Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"}, + } + result := serializeMessages(messages, true) + + data, _ := json.Marshal(result) + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + if _, ok := msgs[1]["reasoning_content"]; ok { + t.Fatalf("reasoning_content should be stripped when strictCompat=true, got %v", msgs[1]["reasoning_content"]) + } + if msgs[1]["content"] != "2" { + t.Fatalf("content should be preserved, got %v", msgs[1]["content"]) + } +} + +func TestSerializeMessages_StrictCompat_StripsExtraContent(t *testing.T) { + messages := []protocoltypes.Message{ + { + Role: "assistant", + Content: "", + ToolCalls: []protocoltypes.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &protocoltypes.FunctionCall{ + Name: "get_weather", + Arguments: `{"city":"SF"}`, + }, + ExtraContent: &protocoltypes.ExtraContent{ + Google: &protocoltypes.GoogleExtra{ + ThoughtSignature: "sig123", + }, + }, + }, + }, + }, + } + result := serializeMessages(messages, true) + + data, _ := json.Marshal(result) + raw := string(data) + if strings.Contains(raw, "extra_content") { + t.Fatalf("extra_content should be stripped when strictCompat=true, got: %s", raw) + } + if strings.Contains(raw, "sig123") { + t.Fatalf("thought_signature value should be stripped when strictCompat=true, got: %s", raw) + } +} + +func TestSerializeMessages_StrictCompat_StripsThoughtSignature(t *testing.T) { + messages := []protocoltypes.Message{ + { + Role: "assistant", + Content: "", + ToolCalls: []protocoltypes.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &protocoltypes.FunctionCall{ + Name: "search", + Arguments: `{"query":"test"}`, + ThoughtSignature: "thought-sig-abc", + }, + }, + }, + }, + } + result := serializeMessages(messages, true) + + data, _ := json.Marshal(result) + raw := string(data) + if strings.Contains(raw, "thought_signature") { + t.Fatalf("thought_signature should be stripped when strictCompat=true, got: %s", raw) + } + if strings.Contains(raw, "thought-sig-abc") { + t.Fatalf("thought_signature value should be stripped when strictCompat=true, got: %s", raw) + } +} + +func TestSerializeMessages_NoStrictCompat_PreservesFields(t *testing.T) { + messages := []protocoltypes.Message{ + { + Role: "assistant", + Content: "result", + ReasoningContent: "my reasoning", + ToolCalls: []protocoltypes.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &protocoltypes.FunctionCall{ + Name: "get_weather", + Arguments: `{"city":"SF"}`, + ThoughtSignature: "thought-sig-xyz", + }, + ExtraContent: &protocoltypes.ExtraContent{ + Google: &protocoltypes.GoogleExtra{ + ThoughtSignature: "sig456", + }, + }, + }, + }, + }, + } + result := serializeMessages(messages, false) + + data, _ := json.Marshal(result) + raw := string(data) + if !strings.Contains(raw, "my reasoning") { + t.Fatalf("reasoning_content should be preserved when strictCompat=false, got: %s", raw) + } + if !strings.Contains(raw, "extra_content") { + t.Fatalf("extra_content should be preserved when strictCompat=false, got: %s", raw) + } + if !strings.Contains(raw, "sig456") { + t.Fatalf("thought_signature value in extra_content should be preserved when strictCompat=false, got: %s", raw) + } +}