feat(providers): add extra_body config to inject custom fields into request body

Allow configuring provider-specific fields like reasoning_split for minimax via
the model config's extra_body map. These fields are merged into the request
body last, giving them precedence over default values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
uiyzzi
2026-03-22 15:49:25 +08:00
parent dd82794255
commit a005e5bb70
7 changed files with 165 additions and 5 deletions
+3
View File
@@ -93,6 +93,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout,
cfg.ExtraBody,
), modelID, nil
case "azure", "azure-openai":
@@ -132,6 +133,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout,
cfg.ExtraBody,
), modelID, nil
case "anthropic":
@@ -157,6 +159,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout,
cfg.ExtraBody,
), modelID, nil
case "anthropic-messages":
+3 -1
View File
@@ -24,12 +24,13 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
}
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0)
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil)
}
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
apiKey, apiBase, proxy, maxTokensField string,
requestTimeoutSeconds int,
extraBody map[string]any,
) *HTTPProvider {
return &HTTPProvider{
delegate: openai_compat.NewProvider(
@@ -38,6 +39,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
proxy,
openai_compat.WithMaxTokensField(maxTokensField),
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
openai_compat.WithExtraBody(extraBody),
),
}
}
+13
View File
@@ -35,6 +35,7 @@ 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
}
type Option func(*Provider)
@@ -55,6 +56,12 @@ func WithRequestTimeout(timeout time.Duration) Option {
}
}
func WithExtraBody(extraBody map[string]any) Option {
return func(p *Provider) {
p.extraBody = extraBody
}
}
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
p := &Provider{
apiKey: apiKey,
@@ -140,6 +147,12 @@ func (p *Provider) buildRequestBody(
}
}
// Merge extra body fields configured per-provider/model.
// These are injected last so they take precedence over defaults.
for k, v := range p.extraBody {
requestBody[k] = v
}
return requestBody
}
@@ -610,6 +610,90 @@ func TestProvider_RequestTimeoutOverride(t *testing.T) {
}
}
func TestProviderChat_ExtraBodyInjected(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); 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()
extraBody := map[string]any{"reasoning_split": true, "custom_field": "test"}
p := NewProvider("key", server.URL, "", WithExtraBody(extraBody))
_, err := p.Chat(
t.Context(),
[]Message{{Role: "user", Content: "hi"}},
nil,
"minimax/abab7",
nil,
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if got, ok := requestBody["reasoning_split"]; !ok || got != true {
t.Fatalf("reasoning_split = %v, want true", got)
}
if got, ok := requestBody["custom_field"]; !ok || got != "test" {
t.Fatalf("custom_field = %v, want test", got)
}
}
func TestProviderChat_ExtraBodyOverridesOptions(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); 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()
extraBody := map[string]any{"temperature": 0.9}
p := NewProvider("key", server.URL, "", WithExtraBody(extraBody))
_, err := p.Chat(
t.Context(),
[]Message{{Role: "user", Content: "hi"}},
nil,
"gpt-4o",
map[string]any{"temperature": 0.5},
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
// ExtraBody takes precedence over options since it is merged last.
if got := requestBody["temperature"]; got != float64(0.9) {
t.Fatalf("temperature = %v, want 0.9 (from extraBody, overriding options)", got)
}
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {