diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index 935c7af..c4e1b18 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -12,6 +12,7 @@ import ( "somegit.dev/Owlibou/gnoma/internal/engine" "somegit.dev/Owlibou/gnoma/internal/provider" + anthropicprov "somegit.dev/Owlibou/gnoma/internal/provider/anthropic" "somegit.dev/Owlibou/gnoma/internal/provider/mistral" "somegit.dev/Owlibou/gnoma/internal/stream" "somegit.dev/Owlibou/gnoma/internal/tool" @@ -146,11 +147,6 @@ func readInput(args []string) (string, error) { return "", nil } -func resolveAPIKey(providerName string) string { - envVar := envKeyFor(providerName) - return os.Getenv(envVar) -} - func envKeyFor(providerName string) string { switch providerName { case "mistral": @@ -166,6 +162,24 @@ func envKeyFor(providerName string) string { } } +func resolveAPIKey(providerName string) string { + // Try primary env var + primary := envKeyFor(providerName) + if key := os.Getenv(primary); key != "" { + return key + } + // Try common alternatives + alternatives := map[string][]string{ + "anthropic": {"ANTHROPICS_API_KEY"}, + } + for _, alt := range alternatives[providerName] { + if key := os.Getenv(alt); key != "" { + return key + } + } + return "" +} + func createProvider(name, apiKey, model string) (provider.Provider, error) { cfg := provider.ProviderConfig{ APIKey: apiKey, @@ -175,8 +189,10 @@ func createProvider(name, apiKey, model string) (provider.Provider, error) { switch name { case "mistral": return mistral.New(cfg) + case "anthropic": + return anthropicprov.New(cfg) default: - return nil, fmt.Errorf("unknown provider %q (M1 supports: mistral)", name) + return nil, fmt.Errorf("unknown provider %q (supports: mistral, anthropic)", name) } } diff --git a/go.mod b/go.mod index d9a0829..4f34c7a 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,15 @@ module somegit.dev/Owlibou/gnoma go 1.26.1 -require github.com/VikingOwl91/mistral-go-sdk v1.2.1 +require ( + github.com/VikingOwl91/mistral-go-sdk v1.2.1 + github.com/anthropics/anthropic-sdk-go v1.29.0 +) + +require ( + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + golang.org/x/sync v0.16.0 // indirect +) diff --git a/go.sum b/go.sum index 8d9a288..fc4adb7 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,28 @@ github.com/VikingOwl91/mistral-go-sdk v1.2.1 h1:6OQMtOzJUFcvFUEtbX9VlglUPBn+dKOrQPnyoVKlpkA= github.com/VikingOwl91/mistral-go-sdk v1.2.1/go.mod h1:f4emNtHUx2zSqY3V0LBz6lNI1jE6q/zh+SEU+/hJ0i4= +github.com/anthropics/anthropic-sdk-go v1.29.0 h1:7h1ZyRflhtxyuFkdwkVuJ1LdFAYdmizvgg0gd1uvOfI= +github.com/anthropics/anthropic-sdk-go v1.29.0/go.mod h1:dSIO7kSrOI7MA4fE6RRVaw8tyWP7HNQU5/H/KS4cax8= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/provider/anthropic/provider.go b/internal/provider/anthropic/provider.go new file mode 100644 index 0000000..ee5fd06 --- /dev/null +++ b/internal/provider/anthropic/provider.go @@ -0,0 +1,105 @@ +package anthropic + +import ( + "context" + "fmt" + + "somegit.dev/Owlibou/gnoma/internal/provider" + "somegit.dev/Owlibou/gnoma/internal/stream" + + anthropic "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" +) + +const defaultModel = "claude-sonnet-4-20250514" + +// Provider implements provider.Provider for the Anthropic API. +type Provider struct { + client *anthropic.Client + name string + model string +} + +// New creates an Anthropic provider from config. +func New(cfg provider.ProviderConfig) (provider.Provider, error) { + if cfg.APIKey == "" { + return nil, fmt.Errorf("anthropic: api key required") + } + + opts := []option.RequestOption{ + option.WithAPIKey(cfg.APIKey), + } + if cfg.BaseURL != "" { + opts = append(opts, option.WithBaseURL(cfg.BaseURL)) + } + + client := anthropic.NewClient(opts...) + + model := cfg.Model + if model == "" { + model = defaultModel + } + + return &Provider{ + client: &client, + name: "anthropic", + model: model, + }, nil +} + +// Stream initiates a streaming message request. +func (p *Provider) Stream(ctx context.Context, req provider.Request) (stream.Stream, error) { + model := req.Model + if model == "" { + model = p.model + } + + params := translateRequest(req) + params.Model = anthropic.Model(model) + + if params.MaxTokens == 0 { + params.MaxTokens = 8192 + } + + raw := p.client.Messages.NewStreaming(ctx, params) + + return newAnthropicStream(raw), nil +} + +// Name returns "anthropic". +func (p *Provider) Name() string { + return p.name +} + +// DefaultModel returns the configured default model. +func (p *Provider) DefaultModel() string { + return p.model +} + +// Models returns known Anthropic models with capabilities. +// Anthropic doesn't have a model listing API, so these are hardcoded. +func (p *Provider) Models(_ context.Context) ([]provider.ModelInfo, error) { + return []provider.ModelInfo{ + { + ID: "claude-opus-4-20250514", Name: "Claude Opus 4", Provider: p.name, + Capabilities: provider.Capabilities{ + ToolUse: true, JSONOutput: true, Thinking: true, Vision: true, + ContextWindow: 200000, MaxOutput: 32000, + }, + }, + { + ID: "claude-sonnet-4-20250514", Name: "Claude Sonnet 4", Provider: p.name, + Capabilities: provider.Capabilities{ + ToolUse: true, JSONOutput: true, Thinking: true, Vision: true, + ContextWindow: 200000, MaxOutput: 16000, + }, + }, + { + ID: "claude-haiku-4-5-20251001", Name: "Claude Haiku 4.5", Provider: p.name, + Capabilities: provider.Capabilities{ + ToolUse: true, JSONOutput: true, Vision: true, + ContextWindow: 200000, MaxOutput: 8192, + }, + }, + }, nil +} diff --git a/internal/provider/anthropic/stream.go b/internal/provider/anthropic/stream.go new file mode 100644 index 0000000..6f5d42d --- /dev/null +++ b/internal/provider/anthropic/stream.go @@ -0,0 +1,164 @@ +package anthropic + +import ( + "encoding/json" + + "somegit.dev/Owlibou/gnoma/internal/message" + "somegit.dev/Owlibou/gnoma/internal/stream" + + anthropic "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/packages/ssestream" +) + +// anthropicStream adapts Anthropic's ssestream to gnoma's stream.Stream. +type anthropicStream struct { + raw *ssestream.Stream[anthropic.MessageStreamEventUnion] + cur stream.Event + err error + model string + stopReason message.StopReason + emittedStop bool + + // Track current content block for tool call assembly + currentToolCallID string + currentToolCallName string + toolCallArgs string // accumulated JSON fragments +} + +func newAnthropicStream(raw *ssestream.Stream[anthropic.MessageStreamEventUnion]) *anthropicStream { + return &anthropicStream{raw: raw} +} + +func (s *anthropicStream) Next() bool { + for s.raw.Next() { + event := s.raw.Current() + + switch variant := event.AsAny().(type) { + case anthropic.MessageStartEvent: + if variant.Message.Model != "" { + s.model = string(variant.Message.Model) + } + usage := translateUsage(variant.Message.Usage) + s.cur = stream.Event{ + Type: stream.EventUsage, + Usage: &usage, + Model: s.model, + } + return true + + case anthropic.ContentBlockStartEvent: + cb := variant.ContentBlock + if toolUse := cb.AsToolUse(); toolUse.ID != "" { + s.currentToolCallID = toolUse.ID + s.currentToolCallName = unsanitizeToolName(toolUse.Name) + s.toolCallArgs = "" + s.cur = stream.Event{ + Type: stream.EventToolCallStart, + ToolCallID: s.currentToolCallID, + ToolCallName: s.currentToolCallName, + } + return true + } + // Text or thinking block start — no event needed, deltas follow + + case anthropic.ContentBlockDeltaEvent: + delta := variant.Delta + switch d := delta.AsAny().(type) { + case anthropic.TextDelta: + if d.Text != "" { + s.cur = stream.Event{ + Type: stream.EventTextDelta, + Text: d.Text, + } + return true + } + case anthropic.InputJSONDelta: + s.toolCallArgs += d.PartialJSON + if d.PartialJSON != "" { + s.cur = stream.Event{ + Type: stream.EventToolCallDelta, + ToolCallID: s.currentToolCallID, + ArgDelta: d.PartialJSON, + } + return true + } + case anthropic.ThinkingDelta: + if d.Thinking != "" { + s.cur = stream.Event{ + Type: stream.EventThinkingDelta, + Text: d.Thinking, + } + return true + } + } + + case anthropic.ContentBlockStopEvent: + // Emit ToolCallDone if we were accumulating a tool call + if s.currentToolCallID != "" { + s.cur = stream.Event{ + Type: stream.EventToolCallDone, + ToolCallID: s.currentToolCallID, + ToolCallName: s.currentToolCallName, + Args: json.RawMessage(s.toolCallArgs), + } + s.currentToolCallID = "" + s.currentToolCallName = "" + s.toolCallArgs = "" + return true + } + + case anthropic.MessageDeltaEvent: + s.stopReason = translateStopReason(anthropic.StopReason(variant.Delta.StopReason)) + if variant.Usage.OutputTokens > 0 { + usage := message.Usage{OutputTokens: variant.Usage.OutputTokens} + s.cur = stream.Event{ + Type: stream.EventUsage, + Usage: &usage, + } + return true + } + + case anthropic.MessageStopEvent: + // Stream complete + } + } + + // Stream ended — emit stop reason + if !s.emittedStop { + s.emittedStop = true + if s.stopReason == "" { + s.stopReason = message.StopEndTurn + } + s.cur = stream.Event{ + Type: stream.EventTextDelta, + StopReason: s.stopReason, + Model: s.model, + } + return true + } + + s.err = s.raw.Err() + return false +} + +func (s *anthropicStream) Current() stream.Event { + return s.cur +} + +func (s *anthropicStream) Err() error { + return s.err +} + +func (s *anthropicStream) Close() error { + return s.raw.Close() +} + +// toolCallDoneFromAccum creates a ToolCallDone event. +// Called when ContentBlockStop arrives for a tool_use block. +func toolCallDoneEvent(id string, args json.RawMessage) stream.Event { + return stream.Event{ + Type: stream.EventToolCallDone, + ToolCallID: id, + Args: args, + } +} diff --git a/internal/provider/anthropic/translate.go b/internal/provider/anthropic/translate.go new file mode 100644 index 0000000..cd25e1d --- /dev/null +++ b/internal/provider/anthropic/translate.go @@ -0,0 +1,202 @@ +package anthropic + +import ( + "encoding/json" + "strings" + + "somegit.dev/Owlibou/gnoma/internal/message" + "somegit.dev/Owlibou/gnoma/internal/provider" + + anthropic "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/packages/param" +) + +// unsanitizeToolName reverses the name transformation for tool calls from the model. +func unsanitizeToolName(name string) string { + // Only reverse fs_ prefix since those are our tool names + if strings.HasPrefix(name, "fs_") { + return "fs." + name[3:] + } + return name +} + +// --- gnoma → Anthropic --- + +func translateMessages(msgs []message.Message) []anthropic.MessageParam { + out := make([]anthropic.MessageParam, 0, len(msgs)) + for _, m := range msgs { + // Skip system messages — they're passed separately + if m.Role == message.RoleSystem { + continue + } + out = append(out, translateMessage(m)...) + } + return out +} + +func translateMessage(m message.Message) []anthropic.MessageParam { + switch m.Role { + case message.RoleUser: + // Check if this is a tool results message + if len(m.Content) > 0 && m.Content[0].Type == message.ContentToolResult { + blocks := make([]anthropic.ContentBlockParamUnion, 0, len(m.Content)) + for _, c := range m.Content { + if c.Type == message.ContentToolResult && c.ToolResult != nil { + blocks = append(blocks, anthropic.NewToolResultBlock( + c.ToolResult.ToolCallID, + c.ToolResult.Content, + c.ToolResult.IsError, + )) + } + } + return []anthropic.MessageParam{anthropic.NewUserMessage(blocks...)} + } + return []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock(m.TextContent())), + } + + case message.RoleAssistant: + blocks := make([]anthropic.ContentBlockParamUnion, 0, len(m.Content)) + for _, c := range m.Content { + switch c.Type { + case message.ContentText: + if c.Text != "" { + blocks = append(blocks, anthropic.NewTextBlock(c.Text)) + } + case message.ContentToolCall: + if c.ToolCall != nil { + blocks = append(blocks, anthropic.ContentBlockParamUnion{ + OfToolUse: &anthropic.ToolUseBlockParam{ + ID: c.ToolCall.ID, + Name: c.ToolCall.Name, + Input: json.RawMessage(c.ToolCall.Arguments), + }, + }) + } + case message.ContentThinking: + if c.Thinking != nil { + if c.Thinking.Redacted { + blocks = append(blocks, anthropic.ContentBlockParamUnion{ + OfRedactedThinking: &anthropic.RedactedThinkingBlockParam{ + Data: c.Thinking.Text, + }, + }) + } else { + blocks = append(blocks, anthropic.ContentBlockParamUnion{ + OfThinking: &anthropic.ThinkingBlockParam{ + Thinking: c.Thinking.Text, + Signature: c.Thinking.Signature, + }, + }) + } + } + } + } + if len(blocks) == 0 { + blocks = append(blocks, anthropic.NewTextBlock("")) + } + return []anthropic.MessageParam{anthropic.NewAssistantMessage(blocks...)} + + default: + return nil + } +} + +// sanitizeToolName replaces characters not allowed by Anthropic's tool name pattern. +// Anthropic requires ^[a-zA-Z0-9_-]{1,128}$ +func sanitizeToolName(name string) string { + return strings.ReplaceAll(name, ".", "_") +} + +func translateTools(defs []provider.ToolDefinition) []anthropic.ToolUnionParam { + if len(defs) == 0 { + return nil + } + tools := make([]anthropic.ToolUnionParam, len(defs)) + for i, d := range defs { + // Parse JSON Schema into Properties + Required + var schema struct { + Properties map[string]any `json:"properties"` + Required []string `json:"required"` + } + if d.Parameters != nil { + _ = json.Unmarshal(d.Parameters, &schema) + } + + tools[i] = anthropic.ToolUnionParam{ + OfTool: &anthropic.ToolParam{ + Name: sanitizeToolName(d.Name), + Description: param.NewOpt(d.Description), + InputSchema: anthropic.ToolInputSchemaParam{ + Properties: schema.Properties, + Required: schema.Required, + }, + }, + } + } + return tools +} + +func translateRequest(req provider.Request) anthropic.MessageNewParams { + params := anthropic.MessageNewParams{ + Model: anthropic.Model(req.Model), + MaxTokens: req.MaxTokens, + Messages: translateMessages(req.Messages), + Tools: translateTools(req.Tools), + } + + if req.SystemPrompt != "" { + params.System = []anthropic.TextBlockParam{ + {Text: req.SystemPrompt}, + } + } + + if req.Temperature != nil { + params.Temperature = param.NewOpt(*req.Temperature) + } + if req.TopP != nil { + params.TopP = param.NewOpt(*req.TopP) + } + if req.TopK != nil { + params.TopK = param.NewOpt(*req.TopK) + } + if len(req.StopSequences) > 0 { + params.StopSequences = req.StopSequences + } + + if req.Thinking != nil && req.Thinking.BudgetTokens > 0 { + params.Thinking = anthropic.ThinkingConfigParamUnion{ + OfEnabled: &anthropic.ThinkingConfigEnabledParam{ + BudgetTokens: req.Thinking.BudgetTokens, + }, + } + } + + return params +} + +// --- Anthropic → gnoma --- + +func translateStopReason(sr anthropic.StopReason) message.StopReason { + switch sr { + case anthropic.StopReasonEndTurn: + return message.StopEndTurn + case anthropic.StopReasonMaxTokens: + return message.StopMaxTokens + case anthropic.StopReasonToolUse: + return message.StopToolUse + case anthropic.StopReasonStopSequence: + return message.StopSequence + default: + return message.StopEndTurn + } +} + +func translateUsage(u anthropic.Usage) message.Usage { + return message.Usage{ + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + CacheReadTokens: u.CacheReadInputTokens, + CacheCreationTokens: u.CacheCreationInputTokens, + } +} diff --git a/internal/provider/anthropic/translate_test.go b/internal/provider/anthropic/translate_test.go new file mode 100644 index 0000000..fcff428 --- /dev/null +++ b/internal/provider/anthropic/translate_test.go @@ -0,0 +1,179 @@ +package anthropic + +import ( + "encoding/json" + "testing" + + "somegit.dev/Owlibou/gnoma/internal/message" + "somegit.dev/Owlibou/gnoma/internal/provider" + + anthropic "github.com/anthropics/anthropic-sdk-go" +) + +func TestTranslateMessages_UserText(t *testing.T) { + msgs := []message.Message{message.NewUserText("hello")} + result := translateMessages(msgs) + + if len(result) != 1 { + t.Fatalf("len = %d, want 1", len(result)) + } + if result[0].Role != anthropic.MessageParamRoleUser { + t.Errorf("Role = %q, want user", result[0].Role) + } +} + +func TestTranslateMessages_SkipsSystem(t *testing.T) { + msgs := []message.Message{ + message.NewSystemText("system prompt"), + message.NewUserText("hello"), + } + result := translateMessages(msgs) + + // System messages are skipped — they go to the System param + if len(result) != 1 { + t.Fatalf("len = %d, want 1 (system skipped)", len(result)) + } +} + +func TestTranslateMessages_AssistantWithToolCalls(t *testing.T) { + msgs := []message.Message{ + message.NewAssistantContent( + message.NewTextContent("running"), + message.NewToolCallContent(message.ToolCall{ + ID: "tc_1", + Name: "bash", + Arguments: json.RawMessage(`{"command":"ls"}`), + }), + ), + } + result := translateMessages(msgs) + + if len(result) != 1 { + t.Fatalf("len = %d, want 1", len(result)) + } + if result[0].Role != anthropic.MessageParamRoleAssistant { + t.Errorf("Role = %q, want assistant", result[0].Role) + } +} + +func TestTranslateMessages_ToolResults(t *testing.T) { + msgs := []message.Message{ + message.NewToolResults( + message.ToolResult{ToolCallID: "tc_1", Content: "output", IsError: false}, + message.ToolResult{ToolCallID: "tc_2", Content: "error", IsError: true}, + ), + } + result := translateMessages(msgs) + + if len(result) != 1 { + t.Fatalf("len = %d, want 1 (single user message with tool results)", len(result)) + } + if result[0].Role != anthropic.MessageParamRoleUser { + t.Errorf("Role = %q, want user", result[0].Role) + } +} + +func TestTranslateTools(t *testing.T) { + defs := []provider.ToolDefinition{ + { + Name: "bash", + Description: "Run a command", + Parameters: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}`), + }, + } + tools := translateTools(defs) + + if len(tools) != 1 { + t.Fatalf("len = %d, want 1", len(tools)) + } + if tools[0].OfTool == nil { + t.Fatal("OfTool should not be nil") + } + if tools[0].OfTool.Name != "bash" { + t.Errorf("Name = %q", tools[0].OfTool.Name) + } +} + +func TestTranslateTools_Empty(t *testing.T) { + tools := translateTools(nil) + if tools != nil { + t.Errorf("expected nil for empty defs") + } +} + +func TestTranslateStopReason(t *testing.T) { + tests := []struct { + input anthropic.StopReason + want message.StopReason + }{ + {anthropic.StopReasonEndTurn, message.StopEndTurn}, + {anthropic.StopReasonMaxTokens, message.StopMaxTokens}, + {anthropic.StopReasonToolUse, message.StopToolUse}, + {anthropic.StopReasonStopSequence, message.StopSequence}, + } + for _, tt := range tests { + got := translateStopReason(tt.input) + if got != tt.want { + t.Errorf("translateStopReason(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestTranslateUsage(t *testing.T) { + u := anthropic.Usage{ + InputTokens: 100, + OutputTokens: 50, + CacheReadInputTokens: 20, + CacheCreationInputTokens: 10, + } + result := translateUsage(u) + + if result.InputTokens != 100 { + t.Errorf("InputTokens = %d", result.InputTokens) + } + if result.OutputTokens != 50 { + t.Errorf("OutputTokens = %d", result.OutputTokens) + } + if result.CacheReadTokens != 20 { + t.Errorf("CacheReadTokens = %d", result.CacheReadTokens) + } + if result.CacheCreationTokens != 10 { + t.Errorf("CacheCreationTokens = %d", result.CacheCreationTokens) + } +} + +func TestTranslateRequest(t *testing.T) { + temp := 0.7 + req := provider.Request{ + Model: "claude-sonnet-4-20250514", + SystemPrompt: "you are helpful", + Messages: []message.Message{ + message.NewSystemText("you are helpful"), + message.NewUserText("hello"), + }, + Tools: []provider.ToolDefinition{ + {Name: "bash", Description: "Run command", Parameters: json.RawMessage(`{"type":"object"}`)}, + }, + MaxTokens: 4096, + Temperature: &temp, + } + + params := translateRequest(req) + + if params.Model != "claude-sonnet-4-20250514" { + t.Errorf("Model = %q", params.Model) + } + if params.MaxTokens != 4096 { + t.Errorf("MaxTokens = %d", params.MaxTokens) + } + // System messages in Messages should be skipped (1 system + 1 user → 1 message) + if len(params.Messages) != 1 { + t.Errorf("len(Messages) = %d, want 1", len(params.Messages)) + } + if len(params.System) != 1 { + t.Errorf("len(System) = %d, want 1", len(params.System)) + } + if len(params.Tools) != 1 { + t.Errorf("len(Tools) = %d, want 1", len(params.Tools)) + } +}