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)
This commit is contained in:
Ed Zynda
2026-03-07 11:03:10 +03:00
parent 4577d218d3
commit 24ea2c94e3
4 changed files with 127 additions and 6 deletions
+40
View File
@@ -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.
+41 -1
View File
@@ -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 }
+38 -2
View File
@@ -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) {
+8 -3
View File
@@ -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...)