thread-master/apps/backend/internal/module/ai/registry.go

248 lines
7.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package ai
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
)
const (
ProviderXAI = "xai"
ProviderOpenCodeGo = "opencode-go"
// ModelsCacheTTL is the fresh model-list window.
ModelsCacheTTL = 5 * time.Minute
// ModelsCacheStaleTTL is the maximum stale fallback window.
ModelsCacheStaleTTL = 15 * time.Minute
modelsRefreshLockTTL = 30 * time.Second
modelsRefreshWait = 25 * time.Millisecond
modelsRefreshChecks = 20
)
// ModelsCacheKey identifies one provider credential without containing the credential.
type ModelsCacheKey struct {
Provider string
CredentialFingerprint string
}
// ModelsCacheValue is the JSON payload persisted by a ModelsCache.
type ModelsCacheValue struct {
Models []string `json:"models"`
FetchedAt int64 `json:"fetchedAt"`
}
// ModelsCache stores model lists and coordinates refreshes across instances.
type ModelsCache interface {
Get(ctx context.Context, key ModelsCacheKey) (ModelsCacheValue, bool, error)
Set(ctx context.Context, key ModelsCacheKey, value ModelsCacheValue, ttl time.Duration) error
AcquireRefresh(ctx context.Context, key ModelsCacheKey, token string, ttl time.Duration) (bool, error)
ReleaseRefresh(ctx context.Context, key ModelsCacheKey, token string) error
}
// Registry holds the immutable provider client registry and a shared models cache.
type Registry struct {
clients map[string]Client
cache ModelsCache
fingerprintSecret []byte
}
func NewRegistry(cache ModelsCache, fingerprintSecret string) *Registry {
return newRegistry(cache, fingerprintSecret, map[string]Client{
ProviderXAI: NewOpenAICompatible(ProviderXAI, "https://api.x.ai/v1"),
ProviderOpenCodeGo: NewOpenAICompatible(ProviderOpenCodeGo, "https://opencode.ai/zen/go/v1"),
})
}
// NewRegistryWithClients injects provider clients for focused tests.
func NewRegistryWithClients(cache ModelsCache, fingerprintSecret string, clients map[string]Client) *Registry {
return newRegistry(cache, fingerprintSecret, clients)
}
func newRegistry(cache ModelsCache, fingerprintSecret string, clients map[string]Client) *Registry {
immutableClients := make(map[string]Client, len(clients))
for provider, client := range clients {
immutableClients[NormalizeProvider(provider)] = client
}
return &Registry{clients: immutableClients, cache: cache, fingerprintSecret: []byte(fingerprintSecret)}
}
func NormalizeProvider(p string) string {
switch strings.ToLower(strings.TrimSpace(p)) {
case "opencode-go", "opencode", "opencode_go":
return ProviderOpenCodeGo
case "xai", "grok", "":
return ProviderXAI
default:
return strings.ToLower(strings.TrimSpace(p))
}
}
func (r *Registry) Client(provider string) (Client, error) {
id := NormalizeProvider(provider)
c, ok := r.clients[id]
if !ok {
return nil, fmt.Errorf("unsupported provider: %s (use xai or opencode-go)", provider)
}
return c, nil
}
// ListModelsCached fetches models via provider API; caches per provider+credential.
// fromCache indicates whether result was served from cache.
func (r *Registry) ListModelsCached(ctx context.Context, provider, apiKey string) (models []string, fromCache bool, err error) {
id := NormalizeProvider(provider)
if _, err := r.Client(id); err != nil {
return nil, false, err
}
key := r.cacheKey(id, apiKey)
now := time.Now().UTC()
stale, hasStale := r.cached(ctx, key, now)
if hasStale && cacheAge(now, stale.FetchedAt) <= ModelsCacheTTL {
return cloneModels(stale.Models), true, nil
}
token, tokenErr := refreshToken()
locked := false
if tokenErr != nil {
err = tokenErr
} else if r.cache != nil {
locked, err = r.cache.AcquireRefresh(ctx, key, token, modelsRefreshLockTTL)
if err != nil {
locked = false
}
}
if locked {
defer func() {
releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
defer cancel()
_ = r.cache.ReleaseRefresh(releaseCtx, key, token)
}()
} else if r.cache != nil && err == nil {
for range modelsRefreshChecks {
select {
case <-ctx.Done():
return nil, false, ctx.Err()
case <-time.After(modelsRefreshWait):
}
if current, ok := r.cached(ctx, key, time.Now().UTC()); ok && cacheAge(time.Now().UTC(), current.FetchedAt) <= ModelsCacheTTL {
return cloneModels(current.Models), true, nil
}
}
}
c, err := r.Client(id)
list, err := c.ListModels(ctx, apiKey)
if err != nil {
if hasStale {
return cloneModels(stale.Models), true, nil
}
return nil, false, err
}
if r.cache != nil {
_ = r.cache.Set(ctx, key, ModelsCacheValue{
Models: cloneModels(list),
FetchedAt: time.Now().UTC().UnixNano(),
}, ModelsCacheStaleTTL)
}
return list, false, nil
}
func (r *Registry) cached(ctx context.Context, key ModelsCacheKey, now time.Time) (ModelsCacheValue, bool) {
if r.cache == nil {
return ModelsCacheValue{}, false
}
value, ok, err := r.cache.Get(ctx, key)
if err != nil || !ok || value.FetchedAt <= 0 {
return ModelsCacheValue{}, false
}
if cacheAge(now, value.FetchedAt) > ModelsCacheStaleTTL {
return ModelsCacheValue{}, false
}
return value, true
}
func (r *Registry) cacheKey(provider, apiKey string) ModelsCacheKey {
mac := hmac.New(sha256.New, r.fingerprintSecret)
_, _ = mac.Write([]byte(apiKey))
return ModelsCacheKey{Provider: provider, CredentialFingerprint: hex.EncodeToString(mac.Sum(nil))}
}
func refreshToken() (string, error) {
var token [16]byte
if _, err := rand.Read(token[:]); err != nil {
return "", err
}
return hex.EncodeToString(token[:]), nil
}
func cacheAge(now time.Time, fetchedAt int64) time.Duration {
age := now.Sub(time.Unix(0, fetchedAt).UTC())
if age < 0 {
return 0
}
return age
}
func cloneModels(models []string) []string {
return append([]string(nil), models...)
}
// FallbackModels when no key / provider error (never empty for UI).
// OpenCode Go 模型 id 見 https://opencode.ai/docs/go/(不是 OpenAI gpt-*
func FallbackModels(provider string) []string {
switch NormalizeProvider(provider) {
case ProviderOpenCodeGo:
return []string{
"kimi-k2.6",
"glm-5.1",
"glm-5.2",
"deepseek-v4-flash",
"deepseek-v4-pro",
"qwen3.7-plus",
"minimax-m2.7",
"mimo-v2.5",
}
default:
return []string{"grok-3", "grok-3-mini", "grok-2"}
}
}
// DefaultModel for provider when member settings model empty / invalid.
func DefaultModel(provider string) string {
list := FallbackModels(provider)
if len(list) == 0 {
return "grok-3"
}
return list[0]
}
// SanitizeModel remaps invalid model ids for a provider (e.g. gpt-4o-mini on opencode-go).
func SanitizeModel(provider, model string) string {
provider = NormalizeProvider(provider)
model = strings.TrimSpace(model)
if model == "" {
return DefaultModel(provider)
}
if provider != ProviderOpenCodeGo {
// xAI允許 grok-*;若誤存 opencode 模型名,仍交由 API 驗證
return model
}
// OpenCode Go常見錯誤 — 從 xAI / OpenAI 帶過來的模型名
lower := strings.ToLower(model)
if strings.HasPrefix(lower, "gpt-") ||
strings.HasPrefix(lower, "o1") ||
strings.HasPrefix(lower, "o3") ||
strings.HasPrefix(lower, "grok") ||
strings.HasPrefix(lower, "claude") ||
strings.HasPrefix(lower, "gemini") {
return DefaultModel(provider)
}
return model
}