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:
parent
e4081070fe
commit
2db44bedda
8 changed files with 246 additions and 133 deletions
25
README.md
25
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.
|
||||
|
||||
<details>
|
||||
<summary><b>Zhipu</b></summary>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
90
pkg/agent/context_compressor_test.go
Normal file
90
pkg/agent/context_compressor_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 != ""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue