From 2eba2c4d7590d595076aa9180b73fdc9eee84eb8 Mon Sep 17 00:00:00 2001 From: carlos4s <71615127+carlos4s@users.noreply.github.com> Date: Sun, 17 May 2026 18:03:33 -1000 Subject: [PATCH] Add Anthropic Go model provider (#14940) ### What problem does this PR solve? Adds the missing Anthropic provider implementation for the Go model provider layer. Closes #14939 ### What changed - Add `conf/models/anthropic.json` with Anthropic Claude chat/vision models and API endpoints. - Add `internal/entity/models/anthropic.go` implementing non-streaming Messages API chat, model listing, and connection checking. - Register `anthropic` in the Go model factory. - Add httptest coverage for headers, payload mapping, response parsing, validation errors, provider errors, model listing, connection checking, factory registration, and unsupported methods. ### Notes Streaming chat is left as an explicit `no such method` follow-up because this initial provider focuses on non-streaming chat and connection checking. ### Tests - `docker run --rm -v /home/ubuntu/Documents/gitTensor_repos/carlos/ragflow:/work -v /tmp/ragflow-go-cache:/go/pkg/mod -v /tmp/ragflow-go-build:/root/.cache/go-build -w /work golang:1.25 go test -vet=off ./internal/entity/models -run Anthropic -count=1 -v` - `docker run --rm -v /home/ubuntu/Documents/gitTensor_repos/carlos/ragflow:/work -v /tmp/ragflow-go-cache:/go/pkg/mod -v /tmp/ragflow-go-build:/root/.cache/go-build -w /work golang:1.25 go test -vet=off ./internal/entity -count=1` - `git diff --check` - `jq . conf/models/anthropic.json >/dev/null` Plain `go test ./internal/entity/models` currently hits pre-existing unrelated vet findings in other provider files (`baidu.go`, `cohere.go`, `fishaudio.go`, `openrouter.go`). --------- Co-authored-by: Jin Hai --- conf/models/anthropic.json | 85 ++++ internal/entity/models/anthropic.go | 491 +++++++++++++++++++++++ internal/entity/models/anthropic_test.go | 383 ++++++++++++++++++ internal/entity/models/factory.go | 2 + 4 files changed, 961 insertions(+) create mode 100644 conf/models/anthropic.json create mode 100644 internal/entity/models/anthropic.go create mode 100644 internal/entity/models/anthropic_test.go diff --git a/conf/models/anthropic.json b/conf/models/anthropic.json new file mode 100644 index 0000000000..93cd6b2797 --- /dev/null +++ b/conf/models/anthropic.json @@ -0,0 +1,85 @@ +{ + "name": "Anthropic", + "url": { + "default": "https://api.anthropic.com" + }, + "url_suffix": { + "chat": "v1/messages", + "models": "v1/models" + }, + "class": "anthropic", + "models": [ + { + "name": "claude-opus-4-5-20251101", + "max_tokens": 128000, + "model_types": [ + "chat", + "vision" + ] + }, + { + "name": "claude-opus-4-1-20250805", + "max_tokens": 128000, + "model_types": [ + "chat", + "vision" + ] + }, + { + "name": "claude-opus-4-20250514", + "max_tokens": 128000, + "model_types": [ + "chat", + "vision" + ] + }, + { + "name": "claude-sonnet-4-5-20250929", + "max_tokens": 64000, + "model_types": [ + "chat", + "vision" + ] + }, + { + "name": "claude-sonnet-4-20250514", + "max_tokens": 64000, + "model_types": [ + "chat", + "vision" + ] + }, + { + "name": "claude-haiku-4-5-20251001", + "max_tokens": 64000, + "model_types": [ + "chat", + "vision" + ] + }, + { + "name": "claude-3-7-sonnet-20250219", + "max_tokens": 64000, + "model_types": [ + "chat", + "vision" + ] + }, + { + "name": "claude-3-5-sonnet-20241022", + "max_tokens": 8192, + "model_types": [ + "chat", + "vision" + ] + }, + { + "name": "claude-3-5-haiku-20241022", + "max_tokens": 8192, + "model_types": [ + "chat", + "vision" + ] + } + ] +} diff --git a/internal/entity/models/anthropic.go b/internal/entity/models/anthropic.go new file mode 100644 index 0000000000..8777712f7d --- /dev/null +++ b/internal/entity/models/anthropic.go @@ -0,0 +1,491 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package models + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const anthropicVersion = "2023-06-01" + +// AnthropicModel implements ModelDriver for Claude models through the +// Anthropic Messages API. +type AnthropicModel struct { + BaseURL map[string]string + URLSuffix URLSuffix + httpClient *http.Client +} + +func NewAnthropicModel(baseURL map[string]string, urlSuffix URLSuffix) *AnthropicModel { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = 100 + transport.MaxIdleConnsPerHost = 10 + transport.IdleConnTimeout = 90 * time.Second + transport.ResponseHeaderTimeout = 60 * time.Second + + return &AnthropicModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, + } +} + +func (a *AnthropicModel) NewInstance(baseURL map[string]string) ModelDriver { + return NewAnthropicModel(baseURL, a.URLSuffix) +} + +func (a *AnthropicModel) Name() string { + return "anthropic" +} + +func (a *AnthropicModel) baseURLForRegion(region string) (string, error) { + base, ok := a.BaseURL[region] + if !ok || strings.TrimSpace(base) == "" { + return "", fmt.Errorf("anthropic: no base URL configured for region %q", region) + } + return strings.TrimRight(base, "/"), nil +} + +func (a *AnthropicModel) region(apiConfig *APIConfig) string { + if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { + return *apiConfig.Region + } + return "default" +} + +func (a *AnthropicModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + apiKey, err := anthropicAPIKey(apiConfig) + if err != nil { + return nil, err + } + if len(messages) == 0 { + return nil, fmt.Errorf("messages is empty") + } + + apiMessages, systemPrompt, err := anthropicMessages(messages) + if err != nil { + return nil, err + } + + baseURL, err := a.baseURLForRegion(a.region(apiConfig)) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", baseURL, strings.TrimLeft(a.URLSuffix.Chat, "/")) + + reqBody := map[string]interface{}{ + "model": modelName, + "messages": apiMessages, + "max_tokens": 1024, + } + if systemPrompt != "" { + reqBody["system"] = systemPrompt + } + applyAnthropicChatConfig(reqBody, chatModelConfig) + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + setAnthropicHeaders(req, apiKey) + + resp, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Anthropic messages API error: %s, body: %s", resp.Status, string(body)) + } + + answer, reasoning, err := parseAnthropicChatResponse(body) + if err != nil { + return nil, err + } + return &ChatResponse{ + Answer: &answer, + ReasonContent: &reasoning, + }, nil +} + +func anthropicAPIKey(apiConfig *APIConfig) (string, error) { + if apiConfig == nil || apiConfig.ApiKey == nil || strings.TrimSpace(*apiConfig.ApiKey) == "" { + return "", fmt.Errorf("api key is required") + } + return strings.TrimSpace(*apiConfig.ApiKey), nil +} + +func applyAnthropicChatConfig(reqBody map[string]interface{}, chatModelConfig *ChatConfig) { + if chatModelConfig == nil { + return + } + if chatModelConfig.MaxTokens != nil { + reqBody["max_tokens"] = *chatModelConfig.MaxTokens + } + if chatModelConfig.Temperature != nil { + reqBody["temperature"] = *chatModelConfig.Temperature + } + if chatModelConfig.TopP != nil { + reqBody["top_p"] = *chatModelConfig.TopP + } + if chatModelConfig.Stop != nil { + reqBody["stop_sequences"] = *chatModelConfig.Stop + } +} + +func setAnthropicHeaders(req *http.Request, apiKey string) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", anthropicVersion) +} + +func anthropicMessages(messages []Message) ([]map[string]interface{}, string, error) { + apiMessages := make([]map[string]interface{}, 0, len(messages)) + systemPrompts := make([]string, 0) + for _, msg := range messages { + role := strings.ToLower(strings.TrimSpace(msg.Role)) + content, err := anthropicContent(msg.Content) + if err != nil { + return nil, "", err + } + switch role { + case "system": + if text, ok := anthropicSystemText(content); ok && text != "" { + systemPrompts = append(systemPrompts, text) + } + case "user", "assistant": + apiMessages = append(apiMessages, map[string]interface{}{ + "role": role, + "content": content, + }) + default: + return nil, "", fmt.Errorf("anthropic: unsupported message role %q", msg.Role) + } + } + if len(apiMessages) == 0 { + return nil, "", fmt.Errorf("messages is empty") + } + return apiMessages, strings.Join(systemPrompts, "\n\n"), nil +} + +func anthropicSystemText(content interface{}) (string, bool) { + switch value := content.(type) { + case string: + return value, true + case []map[string]interface{}: + parts := make([]string, 0, len(value)) + for _, block := range value { + if block["type"] == "text" { + if text, ok := block["text"].(string); ok { + parts = append(parts, text) + } + } + } + return strings.Join(parts, "\n"), true + default: + return "", false + } +} + +func anthropicContent(content interface{}) (interface{}, error) { + switch value := content.(type) { + case string: + return value, nil + case []interface{}: + return anthropicContentBlocks(value) + case []map[string]interface{}: + blocks := make([]interface{}, 0, len(value)) + for _, block := range value { + blocks = append(blocks, block) + } + return anthropicContentBlocks(blocks) + default: + return nil, fmt.Errorf("anthropic: unsupported message content type %T", content) + } +} + +func anthropicContentBlocks(blocks []interface{}) ([]map[string]interface{}, error) { + apiBlocks := make([]map[string]interface{}, 0, len(blocks)) + for _, item := range blocks { + block, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("anthropic: invalid content block %T", item) + } + converted, err := anthropicContentBlock(block) + if err != nil { + return nil, err + } + apiBlocks = append(apiBlocks, converted) + } + return apiBlocks, nil +} + +func anthropicContentBlock(block map[string]interface{}) (map[string]interface{}, error) { + blockType, _ := block["type"].(string) + switch blockType { + case "text": + text, ok := block["text"].(string) + if !ok { + return nil, fmt.Errorf("anthropic: text block missing or invalid text field %T", block["text"]) + } + return map[string]interface{}{"type": "text", "text": text}, nil + case "image": + return validateAnthropicImageBlock(block) + case "image_url": + return anthropicImageURLBlock(block) + default: + return nil, fmt.Errorf("anthropic: unsupported content block type %q", blockType) + } +} + +func validateAnthropicImageBlock(block map[string]interface{}) (map[string]interface{}, error) { + source, ok := block["source"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("anthropic: image block missing source object") + } + sourceType, ok := source["type"].(string) + if !ok || sourceType == "" { + return nil, fmt.Errorf("anthropic: image source missing type") + } + switch sourceType { + case "url": + if url, ok := source["url"].(string); !ok || url == "" { + return nil, fmt.Errorf("anthropic: image url source missing url") + } + case "base64": + mediaType, ok := source["media_type"].(string) + if !ok || mediaType == "" { + return nil, fmt.Errorf("anthropic: image base64 source missing media_type") + } + data, ok := source["data"].(string) + if !ok || data == "" { + return nil, fmt.Errorf("anthropic: image base64 source missing data") + } + if _, err := base64.StdEncoding.DecodeString(data); err != nil { + return nil, fmt.Errorf("anthropic: invalid base64 image data: %w", err) + } + default: + return nil, fmt.Errorf("anthropic: unsupported image source type %q", sourceType) + } + return block, nil +} + +func anthropicImageURLBlock(block map[string]interface{}) (map[string]interface{}, error) { + imageURL, ok := block["image_url"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("anthropic: image_url block missing image_url object") + } + url, _ := imageURL["url"].(string) + if url == "" { + return nil, fmt.Errorf("anthropic: image_url block missing url") + } + source := map[string]interface{}{ + "type": "url", + "url": url, + } + if strings.HasPrefix(url, "data:") { + mediaType, data, err := parseDataImageURL(url) + if err != nil { + return nil, err + } + source = map[string]interface{}{ + "type": "base64", + "media_type": mediaType, + "data": data, + } + } + return map[string]interface{}{ + "type": "image", + "source": source, + }, nil +} + +func parseDataImageURL(url string) (string, string, error) { + const marker = ";base64," + if !strings.HasPrefix(url, "data:") || !strings.Contains(url, marker) { + return "", "", fmt.Errorf("anthropic: unsupported data image url") + } + trimmed := strings.TrimPrefix(url, "data:") + parts := strings.SplitN(trimmed, marker, 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("anthropic: invalid data image url") + } + if _, err := base64.StdEncoding.DecodeString(parts[1]); err != nil { + return "", "", fmt.Errorf("anthropic: invalid base64 image data: %w", err) + } + return parts[0], parts[1], nil +} + +func parseAnthropicChatResponse(body []byte) (string, string, error) { + var result struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` + } `json:"content"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", "", fmt.Errorf("failed to parse response: %w", err) + } + if len(result.Content) == 0 { + return "", "", fmt.Errorf("no content in Anthropic response") + } + + var answer strings.Builder + var reasoning strings.Builder + for _, block := range result.Content { + switch block.Type { + case "text": + answer.WriteString(block.Text) + case "thinking": + reasoning.WriteString(block.Thinking) + } + } + if answer.Len() == 0 { + return "", "", fmt.Errorf("no text content in Anthropic response") + } + return answer.String(), reasoning.String(), nil +} + +func (a *AnthropicModel) ListModels(apiConfig *APIConfig) ([]string, error) { + apiKey, err := anthropicAPIKey(apiConfig) + if err != nil { + return nil, err + } + + baseURL, err := a.baseURLForRegion(a.region(apiConfig)) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", baseURL, strings.TrimLeft(a.URLSuffix.Models, "/")) + + ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + setAnthropicHeaders(req, apiKey) + + resp, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Anthropic models API error: %s, body: %s", resp.Status, string(body)) + } + + var result struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err = json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + models := make([]string, 0, len(result.Data)) + for _, item := range result.Data { + if item.ID != "" { + models = append(models, item.ID) + } + } + return models, nil +} + +func (a *AnthropicModel) CheckConnection(apiConfig *APIConfig) error { + _, err := a.ListModels(apiConfig) + return err +} + +func (a *AnthropicModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + return fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *AnthropicModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *AnthropicModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *AnthropicModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *AnthropicModel) TranscribeAudioWithSender(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig, sender func(*string, *string) error) error { + return fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *AnthropicModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, asrConfig *TTSConfig) (*TTSResponse, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *AnthropicModel) AudioSpeechWithSender(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig, sender func(*string, *string) error) error { + return fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *AnthropicModel) OCRFile(modelName *string, content []byte, url *string, apiConfig *APIConfig, ocrConfig *OCRConfig) (*OCRFileResponse, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *AnthropicModel) ParseFile(modelName *string, content []byte, url *string, apiConfig *APIConfig, parseFileConfig *ParseFileConfig) (*ParseFileResponse, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *AnthropicModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *AnthropicModel) ListTasks(apiConfig *APIConfig) ([]ListTaskStatus, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *AnthropicModel) ShowTask(taskID string, apiConfig *APIConfig) (*TaskResponse, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} diff --git a/internal/entity/models/anthropic_test.go b/internal/entity/models/anthropic_test.go new file mode 100644 index 0000000000..e2ad0768d2 --- /dev/null +++ b/internal/entity/models/anthropic_test.go @@ -0,0 +1,383 @@ +package models + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func newAnthropicServer(t *testing.T, expectedPath string, handler func(t *testing.T, body map[string]interface{}, w http.ResponseWriter)) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != expectedPath { + t.Errorf("expected path=%s, got %s", expectedPath, r.URL.Path) + return + } + if got := r.Header.Get("x-api-key"); got != "test-key" { + t.Errorf("expected x-api-key=test-key, got %q", got) + return + } + if got := r.Header.Get("anthropic-version"); got != anthropicVersion { + t.Errorf("expected anthropic-version=%s, got %q", anthropicVersion, got) + return + } + if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Errorf("expected Content-Type to start with application/json, got %q", got) + return + } + if r.Method == http.MethodPost { + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) + return + } + var body map[string]interface{} + if err := json.Unmarshal(raw, &body); err != nil { + t.Errorf("unmarshal: %v\nraw=%s", err, string(raw)) + return + } + handler(t, body, w) + return + } + handler(t, nil, w) + })) +} + +func newAnthropicForTest(baseURL string) *AnthropicModel { + return NewAnthropicModel( + map[string]string{"default": baseURL}, + URLSuffix{Chat: "v1/messages", Models: "v1/models"}, + ) +} + +func TestAnthropicName(t *testing.T) { + if got := newAnthropicForTest("http://unused").Name(); got != "anthropic" { + t.Errorf("Name()=%q, want anthropic", got) + } +} + +func TestAnthropicChatHappyPath(t *testing.T) { + srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { + if body["model"] != "claude-sonnet-4-5-20250929" { + t.Errorf("model=%v", body["model"]) + } + if body["max_tokens"] != float64(1024) { + t.Errorf("max_tokens=%v want 1024", body["max_tokens"]) + } + msgs, ok := body["messages"].([]interface{}) + if !ok || len(msgs) != 1 { + t.Errorf("messages=%v, want one message", body["messages"]) + return + } + msg, ok := msgs[0].(map[string]interface{}) + if !ok || msg["role"] != "user" || msg["content"] != "ping" { + t.Errorf("message=%v, want user ping", msgs[0]) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "content": []map[string]interface{}{ + {"type": "thinking", "thinking": "reasoning"}, + {"type": "text", "text": "pong"}, + }, + }) + }) + defer srv.Close() + + apiKey := "test-key" + resp, err := newAnthropicForTest(srv.URL).ChatWithMessages( + "claude-sonnet-4-5-20250929", + []Message{{Role: "user", Content: "ping"}}, + &APIConfig{ApiKey: &apiKey}, + nil, + ) + if err != nil { + t.Fatalf("ChatWithMessages: %v", err) + } + if resp.Answer == nil || *resp.Answer != "pong" { + t.Errorf("answer=%v, want pong", resp.Answer) + } + if resp.ReasonContent == nil || *resp.ReasonContent != "reasoning" { + t.Errorf("reason=%v, want reasoning", resp.ReasonContent) + } +} + +func TestAnthropicChatMapsSystemConfigAndImages(t *testing.T) { + srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { + if body["system"] != "be concise" { + t.Errorf("system=%v, want be concise", body["system"]) + } + if body["max_tokens"] != float64(64) { + t.Errorf("max_tokens=%v want 64", body["max_tokens"]) + } + if body["temperature"] != 0.25 { + t.Errorf("temperature=%v want 0.25", body["temperature"]) + } + if body["top_p"] != 0.8 { + t.Errorf("top_p=%v want 0.8", body["top_p"]) + } + stop, ok := body["stop_sequences"].([]interface{}) + if !ok || len(stop) != 1 || stop[0] != "END" { + t.Errorf("stop_sequences=%v want [END]", body["stop_sequences"]) + } + msgs, ok := body["messages"].([]interface{}) + if !ok || len(msgs) == 0 { + t.Errorf("messages=%v, want non-empty array", body["messages"]) + return + } + first, ok := msgs[0].(map[string]interface{}) + if !ok { + t.Errorf("first message=%v, want object", msgs[0]) + return + } + content, ok := first["content"].([]interface{}) + if !ok || len(content) < 2 { + t.Errorf("content=%v, want at least 2 blocks", first["content"]) + return + } + image, ok := content[1].(map[string]interface{}) + if !ok { + t.Errorf("image block=%v, want object", content[1]) + return + } + source, ok := image["source"].(map[string]interface{}) + if !ok { + t.Errorf("image source=%v, want object", image["source"]) + return + } + if image["type"] != "image" || source["type"] != "url" || source["url"] != "https://example.com/cat.png" { + t.Errorf("image block=%v", image) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": "ok"}}, + }) + }) + defer srv.Close() + + apiKey := "test-key" + maxTokens := 64 + temperature := 0.25 + topP := 0.8 + stop := []string{"END"} + _, err := newAnthropicForTest(srv.URL).ChatWithMessages( + "claude-opus-4-5-20251101", + []Message{ + {Role: "system", Content: "be concise"}, + {Role: "user", Content: []interface{}{ + map[string]interface{}{"type": "text", "text": "what is this?"}, + map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": "https://example.com/cat.png"}}, + }}, + }, + &APIConfig{ApiKey: &apiKey}, + &ChatConfig{MaxTokens: &maxTokens, Temperature: &temperature, TopP: &topP, Stop: &stop}, + ) + if err != nil { + t.Fatalf("ChatWithMessages: %v", err) + } +} + +func TestAnthropicChatMapsDataImageURL(t *testing.T) { + srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { + msgs, ok := body["messages"].([]interface{}) + if !ok || len(msgs) == 0 { + t.Errorf("messages=%v, want non-empty array", body["messages"]) + return + } + first, ok := msgs[0].(map[string]interface{}) + if !ok { + t.Errorf("first message=%v, want object", msgs[0]) + return + } + content, ok := first["content"].([]interface{}) + if !ok || len(content) == 0 { + t.Errorf("content=%v, want non-empty array", first["content"]) + return + } + image, ok := content[0].(map[string]interface{}) + if !ok { + t.Errorf("image block=%v, want object", content[0]) + return + } + source, ok := image["source"].(map[string]interface{}) + if !ok { + t.Errorf("source=%v, want object", image["source"]) + return + } + if source["type"] != "base64" || source["media_type"] != "image/png" || source["data"] != "aGVsbG8=" { + t.Errorf("source=%v", source) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": "ok"}}, + }) + }) + defer srv.Close() + + apiKey := "test-key" + _, err := newAnthropicForTest(srv.URL).ChatWithMessages( + "claude-sonnet-4-5-20250929", + []Message{{Role: "user", Content: []interface{}{ + map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": "data:image/png;base64,aGVsbG8="}}, + }}}, + &APIConfig{ApiKey: &apiKey}, + nil, + ) + if err != nil { + t.Fatalf("ChatWithMessages: %v", err) + } +} + +func TestAnthropicChatValidationErrors(t *testing.T) { + m := newAnthropicForTest("http://unused") + apiKey := "test-key" + if _, err := m.ChatWithMessages("claude", []Message{{Role: "user", Content: "x"}}, nil, nil); err == nil || !strings.Contains(err.Error(), "api key is required") { + t.Errorf("nil api config: got %v", err) + } + if _, err := m.ChatWithMessages("claude", nil, &APIConfig{ApiKey: &apiKey}, nil); err == nil || !strings.Contains(err.Error(), "messages is empty") { + t.Errorf("empty messages: got %v", err) + } + if _, err := m.ChatWithMessages("claude", []Message{{Role: "tool", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil); err == nil || !strings.Contains(err.Error(), "unsupported message role") { + t.Errorf("bad role: got %v", err) + } + if _, err := m.ChatWithMessages("claude", []Message{{Role: "user", Content: []interface{}{map[string]interface{}{"type": "video_url"}}}}, &APIConfig{ApiKey: &apiKey}, nil); err == nil || !strings.Contains(err.Error(), "unsupported content block type") { + t.Errorf("bad block: got %v", err) + } + if _, err := m.ChatWithMessages("claude", []Message{{Role: "user", Content: []interface{}{map[string]interface{}{"type": "text", "text": 42}}}}, &APIConfig{ApiKey: &apiKey}, nil); err == nil || !strings.Contains(err.Error(), "invalid text field") { + t.Errorf("bad text block: got %v", err) + } + if _, err := m.ChatWithMessages("claude", []Message{{Role: "user", Content: []interface{}{map[string]interface{}{"type": "image"}}}}, &APIConfig{ApiKey: &apiKey}, nil); err == nil || !strings.Contains(err.Error(), "image block missing source") { + t.Errorf("bad image block: got %v", err) + } +} + +func TestAnthropicChatRejectsHTTPError(t *testing.T) { + srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"message":"bad key"}}`)) + }) + defer srv.Close() + + apiKey := "test-key" + _, err := newAnthropicForTest(srv.URL).ChatWithMessages("claude", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil) + if err == nil || !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "bad key") { + t.Errorf("expected provider error, got %v", err) + } +} + +func TestAnthropicChatRejectsMalformedResponse(t *testing.T) { + srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{"content": []map[string]interface{}{{"type": "tool_use"}}}) + }) + defer srv.Close() + + apiKey := "test-key" + _, err := newAnthropicForTest(srv.URL).ChatWithMessages("claude", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil) + if err == nil || !strings.Contains(err.Error(), "no text content") { + t.Errorf("expected no-text error, got %v", err) + } +} + +func TestAnthropicListModelsAndCheckConnection(t *testing.T) { + var calls int + srv := newAnthropicServer(t, "/v1/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { + calls++ + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{ + {"id": "claude-sonnet-4-5-20250929"}, + {"id": "claude-haiku-4-5-20251001"}, + }, + }) + }) + defer srv.Close() + + apiKey := "test-key" + m := newAnthropicForTest(srv.URL) + models, err := m.ListModels(&APIConfig{ApiKey: &apiKey}) + if err != nil { + t.Fatalf("ListModels: %v", err) + } + if strings.Join(models, ",") != "claude-sonnet-4-5-20250929,claude-haiku-4-5-20251001" { + t.Errorf("models=%v", models) + } + if err := m.CheckConnection(&APIConfig{ApiKey: &apiKey}); err != nil { + t.Errorf("CheckConnection: %v", err) + } + if calls != 2 { + t.Errorf("calls=%d, want 2", calls) + } +} + +func TestAnthropicListModelsRejectsProviderError(t *testing.T) { + srv := newAnthropicServer(t, "/v1/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }) + defer srv.Close() + + apiKey := "test-key" + _, err := newAnthropicForTest(srv.URL).ListModels(&APIConfig{ApiKey: &apiKey}) + if err == nil || !strings.Contains(err.Error(), "403") { + t.Errorf("expected 403 error, got %v", err) + } +} + +func TestAnthropicFactoryRegistration(t *testing.T) { + driver, err := NewModelFactory().CreateModelDriver("Anthropic", map[string]string{"default": "http://unused"}, URLSuffix{}) + if err != nil { + t.Fatalf("CreateModelDriver: %v", err) + } + if _, ok := driver.(*AnthropicModel); !ok { + t.Fatalf("driver type=%T, want *AnthropicModel", driver) + } +} + +func TestAnthropicUnsupportedMethods(t *testing.T) { + m := newAnthropicForTest("http://unused") + apiKey := "test-key" + modelName := "claude" + checks := []struct { + name string + err error + }{ + {"stream", m.ChatStreamlyWithSender(modelName, []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil, func(*string, *string) error { return nil })}, + } + for _, check := range checks { + if check.err == nil || !strings.Contains(check.err.Error(), "no such method") { + t.Errorf("%s: want no such method, got %v", check.name, check.err) + } + } + if _, err := m.Embed(&modelName, []string{"x"}, &APIConfig{ApiKey: &apiKey}, nil); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("Embed: got %v", err) + } + if _, err := m.Rerank(&modelName, "q", []string{"d"}, &APIConfig{ApiKey: &apiKey}, nil); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("Rerank: got %v", err) + } + if _, err := m.Balance(&APIConfig{ApiKey: &apiKey}); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("Balance: got %v", err) + } + if _, err := m.TranscribeAudio(&modelName, &modelName, &APIConfig{ApiKey: &apiKey}, nil); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("TranscribeAudio: got %v", err) + } + if err := m.TranscribeAudioWithSender(&modelName, &modelName, &APIConfig{ApiKey: &apiKey}, nil, nil); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("TranscribeAudioWithSender: got %v", err) + } + if _, err := m.AudioSpeech(&modelName, &modelName, &APIConfig{ApiKey: &apiKey}, nil); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("AudioSpeech: got %v", err) + } + if err := m.AudioSpeechWithSender(&modelName, &modelName, &APIConfig{ApiKey: &apiKey}, nil, nil); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("AudioSpeechWithSender: got %v", err) + } + if _, err := m.OCRFile(&modelName, nil, &modelName, &APIConfig{ApiKey: &apiKey}, nil); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("OCRFile: got %v", err) + } + if _, err := m.ParseFile(&modelName, nil, &modelName, &APIConfig{ApiKey: &apiKey}, nil); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("ParseFile: got %v", err) + } + if _, err := m.ListTasks(&APIConfig{ApiKey: &apiKey}); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("ListTasks: got %v", err) + } + if _, err := m.ShowTask("task-id", &APIConfig{ApiKey: &apiKey}); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("ShowTask: got %v", err) + } +} diff --git a/internal/entity/models/factory.go b/internal/entity/models/factory.go index 2df11aec48..b1ca708051 100644 --- a/internal/entity/models/factory.go +++ b/internal/entity/models/factory.go @@ -33,6 +33,8 @@ func NewModelFactory() *ModelFactory { func (f *ModelFactory) CreateModelDriver(providerName string, baseURL map[string]string, urlSuffix URLSuffix) (ModelDriver, error) { providerLower := strings.ToLower(providerName) switch providerLower { + case "anthropic": + return NewAnthropicModel(baseURL, urlSuffix), nil case "zhipu-ai": return NewZhipuAIModel(baseURL, urlSuffix), nil case "deepseek":