- Remove TenantID field from LLMAuth (JWT claims handle tenant info) - Remove Scopes field (not part of Poimen's design) - Simplify to 3 core auth types: Bearer, API Key, Custom - Update LLMRouterConfig to only include Auth field - Simplify README examples to per-deployment pattern - Focus on secure token management vs multi-tenant isolation - Clarify token rotation pattern for long-running workflows - Update security section with practical vault integration examples TenantID was introduced without proper context. In Poimen: - JWT token itself contains tenant/customer info in claims - Each deployment gets its own LLM_AUTH_TOKEN from vault - LLM API provider (riotpiao.com) validates token at their end - No need for separate tenant header in Poimen layer Simpler, clearer, more maintainable.
229 lines
5.6 KiB
Go
229 lines
5.6 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"`
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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
|
|
}
|