Files
Rogeeandrogee 2b182f9956 H-300: wire Captain Skills into Web runtime (#48)
* H-300: wire Captain Skills into Web runtime

* H-300: enforce effective model and conservative skill budget

* H-300: fix CI gosec step

* ci: extend golangci-lint timeout

* fix lint findings across backend

* fix(push): resolve delivery protocol blockers

* test(repository): close SQLite test databases

* test(repository): reuse SQLite schema per package

* H-307: restore backend Go cache in CI

* H-307: prefetch modules before cold lint

* H-307: resolve govulncheck security gate

* H-307: build lint with patched Go toolchain

* H-307: clear remaining security scan findings

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-19 07:08:14 +08:00

328 lines
10 KiB
Go

package llm
import (
"context"
"errors"
"fmt"
"strings"
"sync"
)
var ErrProviderNotConfigured = errors.New("Copilot provider is not configured")
const (
EmbeddingModeReuseChat = "reuse_chat_credentials"
EmbeddingModeSeparate = "separate"
)
// RuntimeProviderConfig is the database-backed Copilot provider configuration.
// It intentionally contains no enabled flag: Copilot is globally available and
// reports ErrProviderNotConfigured until an administrator saves credentials.
type RuntimeProviderConfig struct {
ChatProvider string
ChatBaseURL string
ChatAPIKey string
ChatModel string
EmbeddingMode string
EmbeddingProvider string
EmbeddingBaseURL string
EmbeddingAPIKey string
EmbeddingModel string
EmbeddingDimensions int
Temperature float64
MaxTokens int
TimeoutSeconds int
MaxRetries int
}
// AccountModelResolver returns an account-specific model for a feature. An
// empty model means the platform ChatModel should be used.
type AccountModelResolver func(ctx context.Context, accountID uint, feature string) (string, error)
type accountFeatureContextKey struct{}
type accountFeatureContext struct {
AccountID uint
Feature string
}
type generationOverrideContextKey struct{}
type generationOverrideContext struct {
Temperature *float64
}
// WithAccountFeature annotates an LLM request so ProviderManager can resolve
// the account-specific model selected on the Copilot settings page.
func WithAccountFeature(ctx context.Context, accountID uint, feature string) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, accountFeatureContextKey{}, accountFeatureContext{
AccountID: accountID,
Feature: strings.TrimSpace(feature),
})
}
// WithTemperatureOverride preserves an explicitly configured Assistant
// temperature while keeping the platform value as the default elsewhere.
func WithTemperatureOverride(ctx context.Context, temperature float64) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, generationOverrideContextKey{}, generationOverrideContext{
Temperature: &temperature,
})
}
type providerSnapshot struct {
chat Provider
embedding Provider
config RuntimeProviderConfig
}
// ProviderManager is a hot-swappable Provider implementation. Services keep a
// stable reference to the manager while settings updates atomically replace the
// providers and defaults used by new requests.
type ProviderManager struct {
mu sync.RWMutex
snapshot *providerSnapshot
modelResolver AccountModelResolver
}
func NewProviderManager() *ProviderManager { return &ProviderManager{} }
func (m *ProviderManager) SetAccountModelResolver(resolver AccountModelResolver) {
m.mu.Lock()
m.modelResolver = resolver
m.mu.Unlock()
}
// Configure validates and constructs all providers before atomically swapping
// the active snapshot. A failed configuration never damages the working one.
func (m *ProviderManager) Configure(cfg RuntimeProviderConfig) error {
snapshot, err := buildProviderSnapshot(cfg)
if err != nil {
return err
}
m.mu.Lock()
m.snapshot = snapshot
m.mu.Unlock()
return nil
}
func buildProviderSnapshot(cfg RuntimeProviderConfig) (*providerSnapshot, error) {
cfg = normalizeRuntimeProviderConfig(cfg)
if cfg.ChatProvider == "" || cfg.ChatAPIKey == "" || cfg.ChatModel == "" {
return nil, ErrProviderNotConfigured
}
chat, err := buildChatProvider(cfg)
if err != nil {
return nil, err
}
embedding, err := buildEmbeddingProvider(cfg, chat)
if err != nil {
return nil, err
}
return &providerSnapshot{chat: chat, embedding: embedding, config: cfg}, nil
}
func normalizeRuntimeProviderConfig(cfg RuntimeProviderConfig) RuntimeProviderConfig {
cfg.ChatProvider = strings.ToLower(strings.TrimSpace(cfg.ChatProvider))
cfg.ChatBaseURL = strings.TrimRight(strings.TrimSpace(cfg.ChatBaseURL), "/")
cfg.ChatAPIKey = strings.TrimSpace(cfg.ChatAPIKey)
cfg.ChatModel = strings.TrimSpace(cfg.ChatModel)
cfg.EmbeddingMode = strings.ToLower(strings.TrimSpace(cfg.EmbeddingMode))
cfg.EmbeddingProvider = strings.ToLower(strings.TrimSpace(cfg.EmbeddingProvider))
cfg.EmbeddingBaseURL = strings.TrimRight(strings.TrimSpace(cfg.EmbeddingBaseURL), "/")
cfg.EmbeddingAPIKey = strings.TrimSpace(cfg.EmbeddingAPIKey)
cfg.EmbeddingModel = strings.TrimSpace(cfg.EmbeddingModel)
if cfg.EmbeddingMode == "" {
cfg.EmbeddingMode = EmbeddingModeReuseChat
}
if cfg.EmbeddingProvider == "" {
cfg.EmbeddingProvider = "openai"
}
if cfg.EmbeddingModel == "" {
cfg.EmbeddingModel = "text-embedding-3-small"
}
if cfg.EmbeddingDimensions == 0 {
cfg.EmbeddingDimensions = 1536
}
if cfg.MaxTokens == 0 {
cfg.MaxTokens = 1024
}
if cfg.Temperature < 0 {
cfg.Temperature = 0.7
}
if cfg.TimeoutSeconds == 0 {
cfg.TimeoutSeconds = 60
}
if cfg.MaxRetries < 0 {
cfg.MaxRetries = 3
}
return cfg
}
func buildChatProvider(cfg RuntimeProviderConfig) (Provider, error) {
switch cfg.ChatProvider {
case "openai", "openai_compatible":
if cfg.ChatProvider == "openai_compatible" && cfg.ChatBaseURL == "" {
return nil, fmt.Errorf("Copilot chat base URL is required for openai_compatible")
}
return NewOpenAIProvider(OpenAIProviderConfig{
APIKey: cfg.ChatAPIKey,
BaseURL: cfg.ChatBaseURL,
Model: cfg.ChatModel,
EmbedModel: cfg.EmbeddingModel,
MaxRetries: cfg.MaxRetries,
MaxRetriesSet: true,
Timeout: cfg.TimeoutSeconds,
}), nil
case "anthropic":
return NewAnthropicProvider(AnthropicProviderConfig{
APIKey: cfg.ChatAPIKey,
BaseURL: cfg.ChatBaseURL,
Model: cfg.ChatModel,
MaxRetries: cfg.MaxRetries,
MaxRetriesSet: true,
Timeout: cfg.TimeoutSeconds,
}), nil
default:
return nil, fmt.Errorf("unsupported Copilot chat provider: %s", cfg.ChatProvider)
}
}
func buildEmbeddingProvider(cfg RuntimeProviderConfig, chat Provider) (Provider, error) {
switch cfg.EmbeddingMode {
case EmbeddingModeReuseChat:
if cfg.ChatProvider == "anthropic" {
return nil, fmt.Errorf("Anthropic chat requires a separate OpenAI-compatible embedding provider")
}
return chat, nil
case EmbeddingModeSeparate:
if cfg.EmbeddingAPIKey == "" {
return nil, fmt.Errorf("Copilot embedding API key is required")
}
if cfg.EmbeddingProvider != "openai" && cfg.EmbeddingProvider != "openai_compatible" {
return nil, fmt.Errorf("unsupported Copilot embedding provider: %s", cfg.EmbeddingProvider)
}
if cfg.EmbeddingProvider == "openai_compatible" && cfg.EmbeddingBaseURL == "" {
return nil, fmt.Errorf("Copilot embedding base URL is required for openai_compatible")
}
return NewOpenAIProvider(OpenAIProviderConfig{
APIKey: cfg.EmbeddingAPIKey,
BaseURL: cfg.EmbeddingBaseURL,
Model: cfg.ChatModel,
EmbedModel: cfg.EmbeddingModel,
MaxRetries: cfg.MaxRetries,
MaxRetriesSet: true,
Timeout: cfg.TimeoutSeconds,
}), nil
default:
return nil, fmt.Errorf("unsupported Copilot embedding mode: %s", cfg.EmbeddingMode)
}
}
func (m *ProviderManager) Clear() {
m.mu.Lock()
m.snapshot = nil
m.mu.Unlock()
}
func (m *ProviderManager) Snapshot() (RuntimeProviderConfig, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.snapshot == nil {
return RuntimeProviderConfig{}, false
}
return m.snapshot.config, true
}
func (m *ProviderManager) current() (*providerSnapshot, AccountModelResolver, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.snapshot == nil {
return nil, m.modelResolver, ErrProviderNotConfigured
}
return m.snapshot, m.modelResolver, nil
}
func resolveFeatureModel(ctx context.Context, resolver AccountModelResolver, fallback string) string {
if resolver == nil || ctx == nil {
return fallback
}
featureCtx, ok := ctx.Value(accountFeatureContextKey{}).(accountFeatureContext)
if !ok || featureCtx.AccountID == 0 || featureCtx.Feature == "" {
return fallback
}
model, err := resolver(ctx, featureCtx.AccountID, featureCtx.Feature)
if err != nil || strings.TrimSpace(model) == "" {
return fallback
}
return strings.TrimSpace(model)
}
func resolveRuntimeChatModel(ctx context.Context, cfg RuntimeProviderConfig, resolver AccountModelResolver) string {
if cfg.ChatProvider == "openai" {
return resolveFeatureModel(ctx, resolver, cfg.ChatModel)
}
return cfg.ChatModel
}
// ResolveChatModel returns the model ChatCompletion will use after applying an
// account feature override.
func (m *ProviderManager) ResolveChatModel(ctx context.Context) (string, error) {
snapshot, resolver, err := m.current()
if err != nil {
return "", err
}
return resolveRuntimeChatModel(ctx, snapshot.config, resolver), nil
}
func applyRuntimeChatConfig(ctx context.Context, req ChatRequest, cfg RuntimeProviderConfig, resolver AccountModelResolver) ChatRequest {
// Account overrides store only a model name. Keep provider/model pairs intact
// for compatible endpoints, where an OpenAI model may not exist.
req.Model = resolveRuntimeChatModel(ctx, cfg, resolver)
req.Temperature = cfg.Temperature
if override, ok := ctx.Value(generationOverrideContextKey{}).(generationOverrideContext); ok && override.Temperature != nil {
req.Temperature = *override.Temperature
}
req.MaxTokens = cfg.MaxTokens
return req
}
func (m *ProviderManager) ChatCompletion(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
snapshot, resolver, err := m.current()
if err != nil {
return nil, err
}
req = applyRuntimeChatConfig(ctx, req, snapshot.config, resolver)
return snapshot.chat.ChatCompletion(ctx, req)
}
func (m *ProviderManager) CreateEmbedding(ctx context.Context, req EmbeddingRequest) (*EmbeddingResponse, error) {
snapshot, _, err := m.current()
if err != nil {
return nil, err
}
req.Model = snapshot.config.EmbeddingModel
req.Dimensions = snapshot.config.EmbeddingDimensions
return snapshot.embedding.CreateEmbedding(ctx, req)
}
func (m *ProviderManager) ChatCompletionStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk) error) error {
snapshot, resolver, err := m.current()
if err != nil {
return err
}
req = applyRuntimeChatConfig(ctx, req, snapshot.config, resolver)
return snapshot.chat.ChatCompletionStream(ctx, req, onChunk)
}
var _ Provider = (*ProviderManager)(nil)