Files
Luminary/internal/provider/anthropic/client.go
T
renjue 76ba500417
CI / docker (push) Successful in 2m4s
Initial commit: Luminary AI Gateway
OpenAI-compatible AI gateway with Vue admin UI, multi-provider egress,
ingress key governance, monitoring, and security controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 21:46:16 +08:00

284 lines
8.0 KiB
Go

package anthropic
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/provider"
)
type Client struct {
http *provider.HTTPClientWrapper
}
func New() *Client {
return &Client{http: provider.NewHTTPClientWrapper()}
}
func (c *Client) Type() string { return "anthropic" }
func (c *Client) ListModels(ctx context.Context, apiKey, baseURL string) ([]provider.ModelInfo, error) {
return c.ListModelsWithConfig(ctx, apiKey, provider.ProviderConfig{
Type: model.ProviderAnthropic,
BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderAnthropic),
})
}
func (c *Client) ListModelsWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) ([]provider.ModelInfo, error) {
cfg.Type = model.ProviderAnthropic
cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderAnthropic)
url := cfg.ResolveEndpointURL(provider.EndpointListModels)
resp, err := provider.ForwardHTTP(ctx, c.http.Client, "GET", url, apiKey, model.ProviderAnthropic, nil, nil)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("anthropic list models: status %d: %s", resp.StatusCode, string(resp.Body))
}
var out struct {
Data []struct {
ID string `json:"id"`
Name string `json:"display_name"`
} `json:"data"`
}
if err := json.Unmarshal(resp.Body, &out); err != nil {
return nil, err
}
models := make([]provider.ModelInfo, 0, len(out.Data))
for _, m := range out.Data {
name := m.Name
if name == "" {
name = m.ID
}
models = append(models, provider.ModelInfo{ID: m.ID, DisplayName: name})
}
return models, nil
}
func (c *Client) ChatCompletion(ctx context.Context, apiKey, baseURL string, req *provider.ChatRequest) (*provider.ChatResponse, error) {
cfg := provider.ProviderConfig{
Type: model.ProviderAnthropic,
BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderAnthropic),
}
anthropicReq, err := convertOpenAIToAnthropic(req)
if err != nil {
return nil, err
}
body, err := json.Marshal(anthropicReq)
if err != nil {
return nil, err
}
url := cfg.ResolveEndpointURL(provider.EndpointChatCompletion)
resp, err := provider.ForwardHTTP(ctx, c.http.Client, "POST", url, apiKey, model.ProviderAnthropic, body, nil)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return &provider.ChatResponse{StatusCode: resp.StatusCode, Body: resp.Body}, nil
}
openAIResp, tokensIn, tokensOut := convertAnthropicToOpenAI(resp.Body, req.Model)
return &provider.ChatResponse{
StatusCode: 200,
Body: openAIResp,
TokensIn: tokensIn,
TokensOut: tokensOut,
}, nil
}
func (c *Client) ForwardWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, error) {
cfg.Type = model.ProviderAnthropic
cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderAnthropic)
if endpoint == provider.EndpointChatCompletion {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err == nil {
modelName, _ := payload["model"].(string)
anthropicReq, err := convertOpenAIToAnthropic(&provider.ChatRequest{Model: modelName, Raw: body})
if err == nil {
body, _ = json.Marshal(anthropicReq)
}
}
}
url := cfg.ResolveEndpointURL(endpoint)
resp, err := provider.ForwardHTTP(ctx, c.http.Client, "POST", url, apiKey, model.ProviderAnthropic, body, nil)
if err != nil {
return nil, err
}
if endpoint == provider.EndpointChatCompletion && resp.StatusCode < 400 {
var payload map[string]any
modelName := ""
_ = json.Unmarshal(body, &payload)
if m, ok := payload["model"].(string); ok {
modelName = m
}
openAIResp, tokensIn, tokensOut := convertAnthropicToOpenAI(resp.Body, modelName)
return &provider.ChatResponse{StatusCode: 200, Body: openAIResp, TokensIn: tokensIn, TokensOut: tokensOut}, nil
}
return &provider.ChatResponse{StatusCode: resp.StatusCode, Body: resp.Body, TokensIn: resp.TokensIn, TokensOut: resp.TokensOut}, nil
}
func (c *Client) HealthCheck(ctx context.Context, apiKey, baseURL string) error {
_, err := c.ListModels(ctx, apiKey, baseURL)
return err
}
func (c *Client) HealthCheckWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) error {
_, err := c.ListModelsWithConfig(ctx, apiKey, cfg)
return err
}
// convertOpenAIToAnthropic and convertAnthropicToOpenAI unchanged below
func convertOpenAIToAnthropic(req *provider.ChatRequest) (map[string]any, error) {
var payload map[string]any
raw := req.Raw
if len(raw) == 0 {
b, _ := json.Marshal(req)
raw = b
}
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, err
}
model, _ := payload["model"].(string)
if model == "" {
model = req.Model
}
msgs, _ := payload["messages"].([]any)
var system string
var anthropicMsgs []map[string]any
for _, m := range msgs {
msg, _ := m.(map[string]any)
role, _ := msg["role"].(string)
if role == "system" {
system = extractOpenAIContent(msg["content"])
continue
}
if role != "assistant" {
role = "user"
}
content := convertOpenAIContentBlocks(msg["content"])
anthropicMsgs = append(anthropicMsgs, map[string]any{"role": role, "content": content})
}
out := map[string]any{
"model": model,
"max_tokens": 4096,
"messages": anthropicMsgs,
}
if system != "" {
out["system"] = system
}
if maxTok, ok := payload["max_tokens"].(float64); ok {
out["max_tokens"] = int(maxTok)
}
return out, nil
}
func extractOpenAIContent(content any) string {
switch v := content.(type) {
case string:
return v
case []any:
var parts []string
for _, p := range v {
part, ok := p.(map[string]any)
if !ok {
continue
}
if text, ok := part["text"].(string); ok {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n")
default:
return ""
}
}
func convertOpenAIContentBlocks(content any) any {
switch v := content.(type) {
case string:
if v == "" {
return ""
}
return v
case []any:
blocks := make([]map[string]any, 0, len(v))
for _, p := range v {
part, ok := p.(map[string]any)
if !ok {
continue
}
partType, _ := part["type"].(string)
switch partType {
case "text":
if text, ok := part["text"].(string); ok {
blocks = append(blocks, map[string]any{"type": "text", "text": text})
}
case "image_url":
if imageURL, ok := part["image_url"].(map[string]any); ok {
if url, ok := imageURL["url"].(string); ok && url != "" {
blocks = append(blocks, map[string]any{
"type": "image",
"source": map[string]any{
"type": "url",
"url": url,
},
})
}
}
case "image":
if source, ok := part["source"].(map[string]any); ok {
blocks = append(blocks, map[string]any{"type": "image", "source": source})
}
default:
if text, ok := part["text"].(string); ok {
blocks = append(blocks, map[string]any{"type": "text", "text": text})
}
}
}
if len(blocks) == 1 {
if text, ok := blocks[0]["text"].(string); ok && blocks[0]["type"] == "text" {
return text
}
}
if len(blocks) > 0 {
return blocks
}
return ""
default:
return ""
}
}
func convertAnthropicToOpenAI(body []byte, model string) ([]byte, int, int) {
var resp struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
_ = json.Unmarshal(body, &resp)
text := ""
if len(resp.Content) > 0 {
text = resp.Content[0].Text
}
openAI := map[string]any{
"id": "chatcmpl-luminary",
"object": "chat.completion",
"model": model,
"choices": []any{map[string]any{"index": 0, "message": map[string]string{"role": "assistant", "content": text}, "finish_reason": "stop"}},
"usage": map[string]int{
"prompt_tokens": resp.Usage.InputTokens,
"completion_tokens": resp.Usage.OutputTokens,
"total_tokens": resp.Usage.InputTokens + resp.Usage.OutputTokens,
},
}
b, _ := json.Marshal(openAI)
return b, resp.Usage.InputTokens, resp.Usage.OutputTokens
}