mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
99dad2b511
Ollama is the zero-cost AI path but the setup row offered only a Server URL, and Patrol then failed on models that cannot emit tool_calls (#1463, #847, #1425, #1152, #880). Bless qwen3:8b, the model family Ollama's own tool-calling docs are written against, verified locally against Patrol's real preflight: qwen3:8b emitted the tool call on every run; qwen3:4b never did (0/4), so no low-RAM tag is suggested. - Registry: SuggestedModel/Note/Equivalents on AIProviderDefinition, projected on /api/settings/ai providers; Ollama default model goes llama3.2 -> qwen3:8b. - Provider row: copyable 'ollama pull qwen3:8b' block with hardware note, and a next-step hint when a successful test resolves a model outside the blessing set. - Model resolution: exact-ID blessed preference, so pulling qwen3:8b makes it the auto-resolved Patrol model with no manual selection. - Readiness copy names the blessed model (its contract pins landed with 94ccc7a0c's staging; this commit restores green). - manual_ollama_preflight_test.go is the env-gated re-blessing harness; contracts updated for ai-runtime, api-contracts, frontend-primitives, and the agent-lifecycle/storage-recovery dependent boundaries; subsystem_lookup_test line pins follow the api-contracts.md insertion.
480 lines
15 KiB
Go
480 lines
15 KiB
Go
package modelresolution
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
)
|
|
|
|
// ResolveConfiguredModel returns the effective model for the current AI config.
|
|
// When the operator has not selected a concrete provider model yet, Pulse resolves
|
|
// one from the provider's live catalog instead of relying on hardcoded vendor IDs.
|
|
func ResolveConfiguredModel(ctx context.Context, cfg *config.AIConfig) (string, error) {
|
|
if cfg == nil {
|
|
return "", fmt.Errorf("Pulse Assistant config is nil")
|
|
}
|
|
|
|
explicit := strings.TrimSpace(cfg.GetModel())
|
|
if explicit != "" {
|
|
if IsModelUsableWithConfig(cfg, explicit) {
|
|
return explicit, nil
|
|
}
|
|
return "", selectedModelProviderError(cfg, explicit)
|
|
}
|
|
|
|
configuredProviders := cfg.GetConfiguredProviders()
|
|
if len(configuredProviders) == 0 {
|
|
return "", fmt.Errorf("no provider configured")
|
|
}
|
|
|
|
return ResolveConfiguredProviderModel(ctx, cfg, configuredProviders[0])
|
|
}
|
|
|
|
// ResolveConfiguredChatModel returns the effective model for interactive chat.
|
|
// A chat-specific selection wins; otherwise this follows the same configured
|
|
// provider resolution as the shared AI runtime.
|
|
func ResolveConfiguredChatModel(ctx context.Context, cfg *config.AIConfig) (string, error) {
|
|
if cfg == nil {
|
|
return "", fmt.Errorf("Pulse Assistant config is nil")
|
|
}
|
|
|
|
explicit := strings.TrimSpace(cfg.GetChatModel())
|
|
if explicit != "" && IsModelUsableForChatWithConfig(cfg, explicit) {
|
|
return explicit, nil
|
|
}
|
|
if explicit != "" {
|
|
return "", selectedChatModelProviderError(cfg, explicit)
|
|
}
|
|
|
|
configuredProviders := cfg.GetConfiguredProviders()
|
|
if len(configuredProviders) == 0 {
|
|
return "", fmt.Errorf("no provider configured")
|
|
}
|
|
|
|
return ResolveConfiguredChatProviderModel(ctx, cfg, configuredProviders[0])
|
|
}
|
|
|
|
// ResolveConfiguredChatModelOffline returns the effective chat model without
|
|
// calling a provider catalog. Interactive chat uses this path so first-token
|
|
// latency is controlled by the selected provider stream, not a preflight
|
|
// /models request.
|
|
func ResolveConfiguredChatModelOffline(cfg *config.AIConfig) (string, error) {
|
|
if cfg == nil {
|
|
return "", fmt.Errorf("Pulse Assistant config is nil")
|
|
}
|
|
|
|
explicit := strings.TrimSpace(cfg.GetChatModel())
|
|
if explicit != "" && IsModelUsableForChatWithConfig(cfg, explicit) {
|
|
return explicit, nil
|
|
}
|
|
if explicit != "" {
|
|
return "", selectedChatModelProviderError(cfg, explicit)
|
|
}
|
|
|
|
configuredProviders := cfg.GetConfiguredProviders()
|
|
if len(configuredProviders) == 0 {
|
|
return "", fmt.Errorf("no provider configured")
|
|
}
|
|
|
|
return ResolveConfiguredChatProviderModelOffline(cfg, configuredProviders[0])
|
|
}
|
|
|
|
func selectedModelProvider(cfg *config.AIConfig, model string) (string, error) {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
return "", fmt.Errorf("selected model route is empty")
|
|
}
|
|
provider, _ := config.ParseModelString(model)
|
|
provider = strings.TrimSpace(provider)
|
|
if provider == "" {
|
|
return "", fmt.Errorf("selected model route %q does not name a provider", model)
|
|
}
|
|
if provider == config.AIProviderQuickstart {
|
|
return "", fmt.Errorf("quickstart provider is retired; configure a provider API key or Ollama")
|
|
}
|
|
if cfg == nil || !cfg.HasProvider(provider) {
|
|
return "", fmt.Errorf("%s provider is not configured for selected model route %q", provider, model)
|
|
}
|
|
return provider, nil
|
|
}
|
|
|
|
func selectedModelProviderError(cfg *config.AIConfig, model string) error {
|
|
if _, err := selectedModelProvider(cfg, model); err != nil {
|
|
return err
|
|
}
|
|
return fmt.Errorf("selected model route %q is not usable with the current Pulse Assistant config", strings.TrimSpace(model))
|
|
}
|
|
|
|
func selectedChatModelProviderError(cfg *config.AIConfig, model string) error {
|
|
if _, err := selectedModelProvider(cfg, model); err != nil {
|
|
return err
|
|
}
|
|
return fmt.Errorf("selected chat model route %q is not usable for Assistant chat with the current Pulse Assistant config", strings.TrimSpace(model))
|
|
}
|
|
|
|
// ResolveConfiguredChatProviderModelOffline resolves a chat-suitable model for
|
|
// a configured provider using only explicit config and stable provider defaults.
|
|
func ResolveConfiguredChatProviderModelOffline(cfg *config.AIConfig, provider string) (string, error) {
|
|
if cfg == nil {
|
|
return "", fmt.Errorf("Pulse Assistant config is nil")
|
|
}
|
|
|
|
provider = strings.TrimSpace(provider)
|
|
if provider == "" {
|
|
return "", fmt.Errorf("provider is required")
|
|
}
|
|
if provider == config.AIProviderQuickstart {
|
|
return "", fmt.Errorf("quickstart provider is retired; configure a provider API key or Ollama")
|
|
}
|
|
if !cfg.HasProvider(provider) {
|
|
return "", fmt.Errorf("%s provider is not configured", provider)
|
|
}
|
|
if preferred := strings.TrimSpace(cfg.GetPreferredModelForProvider(provider)); preferred != "" && modelUsableWithConfig(cfg, preferred, true) {
|
|
return preferred, nil
|
|
}
|
|
if fallback := strings.TrimSpace(config.DefaultModelForProvider(provider)); fallback != "" && modelUsableWithConfig(cfg, fallback, true) {
|
|
return fallback, nil
|
|
}
|
|
return "", fmt.Errorf("no chat model configured for %s provider", provider)
|
|
}
|
|
|
|
type gatewayEquivalentResolver struct {
|
|
gatewayProvider string
|
|
ownerForProvider func(provider string) string
|
|
}
|
|
|
|
var gatewayEquivalentResolvers = []gatewayEquivalentResolver{
|
|
{
|
|
gatewayProvider: config.AIProviderOpenRouter,
|
|
ownerForProvider: openRouterOwnerForProvider,
|
|
},
|
|
}
|
|
|
|
// GatewayEquivalentChatModels maps a configured direct-provider chat model to
|
|
// equivalent routes through configured gateway providers. Gateway route syntax
|
|
// belongs in this model-resolution registry; chat execution consumes candidates
|
|
// without encoding provider-specific fallback policy.
|
|
func GatewayEquivalentChatModels(cfg *config.AIConfig, model string) []string {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
routes := make([]string, 0, len(gatewayEquivalentResolvers))
|
|
seen := make(map[string]struct{}, len(gatewayEquivalentResolvers))
|
|
for _, resolver := range gatewayEquivalentResolvers {
|
|
candidate, ok := resolver.resolve(cfg, model)
|
|
if !ok {
|
|
continue
|
|
}
|
|
candidate = strings.TrimSpace(candidate)
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[candidate]; exists {
|
|
continue
|
|
}
|
|
seen[candidate] = struct{}{}
|
|
routes = append(routes, candidate)
|
|
}
|
|
return routes
|
|
}
|
|
|
|
func (r gatewayEquivalentResolver) resolve(cfg *config.AIConfig, model string) (string, bool) {
|
|
gatewayProvider := strings.TrimSpace(r.gatewayProvider)
|
|
if cfg == nil || gatewayProvider == "" || r.ownerForProvider == nil || !cfg.HasProvider(gatewayProvider) {
|
|
return "", false
|
|
}
|
|
provider, modelName := config.ParseModelString(strings.TrimSpace(model))
|
|
modelName = strings.TrimSpace(modelName)
|
|
if provider == "" || modelName == "" {
|
|
return "", false
|
|
}
|
|
if provider == gatewayProvider ||
|
|
provider == config.AIProviderOllama ||
|
|
provider == config.AIProviderQuickstart {
|
|
return "", false
|
|
}
|
|
gatewayOwner := strings.TrimSpace(r.ownerForProvider(provider))
|
|
if gatewayOwner == "" {
|
|
return "", false
|
|
}
|
|
candidate := config.FormatModelString(gatewayProvider, gatewayOwner+"/"+strings.TrimPrefix(modelName, gatewayOwner+"/"))
|
|
if !IsModelUsableForChatWithConfig(cfg, candidate) {
|
|
return "", false
|
|
}
|
|
return candidate, true
|
|
}
|
|
|
|
func openRouterOwnerForProvider(provider string) string {
|
|
switch strings.TrimSpace(provider) {
|
|
case config.AIProviderAnthropic:
|
|
return "anthropic"
|
|
case config.AIProviderOpenAI:
|
|
return "openai"
|
|
case config.AIProviderDeepSeek:
|
|
return "deepseek"
|
|
case config.AIProviderGemini:
|
|
return "google"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// ResolvePreferredModelForProvider returns the best model to use for the requested
|
|
// provider. Explicit provider-scoped selections win; otherwise Pulse resolves a
|
|
// recommended model from the provider's live catalog.
|
|
func ResolvePreferredModelForProvider(ctx context.Context, cfg *config.AIConfig, provider string) (string, error) {
|
|
if cfg == nil {
|
|
return "", fmt.Errorf("Pulse Assistant config is nil")
|
|
}
|
|
|
|
candidate := strings.TrimSpace(cfg.GetPreferredModelForProvider(provider))
|
|
if candidate != "" && IsModelUsableWithConfig(cfg, candidate) {
|
|
return candidate, nil
|
|
}
|
|
|
|
return ResolveConfiguredProviderModel(ctx, cfg, provider)
|
|
}
|
|
|
|
// ResolveConfiguredProviderModel resolves the best current model for a configured provider.
|
|
func ResolveConfiguredProviderModel(ctx context.Context, cfg *config.AIConfig, provider string) (string, error) {
|
|
return resolveConfiguredProviderModel(ctx, cfg, provider, false)
|
|
}
|
|
|
|
// ResolveConfiguredChatProviderModel resolves a chat-suitable model for a
|
|
// configured provider. Explicit provider preferences win only when they are
|
|
// suitable for Assistant chat; specialized endpoints such as realtime, audio,
|
|
// moderation, and embedding models are skipped.
|
|
func ResolveConfiguredChatProviderModel(ctx context.Context, cfg *config.AIConfig, provider string) (string, error) {
|
|
return resolveConfiguredProviderModel(ctx, cfg, provider, true)
|
|
}
|
|
|
|
func resolveConfiguredProviderModel(ctx context.Context, cfg *config.AIConfig, provider string, chatOnly bool) (string, error) {
|
|
if cfg == nil {
|
|
return "", fmt.Errorf("Pulse Assistant config is nil")
|
|
}
|
|
|
|
provider = strings.TrimSpace(provider)
|
|
if provider == "" {
|
|
return "", fmt.Errorf("provider is required")
|
|
}
|
|
if provider == config.AIProviderQuickstart {
|
|
return "", fmt.Errorf("quickstart provider is retired; configure a provider API key or Ollama")
|
|
}
|
|
if !cfg.HasProvider(provider) {
|
|
return "", fmt.Errorf("%s provider is not configured", provider)
|
|
}
|
|
if preferred := strings.TrimSpace(cfg.GetPreferredModelForProvider(provider)); preferred != "" && modelUsableWithConfig(cfg, preferred, chatOnly) {
|
|
return preferred, nil
|
|
}
|
|
|
|
client, err := providers.NewForProvider(cfg, provider, "")
|
|
if err != nil {
|
|
if fallback := strings.TrimSpace(config.DefaultModelForProvider(provider)); fallback != "" {
|
|
return fallback, nil
|
|
}
|
|
return "", fmt.Errorf("create %s provider for model resolution: %w", provider, err)
|
|
}
|
|
|
|
models, err := client.ListModels(ctx)
|
|
if err != nil {
|
|
if fallback := strings.TrimSpace(config.DefaultModelForProvider(provider)); fallback != "" {
|
|
return fallback, nil
|
|
}
|
|
return "", fmt.Errorf("list %s models: %w", provider, err)
|
|
}
|
|
|
|
selected, ok := SelectRecommendedProviderModel(models)
|
|
if !ok {
|
|
if fallback := strings.TrimSpace(config.DefaultModelForProvider(provider)); fallback != "" {
|
|
return fallback, nil
|
|
}
|
|
return "", fmt.Errorf("provider %s returned no usable models", provider)
|
|
}
|
|
|
|
return config.FormatModelString(provider, selected.ID), nil
|
|
}
|
|
|
|
// SelectRecommendedProviderModel picks the current best candidate from a provider's
|
|
// live model catalog. The policy is intentionally vendor-neutral but Assistant
|
|
// first:
|
|
// 1. prefer Patrol-blessed models (exact-ID match against the registry's
|
|
// suggested models, e.g. Ollama's guided-quickstart pick) so a user who
|
|
// followed the setup instructions gets that model without selecting it,
|
|
// 2. ignore obvious non-chat catalog entries such as moderation, embeddings, and
|
|
// content-safety endpoints,
|
|
// 3. prefer likely chat/instruction models over unknown model families,
|
|
// 4. prefer notable models,
|
|
// 5. then prefer models with a newer created timestamp,
|
|
// 6. then fall back to a stable lexical tie-break.
|
|
func SelectRecommendedProviderModel(models []providers.ModelInfo) (providers.ModelInfo, bool) {
|
|
blessed := config.SuggestedModelIDSet()
|
|
bestIndex := -1
|
|
var best providers.ModelInfo
|
|
for i, candidate := range models {
|
|
if strings.TrimSpace(candidate.ID) == "" {
|
|
continue
|
|
}
|
|
if recommendedModelSuitabilityRank(candidate) >= recommendedModelRankSpecialized {
|
|
continue
|
|
}
|
|
if bestIndex == -1 || recommendedModelBetter(blessed, candidate, i, best, bestIndex) {
|
|
best = candidate
|
|
bestIndex = i
|
|
}
|
|
}
|
|
return best, bestIndex >= 0
|
|
}
|
|
|
|
func recommendedModelBlessed(blessed map[string]struct{}, model providers.ModelInfo) bool {
|
|
_, ok := blessed[strings.ToLower(strings.TrimSpace(model.ID))]
|
|
return ok
|
|
}
|
|
|
|
func recommendedModelBetter(blessed map[string]struct{}, candidate providers.ModelInfo, candidateIndex int, current providers.ModelInfo, currentIndex int) bool {
|
|
candidateBlessed := recommendedModelBlessed(blessed, candidate)
|
|
currentBlessed := recommendedModelBlessed(blessed, current)
|
|
if candidateBlessed != currentBlessed {
|
|
return candidateBlessed
|
|
}
|
|
|
|
candidateRank := recommendedModelSuitabilityRank(candidate)
|
|
currentRank := recommendedModelSuitabilityRank(current)
|
|
if candidateRank != currentRank {
|
|
return candidateRank < currentRank
|
|
}
|
|
|
|
if candidate.Notable != current.Notable {
|
|
return candidate.Notable
|
|
}
|
|
|
|
candidateHasCreatedAt := candidate.CreatedAt > 0
|
|
currentHasCreatedAt := current.CreatedAt > 0
|
|
if candidateHasCreatedAt != currentHasCreatedAt {
|
|
return candidateHasCreatedAt
|
|
}
|
|
if candidate.CreatedAt != current.CreatedAt {
|
|
return candidate.CreatedAt > current.CreatedAt
|
|
}
|
|
|
|
candidateLabel := recommendedModelSortKey(candidate)
|
|
currentLabel := recommendedModelSortKey(current)
|
|
if candidateLabel != currentLabel {
|
|
return candidateLabel < currentLabel
|
|
}
|
|
|
|
return candidateIndex < currentIndex
|
|
}
|
|
|
|
const (
|
|
recommendedModelRankChat = iota
|
|
recommendedModelRankUnknown
|
|
recommendedModelRankSpecialized
|
|
)
|
|
|
|
func recommendedModelSuitabilityRank(model providers.ModelInfo) int {
|
|
return recommendedModelLabelSuitabilityRank(model.ID + " " + model.Name)
|
|
}
|
|
|
|
func recommendedModelLabelSuitabilityRank(label string) int {
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
if label == "" {
|
|
return recommendedModelRankSpecialized
|
|
}
|
|
|
|
for _, marker := range []string{
|
|
"audio",
|
|
"classifier",
|
|
"content-safety",
|
|
"embed",
|
|
"guard",
|
|
"image",
|
|
"moderation",
|
|
"realtime",
|
|
"rerank",
|
|
"reward",
|
|
"speech",
|
|
"transcribe",
|
|
"transcription",
|
|
"tts",
|
|
"whisper",
|
|
} {
|
|
if strings.Contains(label, marker) {
|
|
return recommendedModelRankSpecialized
|
|
}
|
|
}
|
|
|
|
for _, marker := range []string{
|
|
"chat",
|
|
"claude",
|
|
"command",
|
|
"deepseek",
|
|
"flash",
|
|
"gemini",
|
|
"gpt",
|
|
"grok",
|
|
"haiku",
|
|
"instruct",
|
|
"kimi",
|
|
"llama",
|
|
"mistral",
|
|
"mixtral",
|
|
"o1",
|
|
"o3",
|
|
"o4",
|
|
"opus",
|
|
"qwen",
|
|
"sonnet",
|
|
} {
|
|
if strings.Contains(label, marker) {
|
|
return recommendedModelRankChat
|
|
}
|
|
}
|
|
|
|
return recommendedModelRankUnknown
|
|
}
|
|
|
|
func recommendedModelSortKey(model providers.ModelInfo) string {
|
|
label := strings.TrimSpace(model.Name)
|
|
if label == "" {
|
|
label = strings.TrimSpace(model.ID)
|
|
}
|
|
return strings.ToLower(label)
|
|
}
|
|
|
|
// IsModelUsableWithConfig reports whether model names a non-retired provider
|
|
// with usable credentials in the supplied config.
|
|
func IsModelUsableWithConfig(cfg *config.AIConfig, model string) bool {
|
|
return modelUsableWithConfig(cfg, model, false)
|
|
}
|
|
|
|
// IsModelUsableForChatWithConfig reports whether model names a configured
|
|
// provider and does not look like a specialized non-chat endpoint.
|
|
func IsModelUsableForChatWithConfig(cfg *config.AIConfig, model string) bool {
|
|
return modelUsableWithConfig(cfg, model, true)
|
|
}
|
|
|
|
func modelUsableWithConfig(cfg *config.AIConfig, model string, chatOnly bool) bool {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
return false
|
|
}
|
|
provider, _ := config.ParseModelString(model)
|
|
if provider == config.AIProviderQuickstart {
|
|
return false
|
|
}
|
|
if cfg == nil || !cfg.HasProvider(provider) {
|
|
return false
|
|
}
|
|
return !chatOnly || IsModelLikelyChatSuitable(model)
|
|
}
|
|
|
|
// IsModelLikelyChatSuitable reports whether a model string looks usable for
|
|
// text chat. Unknown model families are allowed; obvious specialized endpoints
|
|
// are rejected.
|
|
func IsModelLikelyChatSuitable(model string) bool {
|
|
return recommendedModelLabelSuitabilityRank(model) < recommendedModelRankSpecialized
|
|
}
|