ci / test (push) Failing after 1m49s
- Add LLMAuth struct with support for Bearer, API Key, and Custom auth types - Implement applyAuth() to inject auth headers into LLM requests - Add X-Tenant-ID header for multi-tenant isolation - Add X-OAuth-Scopes header for OAuth2 scope enforcement - Add UpdateAuth() for runtime token refresh (long-running workflows) - Update LLMRouterConfig with Auth and TenantID fields - Document 4 authentication patterns (Bearer, API Key, Custom, Router config) - Add security best practices: token vault integration, tenant isolation, scopes - Add audit headers for compliance & logging - Create multi-tenant router factory pattern Auth types supported: - Bearer: JWT/OAuth2 tokens (most secure for federated access) - API Key: Static keys (X-API-Key header) - Custom: Any custom header-based scheme - None: No authentication Customers can now pass per-tenant JWT tokens with customized scopes and isolated LLM API access per tenant/customer.
245 lines
6.1 KiB
Go
245 lines
6.1 KiB
Go
package routing
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
// llmBaseURL is the base URL for the LLM API
|
|
llmBaseURL string
|
|
)
|
|
|
|
func init() {
|
|
llmBaseURL = os.Getenv("LOCAL_LLM_BASE_URL")
|
|
if llmBaseURL == "" {
|
|
llmBaseURL = "https://api.riotpiao.com"
|
|
}
|
|
}
|
|
|
|
// AuthType specifies the authentication mechanism
|
|
type AuthType string
|
|
|
|
const (
|
|
// AuthTypeNone - no authentication
|
|
AuthTypeNone AuthType = "none"
|
|
// AuthTypeBearer - Bearer token (JWT, OAuth2)
|
|
AuthTypeBearer AuthType = "bearer"
|
|
// AuthTypeAPIKey - API Key authentication
|
|
AuthTypeAPIKey AuthType = "api-key"
|
|
// AuthTypeCustom - Custom header-based authentication
|
|
AuthTypeCustom AuthType = "custom"
|
|
)
|
|
|
|
// LLMAuth configures authentication for LLM API
|
|
type LLMAuth struct {
|
|
// Type of authentication
|
|
Type AuthType `json:"type"`
|
|
|
|
// Token is the JWT/OAuth2 token for Bearer auth
|
|
Token string `json:"token,omitempty"`
|
|
|
|
// APIKey is the API key for API Key auth
|
|
APIKey string `json:"apiKey,omitempty"`
|
|
|
|
// HeaderName is the custom header name for Custom auth
|
|
HeaderName string `json:"headerName,omitempty"`
|
|
|
|
// HeaderValue is the custom header value for Custom auth
|
|
HeaderValue string `json:"headerValue,omitempty"`
|
|
|
|
// TenantID is the tenant/customer ID for multi-tenant federated access
|
|
TenantID string `json:"tenantId,omitempty"`
|
|
|
|
// Scopes are the OAuth2 scopes (space-separated)
|
|
Scopes string `json:"scopes,omitempty"`
|
|
}
|
|
|
|
// LLMClient is a simple LLM client for routing
|
|
type LLMClient struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
auth *LLMAuth
|
|
}
|
|
|
|
// NewLLMClient creates a new LLM client with default (no) auth
|
|
func NewLLMClient() *LLMClient {
|
|
return &LLMClient{
|
|
baseURL: llmBaseURL,
|
|
httpClient: &http.Client{},
|
|
auth: nil,
|
|
}
|
|
}
|
|
|
|
// NewLLMClientWithAuth creates a new LLM client with authentication
|
|
func NewLLMClientWithAuth(auth *LLMAuth) *LLMClient {
|
|
return &LLMClient{
|
|
baseURL: llmBaseURL,
|
|
httpClient: &http.Client{},
|
|
auth: auth,
|
|
}
|
|
}
|
|
|
|
// Name returns the provider name
|
|
func (c *LLMClient) Name() string {
|
|
return "riotpiao"
|
|
}
|
|
|
|
// IsAvailable checks if the LLM service is available
|
|
func (c *LLMClient) IsAvailable(ctx context.Context) error {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("LLM service unavailable: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 500 {
|
|
return fmt.Errorf("LLM service error: %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// llmRequest is the request body for the OpenAI-compatible API
|
|
type llmRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []llmMessage `json:"messages"`
|
|
Stream bool `json:"stream"`
|
|
}
|
|
|
|
type llmMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// llmResponse is the response from the OpenAI-compatible API
|
|
type llmResponse struct {
|
|
Choices []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
} `json:"choices"`
|
|
}
|
|
|
|
// Chat sends a chat completion request
|
|
func (c *LLMClient) Chat(ctx context.Context, systemPrompt, userMessage string) (string, error) {
|
|
req := llmRequest{
|
|
Model: "reasoning",
|
|
Messages: []llmMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: userMessage},
|
|
},
|
|
Stream: false,
|
|
}
|
|
|
|
reqBody, err := json.Marshal(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST",
|
|
fmt.Sprintf("%s/v1/chat/completions", c.baseURL),
|
|
bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create HTTP request: %w", err)
|
|
}
|
|
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
// Apply authentication headers
|
|
if err := c.applyAuth(httpReq); err != nil {
|
|
return "", fmt.Errorf("failed to apply authentication: %w", err)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to connect to LLM API at %s: %w", c.baseURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("LLM API returned status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var respObj llmResponse
|
|
if err := json.Unmarshal(respBody, &respObj); err != nil {
|
|
return "", fmt.Errorf("failed to unmarshal response: %w", err)
|
|
}
|
|
|
|
if len(respObj.Choices) == 0 {
|
|
return "", fmt.Errorf("no choices in response from LLM API")
|
|
}
|
|
|
|
return respObj.Choices[0].Message.Content, nil
|
|
}
|
|
|
|
// applyAuth applies authentication to the HTTP request based on config
|
|
func (c *LLMClient) applyAuth(req *http.Request) error {
|
|
if c.auth == nil || c.auth.Type == AuthTypeNone {
|
|
return nil
|
|
}
|
|
|
|
switch c.auth.Type {
|
|
case AuthTypeBearer:
|
|
if c.auth.Token == "" {
|
|
return fmt.Errorf("bearer token is required but not provided")
|
|
}
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.auth.Token))
|
|
|
|
case AuthTypeAPIKey:
|
|
if c.auth.APIKey == "" {
|
|
return fmt.Errorf("API key is required but not provided")
|
|
}
|
|
// Common API key header names: X-API-Key, api-key, Authorization
|
|
req.Header.Set("X-API-Key", c.auth.APIKey)
|
|
|
|
case AuthTypeCustom:
|
|
if c.auth.HeaderName == "" || c.auth.HeaderValue == "" {
|
|
return fmt.Errorf("custom header name and value are required but not provided")
|
|
}
|
|
req.Header.Set(c.auth.HeaderName, c.auth.HeaderValue)
|
|
}
|
|
|
|
// Add tenant ID if specified (for multi-tenant federated access)
|
|
if c.auth.TenantID != "" {
|
|
req.Header.Set("X-Tenant-ID", c.auth.TenantID)
|
|
}
|
|
|
|
// Add scopes if specified (for OAuth2 flows)
|
|
if c.auth.Scopes != "" {
|
|
req.Header.Set("X-OAuth-Scopes", c.auth.Scopes)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateAuth updates the authentication config at runtime
|
|
func (c *LLMClient) UpdateAuth(auth *LLMAuth) error {
|
|
if auth == nil {
|
|
return fmt.Errorf("auth config cannot be nil")
|
|
}
|
|
c.auth = auth
|
|
return nil
|
|
}
|
|
|
|
// GetAuth returns the current authentication config
|
|
func (c *LLMClient) GetAuth() *LLMAuth {
|
|
return c.auth
|
|
}
|