feat: add responses API support

kujtimiihoxha created

Change summary

internal/agent/agent.go       |  6 ++++++
internal/agent/coordinator.go | 18 +++++++++++++++---
internal/message/content.go   | 29 ++++++++++++++++++++++++-----
3 files changed, 45 insertions(+), 8 deletions(-)

Detailed changes

internal/agent/agent.go 🔗

@@ -12,6 +12,7 @@ import (
 
 	"charm.land/fantasy"
 	"charm.land/fantasy/providers/anthropic"
+	"charm.land/fantasy/providers/openai"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/config"
@@ -241,6 +242,11 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 					currentAssistant.AppendReasoningSignature(reasoning.Signature)
 				}
 			}
+			if openaiData, ok := reasoning.ProviderMetadata[openai.Name]; ok {
+				if reasoning, ok := openaiData.(*openai.ResponsesReasoningMetadata); ok {
+					currentAssistant.SetReasoningResponsesData(reasoning)
+				}
+			}
 			currentAssistant.FinishThinking()
 			return a.messages.Update(genCtx, *currentAssistant)
 		},

internal/agent/coordinator.go 🔗

@@ -176,9 +176,20 @@ func getProviderOptions(model Model, tp catwalk.Type) fantasy.ProviderOptions {
 		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
 			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
 		}
-		parsed, err := openai.ParseOptions(mergedOptions)
-		if err == nil {
-			options[openai.Name] = parsed
+		if openai.IsResponsesModel(model.CatwalkCfg.ID) {
+			if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
+				mergedOptions["reasoning_summary"] = "auto"
+				mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
+			}
+			parsed, err := openai.ParseResponsesOptions(mergedOptions)
+			if err == nil {
+				options[openai.Name] = parsed
+			}
+		} else {
+			parsed, err := openai.ParseOptions(mergedOptions)
+			if err == nil {
+				options[openai.Name] = parsed
+			}
 		}
 	case anthropic.Name:
 		_, hasThink := mergedOptions["thinking"]
@@ -433,6 +444,7 @@ func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map
 func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) fantasy.Provider {
 	opts := []openai.Option{
 		openai.WithAPIKey(apiKey),
+		openai.WithUseResponsesAPI(),
 	}
 	if c.cfg.Options.Debug {
 		httpClient := log.NewHTTPClient()

internal/message/content.go 🔗

@@ -9,6 +9,7 @@ import (
 
 	"charm.land/fantasy"
 	"charm.land/fantasy/providers/anthropic"
+	"charm.land/fantasy/providers/openai"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 )
 
@@ -40,10 +41,11 @@ type ContentPart interface {
 }
 
 type ReasoningContent struct {
-	Thinking   string `json:"thinking"`
-	Signature  string `json:"signature"`
-	StartedAt  int64  `json:"started_at,omitempty"`
-	FinishedAt int64  `json:"finished_at,omitempty"`
+	Thinking      string                             `json:"thinking"`
+	Signature     string                             `json:"signature"`
+	ResponsesData *openai.ResponsesReasoningMetadata `json:"responses_data"`
+	StartedAt     int64                              `json:"started_at,omitempty"`
+	FinishedAt    int64                              `json:"finished_at,omitempty"`
 }
 
 func (tc ReasoningContent) String() string {
@@ -272,6 +274,20 @@ func (m *Message) AppendReasoningSignature(signature string) {
 	m.Parts = append(m.Parts, ReasoningContent{Signature: signature})
 }
 
+func (m *Message) SetReasoningResponsesData(data *openai.ResponsesReasoningMetadata) {
+	for i, part := range m.Parts {
+		if c, ok := part.(ReasoningContent); ok {
+			m.Parts[i] = ReasoningContent{
+				Thinking:      c.Thinking,
+				ResponsesData: data,
+				StartedAt:     c.StartedAt,
+				FinishedAt:    c.FinishedAt,
+			}
+			return
+		}
+	}
+}
+
 func (m *Message) FinishThinking() {
 	for i, part := range m.Parts {
 		if c, ok := part.(ReasoningContent); ok {
@@ -420,10 +436,13 @@ func (m *Message) ToAIMessage() []fantasy.Message {
 		if reasoning.Thinking != "" {
 			reasoningPart := fantasy.ReasoningPart{Text: reasoning.Thinking, ProviderOptions: fantasy.ProviderOptions{}}
 			if reasoning.Signature != "" {
-				reasoningPart.ProviderOptions["anthropic"] = &anthropic.ReasoningOptionMetadata{
+				reasoningPart.ProviderOptions[anthropic.Name] = &anthropic.ReasoningOptionMetadata{
 					Signature: reasoning.Signature,
 				}
 			}
+			if reasoning.ResponsesData != nil {
+				reasoningPart.ProviderOptions[openai.Name] = reasoning.ResponsesData
+			}
 			parts = append(parts, reasoningPart)
 		}
 		for _, call := range m.ToolCalls() {