diff --git a/internal/llm/fantasy_adapter.go b/internal/llm/fantasy_adapter.go index 1137987a3..cb3827fd2 100644 --- a/internal/llm/fantasy_adapter.go +++ b/internal/llm/fantasy_adapter.go @@ -125,26 +125,40 @@ func newFantasyClient(opts fantasyClientOptions) *fantasyClient { var err error switch opts.protocol { case APIContractAnthropic: + if reasoning := ProviderConfigResponsesReasoning(opts.providerConfig); reasoning != nil && reasoning.Enabled != nil { + client.initErr = fmt.Errorf("provider_config.reasoning.enabled is unsupported for Anthropic") + return client + } provider, err = anthropic.New( anthropic.WithBaseURL(strings.TrimRight(opts.baseURL, "/")), anthropic.WithAPIKey(opts.apiKey), anthropic.WithHTTPClient(httpClient), ) case APIContractResponses: + providerOptions, optionsErr := providerConfigSDKOptions(opts.providerConfig, opts.protocol) + if optionsErr != nil { + client.initErr = optionsErr + return client + } provider, err = openai.New( openai.WithBaseURL(fantasyOpenAIBaseURL(opts.baseURL, "/v1/responses")), openai.WithAPIKey(opts.apiKey), openai.WithHTTPClient(httpClient), openai.WithUseResponsesAPI(), openai.WithResponsesAPIFunc(func(string) bool { return true }), - openai.WithSDKOptions(providerConfigSDKOptions(opts.providerConfig, opts.protocol)...), + openai.WithSDKOptions(providerOptions...), ) default: + providerOptions, optionsErr := providerConfigSDKOptions(opts.providerConfig, opts.protocol) + if optionsErr != nil { + client.initErr = optionsErr + return client + } provider, err = openai.New( openai.WithBaseURL(fantasyOpenAIBaseURL(opts.baseURL, opts.chatPath)), openai.WithAPIKey(opts.apiKey), openai.WithHTTPClient(httpClient), - openai.WithSDKOptions(providerConfigSDKOptions(opts.providerConfig, opts.protocol)...), + openai.WithSDKOptions(providerOptions...), ) } if err != nil { @@ -155,10 +169,18 @@ func newFantasyClient(opts fantasyClientOptions) *fantasyClient { return client } -func providerConfigSDKOptions(raw, protocol string) []sdkoption.RequestOption { +func providerConfigSDKOptions(raw, protocol string) ([]sdkoption.RequestOption, error) { var config map[string]json.RawMessage if json.Unmarshal([]byte(raw), &config) != nil { - return nil + return nil, nil + } + if reasoning := ProviderConfigResponsesReasoning(raw); reasoning != nil && protocol == APIContractChatCompletions { + if reasoning.Summary != "" || reasoning.BudgetTokens != 0 { + return nil, fmt.Errorf("provider_config.reasoning summary and budget_tokens are unsupported for Chat Completions") + } + } + if reasoning := ProviderConfigResponsesReasoning(raw); reasoning != nil && protocol == APIContractResponses && reasoning.Enabled != nil { + return nil, fmt.Errorf("provider_config.reasoning.enabled is unsupported for Responses") } generated := map[string]bool{ "model": true, "messages": true, "input": true, "temperature": true, @@ -172,6 +194,18 @@ func providerConfigSDKOptions(raw, protocol string) []sdkoption.RequestOption { generated["parallel_tool_calls"] = true } var options []sdkoption.RequestOption + if protocol == APIContractChatCompletions { + if reasoning := ProviderConfigResponsesReasoning(raw); reasoning != nil { + value := map[string]any{} + if reasoning.Effort != "" { + value["effort"] = reasoning.Effort + } + if reasoning.Enabled != nil { + value["enabled"] = *reasoning.Enabled + } + options = append(options, sdkoption.WithJSONSet("reasoning", value)) + } + } for key, rawValue := range config { if reservedProviderConfigKeys[key] || generated[key] { continue @@ -181,7 +215,7 @@ func providerConfigSDKOptions(raw, protocol string) []sdkoption.RequestOption { options = append(options, sdkoption.WithJSONSet(key, value)) } } - return options + return options, nil } func fantasyOpenAIBaseURL(baseURL, endpointPath string) string { diff --git a/internal/llm/provider_config.go b/internal/llm/provider_config.go index d40fea215..7d8e0cb3a 100644 --- a/internal/llm/provider_config.go +++ b/internal/llm/provider_config.go @@ -41,6 +41,9 @@ type ResponsesReasoningConfig struct { Effort string `json:"effort,omitempty"` Summary string `json:"summary,omitempty"` BudgetTokens int64 `json:"budget_tokens,omitempty"` + // Enabled is supported by OpenAI-compatible Chat Completions providers, + // but not by the Responses or Anthropic contracts. + Enabled *bool `json:"enabled,omitempty"` } // ValidateProviderConfig verifies the per-connection provider_config blob. @@ -84,11 +87,18 @@ func ValidateProviderConfig(raw string) error { } var reasoning ResponsesReasoningConfig if err := json.Unmarshal(rawReasoning, &reasoning); err != nil { - return fmt.Errorf("provider_config.reasoning must contain valid effort, summary, and budget_tokens fields") + return fmt.Errorf("provider_config.reasoning must contain valid effort, summary, budget_tokens, and enabled fields") } if reasoning.BudgetTokens < 0 { return fmt.Errorf("provider_config.reasoning.budget_tokens must be non-negative") } + for key := range reasoningObject { + switch key { + case "effort", "summary", "budget_tokens", "enabled": + default: + return fmt.Errorf("provider_config.reasoning.%s is unsupported", key) + } + } } if rawMax, ok := cfg[providerConfigMaxOutputTokensKey]; ok { var value int @@ -212,7 +222,7 @@ func ProviderConfigResponsesReasoning(raw string) *ResponsesReasoningConfig { } reasoning.Effort = strings.TrimSpace(reasoning.Effort) reasoning.Summary = strings.TrimSpace(reasoning.Summary) - if reasoning.Effort == "" && reasoning.Summary == "" && reasoning.BudgetTokens == 0 { + if reasoning.Effort == "" && reasoning.Summary == "" && reasoning.BudgetTokens == 0 && reasoning.Enabled == nil { return nil } return &reasoning