From 24ea2c94e3ac2bb0c15fc6cb0fb8c272890d049f Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sat, 7 Mar 2026 11:03:10 +0300 Subject: [PATCH] feat: add OpenAI Responses API support for codex/gpt-5/o3/o4 models Enable fantasy's Responses API path (WithUseResponsesAPI) for the OpenAI provider so that models like gpt-5.3-codex, codex-mini-latest, o3, o4-mini, and other Responses-only models work correctly. - Enable WithUseResponsesAPI on both createOpenAIProvider and createAutoRoutedOpenAIProvider - Build provider options for reasoning models (reasoning_summary, encrypted reasoning content) matching crush's coordinator behaviour - Thread ProviderOptions from provider creation through to the fantasy agent in NewAgent, SetModel, and the SDK Complete path - Pass generation parameters (Temperature, MaxTokens, TopP, TopK) to the fantasy agent for all providers (previously only Ollama) - Fix extension tool schema for Responses API: parse Parameters JSON Schema string into fantasy ToolInfo format, ensure Required is never nil (OpenAI rejects null, expects empty array) --- internal/agent/agent.go | 40 ++++++++++++++++++++++++++++++++ internal/extensions/wrapper.go | 42 +++++++++++++++++++++++++++++++++- internal/models/providers.go | 40 ++++++++++++++++++++++++++++++-- pkg/kit/kit.go | 11 ++++++--- 4 files changed, 127 insertions(+), 6 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 9728d4d4..759b7c64 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -157,6 +157,27 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) { )) } + // Pass provider-specific options (e.g. OpenAI Responses API reasoning settings). + if providerResult.ProviderOptions != nil { + agentOpts = append(agentOpts, fantasy.WithProviderOptions(providerResult.ProviderOptions)) + } + + // Pass generation parameters when available. + if agentConfig.ModelConfig != nil { + if agentConfig.ModelConfig.MaxTokens > 0 { + agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(agentConfig.ModelConfig.MaxTokens))) + } + if agentConfig.ModelConfig.Temperature != nil { + agentOpts = append(agentOpts, fantasy.WithTemperature(float64(*agentConfig.ModelConfig.Temperature))) + } + if agentConfig.ModelConfig.TopP != nil { + agentOpts = append(agentOpts, fantasy.WithTopP(float64(*agentConfig.ModelConfig.TopP))) + } + if agentConfig.ModelConfig.TopK != nil { + agentOpts = append(agentOpts, fantasy.WithTopK(int64(*agentConfig.ModelConfig.TopK))) + } + } + // Create the fantasy agent fantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...) @@ -524,6 +545,25 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err )) } + // Pass provider-specific options (e.g. OpenAI Responses API reasoning settings). + if providerResult.ProviderOptions != nil { + agentOpts = append(agentOpts, fantasy.WithProviderOptions(providerResult.ProviderOptions)) + } + + // Pass generation parameters when available. + if config.MaxTokens > 0 { + agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(config.MaxTokens))) + } + if config.Temperature != nil { + agentOpts = append(agentOpts, fantasy.WithTemperature(float64(*config.Temperature))) + } + if config.TopP != nil { + agentOpts = append(agentOpts, fantasy.WithTopP(float64(*config.TopP))) + } + if config.TopK != nil { + agentOpts = append(agentOpts, fantasy.WithTopK(int64(*config.TopK))) + } + newFantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...) // Close old provider. diff --git a/internal/extensions/wrapper.go b/internal/extensions/wrapper.go index c16de0a8..c8cd90a8 100644 --- a/internal/extensions/wrapper.go +++ b/internal/extensions/wrapper.go @@ -2,6 +2,7 @@ package extensions import ( "context" + "encoding/json" "fmt" "charm.land/fantasy" @@ -125,10 +126,49 @@ type extensionTool struct { } func (t *extensionTool) Info() fantasy.ToolInfo { - return fantasy.ToolInfo{ + info := fantasy.ToolInfo{ Name: t.def.Name, Description: t.def.Description, } + + // Parse the extension's JSON Schema and extract the properties map. + // Fantasy expects Parameters to contain property definitions directly + // (e.g. {"command": {"type":"string"}}) and wraps them into a full + // JSON Schema object internally. If the extension provides a full + // schema with "type":"object" and "properties", we extract just the + // properties. Required fields are also extracted if present. + if t.def.Parameters != "" { + var schema map[string]any + if err := json.Unmarshal([]byte(t.def.Parameters), &schema); err == nil { + if props, ok := schema["properties"].(map[string]any); ok { + info.Parameters = props + } else { + // Schema doesn't have "properties" — use as-is (may be + // a flat property map already matching fantasy's format). + info.Parameters = schema + } + // Extract required fields if present. + if req, ok := schema["required"].([]any); ok { + for _, r := range req { + if s, ok := r.(string); ok { + info.Required = append(info.Required, s) + } + } + } + } + } + + // Ensure Parameters and Required are never nil — the OpenAI Responses API + // rejects tools where these fields serialize to JSON null instead of + // empty object/array. + if info.Parameters == nil { + info.Parameters = map[string]any{} + } + if info.Required == nil { + info.Required = []string{} + } + + return info } func (t *extensionTool) ProviderOptions() fantasy.ProviderOptions { return t.providerOptions } diff --git a/internal/models/providers.go b/internal/models/providers.go index 58a71cf8..456c100e 100644 --- a/internal/models/providers.go +++ b/internal/models/providers.go @@ -82,6 +82,9 @@ type ProviderResult struct { // Closer is an optional cleanup function for providers that hold // resources (e.g. kronk's loaded models). May be nil. Closer io.Closer + // ProviderOptions contains provider-specific options to be passed to the + // fantasy agent (e.g. OpenAI Responses API reasoning options). + ProviderOptions fantasy.ProviderOptions } // ParseModelString parses a model string in "provider/model" format (e.g. "anthropic/claude-sonnet-4-5"). @@ -297,6 +300,7 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig, var opts []openai.Option opts = append(opts, openai.WithAPIKey(apiKey)) + opts = append(opts, openai.WithUseResponsesAPI()) if config.ProviderURL != "" { opts = append(opts, openai.WithBaseURL(config.ProviderURL)) @@ -316,7 +320,9 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig, return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err) } - return &ProviderResult{Model: model}, nil + providerOpts := buildOpenAIProviderOptions(modelName) + + return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil } // resolveAPIKey returns the first non-empty API key from the explicit key @@ -347,6 +353,32 @@ func validateModelConfig(config *ProviderConfig, modelInfo *ModelInfo) { } } +// buildOpenAIProviderOptions returns fantasy.ProviderOptions configured for +// OpenAI Responses API models. For reasoning models it sets reasoning_summary +// to "auto" and includes encrypted reasoning content — matching the behaviour +// of crush's coordinator. For non-responses or non-reasoning models the +// returned map is nil (no extra options needed). +func buildOpenAIProviderOptions(modelName string) fantasy.ProviderOptions { + if !openai.IsResponsesModel(modelName) { + return nil + } + + if openai.IsResponsesReasoningModel(modelName) { + reasoningSummary := "auto" + opts := &openai.ResponsesProviderOptions{ + ReasoningSummary: &reasoningSummary, + Include: []openai.IncludeType{ + openai.IncludeReasoningEncryptedContent, + }, + } + return fantasy.ProviderOptions{ + openai.Name: opts, + } + } + + return nil +} + func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) { apiKey, source, err := auth.GetAnthropicAPIKey(config.ProviderAPIKey) if err != nil { @@ -434,6 +466,7 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName var opts []openai.Option opts = append(opts, openai.WithAPIKey(apiKey)) + opts = append(opts, openai.WithUseResponsesAPI()) if config.ProviderURL != "" { opts = append(opts, openai.WithBaseURL(config.ProviderURL)) @@ -453,7 +486,10 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName return nil, fmt.Errorf("failed to create OpenAI model: %w", err) } - return &ProviderResult{Model: model}, nil + // Build provider options for OpenAI Responses API reasoning models. + providerOpts := buildOpenAIProviderOptions(modelName) + + return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil } func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) { diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index bac63c40..86dd8af2 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -618,9 +618,10 @@ func (m *Kit) ReloadExtensions() error { // used, and closed. func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequest) (extensions.CompleteResponse, error) { var ( - llmModel fantasy.LanguageModel - closer func() - usedModel string + llmModel fantasy.LanguageModel + closer func() + usedModel string + providerOps fantasy.ProviderOptions ) if req.Model == "" { @@ -643,6 +644,7 @@ func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequ } llmModel = providerResult.Model usedModel = req.Model + providerOps = providerResult.ProviderOptions closer = func() { if providerResult.Closer != nil { _ = providerResult.Closer.Close() @@ -659,6 +661,9 @@ func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequ if req.MaxTokens > 0 { agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(req.MaxTokens))) } + if providerOps != nil { + agentOpts = append(agentOpts, fantasy.WithProviderOptions(providerOps)) + } completionAgent := fantasy.NewAgent(llmModel, agentOpts...)