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>
199 lines
4.7 KiB
Go
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
|
|
}
|