Skip to content

Commit 45784f9

Browse files
halfaipgclaude
andcommitted
feat: Anthropic Messages API support for CLI
Add dual-protocol LLM client that auto-detects and speaks both OpenAI Chat Completions and Anthropic Messages API. Enables MiniMax Anthropic mode and native Claude API support. - llm_anthropic.go: streaming SSE parser, message/tool conversion, non-streaming for compaction - Protocol auto-detection from URL (/anthropic) or LLM_PROTOCOL env var - 20 unit tests + 4 live integration tests against MiniMax Anthropic - Boot screen and /session show active protocol - MiniMax context window (204K) and cost estimates added Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d7a250a commit 45784f9

7 files changed

Lines changed: 1327 additions & 13 deletions

File tree

boot.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,14 @@ func buildBootSteps(cfg *Config) []bootStep {
191191
if home != "" && strings.HasPrefix(workDisplay, home) {
192192
workDisplay = "~" + workDisplay[len(home):]
193193
}
194+
proto := detectProtocol(cfg.BaseURL)
195+
protoLabel := "chat completions"
196+
if proto == ProtocolAnthropic {
197+
protoLabel = "messages api"
198+
}
194199
return []bootStep{
195200
{label: "config", value: "loaded"},
196-
{label: "provider", value: cfg.Model},
201+
{label: "provider", value: fmt.Sprintf("%s (%s)", cfg.Model, protoLabel)},
197202
{label: "workspace", value: fmt.Sprintf("%s (%d files)", workDisplay, fileCount)},
198203
{label: "terminal", value: TerminalName()},
199204
{label: "status", value: "ready"},

commands.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,16 @@ func init() {
115115
var sb strings.Builder
116116
sb.WriteString(styleMuted.Render(" Session info:") + "\n")
117117
sb.WriteString(styleDim.Render(" Model: ") + styleMuted.Render(m.config.Model) + "\n")
118+
// Protocol (API format)
119+
proto := ProtocolOpenAI
120+
if m.agent != nil {
121+
proto = m.agent.client.Protocol
122+
}
123+
protoLabel := "Chat Completions (OpenAI)"
124+
if proto == ProtocolAnthropic {
125+
protoLabel = "Messages API (Anthropic)"
126+
}
127+
sb.WriteString(styleDim.Render(" Protocol: ") + styleMuted.Render(protoLabel) + "\n")
118128
// Token counts with cost estimate
119129
tokenLine := fmt.Sprintf("%d (%d in + %d out)", totalTokens, m.tokens.PromptTokens, m.tokens.CompletionTokens)
120130
cost := estimateCost(m.config.Model, m.tokens.PromptTokens, m.tokens.CompletionTokens)
@@ -451,6 +461,9 @@ func estimateCost(model string, promptTokens, completionTokens int) string {
451461
"claude-sonnet": {3.00, 15.0},
452462
"claude-opus": {15.0, 75.0},
453463
"claude-haiku": {0.25, 1.25},
464+
"minimax-m2.5": {1.00, 5.00},
465+
"minimax-m2.1": {0.50, 2.50},
466+
"minimax-m2": {0.50, 2.50},
454467
"glm-4": {0.14, 0.14},
455468
"glm-4.7": {0.14, 0.14},
456469
}

compact.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ var defaultContextWindows = map[string]int{
3737
"o3": 200000,
3838
"o4-mini": 200000,
3939
"claude": 200000,
40+
"minimax": 204800,
4041
"glm": 128000,
4142
"gemini": 1000000,
4243
"deepseek": 128000,
@@ -227,8 +228,16 @@ func compactHistory(client *LLMClient, messages []ChatMessage) ([]ChatMessage, b
227228
return compacted, true
228229
}
229230

230-
// nonStreamingChat makes a non-streaming Chat Completions call.
231+
// nonStreamingChat makes a non-streaming LLM call, dispatching by protocol.
231232
func nonStreamingChat(client *LLMClient, messages []ChatMessage) (string, error) {
233+
if client.Protocol == ProtocolAnthropic {
234+
return nonStreamingChatAnthropic(client, messages)
235+
}
236+
return nonStreamingChatOpenAI(client, messages)
237+
}
238+
239+
// nonStreamingChatOpenAI makes a non-streaming OpenAI Chat Completions call.
240+
func nonStreamingChatOpenAI(client *LLMClient, messages []ChatMessage) (string, error) {
232241
body := map[string]interface{}{
233242
"model": client.Model,
234243
"messages": messages,

llm.go

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"net/http"
11+
"os"
1112
"strings"
1213
"time"
1314
)
@@ -108,11 +109,18 @@ type StreamEvent struct {
108109
// LLM Client
109110
// ──────────────────────────────────────────────────────────────
110111

112+
// Protocol constants for API format detection
113+
const (
114+
ProtocolOpenAI = "openai"
115+
ProtocolAnthropic = "anthropic"
116+
)
117+
111118
type LLMClient struct {
112-
APIKey string
113-
BaseURL string
114-
Model string
115-
client *http.Client
119+
APIKey string
120+
BaseURL string
121+
Model string
122+
Protocol string // "openai" or "anthropic"
123+
client *http.Client
116124
}
117125

118126
func NewLLMClient(apiKey, baseURL, model string) *LLMClient {
@@ -126,18 +134,50 @@ func newLLMClientWithTimeout(apiKey, baseURL, model string, timeout time.Duratio
126134
if model == "" {
127135
model = "gpt-4o"
128136
}
137+
baseURL = strings.TrimSuffix(baseURL, "/")
138+
protocol := detectProtocol(baseURL)
139+
129140
return &LLMClient{
130-
APIKey: apiKey,
131-
BaseURL: strings.TrimSuffix(baseURL, "/"),
132-
Model: model,
133-
client: &http.Client{Timeout: timeout},
141+
APIKey: apiKey,
142+
BaseURL: baseURL,
143+
Model: model,
144+
Protocol: protocol,
145+
client: &http.Client{Timeout: timeout},
134146
}
135147
}
136148

137-
// StreamChat sends a streaming Chat Completions request and pushes
138-
// parsed events into the provided channel. Caller should read from ch
139-
// until it is closed.
149+
// detectProtocol determines the API protocol from the base URL and env vars.
150+
func detectProtocol(baseURL string) string {
151+
// Explicit env var override
152+
if p := os.Getenv("LLM_PROTOCOL"); p != "" {
153+
switch strings.ToLower(p) {
154+
case "anthropic", "claude", "messages":
155+
return ProtocolAnthropic
156+
default:
157+
return ProtocolOpenAI
158+
}
159+
}
160+
// Auto-detect from URL
161+
urlLower := strings.ToLower(baseURL)
162+
if strings.Contains(urlLower, "/anthropic") || strings.Contains(urlLower, "anthropic.com") {
163+
return ProtocolAnthropic
164+
}
165+
return ProtocolOpenAI
166+
}
167+
168+
// StreamChat sends a streaming LLM request and pushes parsed events
169+
// into the provided channel. Dispatches to the appropriate protocol
170+
// implementation based on c.Protocol.
140171
func (c *LLMClient) StreamChat(ctx context.Context, messages []ChatMessage, tools []ToolDef, ch chan<- StreamEvent) {
172+
if c.Protocol == ProtocolAnthropic {
173+
c.streamChatAnthropic(ctx, messages, tools, ch)
174+
return
175+
}
176+
c.streamChatOpenAI(ctx, messages, tools, ch)
177+
}
178+
179+
// streamChatOpenAI sends a streaming OpenAI Chat Completions request.
180+
func (c *LLMClient) streamChatOpenAI(ctx context.Context, messages []ChatMessage, tools []ToolDef, ch chan<- StreamEvent) {
141181
defer close(ch)
142182

143183
body := map[string]interface{}{

0 commit comments

Comments
 (0)