diff --git a/services/anthropic/llm.go b/services/anthropic/llm.go index 103a35f..082d2ff 100644 --- a/services/anthropic/llm.go +++ b/services/anthropic/llm.go @@ -7,15 +7,16 @@ import ( "encoding/json" "errors" "fmt" - "github.com/modfin/bellman/models" - "github.com/modfin/bellman/models/gen" - "github.com/modfin/bellman/prompt" - "github.com/modfin/bellman/tools" "io" "log" "net/http" "strings" "sync/atomic" + + "github.com/modfin/bellman/models" + "github.com/modfin/bellman/models/gen" + "github.com/modfin/bellman/prompt" + "github.com/modfin/bellman/tools" ) var requestNo int64 @@ -59,6 +60,7 @@ func (g *generator) Stream(conversation ...prompt.Prompt) (<-chan *gen.StreamRes if resp.StatusCode != http.StatusOK { b, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() return nil, errors.Join(fmt.Errorf("unexpected status code, %d, err: {%s}", resp.StatusCode, string(b)), err) } @@ -180,28 +182,17 @@ func (g *generator) Stream(conversation ...prompt.Prompt) (<-chan *gen.StreamRes } if ss.Delta != nil { if len(toolID) > 0 && len(toolName) > 0 && ss.Delta.PartialJSON != nil { - if toolName == respone_output_callback_name { - // If the tool is the output callback, we just send the partial JSON as text - stream <- &gen.StreamResponse{ - Type: gen.TYPE_DELTA, - Role: prompt.AssistantRole, - Index: ss.Index, - Content: *ss.Delta.PartialJSON, - } - } else { - stream <- &gen.StreamResponse{ - Type: gen.TYPE_DELTA, - Role: prompt.ToolCallRole, - Index: ss.Index, - ToolCall: &tools.Call{ - ID: toolID, - Name: toolName, - Argument: []byte(*ss.Delta.PartialJSON), - Ref: reqModel.toolBelt[toolName], - }, - } + stream <- &gen.StreamResponse{ + Type: gen.TYPE_DELTA, + Role: prompt.ToolCallRole, + Index: ss.Index, + ToolCall: &tools.Call{ + ID: toolID, + Name: toolName, + Argument: []byte(*ss.Delta.PartialJSON), + Ref: reqModel.toolBelt[toolName], + }, } - } if ss.Delta.Text != nil && len(*ss.Delta.Text) > 0 { stream <- &gen.StreamResponse{ @@ -306,12 +297,6 @@ func (g *generator) Prompt(conversation ...prompt.Prompt) (*gen.Response, error) } } - // This is really an output schema callback. So lets just transform it to Text - if len(res.Tools) == 1 && res.Tools[0].Name == respone_output_callback_name { - res.Texts = []string{string(res.Tools[0].Argument)} - res.Tools = nil - } - g.anthropic.log("[gen] response", "request", reqc, "model", g.request.Model.FQN(), @@ -344,16 +329,12 @@ func (g *generator) prompt(conversation ...prompt.Prompt) (*http.Request, reques } if g.request.OutputSchema != nil { - model.Tools = []reqTool{ - { - Name: respone_output_callback_name, - Description: "function that is called with the result of the llm query", - InputSchema: fromBellmanSchema(g.request.OutputSchema), + model.OutputConfig = &reqOutputConfig{ + Format: &reqOutputFormat{ + Type: "json_schema", + Schema: sanitizeForStructuredOutput(fromBellmanSchema(g.request.OutputSchema)), }, } - model.Tool = &reqToolChoice{ - Type: "any", - } } if len(g.request.Tools) > 0 { @@ -368,29 +349,19 @@ func (g *generator) prompt(conversation ...prompt.Prompt) (*http.Request, reques } if g.request.ToolConfig != nil { - _name := "" - _type := "" - + var choice *reqToolChoice switch g.request.ToolConfig.Name { case tools.NoTool.Name: + choice = &reqToolChoice{Type: "none"} case tools.AutoTool.Name: - _type = "auto" + choice = &reqToolChoice{Type: "auto"} case tools.RequiredTool.Name: - _type = "any" + choice = &reqToolChoice{Type: "any"} default: - _type = "tool" - _name = g.request.ToolConfig.Name + choice = &reqToolChoice{Type: "auto"} } - if model.Tool != nil { - model.Tool = &reqToolChoice{ - Type: _type, // // "auto, any, tool" - Name: _name, - } - } - - if g.request.ToolConfig.Name == tools.NoTool.Name { // None is not supporded by Anthropic, so lets just remove the toolks. - model.Tool = nil - model.Tools = nil + if len(model.Tools) > 0 { + model.Tool = choice } } diff --git a/services/anthropic/models.go b/services/anthropic/models.go index cad0934..b89e7bb 100644 --- a/services/anthropic/models.go +++ b/services/anthropic/models.go @@ -8,19 +8,6 @@ const Provider = "Anthropic" const Version = "2023-06-01" -//curl https://api.anthropic.com/v1/models/claude-3-5-sonnet-20241022 \ -// --header "x-api-key: $ANTHROPIC_API_KEY" \ -// --header "anthropic-version: 2023-06-01" -//{ -// "data": [ -// { -// "type": "model", -// "id": "claude-3-5-sonnet-20241022", -// "display_name": "Claude 3.5 Sonnet (New)", -// "created_at": "2024-10-22T00:00:00Z" -// }, -// {... - //type GenModel string // https://docs.anthropic.com/en/docs/about-claude/models @@ -43,25 +30,31 @@ var GenModel_4_0_sonnet_20250514 = gen.Model{ OutputMaxToken: 64_000, } var GenModel_4_5_sonnet_latest = gen.Model{ - Provider: Provider, - Description: "Our smartest model for complex agents and coding", - Name: "claude-4-5-sonnet", - InputMaxToken: 200_000, - OutputMaxToken: 64_000, + Provider: Provider, + Description: "Our smartest model for complex agents and coding", + Name: "claude-4-5-sonnet", + InputMaxToken: 200_000, + OutputMaxToken: 64_000, + SupportTools: true, + SupportStructuredOutput: true, } var GenModel_4_5_sonnet_20250929 = gen.Model{ - Provider: Provider, - Description: "Our smartest model for complex agents and coding", - Name: "claude-sonnet-4-5-20250929", - InputMaxToken: 200_000, - OutputMaxToken: 64_000, + Provider: Provider, + Description: "Our smartest model for complex agents and coding", + Name: "claude-sonnet-4-5-20250929", + InputMaxToken: 200_000, + OutputMaxToken: 64_000, + SupportTools: true, + SupportStructuredOutput: true, } var GenModel_4_6_sonnet_latest = gen.Model{ - Provider: Provider, - Name: "claude-sonnet-4-6", - Description: "The best combination of speed and intelligence", - InputMaxToken: 1_000_000, - OutputMaxToken: 64_000, + Provider: Provider, + Name: "claude-sonnet-4-6", + Description: "The best combination of speed and intelligence", + InputMaxToken: 1_000_000, + OutputMaxToken: 64_000, + SupportTools: true, + SupportStructuredOutput: true, } var GenModel_3_5_sonnet_latest = gen.Model{ Provider: Provider, @@ -94,17 +87,6 @@ var GenModel_3_5_sonnet_20240620 = gen.Model{ SupportStructuredOutput: false, } -//var GenModel_3_sonnet_20240229 = gen.Model{ -// Provider: Provider, -// Name: "claude-3-sonnet-20240229", -// Description: "", -// InputContentTypes: nil, -// InputMaxToken: 0, -// OutputMaxToken: 0, -// SupportTools: false, -// SupportStructuredOutput: false, -//} - var GenModel_4_5_haiku_latest = gen.Model{ Provider: Provider, Name: "claude-haiku-4-5", @@ -146,37 +128,6 @@ var GenModel_3_5_haiku_20241022 = gen.Model{ SupportTools: false, SupportStructuredOutput: false, } -var GenModel_3_haiku_20240307 = gen.Model{ - Provider: Provider, - Name: "claude-3-haiku-20240307", - Description: "", - InputContentTypes: nil, - InputMaxToken: 0, - OutputMaxToken: 0, - SupportTools: false, - SupportStructuredOutput: false, -} - -var GenModel_3_opus_latest = gen.Model{ - Provider: Provider, - Name: "claude-3-opus-latest", - Description: "", - InputContentTypes: nil, - InputMaxToken: 0, - OutputMaxToken: 0, - SupportTools: false, - SupportStructuredOutput: false, -} -var GenModel_3_opus_20240229 = gen.Model{ - Provider: Provider, - Name: "claude-3-opus-20240229", - Description: "", - InputContentTypes: nil, - InputMaxToken: 0, - OutputMaxToken: 0, - SupportTools: false, - SupportStructuredOutput: false, -} var GenModel_4_0_opus_20250514 = gen.Model{ Provider: Provider, Name: "claude-opus-4-20250514", @@ -198,20 +149,29 @@ var GenModel_4_1_opus_20250805 = gen.Model{ SupportStructuredOutput: false, } var GenModel_4_6_opus_latest = gen.Model{ - Provider: Provider, - Name: "claude-opus-4-6", - Description: "The most intelligent model for building agents and coding", - InputMaxToken: 1_000_000, - OutputMaxToken: 128_000, + Provider: Provider, + Name: "claude-opus-4-6", + Description: "The most intelligent model for building agents and coding", + InputMaxToken: 1_000_000, + OutputMaxToken: 128_000, + SupportTools: true, + SupportStructuredOutput: true, +} +var GenModel_4_7_opus_latest = gen.Model{ + Provider: Provider, + Name: "claude-opus-4-7", + Description: "The most intelligent model for building agents and coding", + InputMaxToken: 1_000_000, + OutputMaxToken: 128_000, + SupportTools: true, + SupportStructuredOutput: true, } var GenModels = map[string]gen.Model{ GenModel_3_5_sonnet_latest.Name: GenModel_3_5_sonnet_latest, GenModel_3_5_sonnet_20241022.Name: GenModel_3_5_sonnet_20241022, - //GenModel_3_5_sonnet_20240620.Name: GenModel_3_5_sonnet_20240620, - GenModel_3_5_haiku_latest.Name: GenModel_3_5_haiku_latest, - GenModel_3_5_haiku_20241022.Name: GenModel_3_5_haiku_20241022, - //GenModel_3_haiku_20240307.Name: GenModel_3_haiku_20240307, - GenModel_4_6_opus_latest.Name: GenModel_4_6_opus_latest, - GenModel_4_6_sonnet_latest.Name: GenModel_4_6_sonnet_latest, + GenModel_3_5_haiku_latest.Name: GenModel_3_5_haiku_latest, + GenModel_3_5_haiku_20241022.Name: GenModel_3_5_haiku_20241022, + GenModel_4_6_opus_latest.Name: GenModel_4_6_opus_latest, + GenModel_4_6_sonnet_latest.Name: GenModel_4_6_sonnet_latest, } diff --git a/services/anthropic/request.go b/services/anthropic/request.go index bd2aaac..e383218 100644 --- a/services/anthropic/request.go +++ b/services/anthropic/request.go @@ -27,9 +27,22 @@ type request struct { Thinking *reqExtendedThinking `json:"thinking,omitempty"` + // OutputConfig enables native structured outputs (GA Nov 2025). + // https://platform.claude.com/docs/en/build-with-claude/structured-outputs + OutputConfig *reqOutputConfig `json:"output_config,omitempty"` + toolBelt map[string]*tools.Tool } +type reqOutputConfig struct { + Format *reqOutputFormat `json:"format,omitempty"` +} + +type reqOutputFormat struct { + Type string `json:"type"` // "json_schema" + Schema *JSONSchema `json:"schema,omitempty"` +} + type reqMessages struct { Role string `json:"role"` // assistant or user Content []reqContent `json:"content"` diff --git a/services/anthropic/response.go b/services/anthropic/response.go index 78c9724..2d85994 100644 --- a/services/anthropic/response.go +++ b/services/anthropic/response.go @@ -1,7 +1,5 @@ package anthropic -const respone_output_callback_name = "__bellman__result_callback" - type anthropicResponse struct { Content []struct { Type string `json:"type"` // text or tool_use diff --git a/services/anthropic/schema.go b/services/anthropic/schema.go index 7d9ca90..cea5cc4 100644 --- a/services/anthropic/schema.go +++ b/services/anthropic/schema.go @@ -126,3 +126,55 @@ func fromBellmanSchema(bellmanSchema *schema.JSON) *JSONSchema { return def } + +// sanitizeForStructuredOutput mutates s (and descendants) in place to satisfy +// Anthropic's native structured-outputs constraints: every object must set +// additionalProperties: false, and numeric/array-size constraints are +// unsupported and must be stripped. minItems is clamped to {0, 1}. +func sanitizeForStructuredOutput(s *JSONSchema) *JSONSchema { + if s == nil { + return nil + } + + if isObjectType(s.Type) && s.AdditionalProperties == nil { + s.AdditionalProperties = false + } + + s.Minimum = 0 + s.Maximum = 0 + s.MaxItems = 0 + if s.MinItems > 1 { + s.MinItems = 1 + } + + for k, prop := range s.Properties { + sanitizeForStructuredOutput(&prop) + s.Properties[k] = prop + } + sanitizeForStructuredOutput(s.Items) + for _, d := range s.Defs { + sanitizeForStructuredOutput(d) + } + if nested, ok := s.AdditionalProperties.(JSONSchema); ok { + sanitizeForStructuredOutput(&nested) + s.AdditionalProperties = nested + } + + return s +} + +func isObjectType(t any) bool { + switch v := t.(type) { + case DataType: + return v == Object + case string: + return v == string(Object) + case []any: + for _, x := range v { + if isObjectType(x) { + return true + } + } + } + return false +}