From f14dfed1b4f22d59f5fa77108e89deda7f6ce1fa Mon Sep 17 00:00:00 2001 From: ZanzyTHEbar Date: Thu, 19 Feb 2026 15:47:40 +0000 Subject: [PATCH] fix(fantasy): replace panics with error returns and harden provider logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - google: WithVertex validates project/location at New() instead of panicking; toGooglePrompt returns error for unsupported message roles - anthropic/google: emit CallWarning for skipped system messages instead of silent drops - openai responses: remove unused streamErr variable, simplify error/finish branching in streamObjectWithJSONMode — error events now return immediately without checking a stale flag - openai/openrouter: remove resolved TODO comments, fix import ordering --- .../fantasy/providers/anthropic/anthropic.go | 9 ++++-- internal/fantasy/providers/google/google.go | 28 ++++++++++++------- .../providers/openai/language_model_hooks.go | 1 - .../openai/responses_language_model.go | 18 +++++------- .../openrouter/language_model_hooks.go | 4 +-- 5 files changed, 33 insertions(+), 27 deletions(-) diff --git a/internal/fantasy/providers/anthropic/anthropic.go b/internal/fantasy/providers/anthropic/anthropic.go index 3292e167d..ca4b70bdf 100644 --- a/internal/fantasy/providers/anthropic/anthropic.go +++ b/internal/fantasy/providers/anthropic/anthropic.go @@ -7,11 +7,12 @@ import ( "encoding/base64" "errors" "fmt" - jsonv2 "github.com/go-json-experiment/json" "io" "maps" "strings" + jsonv2 "github.com/go-json-experiment/json" + "charm.land/fantasy" "charm.land/fantasy/object" "github.com/aws/aws-sdk-go-v2/config" @@ -542,8 +543,10 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl switch block.Role { case fantasy.MessageRoleSystem: if finishedSystemBlock { - // skip multiple system messages that are separated by user/assistant messages - // TODO: see if we need to send error here? + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeOther, + Message: "anthropic: additional system message after user/assistant messages was skipped", + }) continue } finishedSystemBlock = true diff --git a/internal/fantasy/providers/google/google.go b/internal/fantasy/providers/google/google.go index a7d8fe900..3d93793ca 100644 --- a/internal/fantasy/providers/google/google.go +++ b/internal/fantasy/providers/google/google.go @@ -5,12 +5,13 @@ import ( "context" "errors" "fmt" - jsonv2 "github.com/go-json-experiment/json" "maps" "net/http" "reflect" "strings" + jsonv2 "github.com/go-json-experiment/json" + "charm.land/fantasy" "charm.land/fantasy/object" "charm.land/fantasy/providers/anthropic" @@ -62,6 +63,10 @@ func New(opts ...Option) (fantasy.Provider, error) { options.name = cmp.Or(options.name, Name) + if options.backend == genai.BackendVertexAI && (options.project == "" || options.location == "") { + return nil, errors.New("google: WithVertex requires non-empty project and location") + } + return &provider{ options: options, }, nil @@ -85,10 +90,8 @@ func WithGeminiAPIKey(apiKey string) Option { } // WithVertex configures the Google provider to use Vertex AI. +// Both project and location are validated when New() is called. func WithVertex(project, location string) Option { - if project == "" || location == "" { - panic("project and location must be provided") - } return func(o *options) { o.backend = genai.BackendVertexAI o.apiKey = "" @@ -220,7 +223,10 @@ func (g languageModel) prepareParams(call fantasy.Call) (*genai.GenerateContentC } } - systemInstructions, content, warnings := toGooglePrompt(call.Prompt) + systemInstructions, content, warnings, err := toGooglePrompt(call.Prompt) + if err != nil { + return nil, nil, nil, err + } if providerOptions.ThinkingConfig != nil { if providerOptions.ThinkingConfig.IncludeThoughts != nil && @@ -324,7 +330,7 @@ func (g languageModel) prepareParams(call fantasy.Call) (*genai.GenerateContentC return config, content, warnings, nil } -func toGooglePrompt(prompt fantasy.Prompt) (*genai.Content, []*genai.Content, []fantasy.CallWarning) { //nolint: unparam +func toGooglePrompt(prompt fantasy.Prompt) (*genai.Content, []*genai.Content, []fantasy.CallWarning, error) { var systemInstructions *genai.Content var content []*genai.Content var warnings []fantasy.CallWarning @@ -334,8 +340,10 @@ func toGooglePrompt(prompt fantasy.Prompt) (*genai.Content, []*genai.Content, [] switch msg.Role { case fantasy.MessageRoleSystem: if finishedSystemBlock { - // skip multiple system messages that are separated by user/assistant messages - // TODO: see if we need to send error here? + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeOther, + Message: "google: additional system message after user/assistant messages was skipped", + }) continue } finishedSystemBlock = true @@ -514,10 +522,10 @@ func toGooglePrompt(prompt fantasy.Prompt) (*genai.Content, []*genai.Content, [] }) } default: - panic("unsupported message role: " + msg.Role) + return nil, nil, nil, fmt.Errorf("google: unsupported message role: %s", msg.Role) } } - return systemInstructions, content, warnings + return systemInstructions, content, warnings, nil } // Generate implements fantasy.LanguageModel. diff --git a/internal/fantasy/providers/openai/language_model_hooks.go b/internal/fantasy/providers/openai/language_model_hooks.go index d58c1794f..a10dce156 100644 --- a/internal/fantasy/providers/openai/language_model_hooks.go +++ b/internal/fantasy/providers/openai/language_model_hooks.go @@ -539,7 +539,6 @@ func DefaultToPrompt(prompt fantasy.Prompt, _, _ string) ([]openai.ChatCompletio } messages = append(messages, openai.ToolMessage(output.Text, toolResultPart.ToolCallID)) case fantasy.ToolResultContentTypeError: - // TODO: check if better handling is needed output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output) if !ok { warnings = append(warnings, fantasy.CallWarning{ diff --git a/internal/fantasy/providers/openai/responses_language_model.go b/internal/fantasy/providers/openai/responses_language_model.go index 20624d578..7064b2095 100644 --- a/internal/fantasy/providers/openai/responses_language_model.go +++ b/internal/fantasy/providers/openai/responses_language_model.go @@ -4,10 +4,11 @@ import ( "context" "encoding/base64" "fmt" - jsonv2 "github.com/go-json-experiment/json" "reflect" "strings" + jsonv2 "github.com/go-json-experiment/json" + "charm.land/fantasy" "charm.land/fantasy/object" "charm.land/fantasy/schema" @@ -1232,7 +1233,6 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca var lastParsedObject any var usage fantasy.Usage var finishReason fantasy.FinishReason - var streamErr error hasFunctionCall := false for stream.Next() { @@ -1299,13 +1299,10 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca case "error": errorEvent := event.AsError() - streamErr = fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code) - if !yield(fantasy.ObjectStreamPart{ + yield(fantasy.ObjectStreamPart{ Type: fantasy.ObjectStreamPartTypeError, - Error: streamErr, - }) { - return - } + Error: fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code), + }) return } } @@ -1320,14 +1317,13 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca } // Final validation and emit - if streamErr == nil && lastParsedObject != nil { + if lastParsedObject != nil { yield(fantasy.ObjectStreamPart{ Type: fantasy.ObjectStreamPartTypeFinish, Usage: usage, FinishReason: finishReason, }) - } else if streamErr == nil && lastParsedObject == nil { - // No object was generated + } else { yield(fantasy.ObjectStreamPart{ Type: fantasy.ObjectStreamPartTypeError, Error: &fantasy.NoObjectGeneratedError{ diff --git a/internal/fantasy/providers/openrouter/language_model_hooks.go b/internal/fantasy/providers/openrouter/language_model_hooks.go index 0f0f69b9f..e47c273ac 100644 --- a/internal/fantasy/providers/openrouter/language_model_hooks.go +++ b/internal/fantasy/providers/openrouter/language_model_hooks.go @@ -3,10 +3,11 @@ package openrouter import ( "encoding/base64" "fmt" - jsonv2 "github.com/go-json-experiment/json" "maps" "strings" + jsonv2 "github.com/go-json-experiment/json" + "charm.land/fantasy" "charm.land/fantasy/providers/anthropic" "charm.land/fantasy/providers/google" @@ -1010,7 +1011,6 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. } messages = append(messages, tr) case fantasy.ToolResultContentTypeError: - // TODO: check if better handling is needed output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output) if !ok { warnings = append(warnings, fantasy.CallWarning{