package llm import ( "fmt" "strings" ) // APIFormat 是自定义供应商的 API 协议格式。 type APIFormat = string const ( // FormatAnthropic 走 Anthropic Messages API(/v1/messages)。 FormatAnthropic = "anthropic" // FormatChatCompletions 走 OpenAI Chat Completions(/chat/completions)。 FormatChatCompletions = "chat_completions" // FormatResponses 走 OpenAI Responses API(/responses)。 FormatResponses = "responses" ) // NormalizeAPIFormat 归一化用户输入的格式别名;未知格式返回空串。 func NormalizeAPIFormat(s string) APIFormat { switch strings.ToLower(strings.TrimSpace(s)) { case "anthropic", "anthropic_messages", "messages": return FormatAnthropic case "chat_completions", "chatcompletions", "openai", "chat": return FormatChatCompletions case "responses", "response": return FormatResponses } return "" } // ProviderSpec 描述一个模型供应商的接入参数。 type ProviderSpec struct { Name string // 展示名 BaseURL string APIKey string APIFormat APIFormat Model string // 默认模型 AllowedModels []string // /models 白名单;空 = 不筛选 MaxTokens int // anthropic 单次回复上限 } // NewProvider 按 spec 构造对应协议的 Provider。 func NewProvider(spec ProviderSpec) (Provider, error) { format := spec.APIFormat if format == "" { format = guessFormat(spec.BaseURL) } switch format { case FormatAnthropic: return &AnthropicProvider{BaseURL: spec.BaseURL, APIKey: spec.APIKey, Model: spec.Model, MaxTokens: spec.MaxTokens}, nil case FormatResponses: return &ResponsesProvider{BaseURL: spec.BaseURL, APIKey: spec.APIKey, Model: spec.Model}, nil case FormatChatCompletions: return &OpenAIProvider{BaseURL: spec.BaseURL, APIKey: spec.APIKey, Model: spec.Model}, nil } return nil, fmt.Errorf("llm: unknown api_format %q (want anthropic | chat_completions | responses)", spec.APIFormat) } // guessFormat 依据 BaseURL 猜协议:含 "anthropic" 视为 anthropic,其余默认 chat_completions。 func guessFormat(baseURL string) APIFormat { if strings.Contains(strings.ToLower(baseURL), "anthropic") { return FormatAnthropic } return FormatChatCompletions } // DedupeModels 去空白、大小写不敏感去重(保留首次出现的原始写法与顺序)。 func DedupeModels(in []string) []string { seen := make(map[string]bool, len(in)) out := make([]string, 0, len(in)) for _, s := range in { s = strings.TrimSpace(s) if s == "" || seen[strings.ToLower(s)] { continue } seen[strings.ToLower(s)] = true out = append(out, s) } if len(out) == 0 { return nil } return out } // FilterAllowed 按白名单筛选模型列表;白名单为空返回原列表。 // 支持通配前缀("glm-*")与精确名;大小写不敏感。 func FilterAllowed(models []string, allowed []string) []string { if len(allowed) == 0 { return models } out := make([]string, 0, len(models)) for _, m := range models { for _, a := range allowed { if matchesAllowed(m, a) { out = append(out, m) break } } } return out } // matchesAllowed 报告单个模型是否匹配一条条目:精确名(EqualFold)或 // 前缀通配(尾部 *)。FilterAllowed(条目=白名单、模型=候选)与 // MissingRegisteredModels(条目=登记模型、模型=远端目录)共用同一匹配语义, // 两处口径漂移会让"下架判定"和"白名单筛选"打架。 func matchesAllowed(model, pattern string) bool { pattern = strings.ToLower(strings.TrimSpace(pattern)) if pattern == "" { return false } if strings.HasSuffix(pattern, "*") { return strings.HasPrefix(strings.ToLower(model), strings.TrimSuffix(pattern, "*")) } return strings.EqualFold(model, pattern) } // MissingRegisteredModels 返回登记模型里在 fetched(远端 /models 目录)中 // 已不存在的条目——「已下架」的判定源,TUI 多选表格与 WebUI 供应商页共用。 // 保序、保留原文写法、大小写不敏感去重;只做精确名比对(EqualFold), // 通配条目(含 *)**不参与下架判定**:模式没法对具体 ID 目录作存在性结论, // 按"目录里仍有前缀匹配"算存活会掩盖真下架,按字面比对又必然误报。 // // fetched 为空(拉取失败 / anthropic 无枚举端点)返回 nil:没有目录就没有 // 资格谈下架,误标比漏标危害大。 func MissingRegisteredModels(registered, fetched []string) []string { if len(registered) == 0 || len(fetched) == 0 { return nil } seen := map[string]bool{} var out []string for _, r := range registered { r = strings.TrimSpace(r) if r == "" || seen[strings.ToLower(r)] || strings.HasSuffix(r, "*") { continue // 通配条目跳过判定(见上),不代表存活也不标下架 } seen[strings.ToLower(r)] = true alive := false for _, m := range fetched { if strings.EqualFold(m, r) { alive = true break } } if !alive { out = append(out, r) } } return out }