From 2db44bedda0d60cd63a8df4a5d37ac3a2a2cde0b Mon Sep 17 00:00:00 2001 From: Sai Balusu Date: Mon, 9 Mar 2026 16:40:08 -0400 Subject: [PATCH] refactor: decompose agent loop, fix compressor regression, tighten error classifier - Fix token estimation regression in context_compressor (missing context, shutdown wiring, correct token counting) - Add EstimateTokens tests in context_compressor_test.go - Wire graceful shutdown in loop.go, remove dead summarizing field - Add iteration number to tool_executor logs, fix missing ctx - Tighten invalidparameter regex in error_classifier to avoid false positives - Add IsContextWindowError tests in error_classifier_test.go - Reduce duplication in factory.go with simpleInference helper - Update README with new providers (mistral, minimax, avian) and features Co-Authored-By: Claude Opus 4.6 --- README.md | 25 +++- pkg/agent/context_compressor.go | 29 +++-- pkg/agent/context_compressor_test.go | 90 +++++++++++++++ pkg/agent/loop.go | 35 +++--- pkg/agent/tool_executor.go | 4 +- pkg/providers/error_classifier.go | 6 +- pkg/providers/error_classifier_test.go | 37 ++++++ pkg/providers/factory.go | 153 ++++++++++--------------- 8 files changed, 246 insertions(+), 133 deletions(-) create mode 100644 pkg/agent/context_compressor_test.go diff --git a/README.md b/README.md index 5cf9f6143..4bf218008 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,12 @@ 🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement. +đŸŽ™ī¸ **Voice & Media**: Built-in voice transcription (via Groq Whisper) and media attachment handling across all channels. + +🔌 **MCP Support**: Connect external tool servers via the Model Context Protocol for extensible capabilities. + +🔀 **Model Routing**: Automatic light/heavy model routing to reduce costs — simple queries go to cheaper models, complex ones to full-power models. + | | OpenClaw | NanoBot | **PicoClaw** | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | **Language** | TypeScript | Python | **Go** | @@ -984,6 +990,9 @@ The subagent has access to tools (message, web_search, etc.) and can communicate | `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | +| `minimax` | LLM (MiniMax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `avian` | LLM (Avian direct) | [avian.io](https://avian.io) | | `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | @@ -1021,6 +1030,9 @@ This design also enables **multi-agent support** with flexible provider selectio | **įĢåąąåŧ•擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | | **įĨžįŽ—äē‘** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | +| **Mistral** | `mistral/` | `https://api.mistral.ai/v1` | OpenAI | [Get Key](https://console.mistral.ai) | +| **MiniMax** | `minimax/` | `https://api.minimaxi.com/v1` | OpenAI | [Get Key](https://platform.minimaxi.com) | +| **Avian** | `avian/` | `https://api.avian.io/v1` | OpenAI | [Get Key](https://avian.io) | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | @@ -1200,13 +1212,16 @@ For detailed migration guide, see [docs/migration/model-list-migration.md](docs/ ### Provider Architecture -PicoClaw routes providers by protocol family: +PicoClaw uses a **table-driven provider registry** that routes providers by protocol family: -- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints. -- Anthropic protocol: Claude-native API behavior. -- Codex/OAuth path: OpenAI OAuth/token authentication route. +- **OpenAI-compatible protocol**: OpenRouter, Groq, Zhipu, DeepSeek, Mistral, MiniMax, Avian, Ollama, VLLM, and other OpenAI-compatible gateways. +- **Anthropic protocol**: Claude-native API behavior (with OAuth/token support). +- **Codex/OAuth path**: OpenAI OAuth/token authentication route. +- **Special providers**: Claude CLI, Codex CLI, GitHub Copilot (gRPC), Antigravity (Google Cloud OAuth). -This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`). +Adding a new OpenAI-compatible provider requires only a registry entry (`api_base` + `api_key` accessor) — no switch statements or code changes. + +The agent loop uses a **fallback chain** with cooldown tracking, error classification, and exponential backoff. Context window errors trigger automatic **history compression**; rate limits and server errors trigger fallback to the next configured model.
Zhipu diff --git a/pkg/agent/context_compressor.go b/pkg/agent/context_compressor.go index d9c3bd517..ddd87e41e 100644 --- a/pkg/agent/context_compressor.go +++ b/pkg/agent/context_compressor.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" "sync" - "unicode/utf8" + "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/constants" @@ -19,13 +19,16 @@ import ( type ContextCompressor struct { bus *bus.MessageBus summarizing *sync.Map + wg *sync.WaitGroup } // NewContextCompressor creates a new ContextCompressor. -func NewContextCompressor(msgBus *bus.MessageBus, summarizing *sync.Map) *ContextCompressor { +// The wg parameter tracks in-flight goroutines for graceful shutdown. +func NewContextCompressor(msgBus *bus.MessageBus, summarizing *sync.Map, wg *sync.WaitGroup) *ContextCompressor { return &ContextCompressor{ bus: msgBus, summarizing: summarizing, + wg: wg, } } @@ -38,10 +41,14 @@ func (cc *ContextCompressor) MaybeSummarize(agent *AgentInstance, sessionKey, ch if len(newHistory) > SummarizeMessageThreshold || tokenEstimate > threshold { summarizeKey := agent.ID + ":" + sessionKey if _, loading := cc.summarizing.LoadOrStore(summarizeKey, true); !loading { + cc.wg.Add(1) go func() { + defer cc.wg.Done() defer cc.summarizing.Delete(summarizeKey) if !constants.IsInternalChannel(channel) { - cc.bus.PublishOutbound(bus.OutboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + cc.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: "Memory threshold reached. Optimizing conversation history...", @@ -130,8 +137,10 @@ func (cc *ContextCompressor) SummarizeSession(agent *AgentInstance, sessionKey s if m.Role != "user" && m.Role != "assistant" { continue } - // Use character-based estimation (2.5 chars per token = totalChars * 2 / 5) - msgTokens := utf8.RuneCountInString(m.Content) * 2 / 5 + // Use byte-based estimation (~2 bytes per token), matching original behavior. + // For ASCII text this gives 0.5 chars/token; for CJK (3 bytes/rune) it + // over-estimates slightly, which is the safer direction for the guard. + msgTokens := len(m.Content) / 2 if msgTokens > maxMessageTokens { omitted = true continue @@ -225,13 +234,11 @@ func (cc *ContextCompressor) SummarizeBatch( } // EstimateTokens estimates the number of tokens in a message list. -// Uses a safe heuristic of 2.5 characters per token to account for CJK and other -// overheads better than the previous 3 chars/token. +// Uses byte length / 2 (~2 bytes per token) to match the original behavior. func (cc *ContextCompressor) EstimateTokens(messages []providers.Message) int { - totalChars := 0 + totalBytes := 0 for _, m := range messages { - totalChars += utf8.RuneCountInString(m.Content) + totalBytes += len(m.Content) } - // 2.5 chars per token = totalChars * 2 / 5 - return totalChars * 2 / 5 + return totalBytes / 2 } diff --git a/pkg/agent/context_compressor_test.go b/pkg/agent/context_compressor_test.go new file mode 100644 index 000000000..58bdcdb08 --- /dev/null +++ b/pkg/agent/context_compressor_test.go @@ -0,0 +1,90 @@ +package agent + +import ( + "sync" + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestEstimateTokens_ByteBased(t *testing.T) { + cc := NewContextCompressor(nil, &sync.Map{}, &sync.WaitGroup{}) + + tests := []struct { + name string + messages []providers.Message + want int + }{ + { + name: "empty", + messages: nil, + want: 0, + }, + { + name: "ascii text", + messages: []providers.Message{ + {Content: "hello world"}, // 11 bytes → 5 tokens + }, + want: 5, + }, + { + name: "multiple messages", + messages: []providers.Message{ + {Content: "hello"}, // 5 bytes + {Content: "world"}, // 5 bytes → total 10 → 5 tokens + }, + want: 5, + }, + { + name: "CJK text (multi-byte runes)", + messages: []providers.Message{ + {Content: "äŊ åĨŊä¸–į•Œ"}, // 12 bytes (3 per rune) → 6 tokens + }, + want: 6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cc.EstimateTokens(tt.messages) + if got != tt.want { + t.Errorf("EstimateTokens() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestEstimateTokens_MatchesOriginalBehavior(t *testing.T) { + // The original code used len(m.Content) / 2. + // Verify that the refactored code produces identical results. + cc := NewContextCompressor(nil, &sync.Map{}, &sync.WaitGroup{}) + + messages := []providers.Message{ + {Content: "The quick brown fox jumps over the lazy dog"}, + {Content: "Hello, world!"}, + } + + // Original: sum of len(m.Content) / 2 for each message? No — it summed all + // then divided. Let's compute manually: + // "The quick brown fox jumps over the lazy dog" = 43 bytes + // "Hello, world!" = 13 bytes + // Total = 56 bytes → 56/2 = 28 + want := 28 + got := cc.EstimateTokens(messages) + if got != want { + t.Errorf("EstimateTokens() = %d, want %d (original len/2 behavior)", got, want) + } +} + +func TestForceCompression_MinHistory(t *testing.T) { + cc := NewContextCompressor(nil, &sync.Map{}, &sync.WaitGroup{}) + + // Create a mock agent with minimal history (below MinHistoryForCompression) + // ForceCompression should be a no-op. + // We can't easily test this without a full AgentInstance, but we can verify + // the threshold constant is reasonable. + if MinHistoryForCompression < 2 { + t.Errorf("MinHistoryForCompression = %d, should be at least 2", MinHistoryForCompression) + } + _ = cc // used above +} diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 56a620094..868f2bb13 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -41,7 +41,6 @@ type AgentLoop struct { registry *AgentRegistry state *state.Manager running atomic.Bool - summarizing sync.Map fallback *providers.FallbackChain channelManager *channels.Manager mediaStore media.MediaStore @@ -53,9 +52,10 @@ type AgentLoop struct { toolExec *ToolExecutor // Graceful shutdown support - cancelCtx context.Context - cancelFunc context.CancelFunc - wg sync.WaitGroup + cancelCtx context.Context + cancelFunc context.CancelFunc + wg sync.WaitGroup + summarizing sync.Map // owned here, pointer shared with compressor } // processOptions configures how a message is processed @@ -102,28 +102,25 @@ func NewAgentLoop( stateManager = state.NewManager(defaultAgent.Workspace) } - // Initialize extracted components - summarizing := &sync.Map{} - compressor := NewContextCompressor(msgBus, summarizing) - toolExec := NewToolExecutor(msgBus) - // Create cancellation context for graceful shutdown cancelCtx, cancelFunc := context.WithCancel(context.Background()) al := &AgentLoop{ - bus: msgBus, - cfg: cfg, - registry: registry, - state: stateManager, - summarizing: sync.Map{}, - fallback: fallbackChain, + bus: msgBus, + cfg: cfg, + registry: registry, + state: stateManager, + fallback: fallbackChain, cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), - compressor: compressor, - toolExec: toolExec, - cancelCtx: cancelCtx, - cancelFunc: cancelFunc, + toolExec: NewToolExecutor(msgBus), + cancelCtx: cancelCtx, + cancelFunc: cancelFunc, } + // Wire compressor with references to al.wg (shutdown tracking) and + // al.summarizing (dedup map) — must happen after al is allocated. + al.compressor = NewContextCompressor(msgBus, &al.summarizing, &al.wg) + return al } diff --git a/pkg/agent/tool_executor.go b/pkg/agent/tool_executor.go index a13a0dccd..f12fc1d2a 100644 --- a/pkg/agent/tool_executor.go +++ b/pkg/agent/tool_executor.go @@ -32,6 +32,7 @@ func (te *ToolExecutor) ExecuteToolCalls( agent *AgentInstance, toolCalls []providers.ToolCall, opts processOptions, + iteration int, ) []providers.Message { var resultMessages []providers.Message @@ -42,6 +43,7 @@ func (te *ToolExecutor) ExecuteToolCalls( map[string]any{ "agent_id": agent.ID, "tool": tc.Name, + "iteration": iteration, }) // Create async callback for tools that implement AsyncTool @@ -69,7 +71,7 @@ func (te *ToolExecutor) ExecuteToolCalls( // Send ForUser content to user immediately if not Silent if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { - te.bus.PublishOutbound(bus.OutboundMessage{ + te.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, Content: toolResult.ForUser, diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index f9b6e9c71..1b3916056 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -100,7 +100,7 @@ var ( rxp(`total tokens.*exceed`), substr("context_length_exceeded"), substr("maximum context length"), - substr("invalidparameter"), + rxp(`invalidparameter.*token`), rxp(`tokens?.*exceed`), rxp(`exceeds? the (model|max).*token`), } @@ -238,8 +238,8 @@ func IsImageSizeError(msg string) bool { } // IsContextWindowError returns true if the error indicates a context window / token limit -// exhaustion. This is more precise than naive string matching and avoids false positives -// on errors like "invalid authentication token". +// exhaustion. Uses targeted regex/substring patterns to reduce false positives +// (e.g. "invalidparameter" is scoped to token-related errors only). func IsContextWindowError(err error) bool { if err == nil { return false diff --git a/pkg/providers/error_classifier_test.go b/pkg/providers/error_classifier_test.go index 67d9af62b..06e81cf1c 100644 --- a/pkg/providers/error_classifier_test.go +++ b/pkg/providers/error_classifier_test.go @@ -336,3 +336,40 @@ func TestIsImageSizeError(t *testing.T) { t.Error("should not match normal error") } } + +func TestIsContextWindowError(t *testing.T) { + // Should match token-related errors + positives := []string{ + "context window length exceeded", + "maximum context length is 128000 tokens", + "context_length_exceeded", + "max token limit reached", + "total tokens exceeded model limit", + "tokens exceeded the maximum", + "exceeds the model token limit", + "invalidparameter: max_tokens exceeded", + } + for _, msg := range positives { + if !IsContextWindowError(errors.New(msg)) { + t.Errorf("expected true for %q", msg) + } + } + + // Should NOT match non-token errors (false positive regression test) + negatives := []string{ + "invalidparameter: temperature must be between 0 and 2", + "invalidparameter: model not found", + "invalid authentication token", + "normal error message", + } + for _, msg := range negatives { + if IsContextWindowError(errors.New(msg)) { + t.Errorf("expected false for %q (false positive)", msg) + } + } + + // Nil error + if IsContextWindowError(nil) { + t.Error("expected false for nil error") + } +} diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index 4a00b6e42..57fc63bd4 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -183,20 +183,56 @@ type modelInferenceEntry struct { apply func(cfg *config.Config, sel *providerSelection) bool } -// modelInferenceRegistry defines fallback model → provider inference rules. -// Order matters: first match wins. -var modelInferenceRegistry = []modelInferenceEntry{ - // Moonshot/Kimi - { +// simpleInference creates a modelInferenceEntry for standard providers. +// It matches model names by keyword (substring of lowercase model) or prefix (of original model), +// checks that the provider has credentials, and delegates to standardProviderRegistry. +// This eliminates boilerplate for providers that follow the standard pattern. +func simpleInference(registryKey string, keywords []string, prefixes []string) modelInferenceEntry { + return modelInferenceEntry{ matches: func(lm, m string, cfg *config.Config) bool { - return (strings.Contains(lm, "kimi") || strings.Contains(lm, "moonshot") || strings.HasPrefix(m, "moonshot/")) && - cfg.Providers.Moonshot.APIKey != "" + nameMatch := false + for _, p := range prefixes { + if strings.HasPrefix(m, p) { + nameMatch = true + break + } + } + if !nameMatch { + for _, k := range keywords { + if strings.Contains(lm, k) { + nameMatch = true + break + } + } + } + if !nameMatch { + return false + } + entry, ok := standardProviderRegistry[registryKey] + if !ok { + return false + } + if entry.hasKey != nil { + return entry.hasKey(cfg) + } + key, _, _ := entry.getConfig(cfg) + return key != "" }, apply: func(cfg *config.Config, sel *providerSelection) bool { - return applyStandardProvider(cfg, sel, standardProviderRegistry["moonshot"]) + return applyStandardProvider(cfg, sel, standardProviderRegistry[registryKey]) }, - }, - // OpenRouter-prefixed models (openrouter/, anthropic/, openai/, meta-llama/, deepseek/, google/) + } +} + +// modelInferenceRegistry defines fallback model → provider inference rules. +// Order matters: first match wins. +// Simple providers use simpleInference() to reduce boilerplate; complex providers +// (with OAuth, special auth, or multi-prefix routing) use custom entries. +var modelInferenceRegistry = []modelInferenceEntry{ + // Moonshot/Kimi + simpleInference("moonshot", []string{"kimi", "moonshot"}, []string{"moonshot/"}), + // OpenRouter-prefixed models — no credential check in matches because OpenRouter + // acts as a router; credential check happens in applyStandardProvider. { matches: func(_, m string, _ *config.Config) bool { for _, prefix := range []string{"openrouter/", "anthropic/", "openai/", "meta-llama/", "deepseek/", "google/"} { @@ -210,7 +246,7 @@ var modelInferenceRegistry = []modelInferenceEntry{ return applyStandardProvider(cfg, sel, standardProviderRegistry["openrouter"]) }, }, - // Claude models → Anthropic (with OAuth support) + // Claude models → Anthropic (custom: OAuth/token auth support) { matches: func(lm, m string, cfg *config.Config) bool { return (strings.Contains(lm, "claude") || strings.HasPrefix(m, "anthropic/")) && @@ -234,7 +270,7 @@ var modelInferenceRegistry = []modelInferenceEntry{ return true }, }, - // GPT models → OpenAI (with OAuth/codex-cli support) + // GPT models → OpenAI (custom: OAuth/codex-cli/web-search support) { matches: func(lm, m string, cfg *config.Config) bool { return (strings.Contains(lm, "gpt") || strings.HasPrefix(m, "openai/")) && @@ -259,88 +295,17 @@ var modelInferenceRegistry = []modelInferenceEntry{ return true }, }, - // Gemini - { - matches: func(lm, m string, cfg *config.Config) bool { - return (strings.Contains(lm, "gemini") || strings.HasPrefix(m, "google/")) && cfg.Providers.Gemini.APIKey != "" - }, - apply: func(cfg *config.Config, sel *providerSelection) bool { - return applyStandardProvider(cfg, sel, standardProviderRegistry["gemini"]) - }, - }, - // Zhipu/GLM - { - matches: func(lm, _ string, cfg *config.Config) bool { - return (strings.Contains(lm, "glm") || strings.Contains(lm, "zhipu") || strings.Contains(lm, "zai")) && cfg.Providers.Zhipu.APIKey != "" - }, - apply: func(cfg *config.Config, sel *providerSelection) bool { - return applyStandardProvider(cfg, sel, standardProviderRegistry["zhipu"]) - }, - }, - // Groq - { - matches: func(lm, m string, cfg *config.Config) bool { - return (strings.Contains(lm, "groq") || strings.HasPrefix(m, "groq/")) && cfg.Providers.Groq.APIKey != "" - }, - apply: func(cfg *config.Config, sel *providerSelection) bool { - return applyStandardProvider(cfg, sel, standardProviderRegistry["groq"]) - }, - }, - // Nvidia - { - matches: func(lm, m string, cfg *config.Config) bool { - return (strings.Contains(lm, "nvidia") || strings.HasPrefix(m, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "" - }, - apply: func(cfg *config.Config, sel *providerSelection) bool { - return applyStandardProvider(cfg, sel, standardProviderRegistry["nvidia"]) - }, - }, - // Ollama - { - matches: func(lm, m string, cfg *config.Config) bool { - return (strings.Contains(lm, "ollama") || strings.HasPrefix(m, "ollama/")) && cfg.Providers.Ollama.APIKey != "" - }, - apply: func(cfg *config.Config, sel *providerSelection) bool { - return applyStandardProvider(cfg, sel, standardProviderRegistry["ollama"]) - }, - }, - // Mistral - { - matches: func(lm, m string, cfg *config.Config) bool { - return (strings.Contains(lm, "mistral") || strings.HasPrefix(m, "mistral/")) && cfg.Providers.Mistral.APIKey != "" - }, - apply: func(cfg *config.Config, sel *providerSelection) bool { - return applyStandardProvider(cfg, sel, standardProviderRegistry["mistral"]) - }, - }, - // Vivgrid - { - matches: func(_, m string, cfg *config.Config) bool { - return strings.HasPrefix(m, "vivgrid/") && cfg.Providers.Vivgrid.APIKey != "" - }, - apply: func(cfg *config.Config, sel *providerSelection) bool { - return applyStandardProvider(cfg, sel, standardProviderRegistry["vivgrid"]) - }, - }, - // Minimax - { - matches: func(lm, m string, cfg *config.Config) bool { - return (strings.Contains(lm, "minimax") || strings.HasPrefix(m, "minimax/")) && cfg.Providers.Minimax.APIKey != "" - }, - apply: func(cfg *config.Config, sel *providerSelection) bool { - return applyStandardProvider(cfg, sel, standardProviderRegistry["minimax"]) - }, - }, - // Avian - { - matches: func(_, m string, cfg *config.Config) bool { - return strings.HasPrefix(m, "avian/") && cfg.Providers.Avian.APIKey != "" - }, - apply: func(cfg *config.Config, sel *providerSelection) bool { - return applyStandardProvider(cfg, sel, standardProviderRegistry["avian"]) - }, - }, - // VLLM (fallback if API base is configured) + // Standard providers — simple keyword/prefix matching + simpleInference("gemini", []string{"gemini"}, []string{"google/"}), + simpleInference("zhipu", []string{"glm", "zhipu", "zai"}, nil), + simpleInference("groq", []string{"groq"}, []string{"groq/"}), + simpleInference("nvidia", []string{"nvidia"}, []string{"nvidia/"}), + simpleInference("ollama", []string{"ollama"}, []string{"ollama/"}), + simpleInference("mistral", []string{"mistral"}, []string{"mistral/"}), + simpleInference("vivgrid", nil, []string{"vivgrid/"}), + simpleInference("minimax", []string{"minimax"}, []string{"minimax/"}), + simpleInference("avian", nil, []string{"avian/"}), + // VLLM (custom: matches any model if API base is configured) { matches: func(_, _ string, cfg *config.Config) bool { return cfg.Providers.VLLM.APIBase != ""