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:
@@ -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
13
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
|
||||
)
|
||||
|
||||
26
go.sum
26
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=
|
||||
|
||||
105
internal/provider/anthropic/provider.go
Normal file
105
internal/provider/anthropic/provider.go
Normal 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
|
||||
}
|
||||
164
internal/provider/anthropic/stream.go
Normal file
164
internal/provider/anthropic/stream.go
Normal 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,
|
||||
}
|
||||
}
|
||||
202
internal/provider/anthropic/translate.go
Normal file
202
internal/provider/anthropic/translate.go
Normal 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,
|
||||
}
|
||||
}
|
||||
179
internal/provider/anthropic/translate_test.go
Normal file
179
internal/provider/anthropic/translate_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user