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>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user