fix(fantasy): replace panics with error returns and harden provider logic

- 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
This commit is contained in:
ZanzyTHEbar 2026-02-19 15:47:40 +00:00
parent dc84a4b57f
commit f14dfed1b4
5 changed files with 33 additions and 27 deletions

View file

@ -7,11 +7,12 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"io" "io"
"maps" "maps"
"strings" "strings"
jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy" "charm.land/fantasy"
"charm.land/fantasy/object" "charm.land/fantasy/object"
"github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/config"
@ -542,8 +543,10 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
switch block.Role { switch block.Role {
case fantasy.MessageRoleSystem: case fantasy.MessageRoleSystem:
if finishedSystemBlock { if finishedSystemBlock {
// skip multiple system messages that are separated by user/assistant messages warnings = append(warnings, fantasy.CallWarning{
// TODO: see if we need to send error here? Type: fantasy.CallWarningTypeOther,
Message: "anthropic: additional system message after user/assistant messages was skipped",
})
continue continue
} }
finishedSystemBlock = true finishedSystemBlock = true

View file

@ -5,12 +5,13 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"maps" "maps"
"net/http" "net/http"
"reflect" "reflect"
"strings" "strings"
jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy" "charm.land/fantasy"
"charm.land/fantasy/object" "charm.land/fantasy/object"
"charm.land/fantasy/providers/anthropic" "charm.land/fantasy/providers/anthropic"
@ -62,6 +63,10 @@ func New(opts ...Option) (fantasy.Provider, error) {
options.name = cmp.Or(options.name, Name) 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{ return &provider{
options: options, options: options,
}, nil }, nil
@ -85,10 +90,8 @@ func WithGeminiAPIKey(apiKey string) Option {
} }
// WithVertex configures the Google provider to use Vertex AI. // 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 { func WithVertex(project, location string) Option {
if project == "" || location == "" {
panic("project and location must be provided")
}
return func(o *options) { return func(o *options) {
o.backend = genai.BackendVertexAI o.backend = genai.BackendVertexAI
o.apiKey = "" 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 != nil {
if providerOptions.ThinkingConfig.IncludeThoughts != nil && if providerOptions.ThinkingConfig.IncludeThoughts != nil &&
@ -324,7 +330,7 @@ func (g languageModel) prepareParams(call fantasy.Call) (*genai.GenerateContentC
return config, content, warnings, nil 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 systemInstructions *genai.Content
var content []*genai.Content var content []*genai.Content
var warnings []fantasy.CallWarning var warnings []fantasy.CallWarning
@ -334,8 +340,10 @@ func toGooglePrompt(prompt fantasy.Prompt) (*genai.Content, []*genai.Content, []
switch msg.Role { switch msg.Role {
case fantasy.MessageRoleSystem: case fantasy.MessageRoleSystem:
if finishedSystemBlock { if finishedSystemBlock {
// skip multiple system messages that are separated by user/assistant messages warnings = append(warnings, fantasy.CallWarning{
// TODO: see if we need to send error here? Type: fantasy.CallWarningTypeOther,
Message: "google: additional system message after user/assistant messages was skipped",
})
continue continue
} }
finishedSystemBlock = true finishedSystemBlock = true
@ -514,10 +522,10 @@ func toGooglePrompt(prompt fantasy.Prompt) (*genai.Content, []*genai.Content, []
}) })
} }
default: 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. // Generate implements fantasy.LanguageModel.

View file

@ -539,7 +539,6 @@ func DefaultToPrompt(prompt fantasy.Prompt, _, _ string) ([]openai.ChatCompletio
} }
messages = append(messages, openai.ToolMessage(output.Text, toolResultPart.ToolCallID)) messages = append(messages, openai.ToolMessage(output.Text, toolResultPart.ToolCallID))
case fantasy.ToolResultContentTypeError: case fantasy.ToolResultContentTypeError:
// TODO: check if better handling is needed
output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output) output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output)
if !ok { if !ok {
warnings = append(warnings, fantasy.CallWarning{ warnings = append(warnings, fantasy.CallWarning{

View file

@ -4,10 +4,11 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"reflect" "reflect"
"strings" "strings"
jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy" "charm.land/fantasy"
"charm.land/fantasy/object" "charm.land/fantasy/object"
"charm.land/fantasy/schema" "charm.land/fantasy/schema"
@ -1232,7 +1233,6 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca
var lastParsedObject any var lastParsedObject any
var usage fantasy.Usage var usage fantasy.Usage
var finishReason fantasy.FinishReason var finishReason fantasy.FinishReason
var streamErr error
hasFunctionCall := false hasFunctionCall := false
for stream.Next() { for stream.Next() {
@ -1299,13 +1299,10 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca
case "error": case "error":
errorEvent := event.AsError() errorEvent := event.AsError()
streamErr = fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code) yield(fantasy.ObjectStreamPart{
if !yield(fantasy.ObjectStreamPart{
Type: fantasy.ObjectStreamPartTypeError, Type: fantasy.ObjectStreamPartTypeError,
Error: streamErr, Error: fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code),
}) { })
return
}
return return
} }
} }
@ -1320,14 +1317,13 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca
} }
// Final validation and emit // Final validation and emit
if streamErr == nil && lastParsedObject != nil { if lastParsedObject != nil {
yield(fantasy.ObjectStreamPart{ yield(fantasy.ObjectStreamPart{
Type: fantasy.ObjectStreamPartTypeFinish, Type: fantasy.ObjectStreamPartTypeFinish,
Usage: usage, Usage: usage,
FinishReason: finishReason, FinishReason: finishReason,
}) })
} else if streamErr == nil && lastParsedObject == nil { } else {
// No object was generated
yield(fantasy.ObjectStreamPart{ yield(fantasy.ObjectStreamPart{
Type: fantasy.ObjectStreamPartTypeError, Type: fantasy.ObjectStreamPartTypeError,
Error: &fantasy.NoObjectGeneratedError{ Error: &fantasy.NoObjectGeneratedError{

View file

@ -3,10 +3,11 @@ package openrouter
import ( import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"maps" "maps"
"strings" "strings"
jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy" "charm.land/fantasy"
"charm.land/fantasy/providers/anthropic" "charm.land/fantasy/providers/anthropic"
"charm.land/fantasy/providers/google" "charm.land/fantasy/providers/google"
@ -1010,7 +1011,6 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
} }
messages = append(messages, tr) messages = append(messages, tr)
case fantasy.ToolResultContentTypeError: case fantasy.ToolResultContentTypeError:
// TODO: check if better handling is needed
output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output) output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output)
if !ok { if !ok {
warnings = append(warnings, fantasy.CallWarning{ warnings = append(warnings, fantasy.CallWarning{