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 <noreply@anthropic.com>
This commit is contained in:
parent
014278357a
commit
37d6133ab9
5 changed files with 189 additions and 58 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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...),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue