feat: add Anthropic provider adapter

Streaming, tool use (with InputJSONDelta assembly), thinking blocks,
cache token tracking, system prompt separation. Tool name sanitization
(fs.read → fs_read) for Anthropic's naming constraints with reverse
translation on tool call responses.

Hardcoded model list with capabilities (Opus 4, Sonnet 4, Haiku 4.5).
Wired into CLI with ANTHROPIC_API_KEY + ANTHROPICS_API_KEY env support.

Also: migrated Mistral SDK to github.com/VikingOwl91/mistral-go-sdk.

Live verified: text streaming + tool calling with claude-sonnet-4.
126 tests across 9 packages.
This commit is contained in:
2026-04-03 13:11:00 +02:00
parent 625f807cd5
commit b0fc4bbbc7
7 changed files with 710 additions and 7 deletions

View File

@@ -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)
}
}

13
go.mod
View File

@@ -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
)

26
go.sum
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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))
}
}