76ba500417
CI / docker (push) Successful in 2m4s
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>
284 lines
8.0 KiB
Go
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
|
|
}
|