Files
pulse/internal/config/ai_providers.go
T
2026-07-14 22:57:38 +01:00

421 lines
16 KiB
Go

package config
import "strings"
// AIProviderProtocol identifies the transport/runtime shape a provider uses.
type AIProviderProtocol string
const (
AIProviderProtocolAnthropic AIProviderProtocol = "anthropic"
AIProviderProtocolOpenAICompatible AIProviderProtocol = "openai_compatible"
AIProviderProtocolGemini AIProviderProtocol = "gemini"
AIProviderProtocolOllama AIProviderProtocol = "ollama"
AIProviderProtocolRetired AIProviderProtocol = "retired"
AIProviderProtocolSubscriptionAgent AIProviderProtocol = "subscription_agent"
)
// AIProviderModelDefinition is the config-layer representation of catalog
// models that Pulse should present even when a provider's /models endpoint is
// absent, incomplete, or temporarily unavailable.
type AIProviderModelDefinition struct {
ID string
Name string
Description string
Notable bool
}
// AIProviderDefinition is the canonical internal registry record for provider
// metadata and runtime capability selection.
type AIProviderDefinition struct {
ID string
DisplayName string
Description string
Protocol AIProviderProtocol
DefaultModel string
DefaultBaseURL string
APIKeyField string
ConfiguredField string
ClearKeyField string
BaseURLField string
RequiresAPIKey bool
UserConfigurable bool
Gateway bool
ModelsDevProviderID string
EnvVars []string
DocsURL string
FallbackModels []AIProviderModelDefinition
// SuggestedModel is a model verified to pass Patrol's tool-call
// preflight, surfaced as a guided quickstart on the provider's setup
// row. Only set for providers where users must choose a model without
// a curated catalog (Ollama). Re-verify with RunPatrolToolPreflight
// before changing the blessing.
SuggestedModel string
// SuggestedModelNote is the hardware-expectations copy rendered next
// to the suggested model's pull command.
SuggestedModelNote string
// SuggestedModelEquivalents lists additional catalog IDs that satisfy
// the same blessing (smaller same-family tags, alias tags).
SuggestedModelEquivalents []string
}
// OllamaSuggestedPatrolModel is the Ollama model blessed for Patrol:
// verified to emit tool_calls through Patrol's preflight
// (RunPatrolToolPreflight). qwen3 is the model family Ollama's own
// tool-calling docs are written against. qwen3:4b was tried as a low-RAM
// alternative and consistently failed the preflight (0/4 runs emitted a
// tool call), so no smaller tag is suggested. When re-blessing, re-run
// the preflight against the new tag and sweep the test pins on the
// readiness copy.
const OllamaSuggestedPatrolModel = "qwen3:8b"
func aiProviderDefinitions() []AIProviderDefinition {
return []AIProviderDefinition{
{
ID: AIProviderAnthropic,
DisplayName: "Anthropic",
Description: "Claude models from Anthropic",
Protocol: AIProviderProtocolAnthropic,
DefaultModel: "claude-3-5-sonnet-latest",
APIKeyField: "anthropic_api_key",
ConfiguredField: "anthropic_configured",
ClearKeyField: "clear_anthropic_key",
RequiresAPIKey: true,
UserConfigurable: true,
EnvVars: []string{"ANTHROPIC_API_KEY"},
DocsURL: "https://docs.anthropic.com/en/api/getting-started",
},
{
ID: AIProviderOpenAI,
DisplayName: "OpenAI",
Description: "GPT and reasoning models from OpenAI, or a custom OpenAI-compatible endpoint",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "gpt-4o",
APIKeyField: "openai_api_key",
ConfiguredField: "openai_configured",
ClearKeyField: "clear_openai_key",
BaseURLField: "openai_base_url",
RequiresAPIKey: true,
UserConfigurable: true,
EnvVars: []string{"OPENAI_API_KEY"},
DocsURL: "https://platform.openai.com/docs/api-reference/chat",
},
{
ID: AIProviderOpenRouter,
DisplayName: "OpenRouter",
Description: "Gateway for OpenAI-compatible hosted models",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "openai/gpt-4o-mini",
DefaultBaseURL: DefaultOpenRouterBaseURL,
APIKeyField: "openrouter_api_key",
ConfiguredField: "openrouter_configured",
ClearKeyField: "clear_openrouter_key",
RequiresAPIKey: true,
UserConfigurable: true,
Gateway: true,
ModelsDevProviderID: "openrouter",
EnvVars: []string{"OPENROUTER_API_KEY"},
DocsURL: "https://openrouter.ai/docs",
},
{
ID: AIProviderDeepSeek,
DisplayName: "DeepSeek",
Description: "DeepSeek direct API models",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: DeepSeekModelV4Flash,
DefaultBaseURL: DefaultDeepSeekBaseURL,
APIKeyField: "deepseek_api_key",
ConfiguredField: "deepseek_configured",
ClearKeyField: "clear_deepseek_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "deepseek",
EnvVars: []string{"DEEPSEEK_API_KEY"},
DocsURL: "https://api-docs.deepseek.com/",
FallbackModels: []AIProviderModelDefinition{
{
ID: DeepSeekModelV4Flash,
Name: "DeepSeek V4 Flash",
Description: "DeepSeek: current V4 Flash direct API model",
Notable: true,
},
{
ID: DeepSeekModelV4Pro,
Name: "DeepSeek V4 Pro",
Description: "DeepSeek: current V4 Pro direct API model",
Notable: true,
},
{
ID: DeepSeekModelLegacyChat,
Name: "DeepSeek Chat (legacy alias)",
Description: "DeepSeek: legacy alias currently routing to V4 Flash",
},
{
ID: DeepSeekModelLegacyReasoner,
Name: "DeepSeek Reasoner (legacy alias)",
Description: "DeepSeek: legacy alias currently routing to V4 Flash",
},
},
},
{
ID: AIProviderGemini,
DisplayName: "Google Gemini",
Description: "Gemini models from Google",
Protocol: AIProviderProtocolGemini,
DefaultModel: "gemini-1.5-pro",
DefaultBaseURL: DefaultGeminiBaseURL,
APIKeyField: "gemini_api_key",
ConfiguredField: "gemini_configured",
ClearKeyField: "clear_gemini_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "google",
EnvVars: []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"},
DocsURL: "https://ai.google.dev/gemini-api/docs",
},
{
ID: AIProviderZai,
DisplayName: "Z.ai",
Description: "GLM models from Z.ai using the OpenAI-compatible API",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "glm-5.2",
DefaultBaseURL: DefaultZaiBaseURL,
APIKeyField: "zai_api_key",
ConfiguredField: "zai_configured",
ClearKeyField: "clear_zai_key",
BaseURLField: "zai_base_url",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "zai",
EnvVars: []string{"ZHIPU_API_KEY", "ZAI_API_KEY"},
DocsURL: "https://docs.z.ai/guides/develop/openai/python",
FallbackModels: []AIProviderModelDefinition{
{
ID: "glm-5.2",
Name: "GLM 5.2",
Description: "Z.ai: GLM 5.2 direct API model",
Notable: true,
},
},
},
{
ID: AIProviderGroq,
DisplayName: "Groq",
Description: "Groq hosted models using the OpenAI-compatible API",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "llama-3.3-70b-versatile",
DefaultBaseURL: DefaultGroqBaseURL,
APIKeyField: "groq_api_key",
ConfiguredField: "groq_configured",
ClearKeyField: "clear_groq_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "groq",
EnvVars: []string{"GROQ_API_KEY"},
DocsURL: "https://console.groq.com/docs/openai",
},
{
ID: AIProviderMistral,
DisplayName: "Mistral",
Description: "Mistral models using the OpenAI-compatible chat API",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "mistral-large-latest",
DefaultBaseURL: DefaultMistralBaseURL,
APIKeyField: "mistral_api_key",
ConfiguredField: "mistral_configured",
ClearKeyField: "clear_mistral_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "mistral",
EnvVars: []string{"MISTRAL_API_KEY"},
DocsURL: "https://docs.mistral.ai/resources/migration-guides",
},
{
ID: AIProviderCerebras,
DisplayName: "Cerebras",
Description: "Cerebras Inference models using the OpenAI-compatible API",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "llama-4-scout-17b-16e-instruct",
DefaultBaseURL: DefaultCerebrasBaseURL,
APIKeyField: "cerebras_api_key",
ConfiguredField: "cerebras_configured",
ClearKeyField: "clear_cerebras_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "cerebras",
EnvVars: []string{"CEREBRAS_API_KEY"},
DocsURL: "https://inference-docs.cerebras.ai/resources/openai",
},
{
ID: AIProviderTogether,
DisplayName: "Together AI",
Description: "Together AI models using the OpenAI-compatible API",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
DefaultBaseURL: DefaultTogetherBaseURL,
APIKeyField: "together_api_key",
ConfiguredField: "together_configured",
ClearKeyField: "clear_together_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "togetherai",
EnvVars: []string{"TOGETHER_API_KEY"},
DocsURL: "https://docs.together.ai/docs/inference/openai-compatibility",
},
{
ID: AIProviderFireworks,
DisplayName: "Fireworks AI",
Description: "Fireworks models using the OpenAI-compatible API",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "accounts/fireworks/models/llama-v3p1-70b-instruct",
DefaultBaseURL: DefaultFireworksBaseURL,
APIKeyField: "fireworks_api_key",
ConfiguredField: "fireworks_configured",
ClearKeyField: "clear_fireworks_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "fireworks-ai",
EnvVars: []string{"FIREWORKS_API_KEY"},
DocsURL: "https://docs.fireworks.ai/tools-sdks/openai-compatibility",
},
{
ID: AIProviderCodexSubscription,
DisplayName: "Codex subscription (local)",
Description: "Model turns through a locally installed Codex CLI signed in with ChatGPT",
Protocol: AIProviderProtocolSubscriptionAgent,
DefaultModel: "gpt-5.6-luna",
ConfiguredField: "codex_subscription_enabled",
UserConfigurable: true,
DocsURL: "https://learn.chatgpt.com/docs/auth",
FallbackModels: []AIProviderModelDefinition{
{ID: "gpt-5.6-luna", Name: "GPT-5.6 Luna", Description: "Availability depends on the installed Codex CLI and ChatGPT plan", Notable: true},
},
},
{
ID: AIProviderClaudeSubscription,
DisplayName: "Claude subscription (local)",
Description: "Model turns through a locally installed Claude CLI signed in with a Claude plan",
Protocol: AIProviderProtocolSubscriptionAgent,
DefaultModel: "sonnet",
ConfiguredField: "claude_subscription_enabled",
UserConfigurable: true,
DocsURL: "https://support.claude.com/en/articles/11145838-use-claude-code-with-your-pro-or-max-plan",
FallbackModels: []AIProviderModelDefinition{
{ID: "sonnet", Name: "Claude Sonnet", Description: "Current Sonnet alias available to the locally authenticated Claude CLI", Notable: true},
{ID: "opus", Name: "Claude Opus", Description: "Current Opus alias available to the locally authenticated Claude CLI", Notable: true},
},
},
{
ID: AIProviderOllama,
DisplayName: "Ollama",
Description: "Local models served by Ollama",
Protocol: AIProviderProtocolOllama,
DefaultModel: OllamaSuggestedPatrolModel,
DefaultBaseURL: DefaultOllamaBaseURL,
ConfiguredField: "ollama_configured",
ClearKeyField: "clear_ollama_url",
BaseURLField: "ollama_base_url",
UserConfigurable: true,
DocsURL: "https://ollama.com",
SuggestedModel: OllamaSuggestedPatrolModel,
SuggestedModelNote: "Allow about 8 GB of free RAM (5.2 GB download). Smaller qwen3 tags did not pass Patrol's tool check reliably.",
SuggestedModelEquivalents: []string{"qwen3:latest"},
},
{
ID: AIProviderQuickstart,
DisplayName: "Pulse hosted quickstart",
Description: "Retired pre-GA hosted proxy marker retained for migration cleanup",
Protocol: AIProviderProtocolRetired,
DefaultModel: "",
ConfiguredField: "quickstart_configured",
},
}
}
// AIProviderDefinitions returns the ordered provider registry. The slice and
// nested slices are copies so callers cannot mutate the canonical records.
func AIProviderDefinitions() []AIProviderDefinition {
defs := aiProviderDefinitions()
for i := range defs {
defs[i].EnvVars = append([]string(nil), defs[i].EnvVars...)
defs[i].FallbackModels = append([]AIProviderModelDefinition(nil), defs[i].FallbackModels...)
defs[i].SuggestedModelEquivalents = append([]string(nil), defs[i].SuggestedModelEquivalents...)
}
return defs
}
// AIConfigurableProviderDefinitions returns user-configurable providers in UI
// and model-list order.
func AIConfigurableProviderDefinitions() []AIProviderDefinition {
defs := AIProviderDefinitions()
out := make([]AIProviderDefinition, 0, len(defs))
for _, def := range defs {
if def.UserConfigurable {
out = append(out, def)
}
}
return out
}
func LookupAIProviderDefinition(provider string) (AIProviderDefinition, bool) {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return AIProviderDefinition{}, false
}
for _, def := range aiProviderDefinitions() {
if def.ID == provider {
def.EnvVars = append([]string(nil), def.EnvVars...)
def.FallbackModels = append([]AIProviderModelDefinition(nil), def.FallbackModels...)
def.SuggestedModelEquivalents = append([]string(nil), def.SuggestedModelEquivalents...)
return def, true
}
}
return AIProviderDefinition{}, false
}
// SuggestedModelForProvider returns the provider's Patrol-blessed model ID,
// or "" when the provider has no blessing.
func SuggestedModelForProvider(provider string) string {
def, ok := LookupAIProviderDefinition(provider)
if !ok {
return ""
}
return def.SuggestedModel
}
// SuggestedModelIDSet returns the normalized (lowercase, trimmed) union of
// every provider's suggested model plus its equivalents. Model recommendation
// uses exact-ID membership here so blessed models outrank catalog neighbours
// without fuzzy family matching pulling in unrelated cloud variants.
func SuggestedModelIDSet() map[string]struct{} {
out := make(map[string]struct{})
for _, def := range aiProviderDefinitions() {
for _, id := range append([]string{def.SuggestedModel}, def.SuggestedModelEquivalents...) {
id = strings.ToLower(strings.TrimSpace(id))
if id != "" {
out[id] = struct{}{}
}
}
}
return out
}
func IsOpenAICompatibleProvider(provider string) bool {
def, ok := LookupAIProviderDefinition(provider)
return ok && def.Protocol == AIProviderProtocolOpenAICompatible
}
func AIProviderFallbackModels(provider string) []AIProviderModelDefinition {
def, ok := LookupAIProviderDefinition(provider)
if !ok || len(def.FallbackModels) == 0 {
return nil
}
return append([]AIProviderModelDefinition(nil), def.FallbackModels...)
}
func AIProviderDisplayName(provider string) string {
if def, ok := LookupAIProviderDefinition(provider); ok && def.DisplayName != "" {
return def.DisplayName
}
return provider
}