Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 27 additions & 56 deletions services/anthropic/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
}

Expand Down
122 changes: 41 additions & 81 deletions services/anthropic/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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,
}
13 changes: 13 additions & 0 deletions services/anthropic/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
2 changes: 0 additions & 2 deletions services/anthropic/response.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down
52 changes: 52 additions & 0 deletions services/anthropic/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading