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 <noreply@anthropic.com>
This commit is contained in:
Sai Balusu 2026-03-09 16:40:08 -04:00
parent e4081070fe
commit 2db44bedda
8 changed files with 246 additions and 133 deletions

View file

@ -73,6 +73,12 @@
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement. 🤖 **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** | | | OpenClaw | NanoBot | **PicoClaw** |
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
| **Language** | TypeScript | Python | **Go** | | **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) | | `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) | | `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) | | `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) | | `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) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | | `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) | | **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | | **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 | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **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 ### 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. - **OpenAI-compatible protocol**: OpenRouter, Groq, Zhipu, DeepSeek, Mistral, MiniMax, Avian, Ollama, VLLM, and other OpenAI-compatible gateways.
- Anthropic protocol: Claude-native API behavior. - **Anthropic protocol**: Claude-native API behavior (with OAuth/token support).
- Codex/OAuth path: OpenAI OAuth/token authentication route. - **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.
<details> <details>
<summary><b>Zhipu</b></summary> <summary><b>Zhipu</b></summary>

View file

@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
"unicode/utf8" "time"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/constants"
@ -19,13 +19,16 @@ import (
type ContextCompressor struct { type ContextCompressor struct {
bus *bus.MessageBus bus *bus.MessageBus
summarizing *sync.Map summarizing *sync.Map
wg *sync.WaitGroup
} }
// NewContextCompressor creates a new ContextCompressor. // 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{ return &ContextCompressor{
bus: msgBus, bus: msgBus,
summarizing: summarizing, summarizing: summarizing,
wg: wg,
} }
} }
@ -38,10 +41,14 @@ func (cc *ContextCompressor) MaybeSummarize(agent *AgentInstance, sessionKey, ch
if len(newHistory) > SummarizeMessageThreshold || tokenEstimate > threshold { if len(newHistory) > SummarizeMessageThreshold || tokenEstimate > threshold {
summarizeKey := agent.ID + ":" + sessionKey summarizeKey := agent.ID + ":" + sessionKey
if _, loading := cc.summarizing.LoadOrStore(summarizeKey, true); !loading { if _, loading := cc.summarizing.LoadOrStore(summarizeKey, true); !loading {
cc.wg.Add(1)
go func() { go func() {
defer cc.wg.Done()
defer cc.summarizing.Delete(summarizeKey) defer cc.summarizing.Delete(summarizeKey)
if !constants.IsInternalChannel(channel) { 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, Channel: channel,
ChatID: chatID, ChatID: chatID,
Content: "Memory threshold reached. Optimizing conversation history...", 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" { if m.Role != "user" && m.Role != "assistant" {
continue continue
} }
// Use character-based estimation (2.5 chars per token = totalChars * 2 / 5) // Use byte-based estimation (~2 bytes per token), matching original behavior.
msgTokens := utf8.RuneCountInString(m.Content) * 2 / 5 // 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 { if msgTokens > maxMessageTokens {
omitted = true omitted = true
continue continue
@ -225,13 +234,11 @@ func (cc *ContextCompressor) SummarizeBatch(
} }
// EstimateTokens estimates the number of tokens in a message list. // 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 // Uses byte length / 2 (~2 bytes per token) to match the original behavior.
// overheads better than the previous 3 chars/token.
func (cc *ContextCompressor) EstimateTokens(messages []providers.Message) int { func (cc *ContextCompressor) EstimateTokens(messages []providers.Message) int {
totalChars := 0 totalBytes := 0
for _, m := range messages { for _, m := range messages {
totalChars += utf8.RuneCountInString(m.Content) totalBytes += len(m.Content)
} }
// 2.5 chars per token = totalChars * 2 / 5 return totalBytes / 2
return totalChars * 2 / 5
} }

View file

@ -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
}

View file

@ -41,7 +41,6 @@ type AgentLoop struct {
registry *AgentRegistry registry *AgentRegistry
state *state.Manager state *state.Manager
running atomic.Bool running atomic.Bool
summarizing sync.Map
fallback *providers.FallbackChain fallback *providers.FallbackChain
channelManager *channels.Manager channelManager *channels.Manager
mediaStore media.MediaStore mediaStore media.MediaStore
@ -53,9 +52,10 @@ type AgentLoop struct {
toolExec *ToolExecutor toolExec *ToolExecutor
// Graceful shutdown support // Graceful shutdown support
cancelCtx context.Context cancelCtx context.Context
cancelFunc context.CancelFunc cancelFunc context.CancelFunc
wg sync.WaitGroup wg sync.WaitGroup
summarizing sync.Map // owned here, pointer shared with compressor
} }
// processOptions configures how a message is processed // processOptions configures how a message is processed
@ -102,28 +102,25 @@ func NewAgentLoop(
stateManager = state.NewManager(defaultAgent.Workspace) stateManager = state.NewManager(defaultAgent.Workspace)
} }
// Initialize extracted components
summarizing := &sync.Map{}
compressor := NewContextCompressor(msgBus, summarizing)
toolExec := NewToolExecutor(msgBus)
// Create cancellation context for graceful shutdown // Create cancellation context for graceful shutdown
cancelCtx, cancelFunc := context.WithCancel(context.Background()) cancelCtx, cancelFunc := context.WithCancel(context.Background())
al := &AgentLoop{ al := &AgentLoop{
bus: msgBus, bus: msgBus,
cfg: cfg, cfg: cfg,
registry: registry, registry: registry,
state: stateManager, state: stateManager,
summarizing: sync.Map{}, fallback: fallbackChain,
fallback: fallbackChain,
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
compressor: compressor, toolExec: NewToolExecutor(msgBus),
toolExec: toolExec, cancelCtx: cancelCtx,
cancelCtx: cancelCtx, cancelFunc: cancelFunc,
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 return al
} }

View file

@ -32,6 +32,7 @@ func (te *ToolExecutor) ExecuteToolCalls(
agent *AgentInstance, agent *AgentInstance,
toolCalls []providers.ToolCall, toolCalls []providers.ToolCall,
opts processOptions, opts processOptions,
iteration int,
) []providers.Message { ) []providers.Message {
var resultMessages []providers.Message var resultMessages []providers.Message
@ -42,6 +43,7 @@ func (te *ToolExecutor) ExecuteToolCalls(
map[string]any{ map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
"tool": tc.Name, "tool": tc.Name,
"iteration": iteration,
}) })
// Create async callback for tools that implement AsyncTool // 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 // Send ForUser content to user immediately if not Silent
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
te.bus.PublishOutbound(bus.OutboundMessage{ te.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel, Channel: opts.Channel,
ChatID: opts.ChatID, ChatID: opts.ChatID,
Content: toolResult.ForUser, Content: toolResult.ForUser,

View file

@ -100,7 +100,7 @@ var (
rxp(`total tokens.*exceed`), rxp(`total tokens.*exceed`),
substr("context_length_exceeded"), substr("context_length_exceeded"),
substr("maximum context length"), substr("maximum context length"),
substr("invalidparameter"), rxp(`invalidparameter.*token`),
rxp(`tokens?.*exceed`), rxp(`tokens?.*exceed`),
rxp(`exceeds? the (model|max).*token`), 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 // 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 // exhaustion. Uses targeted regex/substring patterns to reduce false positives
// on errors like "invalid authentication token". // (e.g. "invalidparameter" is scoped to token-related errors only).
func IsContextWindowError(err error) bool { func IsContextWindowError(err error) bool {
if err == nil { if err == nil {
return false return false

View file

@ -336,3 +336,40 @@ func TestIsImageSizeError(t *testing.T) {
t.Error("should not match normal error") 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")
}
}

View file

@ -183,20 +183,56 @@ type modelInferenceEntry struct {
apply func(cfg *config.Config, sel *providerSelection) bool apply func(cfg *config.Config, sel *providerSelection) bool
} }
// modelInferenceRegistry defines fallback model → provider inference rules. // simpleInference creates a modelInferenceEntry for standard providers.
// Order matters: first match wins. // It matches model names by keyword (substring of lowercase model) or prefix (of original model),
var modelInferenceRegistry = []modelInferenceEntry{ // checks that the provider has credentials, and delegates to standardProviderRegistry.
// Moonshot/Kimi // 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 { matches: func(lm, m string, cfg *config.Config) bool {
return (strings.Contains(lm, "kimi") || strings.Contains(lm, "moonshot") || strings.HasPrefix(m, "moonshot/")) && nameMatch := false
cfg.Providers.Moonshot.APIKey != "" 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 { 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 { matches: func(_, m string, _ *config.Config) bool {
for _, prefix := range []string{"openrouter/", "anthropic/", "openai/", "meta-llama/", "deepseek/", "google/"} { for _, prefix := range []string{"openrouter/", "anthropic/", "openai/", "meta-llama/", "deepseek/", "google/"} {
@ -210,7 +246,7 @@ var modelInferenceRegistry = []modelInferenceEntry{
return applyStandardProvider(cfg, sel, standardProviderRegistry["openrouter"]) 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 { matches: func(lm, m string, cfg *config.Config) bool {
return (strings.Contains(lm, "claude") || strings.HasPrefix(m, "anthropic/")) && return (strings.Contains(lm, "claude") || strings.HasPrefix(m, "anthropic/")) &&
@ -234,7 +270,7 @@ var modelInferenceRegistry = []modelInferenceEntry{
return true 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 { matches: func(lm, m string, cfg *config.Config) bool {
return (strings.Contains(lm, "gpt") || strings.HasPrefix(m, "openai/")) && return (strings.Contains(lm, "gpt") || strings.HasPrefix(m, "openai/")) &&
@ -259,88 +295,17 @@ var modelInferenceRegistry = []modelInferenceEntry{
return true return true
}, },
}, },
// Gemini // Standard providers — simple keyword/prefix matching
{ simpleInference("gemini", []string{"gemini"}, []string{"google/"}),
matches: func(lm, m string, cfg *config.Config) bool { simpleInference("zhipu", []string{"glm", "zhipu", "zai"}, nil),
return (strings.Contains(lm, "gemini") || strings.HasPrefix(m, "google/")) && cfg.Providers.Gemini.APIKey != "" simpleInference("groq", []string{"groq"}, []string{"groq/"}),
}, simpleInference("nvidia", []string{"nvidia"}, []string{"nvidia/"}),
apply: func(cfg *config.Config, sel *providerSelection) bool { simpleInference("ollama", []string{"ollama"}, []string{"ollama/"}),
return applyStandardProvider(cfg, sel, standardProviderRegistry["gemini"]) simpleInference("mistral", []string{"mistral"}, []string{"mistral/"}),
}, simpleInference("vivgrid", nil, []string{"vivgrid/"}),
}, simpleInference("minimax", []string{"minimax"}, []string{"minimax/"}),
// Zhipu/GLM simpleInference("avian", nil, []string{"avian/"}),
{ // VLLM (custom: matches any model if API base is configured)
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)
{ {
matches: func(_, _ string, cfg *config.Config) bool { matches: func(_, _ string, cfg *config.Config) bool {
return cfg.Providers.VLLM.APIBase != "" return cfg.Providers.VLLM.APIBase != ""