// Package anthropic provides an implementation of the fantasy AI SDK for Anthropic's language models. package anthropic import ( "cmp" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "maps" "math" "slices" "strconv" "strings" "charm.land/fantasy" "charm.land/fantasy/object" "charm.land/fantasy/providers/internal/httpheaders" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/bedrock" "github.com/anthropics/anthropic-sdk-go/option" "github.com/anthropics/anthropic-sdk-go/packages/param" "github.com/anthropics/anthropic-sdk-go/vertex" "github.com/aws/aws-sdk-go-v2/config" "golang.org/x/oauth2/google" ) // betaRequestOptions converts beta flag strings into request // options that enable the corresponding Anthropic beta APIs. func betaRequestOptions(flags []string) []option.RequestOption { if len(flags) == 0 { return nil } opts := []option.RequestOption{option.WithQuery("beta", "true")} for _, flag := range flags { opts = append(opts, option.WithHeaderAdd("anthropic-beta", flag)) } return opts } func thinkingDisplay(providerOptions *ProviderOptions, modelID string) (ThinkingDisplay, bool) { if providerOptions != nil && providerOptions.ThinkingDisplay != nil && *providerOptions.ThinkingDisplay != "" { return *providerOptions.ThinkingDisplay, true } if defaultsToOmittedThinkingDisplay(modelID) { return ThinkingDisplaySummarized, true } return "", false } func defaultsToAdaptiveThinking(model string) bool { model = strings.ToLower(strings.TrimSpace(model)) return strings.Contains(model, "claude-mythos-preview") } // requiresAdaptiveThinking reports whether the model rejects a manual // budget_tokens configuration and must be sent adaptive thinking instead. func requiresAdaptiveThinking(model string) bool { return defaultsToAdaptiveThinking(model) || defaultsToOmittedOpusThinkingDisplay(model) } func setThinkingDisplay(param interface{ SetExtraFields(map[string]any) }, display ThinkingDisplay) { param.SetExtraFields(map[string]any{"display": string(display)}) } // omittedThinkingDisplayFamilies are the model families that default to // display "omitted". Matched by substring so dated snapshots and // platform-qualified ids resolve the same as the bare alias. Keep in step // with the display list in the thinking docs: // https://platform.claude.com/docs/en/build-with-claude/thinking#controlling-thinking-display var omittedThinkingDisplayFamilies = []string{ "claude-opus-5", "claude-sonnet-5", "claude-fable-5", "claude-mythos-5", } // defaultsToOmittedThinkingDisplay reports whether the model returns empty // thinking text unless a display is requested. Broader than // [requiresAdaptiveThinking] on purpose: a display is accepted alongside // either thinking type, so it is safe to list a model here. func defaultsToOmittedThinkingDisplay(model string) bool { model = strings.ToLower(strings.TrimSpace(model)) if defaultsToAdaptiveThinking(model) || defaultsToOmittedOpusThinkingDisplay(model) { return true } for _, family := range omittedThinkingDisplayFamilies { if strings.Contains(model, family) { return true } } return false } func defaultsToOmittedOpusThinkingDisplay(model string) bool { _, suffix, ok := strings.Cut(model, "claude-opus-4-") if !ok { return false } versionEnd := 0 for versionEnd < len(suffix) && suffix[versionEnd] >= '0' && suffix[versionEnd] <= '9' { versionEnd++ } if versionEnd == 0 || versionEnd > 2 { return false } minor, err := strconv.Atoi(suffix[:versionEnd]) return err == nil && minor >= 7 } // buildRequestOptions constructs the common request options shared // by Generate and Stream: user-agent, raw tool injection, and any // beta API flags. func buildRequestOptions(call fantasy.Call, rawTools []json.RawMessage, betaFlags []string) []option.RequestOption { providerOptions := &ProviderOptions{} if v, ok := call.ProviderOptions[Name]; ok { providerOptions, _ = v.(*ProviderOptions) } reqOpts := callUARequestOptions(call) reqOpts = append(reqOpts, callHeadersRequestOptions(call)...) if len(rawTools) > 0 { // Tools are injected as raw JSON rather than via params.Tools // because the SDK doesn't model beta tool types (e.g. computer // use). If the SDK adds validation that reads params.Tools, // this will need updating. reqOpts = append(reqOpts, option.WithJSONSet("tools", rawTools)) } for k, v := range providerOptions.ExtraBody { reqOpts = append(reqOpts, option.WithJSONSet(k, v)) } if len(betaFlags) > 0 { reqOpts = append(reqOpts, betaRequestOptions(betaFlags)...) } return reqOpts } const ( // Name is the name of the Anthropic provider. Name = "anthropic" // DefaultURL is the default URL for the Anthropic API. DefaultURL = "https://api.anthropic.com" // VertexAuthScope is the auth scope required for vertex auth if using a Service Account JSON file (e.g. GOOGLE_APPLICATION_CREDENTIALS). VertexAuthScope = "https://www.googleapis.com/auth/cloud-platform" ) type options struct { baseURL string apiKey string name string headers map[string]string userAgent string client option.HTTPClient vertexProject string vertexLocation string skipAuth bool useBedrock bool bedrockRegion string objectMode fantasy.ObjectMode } type provider struct { options options } // Option defines a function that configures Anthropic provider options. type Option = func(*options) // New creates a new Anthropic provider with the given options. func New(opts ...Option) (fantasy.Provider, error) { providerOptions := options{ headers: map[string]string{}, objectMode: fantasy.ObjectModeAuto, } for _, o := range opts { o(&providerOptions) } if !providerOptions.useBedrock { providerOptions.baseURL = cmp.Or(providerOptions.baseURL, DefaultURL) } providerOptions.name = cmp.Or(providerOptions.name, Name) return &provider{options: providerOptions}, nil } // WithBaseURL sets the base URL for the Anthropic provider. func WithBaseURL(baseURL string) Option { return func(o *options) { o.baseURL = baseURL } } // WithAPIKey sets the API key for the Anthropic provider. func WithAPIKey(apiKey string) Option { return func(o *options) { o.apiKey = apiKey } } // WithVertex configures the Anthropic provider to use Vertex AI. func WithVertex(project, location string) Option { return func(o *options) { o.vertexProject = project o.vertexLocation = location } } // WithSkipAuth configures whether to skip authentication for the Anthropic provider. func WithSkipAuth(skip bool) Option { return func(o *options) { o.skipAuth = skip } } // WithBedrock configures the Anthropic provider to use AWS Bedrock. func WithBedrock() Option { return func(o *options) { o.useBedrock = true } } // WithBedrockRegion sets the AWS region for the Bedrock provider. func WithBedrockRegion(region string) Option { return func(o *options) { o.bedrockRegion = region } } // WithName sets the name for the Anthropic provider. func WithName(name string) Option { return func(o *options) { o.name = name } } // WithHeaders sets the headers for the Anthropic provider. func WithHeaders(headers map[string]string) Option { return func(o *options) { maps.Copy(o.headers, headers) } } // WithHTTPClient sets the HTTP client for the Anthropic provider. func WithHTTPClient(client option.HTTPClient) Option { return func(o *options) { o.client = client } } // WithUserAgent sets an explicit User-Agent header, overriding the default and any // value set via WithHeaders. func WithUserAgent(ua string) Option { return func(o *options) { o.userAgent = ua } } // WithObjectMode sets the object generation mode. func WithObjectMode(om fantasy.ObjectMode) Option { return func(o *options) { // not supported if om == fantasy.ObjectModeJSON { om = fantasy.ObjectModeAuto } o.objectMode = om } } func (a *provider) LanguageModel(ctx context.Context, modelID string) (fantasy.LanguageModel, error) { clientOptions := make([]option.RequestOption, 0, 5+len(a.options.headers)) clientOptions = append(clientOptions, option.WithMaxRetries(0)) if a.options.apiKey != "" && !a.options.useBedrock { clientOptions = append(clientOptions, option.WithAPIKey(a.options.apiKey)) } if !a.options.useBedrock && a.options.baseURL != "" { clientOptions = append(clientOptions, option.WithBaseURL(a.options.baseURL)) } defaultUA := httpheaders.DefaultUserAgent(fantasy.Version) resolved := httpheaders.ResolveHeaders(a.options.headers, a.options.userAgent, defaultUA) for key, value := range resolved { clientOptions = append(clientOptions, option.WithHeader(key, value)) } if a.options.client != nil { clientOptions = append(clientOptions, option.WithHTTPClient(a.options.client)) } if a.options.vertexProject != "" && a.options.vertexLocation != "" { var credentials *google.Credentials if a.options.skipAuth { credentials = &google.Credentials{TokenSource: &googleDummyTokenSource{}} } else { var err error credentials, err = google.FindDefaultCredentials(ctx, VertexAuthScope) if err != nil { return nil, err } } clientOptions = append( clientOptions, vertex.WithCredentials( ctx, a.options.vertexLocation, a.options.vertexProject, credentials, ), ) } if a.options.useBedrock { if a.options.skipAuth || a.options.apiKey != "" { clientOptions = append( clientOptions, bedrock.WithConfig(bedrockBasicAuthConfig(a.options.apiKey, a.options.bedrockRegion)), ) } else { if cfg, err := config.LoadDefaultConfig(ctx); err == nil { // The upstream Anthropic SDK prioritizes a BearerAuthTokenProvider // over SigV4 credentials. When using AWS SSO, the default config // populates both, causing the SSO bearer token to be sent to // Bedrock, which rejects it ("Invalid API Key format"). Clear // the provider so the SDK falls back to SigV4 signing. // AWS_BEARER_TOKEN_BEDROCK is still honored by bedrock.WithConfig // when the provider is nil. cfg.BearerAuthTokenProvider = nil cfg.Region = cmp.Or(a.options.bedrockRegion, cfg.Region) clientOptions = append( clientOptions, bedrock.WithConfig(cfg), ) } } if a.options.baseURL != "" { clientOptions = append(clientOptions, option.WithBaseURL(a.options.baseURL)) } } return languageModel{ modelID: modelID, provider: a.options.name, options: a.options, client: anthropic.NewClient(clientOptions...), }, nil } type languageModel struct { provider string modelID string client anthropic.Client options options } // Model implements fantasy.LanguageModel. func (a languageModel) Model() string { return a.modelID } // Provider implements fantasy.LanguageModel. func (a languageModel) Provider() string { return a.provider } func (a languageModel) prepareParams(call fantasy.Call) ( params *anthropic.MessageNewParams, rawTools []json.RawMessage, warnings []fantasy.CallWarning, betaFlags []string, err error, ) { params = &anthropic.MessageNewParams{} providerOptions := &ProviderOptions{} if v, ok := call.ProviderOptions[Name]; ok { providerOptions, ok = v.(*ProviderOptions) if !ok { return nil, nil, nil, nil, &fantasy.Error{Title: "invalid argument", Message: "anthropic provider options should be *anthropic.ProviderOptions"} } } sendReasoning := true if providerOptions.SendReasoning != nil { sendReasoning = *providerOptions.SendReasoning } systemBlocks, messages, warnings := toPrompt(call.Prompt, sendReasoning) if call.FrequencyPenalty != nil { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeUnsupportedSetting, Setting: "FrequencyPenalty", }) } if call.PresencePenalty != nil { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeUnsupportedSetting, Setting: "PresencePenalty", }) } params.System = systemBlocks params.Messages = messages params.Model = a.modelID params.MaxTokens = 4096 if call.MaxOutputTokens != nil { params.MaxTokens = *call.MaxOutputTokens } if call.Temperature != nil { params.Temperature = param.NewOpt(*call.Temperature) } if call.TopK != nil { params.TopK = param.NewOpt(*call.TopK) } if call.TopP != nil { params.TopP = param.NewOpt(*call.TopP) } switch { case providerOptions.Effort != nil: effort := *providerOptions.Effort params.OutputConfig = anthropic.OutputConfigParam{ Effort: anthropic.OutputConfigEffort(effort), } adaptive := anthropic.ThinkingConfigAdaptiveParam{} if display, ok := thinkingDisplay(providerOptions, a.modelID); ok { setThinkingDisplay(&adaptive, display) } params.Thinking.OfAdaptive = &adaptive case providerOptions.Thinking != nil: if providerOptions.Thinking.BudgetTokens == 0 { return nil, nil, nil, nil, &fantasy.Error{Title: "no budget", Message: "thinking requires budget"} } if requiresAdaptiveThinking(a.modelID) { adaptive := anthropic.ThinkingConfigAdaptiveParam{} if display, ok := thinkingDisplay(providerOptions, a.modelID); ok { setThinkingDisplay(&adaptive, display) } params.Thinking.OfAdaptive = &adaptive } else { params.Thinking = anthropic.ThinkingConfigParamOfEnabled(providerOptions.Thinking.BudgetTokens) if display, ok := thinkingDisplay(providerOptions, a.modelID); ok { setThinkingDisplay(params.Thinking.OfEnabled, display) } } if call.Temperature != nil { params.Temperature = param.Opt[float64]{} warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeUnsupportedSetting, Setting: "temperature", Details: "temperature is not supported when thinking is enabled", }) } if call.TopP != nil { params.TopP = param.Opt[float64]{} warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeUnsupportedSetting, Setting: "TopP", Details: "TopP is not supported when thinking is enabled", }) } if call.TopK != nil { params.TopK = param.Opt[int64]{} warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeUnsupportedSetting, Setting: "TopK", Details: "TopK is not supported when thinking is enabled", }) } case defaultsToAdaptiveThinking(a.modelID): adaptive := anthropic.ThinkingConfigAdaptiveParam{} if display, ok := thinkingDisplay(providerOptions, a.modelID); ok { setThinkingDisplay(&adaptive, display) } params.Thinking.OfAdaptive = &adaptive } if len(call.Tools) > 0 { disableParallelToolUse := false if providerOptions.DisableParallelToolUse != nil { disableParallelToolUse = *providerOptions.DisableParallelToolUse } var toolChoice *anthropic.ToolChoiceUnionParam var toolWarnings []fantasy.CallWarning rawTools, toolChoice, toolWarnings, betaFlags = a.toTools(call.Tools, call.ToolChoice, disableParallelToolUse) if toolChoice != nil { params.ToolChoice = *toolChoice } warnings = append(warnings, toolWarnings...) } return params, rawTools, warnings, betaFlags, nil } func (a *provider) Name() string { return Name } // GetCacheControl extracts cache control settings from provider options. func GetCacheControl(providerOptions fantasy.ProviderOptions) *CacheControl { if anthropicOptions, ok := providerOptions[Name]; ok { if options, ok := anthropicOptions.(*ProviderCacheControlOptions); ok { return &options.CacheControl } } return nil } // GetReasoningMetadata extracts reasoning metadata from provider options. func GetReasoningMetadata(providerOptions fantasy.ProviderOptions) *ReasoningOptionMetadata { if anthropicOptions, ok := providerOptions[Name]; ok { if reasoning, ok := anthropicOptions.(*ReasoningOptionMetadata); ok { return reasoning } } return nil } func reasoningProviderMetadata(signature, redactedData string) fantasy.ProviderMetadata { switch { case signature != "": return fantasy.ProviderMetadata{ Name: &ReasoningOptionMetadata{Signature: signature}, } case redactedData != "": return fantasy.ProviderMetadata{ Name: &ReasoningOptionMetadata{RedactedData: redactedData}, } default: return nil } } type messageBlock struct { Role fantasy.MessageRole Messages []fantasy.Message } func groupIntoBlocks(prompt fantasy.Prompt) []*messageBlock { var blocks []*messageBlock var currentBlock *messageBlock for _, msg := range prompt { switch msg.Role { case fantasy.MessageRoleSystem: if currentBlock == nil || currentBlock.Role != fantasy.MessageRoleSystem { currentBlock = &messageBlock{ Role: fantasy.MessageRoleSystem, Messages: []fantasy.Message{}, } blocks = append(blocks, currentBlock) } currentBlock.Messages = append(currentBlock.Messages, msg) case fantasy.MessageRoleUser: if currentBlock == nil || currentBlock.Role != fantasy.MessageRoleUser { currentBlock = &messageBlock{ Role: fantasy.MessageRoleUser, Messages: []fantasy.Message{}, } blocks = append(blocks, currentBlock) } currentBlock.Messages = append(currentBlock.Messages, msg) case fantasy.MessageRoleAssistant: if currentBlock == nil || currentBlock.Role != fantasy.MessageRoleAssistant { currentBlock = &messageBlock{ Role: fantasy.MessageRoleAssistant, Messages: []fantasy.Message{}, } blocks = append(blocks, currentBlock) } currentBlock.Messages = append(currentBlock.Messages, msg) case fantasy.MessageRoleTool: if currentBlock == nil || currentBlock.Role != fantasy.MessageRoleUser { currentBlock = &messageBlock{ Role: fantasy.MessageRoleUser, Messages: []fantasy.Message{}, } blocks = append(blocks, currentBlock) } currentBlock.Messages = append(currentBlock.Messages, msg) } } return blocks } func anyToStringSlice(v any) []string { switch typed := v.(type) { case []string: if len(typed) == 0 { return nil } out := make([]string, len(typed)) copy(out, typed) return out case []any: if len(typed) == 0 { return nil } out := make([]string, 0, len(typed)) for _, item := range typed { s, ok := item.(string) if !ok || s == "" { continue } out = append(out, s) } if len(out) == 0 { return nil } return out default: return nil } } const maxExactIntFloat64 = float64(1<<53 - 1) // asProviderDefinedTool extracts the ProviderDefinedTool from a // Tool, handling both ProviderDefinedTool and // ExecutableProviderTool. func asProviderDefinedTool(tool fantasy.Tool) (fantasy.ProviderDefinedTool, bool) { if pdt, ok := tool.(fantasy.ProviderDefinedTool); ok { return pdt, true } if ept, ok := tool.(fantasy.ExecutableProviderTool); ok { return ept.Definition(), true } return fantasy.ProviderDefinedTool{}, false } func anyToInt64(v any) (int64, bool) { switch typed := v.(type) { case int: return int64(typed), true case int8: return int64(typed), true case int16: return int64(typed), true case int32: return int64(typed), true case int64: return typed, true case uint: u64 := uint64(typed) if u64 > math.MaxInt64 { return 0, false } return int64(u64), true case uint8: return int64(typed), true case uint16: return int64(typed), true case uint32: return int64(typed), true case uint64: if typed > math.MaxInt64 { return 0, false } return int64(typed), true case float32: f := float64(typed) if math.Trunc(f) != f || math.IsNaN(f) || math.IsInf(f, 0) || f < -maxExactIntFloat64 || f > maxExactIntFloat64 { return 0, false } return int64(f), true case float64: if math.Trunc(typed) != typed || math.IsNaN(typed) || math.IsInf(typed, 0) || typed < -maxExactIntFloat64 || typed > maxExactIntFloat64 { return 0, false } return int64(typed), true case json.Number: parsed, err := typed.Int64() if err != nil { return 0, false } return parsed, true default: return 0, false } } func anyToUserLocation(v any) *UserLocation { switch typed := v.(type) { case *UserLocation: return typed case UserLocation: loc := typed return &loc case map[string]any: loc := &UserLocation{} if city, ok := typed["city"].(string); ok { loc.City = city } if region, ok := typed["region"].(string); ok { loc.Region = region } if country, ok := typed["country"].(string); ok { loc.Country = country } if timezone, ok := typed["timezone"].(string); ok { loc.Timezone = timezone } if loc.City == "" && loc.Region == "" && loc.Country == "" && loc.Timezone == "" { return nil } return loc default: return nil } } func (a languageModel) toTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice, disableParallelToolCalls bool) (rawTools []json.RawMessage, anthropicToolChoice *anthropic.ToolChoiceUnionParam, warnings []fantasy.CallWarning, betaFlags []string) { for _, tool := range tools { if tool.GetType() == fantasy.ToolTypeFunction { ft, ok := tool.(fantasy.FunctionTool) if !ok { continue } required := []string{} var properties any if props, ok := ft.InputSchema["properties"]; ok { properties = props } if req, ok := ft.InputSchema["required"]; ok { if reqArr, ok := req.([]string); ok { required = reqArr } } cacheControl := GetCacheControl(ft.ProviderOptions) anthropicTool := anthropic.ToolParam{ Name: ft.Name, Description: anthropic.String(ft.Description), InputSchema: anthropic.ToolInputSchemaParam{ Properties: properties, Required: required, }, } if cacheControl != nil { anthropicTool.CacheControl = anthropic.NewCacheControlEphemeralParam() } raw, err := json.Marshal(anthropic.ToolUnionParam{OfTool: &anthropicTool}) if err != nil { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Tool: tool, Message: fmt.Sprintf("failed to marshal function tool: %v", err), }) continue } rawTools = append(rawTools, raw) continue } if tool.GetType() == fantasy.ToolTypeProviderDefined { pt, ok := asProviderDefinedTool(tool) if !ok { continue } switch pt.ID { case "web_search": webSearchTool := anthropic.WebSearchTool20250305Param{} if pt.Args != nil { if domains := anyToStringSlice(pt.Args["allowed_domains"]); len(domains) > 0 { webSearchTool.AllowedDomains = domains } if domains := anyToStringSlice(pt.Args["blocked_domains"]); len(domains) > 0 { webSearchTool.BlockedDomains = domains } if maxUses, ok := anyToInt64(pt.Args["max_uses"]); ok && maxUses > 0 { webSearchTool.MaxUses = param.NewOpt(maxUses) } if loc := anyToUserLocation(pt.Args["user_location"]); loc != nil { var ulp anthropic.UserLocationParam if loc.City != "" { ulp.City = param.NewOpt(loc.City) } if loc.Region != "" { ulp.Region = param.NewOpt(loc.Region) } if loc.Country != "" { ulp.Country = param.NewOpt(loc.Country) } if loc.Timezone != "" { ulp.Timezone = param.NewOpt(loc.Timezone) } webSearchTool.UserLocation = ulp } } raw, err := json.Marshal(anthropic.ToolUnionParam{ OfWebSearchTool20250305: &webSearchTool, }) if err != nil { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Tool: tool, Message: fmt.Sprintf("failed to marshal web search tool: %v", err), }) continue } rawTools = append(rawTools, raw) continue } if IsComputerUseTool(tool) { raw, err := computerUseToolJSON(pt) if err != nil { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Tool: tool, Message: fmt.Sprintf("failed to build computer use tool: %v", err), }) continue } version, ok := getComputerUseVersion(pt) if ok { flag, err := computerUseBetaFlag(version) if err != nil { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Tool: tool, Message: fmt.Sprintf("unsupported computer use version: %v", err), }) continue } betaFlags = append(betaFlags, flag) } rawTools = append(rawTools, raw) continue } warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeUnsupportedTool, Tool: tool, Message: "tool is not supported", }) continue } warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeUnsupportedTool, Tool: tool, Message: "tool is not supported", }) } // NOTE: Bedrock does not support this attribute. var disableParallelToolUse param.Opt[bool] if !a.options.useBedrock { disableParallelToolUse = param.NewOpt(disableParallelToolCalls) } if toolChoice == nil { if disableParallelToolCalls { anthropicToolChoice = &anthropic.ToolChoiceUnionParam{ OfAuto: &anthropic.ToolChoiceAutoParam{ Type: "auto", DisableParallelToolUse: disableParallelToolUse, }, } } return rawTools, anthropicToolChoice, warnings, betaFlags } switch *toolChoice { case fantasy.ToolChoiceAuto: anthropicToolChoice = &anthropic.ToolChoiceUnionParam{ OfAuto: &anthropic.ToolChoiceAutoParam{ Type: "auto", DisableParallelToolUse: disableParallelToolUse, }, } case fantasy.ToolChoiceRequired: anthropicToolChoice = &anthropic.ToolChoiceUnionParam{ OfAny: &anthropic.ToolChoiceAnyParam{ Type: "any", DisableParallelToolUse: disableParallelToolUse, }, } case fantasy.ToolChoiceNone: none := anthropic.NewToolChoiceNoneParam() anthropicToolChoice = &anthropic.ToolChoiceUnionParam{ OfNone: &none, } default: anthropicToolChoice = &anthropic.ToolChoiceUnionParam{ OfTool: &anthropic.ToolChoiceToolParam{ Type: "tool", Name: string(*toolChoice), DisableParallelToolUse: disableParallelToolUse, }, } } return rawTools, anthropicToolChoice, warnings, betaFlags } func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBlockParam, []anthropic.MessageParam, []fantasy.CallWarning) { var systemBlocks []anthropic.TextBlockParam var messages []anthropic.MessageParam var warnings []fantasy.CallWarning blocks := groupIntoBlocks(prompt) finishedSystemBlock := false for _, block := range blocks { 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? continue } finishedSystemBlock = true for _, msg := range block.Messages { for i, part := range msg.Content { isLastPart := i == len(msg.Content)-1 cacheControl := GetCacheControl(part.Options()) if cacheControl == nil && isLastPart { cacheControl = GetCacheControl(msg.ProviderOptions) } text, ok := fantasy.AsMessagePart[fantasy.TextPart](part) if !ok { continue } textBlock := anthropic.TextBlockParam{ Text: text.Text, } if cacheControl != nil { textBlock.CacheControl = anthropic.NewCacheControlEphemeralParam() } systemBlocks = append(systemBlocks, textBlock) } } case fantasy.MessageRoleUser: var anthropicContent []anthropic.ContentBlockParamUnion for _, msg := range block.Messages { if msg.Role == fantasy.MessageRoleUser { for i, part := range msg.Content { isLastPart := i == len(msg.Content)-1 cacheControl := GetCacheControl(part.Options()) if cacheControl == nil && isLastPart { cacheControl = GetCacheControl(msg.ProviderOptions) } switch part.GetType() { case fantasy.ContentTypeText: text, ok := fantasy.AsMessagePart[fantasy.TextPart](part) if !ok { continue } textBlock := &anthropic.TextBlockParam{ Text: text.Text, } if cacheControl != nil { textBlock.CacheControl = anthropic.NewCacheControlEphemeralParam() } anthropicContent = append(anthropicContent, anthropic.ContentBlockParamUnion{ OfText: textBlock, }) case fantasy.ContentTypeSource: // Source content from web search results is not a // recognized Anthropic content block type; skip it. continue case fantasy.ContentTypeFile: file, ok := fantasy.AsMessagePart[fantasy.FilePart](part) if !ok { continue } switch { case strings.HasPrefix(file.MediaType, "image/"): base64Encoded := base64.StdEncoding.EncodeToString(file.Data) imageBlock := anthropic.NewImageBlockBase64(file.MediaType, base64Encoded) if cacheControl != nil { imageBlock.OfImage.CacheControl = anthropic.NewCacheControlEphemeralParam() } anthropicContent = append(anthropicContent, imageBlock) case file.MediaType == "application/pdf": base64Encoded := base64.StdEncoding.EncodeToString(file.Data) docBlock := anthropic.NewDocumentBlock(anthropic.Base64PDFSourceParam{ Data: base64Encoded, }) docBlock.OfDocument.Title = anthropic.String(sanitizeAnthropicDocumentTitle(file.Filename)) if cacheControl != nil { docBlock.OfDocument.CacheControl = anthropic.NewCacheControlEphemeralParam() } anthropicContent = append(anthropicContent, docBlock) case strings.HasPrefix(file.MediaType, "text/"): documentBlock := anthropic.NewDocumentBlock(anthropic.PlainTextSourceParam{ Data: string(file.Data), }) documentBlock.OfDocument.Title = anthropic.String(sanitizeAnthropicDocumentTitle(file.Filename)) if cacheControl != nil { documentBlock.OfDocument.CacheControl = anthropic.NewCacheControlEphemeralParam() } anthropicContent = append(anthropicContent, documentBlock) default: warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Message: fmt.Sprintf("file part media type %s not supported", file.MediaType), }) } } } } else if msg.Role == fantasy.MessageRoleTool { for i, part := range msg.Content { isLastPart := i == len(msg.Content)-1 cacheControl := GetCacheControl(part.Options()) if cacheControl == nil && isLastPart { cacheControl = GetCacheControl(msg.ProviderOptions) } result, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part) if !ok { continue } toolResultBlock := anthropic.ToolResultBlockParam{ ToolUseID: result.ToolCallID, } switch result.Output.GetType() { case fantasy.ToolResultContentTypeText: content, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result.Output) if !ok { continue } toolResultBlock.Content = []anthropic.ToolResultBlockParamContentUnion{ { OfText: &anthropic.TextBlockParam{ Text: content.Text, }, }, } case fantasy.ToolResultContentTypeMedia: content, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](result.Output) if !ok { continue } contentBlocks := []anthropic.ToolResultBlockParamContentUnion{ { OfImage: anthropic.NewImageBlockBase64(content.MediaType, content.Data).OfImage, }, } if content.Text != "" { contentBlocks = append(contentBlocks, anthropic.ToolResultBlockParamContentUnion{ OfText: &anthropic.TextBlockParam{ Text: content.Text, }, }) } toolResultBlock.Content = contentBlocks case fantasy.ToolResultContentTypeError: content, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result.Output) if !ok { continue } toolResultBlock.Content = []anthropic.ToolResultBlockParamContentUnion{ { OfText: &anthropic.TextBlockParam{ Text: content.Error.Error(), }, }, } toolResultBlock.IsError = param.NewOpt(true) } if cacheControl != nil { toolResultBlock.CacheControl = anthropic.NewCacheControlEphemeralParam() } anthropicContent = append(anthropicContent, anthropic.ContentBlockParamUnion{ OfToolResult: &toolResultBlock, }) } } } if !hasVisibleUserContent(anthropicContent) { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Message: "dropping empty user message (contains neither user-facing content nor tool results)", }) continue } messages = append(messages, anthropic.NewUserMessage(anthropicContent...)) case fantasy.MessageRoleAssistant: var anthropicContent []anthropic.ContentBlockParamUnion for _, msg := range block.Messages { for i, part := range msg.Content { isLastPart := i == len(msg.Content)-1 cacheControl := GetCacheControl(part.Options()) if cacheControl == nil && isLastPart { cacheControl = GetCacheControl(msg.ProviderOptions) } switch part.GetType() { case fantasy.ContentTypeText: text, ok := fantasy.AsMessagePart[fantasy.TextPart](part) if !ok { continue } textBlock := &anthropic.TextBlockParam{ Text: text.Text, } if cacheControl != nil { textBlock.CacheControl = anthropic.NewCacheControlEphemeralParam() } anthropicContent = append(anthropicContent, anthropic.ContentBlockParamUnion{ OfText: textBlock, }) case fantasy.ContentTypeReasoning: reasoning, ok := fantasy.AsMessagePart[fantasy.ReasoningPart](part) if !ok { continue } if !sendReasoningData { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Message: "sending reasoning content is disabled for this model", }) continue } reasoningMetadata := GetReasoningMetadata(part.Options()) if reasoningMetadata == nil { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Message: "unsupported reasoning metadata", }) continue } if reasoningMetadata.Signature != "" { anthropicContent = append(anthropicContent, anthropic.NewThinkingBlock(reasoningMetadata.Signature, reasoning.Text)) } else if reasoningMetadata.RedactedData != "" { anthropicContent = append(anthropicContent, anthropic.NewRedactedThinkingBlock(reasoningMetadata.RedactedData)) } else { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Message: "unsupported reasoning metadata", }) continue } case fantasy.ContentTypeToolCall: toolCall, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](part) if !ok { continue } if toolCall.ProviderExecuted { // Reconstruct server_tool_use block for // multi-turn round-tripping. inputAny, warning := decodeToolCallInputAny(toolCall) if warning != nil { warnings = append(warnings, *warning) } anthropicContent = append(anthropicContent, anthropic.ContentBlockParamUnion{ OfServerToolUse: &anthropic.ServerToolUseBlockParam{ ID: toolCall.ToolCallID, Name: anthropic.ServerToolUseBlockParamName(toolCall.ToolName), Input: inputAny, }, }) continue } inputMap, warning := decodeToolCallInputMap(toolCall) if warning != nil { warnings = append(warnings, *warning) } toolUseBlock := anthropic.NewToolUseBlock(toolCall.ToolCallID, inputMap, toolCall.ToolName) if cacheControl != nil { toolUseBlock.OfToolUse.CacheControl = anthropic.NewCacheControlEphemeralParam() } anthropicContent = append(anthropicContent, toolUseBlock) case fantasy.ContentTypeToolResult: result, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part) if !ok { continue } if result.ProviderExecuted { // Reconstruct web_search_tool_result blocks, // including encrypted content and errors, for // round-tripping. searchMeta := &WebSearchResultMetadata{} if webMeta, ok := result.ProviderOptions[Name]; ok { if typed, ok := webMeta.(*WebSearchResultMetadata); ok { searchMeta = typed } } anthropicContent = append(anthropicContent, buildWebSearchToolResultBlock(result.ToolCallID, searchMeta)) continue } case fantasy.ContentTypeSource: // Source content from web search results is not a // recognized Anthropic content block type; skip it. continue } } } if !hasVisibleAssistantContent(anthropicContent) { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Message: "dropping empty assistant message (contains neither user-facing content nor tool calls)", }) continue } messages = append(messages, anthropic.NewAssistantMessage(anthropicContent...)) } } return systemBlocks, messages, warnings } func hasVisibleUserContent(content []anthropic.ContentBlockParamUnion) bool { for _, block := range content { if block.OfText != nil || block.OfImage != nil || block.OfDocument != nil || block.OfToolResult != nil { return true } } return false } func hasVisibleAssistantContent(content []anthropic.ContentBlockParamUnion) bool { for _, block := range content { if block.OfText != nil || block.OfToolUse != nil || block.OfServerToolUse != nil || block.OfWebSearchToolResult != nil { return true } } return false } // decodeToolCallInputMap unmarshals a ToolCallPart.Input into a map for // reconstructing an Anthropic tool_use block. The Anthropic API rejects any // request whose tool_result lacks a matching tool_use in the previous // message, so this helper never drops the block: empty input becomes {}, // and malformed input falls back to {} with a CallWarning. The caller still // emits a tool_use block with the original ToolCallID, preserving the pair. func decodeToolCallInputMap(toolCall fantasy.ToolCallPart) (map[string]any, *fantasy.CallWarning) { if strings.TrimSpace(toolCall.Input) == "" { return map[string]any{}, nil } var inputMap map[string]any if err := json.Unmarshal([]byte(toolCall.Input), &inputMap); err != nil { return map[string]any{}, &fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Message: fmt.Sprintf( "tool call %q has malformed input JSON; emitting empty arguments to preserve tool_use ↔ tool_result pairing: %s", toolCall.ToolCallID, err, ), } } if inputMap == nil { return map[string]any{}, nil } return inputMap, nil } // decodeToolCallInputAny is the server_tool_use counterpart to // decodeToolCallInputMap. ServerToolUseBlockParam.Input has type `any` so // nil is acceptable for the empty case. func decodeToolCallInputAny(toolCall fantasy.ToolCallPart) (any, *fantasy.CallWarning) { if strings.TrimSpace(toolCall.Input) == "" { return nil, nil } var inputAny any if err := json.Unmarshal([]byte(toolCall.Input), &inputAny); err != nil { return nil, &fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Message: fmt.Sprintf( "server tool call %q has malformed input JSON; emitting empty arguments to preserve tool_use ↔ tool_result pairing: %s", toolCall.ToolCallID, err, ), } } return inputAny, nil } // buildWebSearchToolResultBlock constructs an Anthropic // web_search_tool_result content block from structured metadata. func buildWebSearchToolResultBlock(toolCallID string, searchMeta *WebSearchResultMetadata) anthropic.ContentBlockParamUnion { var content anthropic.WebSearchToolResultBlockParamContentUnion switch { case searchMeta != nil && len(searchMeta.Results) > 0: resultBlocks := make([]anthropic.WebSearchResultBlockParam, 0, len(searchMeta.Results)) for _, r := range searchMeta.Results { block := anthropic.WebSearchResultBlockParam{ URL: r.URL, Title: r.Title, EncryptedContent: r.EncryptedContent, } if r.PageAge != "" { block.PageAge = param.NewOpt(r.PageAge) } resultBlocks = append(resultBlocks, block) } content = anthropic.WebSearchToolResultBlockParamContentUnion{ OfWebSearchToolResultBlockItem: resultBlocks, } case searchMeta != nil && searchMeta.ErrorCode != "": content = anthropic.NewWebSearchToolRequestError( anthropic.WebSearchToolResultErrorCode(searchMeta.ErrorCode), ) default: content = anthropic.WebSearchToolResultBlockParamContentUnion{ OfWebSearchToolResultBlockItem: []anthropic.WebSearchResultBlockParam{}, } } return anthropic.ContentBlockParamUnion{ OfWebSearchToolResult: &anthropic.WebSearchToolResultBlockParam{ ToolUseID: toolCallID, Content: content, }, } } func mapFinishReason(finishReason string) fantasy.FinishReason { switch finishReason { case "end_turn", "pause_turn", "stop_sequence": return fantasy.FinishReasonStop case "max_tokens", "model_context_window_exceeded": return fantasy.FinishReasonLength case "tool_use": return fantasy.FinishReasonToolCalls case "refusal", "content_filtered", "guardrail_intervened": // "refusal" is the native Anthropic safety stop. Bedrock // reports guardrail / content-filter blocks with its own // stop reasons instead, so map those here too. return fantasy.FinishReasonContentFilter default: return fantasy.FinishReasonUnknown } } // Generate implements fantasy.LanguageModel. func (a languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) { params, rawTools, warnings, betaFlags, err := a.prepareParams(call) if err != nil { return nil, err } reqOpts := buildRequestOptions(call, rawTools, betaFlags) response, err := a.client.Messages.New(ctx, *params, reqOpts...) if err != nil { return nil, toProviderErr(err) } if response == nil { return nil, &fantasy.Error{Title: "no response", Message: "provider returned nil response"} } var content []fantasy.Content for _, block := range response.Content { switch block.Type { case "text": text, ok := block.AsAny().(anthropic.TextBlock) if !ok { continue } content = append(content, fantasy.TextContent{ Text: text.Text, }) case "thinking": reasoning, ok := block.AsAny().(anthropic.ThinkingBlock) if !ok { continue } content = append(content, fantasy.ReasoningContent{ Text: reasoning.Thinking, ProviderMetadata: fantasy.ProviderMetadata{ Name: &ReasoningOptionMetadata{ Signature: reasoning.Signature, }, }, }) case "redacted_thinking": reasoning, ok := block.AsAny().(anthropic.RedactedThinkingBlock) if !ok { continue } content = append(content, fantasy.ReasoningContent{ Text: "", ProviderMetadata: fantasy.ProviderMetadata{ Name: &ReasoningOptionMetadata{ RedactedData: reasoning.Data, }, }, }) case "tool_use": toolUse, ok := block.AsAny().(anthropic.ToolUseBlock) if !ok { continue } content = append(content, fantasy.ToolCallContent{ ToolCallID: toolUse.ID, ToolName: toolUse.Name, Input: string(toolUse.Input), ProviderExecuted: false, }) case "server_tool_use": serverToolUse, ok := block.AsAny().(anthropic.ServerToolUseBlock) if !ok { continue } var inputStr string if b, err := json.Marshal(serverToolUse.Input); err == nil { inputStr = string(b) } content = append(content, fantasy.ToolCallContent{ ToolCallID: serverToolUse.ID, ToolName: string(serverToolUse.Name), Input: inputStr, ProviderExecuted: true, }) case "web_search_tool_result": webSearchResult, ok := block.AsAny().(anthropic.WebSearchToolResultBlock) if !ok { continue } // Extract search results as sources/citations, preserving // encrypted_content for multi-turn round-tripping. toolResult := fantasy.ToolResultContent{ ToolCallID: webSearchResult.ToolUseID, ToolName: "web_search", ProviderExecuted: true, } if items := webSearchResult.Content.OfWebSearchResultBlockArray; len(items) > 0 { var metadataResults []WebSearchResultItem for _, item := range items { content = append(content, fantasy.SourceContent{ SourceType: fantasy.SourceTypeURL, ID: item.URL, URL: item.URL, Title: item.Title, }) metadataResults = append(metadataResults, WebSearchResultItem{ URL: item.URL, Title: item.Title, EncryptedContent: item.EncryptedContent, PageAge: item.PageAge, }) } toolResult.ProviderMetadata = fantasy.ProviderMetadata{ Name: &WebSearchResultMetadata{ Results: metadataResults, }, } } else if webSearchResult.Content.ErrorCode != "" { toolResult.ProviderMetadata = fantasy.ProviderMetadata{ Name: &WebSearchResultMetadata{ ErrorCode: string(webSearchResult.Content.ErrorCode), }, } } content = append(content, toolResult) } } return &fantasy.Response{ Content: content, Usage: fantasy.Usage{ InputTokens: response.Usage.InputTokens, OutputTokens: response.Usage.OutputTokens, TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens, CacheCreationTokens: response.Usage.CacheCreationInputTokens, CacheReadTokens: response.Usage.CacheReadInputTokens, }, FinishReason: mapFinishReason(string(response.StopReason)), ProviderMetadata: fantasy.ProviderMetadata{}, Warnings: warnings, }, nil } // Stream implements fantasy.LanguageModel. func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) { params, rawTools, warnings, betaFlags, err := a.prepareParams(call) if err != nil { return nil, err } reqOpts := buildRequestOptions(call, rawTools, betaFlags) stream := a.client.Messages.NewStreaming(ctx, *params, reqOpts...) acc := anthropic.Message{} return func(yield func(fantasy.StreamPart) bool) { if len(warnings) > 0 { if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeWarnings, Warnings: warnings, }) { return } } sawMessageStop := false // openToolBlocks holds tool_use blocks announced by content_block_start // but not yet ended. The accumulator cannot serve this: it drops blocks // it could not index, and max_tokens truncation sends no stop event. openToolBlocks := map[int64]*openToolBlock{} for stream.Next() { chunk := stream.Current() _ = acc.Accumulate(chunk) switch chunk.Type { case "content_block_start": contentBlockType := chunk.ContentBlock.Type if contentBlockType == "tool_use" || contentBlockType == "server_tool_use" { openToolBlocks[chunk.Index] = &openToolBlock{ id: chunk.ContentBlock.ID, name: chunk.ContentBlock.Name, providerExecuted: contentBlockType == "server_tool_use", } } switch contentBlockType { case "text": if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeTextStart, ID: fmt.Sprintf("%d", chunk.Index), }) { return } case "thinking": if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeReasoningStart, ID: fmt.Sprintf("%d", chunk.Index), }) { return } case "redacted_thinking": if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeReasoningStart, ID: fmt.Sprintf("%d", chunk.Index), ProviderMetadata: reasoningProviderMetadata("", chunk.ContentBlock.Data), }) { return } case "tool_use": if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeToolInputStart, ID: chunk.ContentBlock.ID, ToolCallName: chunk.ContentBlock.Name, ToolCallInput: "", }) { return } case "server_tool_use": if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeToolInputStart, ID: chunk.ContentBlock.ID, ToolCallName: chunk.ContentBlock.Name, ToolCallInput: "", ProviderExecuted: true, }) { return } } case "content_block_stop": // Tool blocks end from openToolBlocks so that an accumulator // that dropped the block cannot swallow the call. if open, ok := openToolBlocks[chunk.Index]; ok { delete(openToolBlocks, chunk.Index) if !open.close(acc, chunk.Index, yield) { return } continue } if len(acc.Content)-1 < int(chunk.Index) { continue } contentBlock := acc.Content[int(chunk.Index)] switch contentBlock.Type { case "text": if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeTextEnd, ID: fmt.Sprintf("%d", chunk.Index), }) { return } case "thinking": if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeReasoningEnd, ID: fmt.Sprintf("%d", chunk.Index), ProviderMetadata: reasoningProviderMetadata(contentBlock.Signature, ""), }) { return } case "redacted_thinking": if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeReasoningEnd, ID: fmt.Sprintf("%d", chunk.Index), ProviderMetadata: reasoningProviderMetadata("", contentBlock.Data), }) { return } case "tool_use": // Handled above, from openToolBlocks. case "web_search_tool_result": // Read search results directly from the ContentBlockUnion // struct fields instead of using AsAny(). The Anthropic SDK's // Accumulate re-marshals the content block at content_block_stop, // which corrupts JSON.raw for inline union types like // WebSearchToolResultBlockContentUnion. The struct fields // themselves remain correctly populated from content_block_start. var metadataResults []WebSearchResultItem var providerMeta fantasy.ProviderMetadata if items := contentBlock.Content.OfWebSearchResultBlockArray; len(items) > 0 { for _, item := range items { if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeSource, ID: item.URL, SourceType: fantasy.SourceTypeURL, URL: item.URL, Title: item.Title, }) { return } metadataResults = append(metadataResults, WebSearchResultItem{ URL: item.URL, Title: item.Title, EncryptedContent: item.EncryptedContent, PageAge: item.PageAge, }) } } if len(metadataResults) > 0 { providerMeta = fantasy.ProviderMetadata{ Name: &WebSearchResultMetadata{ Results: metadataResults, }, } } else if contentBlock.Content.ErrorCode != "" { providerMeta = fantasy.ProviderMetadata{ Name: &WebSearchResultMetadata{ ErrorCode: string(contentBlock.Content.ErrorCode), }, } } if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeToolResult, ID: contentBlock.ToolUseID, ToolCallName: "web_search", ProviderExecuted: true, ProviderMetadata: providerMeta, }) { return } } case "content_block_delta": switch chunk.Delta.Type { case "text_delta": if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeTextDelta, ID: fmt.Sprintf("%d", chunk.Index), Delta: chunk.Delta.Text, }) { return } case "thinking_delta": if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeReasoningDelta, ID: fmt.Sprintf("%d", chunk.Index), Delta: chunk.Delta.Thinking, }) { return } case "signature_delta": if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeReasoningDelta, ID: fmt.Sprintf("%d", chunk.Index), ProviderMetadata: fantasy.ProviderMetadata{ Name: &ReasoningOptionMetadata{ Signature: chunk.Delta.Signature, }, }, }) { return } case "input_json_delta": // Resolved from openToolBlocks, not the accumulator: the // accumulator drops blocks it could not index, and a // dropped delta silently shortens the tool call. open, ok := openToolBlocks[chunk.Index] if !ok { continue } open.input.WriteString(chunk.Delta.PartialJSON) if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeToolInputDelta, ID: open.id, ToolCallInput: chunk.Delta.PartialJSON, }) { return } } case "message_stop": sawMessageStop = true default: // Catch-all on purpose: Anthropic may add event types and // documents that unknown ones should be handled gracefully. // https://platform.claude.com/docs/en/build-with-claude/streaming if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeKeepalive}) { return } } } err := stream.Err() if err != nil && !errors.Is(err, io.EOF) { yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, Error: toProviderErr(err), }) return } // Anthropic's SSE protocol reports the stop_reason in message_delta // and then terminates the message with message_stop. Require both so // a socket close after only one of those signals is retried. if !sawMessageStop || acc.StopReason == "" { err := ctx.Err() if err == nil { err = fantasy.NewIncompleteStreamError() } yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, Error: err, }) return } // A turn stopped at max_tokens sends no content_block_stop for the // block it was writing. Emit what is still open, truncated arguments // and all, so the call is reported invalid rather than dropped. for _, index := range slices.Sorted(maps.Keys(openToolBlocks)) { open := openToolBlocks[index] if !open.close(acc, index, yield) { return } } yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeFinish, ID: acc.ID, FinishReason: mapFinishReason(string(acc.StopReason)), Usage: fantasy.Usage{ InputTokens: acc.Usage.InputTokens, OutputTokens: acc.Usage.OutputTokens, TotalTokens: acc.Usage.InputTokens + acc.Usage.OutputTokens, CacheCreationTokens: acc.Usage.CacheCreationInputTokens, CacheReadTokens: acc.Usage.CacheReadInputTokens, }, ProviderMetadata: fantasy.ProviderMetadata{}, }) }, nil } // openToolBlock is a tool_use block announced on the stream but not yet // ended. It carries the arguments seen so far so the call can still be // reported if no content_block_stop arrives. type openToolBlock struct { id string name string providerExecuted bool input strings.Builder } // close emits the end-of-input and tool call parts for the block. It reports // whether the consumer wants more parts. func (o *openToolBlock) close(acc anthropic.Message, index int64, yield func(fantasy.StreamPart) bool) bool { if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeToolInputEnd, ID: o.id, ProviderExecuted: o.providerExecuted, }) { return false } return yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeToolCall, ID: o.id, ToolCallName: o.name, ToolCallInput: o.arguments(acc, index), ProviderExecuted: o.providerExecuted, }) } // arguments returns the call's arguments as JSON text. Deltas win; a block // with no deltas falls back to the accumulator, then to an empty object so a // no-argument call is still valid JSON. func (o *openToolBlock) arguments(acc anthropic.Message, index int64) string { if o.input.Len() > 0 { return o.input.String() } // Only trust the accumulator when the block sitting at this index // carries this call's ID. Index drift between the stream and the // accumulator is the very thing this tracking exists to survive, so // reading by position alone would be trusting the one thing already // known to be unreliable, and another call's arguments are worse than // none. A negative index cannot address a block at all. if index >= 0 && int(index) < len(acc.Content) { if block := acc.Content[index]; block.ID == o.id { if input := string(block.Input); input != "" { return input } } } return "{}" } // GenerateObject implements fantasy.LanguageModel. func (a languageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { switch a.options.objectMode { case fantasy.ObjectModeText: return object.GenerateWithText(ctx, a, call) default: return object.GenerateWithTool(ctx, a, call) } } // StreamObject implements fantasy.LanguageModel. func (a languageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) { switch a.options.objectMode { case fantasy.ObjectModeText: return object.StreamWithText(ctx, a, call) default: return object.StreamWithTool(ctx, a, call) } }