Files
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

199 lines
4.7 KiB
Go

package provider
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/rose_cat707/luminary/internal/model"
)
type ForwardRequest struct {
Endpoint EndpointType
Method string
Body []byte
Headers map[string]string
}
type ForwardResponse struct {
StatusCode int
Body []byte
TokensIn int
TokensOut int
}
type ProviderConfig struct {
Type model.ProviderType
Category model.ProviderCategory
BaseURL string
EndpointPaths map[string]string // endpoint key -> path override
}
func ParseEndpointPaths(jsonStr string) map[string]string {
out := map[string]string{}
if jsonStr == "" || jsonStr == "{}" {
return out
}
_ = json.Unmarshal([]byte(jsonStr), &out)
return out
}
func ParseAllowedEndpoints(jsonStr string, category model.ProviderCategory) []string {
var out []string
if jsonStr == "" {
return DefaultEndpointsForCategory(category)
}
_ = json.Unmarshal([]byte(jsonStr), &out)
if len(out) == 0 {
return DefaultEndpointsForCategory(category)
}
return out
}
func (cfg *ProviderConfig) ResolveEndpointURL(endpoint EndpointType) string {
override := cfg.EndpointPaths[string(endpoint)]
defaultPath := DefaultPath(cfg.Type, endpoint)
return ResolveURL(cfg.BaseURL, override, defaultPath)
}
func ForwardHTTP(ctx context.Context, client *http.Client, method, url string, apiKey string, providerType model.ProviderType, body []byte, extraHeaders map[string]string) (*ForwardResponse, error) {
var reader io.Reader
if len(body) > 0 {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, url, reader)
if err != nil {
return nil, err
}
setAuthHeaders(req, apiKey, providerType)
req.Header.Set("Content-Type", "application/json")
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
tokensIn, tokensOut := parseUsageJSON(respBody)
return &ForwardResponse{
StatusCode: resp.StatusCode,
Body: respBody,
TokensIn: tokensIn,
TokensOut: tokensOut,
}, nil
}
func setAuthHeaders(req *http.Request, apiKey string, providerType model.ProviderType) {
switch providerType {
case model.ProviderAnthropic:
req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
case model.ProviderAzureOpenAI:
if apiKey != "" {
req.Header.Set("api-key", apiKey)
}
case model.ProviderOllama:
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
default:
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
}
}
func parseUsageJSON(body []byte) (int, int) {
var u struct {
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
_ = json.Unmarshal(body, &u)
in := u.Usage.PromptTokens
out := u.Usage.CompletionTokens
if in == 0 {
in = u.Usage.InputTokens
}
if out == 0 {
out = u.Usage.OutputTokens
}
return in, out
}
func DefaultBaseURL(providerType model.ProviderType) string {
switch providerType {
case model.ProviderAnthropic:
return "https://api.anthropic.com/v1"
case model.ProviderAzureOpenAI:
return "" // must be configured per deployment
case model.ProviderOpenRouter:
return "https://openrouter.ai/api/v1"
case model.ProviderOllama:
return "http://127.0.0.1:11434/v1"
default:
return "https://api.openai.com/v1"
}
}
func IsOpenAICompatible(t model.ProviderType) bool {
switch t {
case model.ProviderOpenAI, model.ProviderCustom, model.ProviderAzureOpenAI, model.ProviderOpenRouter, model.ProviderOllama:
return true
default:
return false
}
}
func NormalizeBaseURL(baseURL string, providerType model.ProviderType) string {
if baseURL == "" {
return DefaultBaseURL(providerType)
}
return baseURL
}
func IsEndpointAllowed(allowed []string, endpoint EndpointType) bool {
for _, a := range allowed {
if a == string(endpoint) || a == "*" {
return true
}
}
return false
}
func NewHTTPClient() *http.Client {
return &http.Client{Timeout: 120 * time.Second}
}
func ValidateCategoryEndpoints(category model.ProviderCategory, allowed []string) error {
if !model.IsCategorySupported(category) {
return fmt.Errorf("category %s is not supported yet", category)
}
valid := EndpointsForCategory(category)
validSet := map[string]bool{}
for _, e := range valid {
validSet[string(e.Key)] = true
}
for _, a := range allowed {
if a == "*" {
continue
}
if !validSet[a] {
return fmt.Errorf("endpoint %s is not available for category %s", a, category)
}
}
return nil
}