fix(providers): improve error handling and add edge case tests
- fix ToolCalls nil vs empty slice issue to ensure consistent JSON serialization - add detailed HTTP error handling for common status codes (401, 429, 400, 404, 500, 503) - add edge case tests for buildRequestBody and parseResponseBody - clarify anthropic vs anthropic-messages protocol differences in docs
This commit is contained in:
parent
94c7d879f8
commit
a22e027905
4 changed files with 186 additions and 10 deletions
|
|
@ -1111,9 +1111,11 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
||||||
```
|
```
|
||||||
|
|
||||||
> Use `anthropic-messages` protocol when:
|
> Use `anthropic-messages` protocol when:
|
||||||
> - Connecting directly to Anthropic's API (fixes 404 errors with `/v1/messages` endpoint)
|
> - Using third-party proxies that only support Anthropic's native `/v1/messages` endpoint (not OpenAI-compatible `/v1/chat/completions`)
|
||||||
> - Using custom endpoints that only support Anthropic's native format
|
> - Connecting to services like MiniMax, Synthetic that require Anthropic's native message format
|
||||||
> - Avoiding OpenAI-compatible wrapper layers
|
> - The existing `anthropic` protocol returns 404 errors (indicating the endpoint doesn't support OpenAI-compatible format)
|
||||||
|
>
|
||||||
|
> **Note:** The `anthropic` protocol uses OpenAI-compatible format (`/v1/chat/completions`), while `anthropic-messages` uses Anthropic's native format (`/v1/messages`). Choose based on your endpoint's supported format.
|
||||||
|
|
||||||
**Ollama (local)**
|
**Ollama (local)**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -607,9 +607,11 @@ Agent 读取 HEARTBEAT.md
|
||||||
```
|
```
|
||||||
|
|
||||||
> 使用 `anthropic-messages` 协议的场景:
|
> 使用 `anthropic-messages` 协议的场景:
|
||||||
> - 直接连接 Anthropic API(修复 `/v1/messages` 端点的 404 错误)
|
> - 使用仅支持 Anthropic 原生 `/v1/messages` 端点的第三方代理(不支持 OpenAI 兼容的 `/v1/chat/completions`)
|
||||||
> - 使用仅支持 Anthropic 原生格式的自定义端点
|
> - 连接到 MiniMax、Synthetic 等需要 Anthropic 原生消息格式的服务
|
||||||
> - 避免 OpenAI 兼容包装层
|
> - 现有的 `anthropic` 协议返回 404 错误(说明端点不支持 OpenAI 兼容格式)
|
||||||
|
>
|
||||||
|
> **注意:** `anthropic` 协议使用 OpenAI 兼容格式(`/v1/chat/completions`),而 `anthropic-messages` 使用 Anthropic 原生格式(`/v1/messages`)。请根据端点支持的格式选择。
|
||||||
|
|
||||||
**Ollama (本地)**
|
**Ollama (本地)**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,24 @@ func (p *Provider) Chat(
|
||||||
return nil, fmt.Errorf("reading response body: %w", err)
|
return nil, fmt.Errorf("reading response body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for HTTP errors
|
// Check for HTTP errors with detailed messages
|
||||||
if resp.StatusCode != http.StatusOK {
|
switch resp.StatusCode {
|
||||||
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
case http.StatusUnauthorized:
|
||||||
|
return nil, fmt.Errorf("authentication failed (401): check your API key")
|
||||||
|
case http.StatusTooManyRequests:
|
||||||
|
return nil, fmt.Errorf("rate limited (429): %s", string(body))
|
||||||
|
case http.StatusBadRequest:
|
||||||
|
return nil, fmt.Errorf("bad request (400): %s", string(body))
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return nil, fmt.Errorf("endpoint not found (404): %s", string(body))
|
||||||
|
case http.StatusInternalServerError:
|
||||||
|
return nil, fmt.Errorf("internal server error (500): %s", string(body))
|
||||||
|
case http.StatusServiceUnavailable:
|
||||||
|
return nil, fmt.Errorf("service unavailable (503): %s", string(body))
|
||||||
|
default:
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse response
|
// Parse response
|
||||||
|
|
@ -274,7 +289,7 @@ func parseResponseBody(body []byte) (*LLMResponse, error) {
|
||||||
|
|
||||||
// Extract content and tool calls
|
// Extract content and tool calls
|
||||||
var content strings.Builder
|
var content strings.Builder
|
||||||
var toolCalls []ToolCall
|
toolCalls := make([]ToolCall, 0) // Initialize as empty slice (not nil) for consistent JSON serialization
|
||||||
|
|
||||||
for _, block := range resp.Content {
|
for _, block := range resp.Content {
|
||||||
switch block.Type {
|
switch block.Type {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -431,6 +432,162 @@ func TestGetDefaultModel(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBuildRequestBodyEdgeCases tests edge cases for buildRequestBody.
|
||||||
|
func TestBuildRequestBodyEdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
messages []Message
|
||||||
|
tools []ToolDefinition
|
||||||
|
model string
|
||||||
|
options map[string]any
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty message list",
|
||||||
|
messages: []Message{},
|
||||||
|
model: "test-model",
|
||||||
|
options: map[string]any{
|
||||||
|
"max_tokens": 8192,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "very long system message",
|
||||||
|
messages: []Message{
|
||||||
|
{Role: "system", Content: strings.Repeat("This is a very long system prompt. ", 1000)},
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
},
|
||||||
|
model: "test-model",
|
||||||
|
options: map[string]any{
|
||||||
|
"max_tokens": 8192,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple consecutive system messages",
|
||||||
|
messages: []Message{
|
||||||
|
{Role: "system", Content: "First system message"},
|
||||||
|
{Role: "system", Content: "Second system message"},
|
||||||
|
{Role: "system", Content: "Third system message"},
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
},
|
||||||
|
model: "test-model",
|
||||||
|
options: map[string]any{
|
||||||
|
"max_tokens": 8192,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tool result without tool call",
|
||||||
|
messages: []Message{
|
||||||
|
{Role: "user", Content: "Use a tool"},
|
||||||
|
{Role: "assistant", Content: "", ToolCalls: []ToolCall{
|
||||||
|
{ID: "tool-1", Name: "test_tool", Arguments: map[string]any{"arg": "value"}},
|
||||||
|
}},
|
||||||
|
{Role: "user", ToolCallID: "tool-1", Content: "Tool result"},
|
||||||
|
},
|
||||||
|
model: "test-model",
|
||||||
|
options: map[string]any{
|
||||||
|
"max_tokens": 8192,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := buildRequestBody(tt.messages, tt.tools, tt.model, tt.options)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("buildRequestBody() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify basic structure
|
||||||
|
if got == nil {
|
||||||
|
t.Error("buildRequestBody() returned nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got["model"] != tt.model {
|
||||||
|
t.Errorf("model = %v, want %v", got["model"], tt.model)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody.
|
||||||
|
func TestParseResponseBodyEdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body []byte
|
||||||
|
wantErr bool
|
||||||
|
check func(*testing.T, *LLMResponse)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty content blocks",
|
||||||
|
body: []byte(`{
|
||||||
|
"id": "msg-empty",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [],
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"model": "test-model",
|
||||||
|
"usage": {"input_tokens": 5, "output_tokens": 0}
|
||||||
|
}`),
|
||||||
|
wantErr: false,
|
||||||
|
check: func(t *testing.T, resp *LLMResponse) {
|
||||||
|
if resp.Content != "" {
|
||||||
|
t.Errorf("Content = %q, want empty string", resp.Content)
|
||||||
|
}
|
||||||
|
if len(resp.ToolCalls) != 0 {
|
||||||
|
t.Errorf("ToolCalls length = %d, want 0", len(resp.ToolCalls))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple tool use blocks",
|
||||||
|
body: []byte(`{
|
||||||
|
"id": "msg-multi",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "tool_use", "id": "tool-1", "name": "func1", "input": {"arg": "val1"}},
|
||||||
|
{"type": "tool_use", "id": "tool-2", "name": "func2", "input": {"arg": "val2"}}
|
||||||
|
],
|
||||||
|
"stop_reason": "tool_use",
|
||||||
|
"model": "test-model",
|
||||||
|
"usage": {"input_tokens": 10, "output_tokens": 20}
|
||||||
|
}`),
|
||||||
|
wantErr: false,
|
||||||
|
check: func(t *testing.T, resp *LLMResponse) {
|
||||||
|
if len(resp.ToolCalls) != 2 {
|
||||||
|
t.Errorf("ToolCalls length = %d, want 2", len(resp.ToolCalls))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed JSON response",
|
||||||
|
body: []byte(`{invalid json`),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseResponseBody(tt.body)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("parseResponseBody() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tt.check != nil && err == nil {
|
||||||
|
tt.check(t, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestProviderChatErrors tests error handling in Chat.
|
// TestProviderChatErrors tests error handling in Chat.
|
||||||
// Note: apiBase check removed as it's dead code - normalizeBaseURL() always provides a default.
|
// Note: apiBase check removed as it's dead code - normalizeBaseURL() always provides a default.
|
||||||
func TestProviderChatErrors(t *testing.T) {
|
func TestProviderChatErrors(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue