diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index bee8d91a7..c6ed90cbe 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1044,7 +1044,12 @@ func (al *AgentLoop) runLLMIteration( ctx, activeCandidates, func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { - return agent.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts) + modelRef := provider + "/" + model + candidateProvider, candidateModel, err := providers.CreateProviderForModelRef(al.cfg, modelRef) + if err != nil { + return nil, err + } + return candidateProvider.Chat(ctx, messages, providerToolDefs, candidateModel, llmOpts) }, ) if fbErr != nil { diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 2e456fa60..1db4ddf56 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -3,6 +3,8 @@ package agent import ( "context" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "slices" @@ -342,6 +344,22 @@ func (m *countingMockProvider) GetDefaultModel() string { return "counting-mock-model" } +type alwaysFailingMockProvider struct{} + +func (m *alwaysFailingMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + return nil, fmt.Errorf("API request failed:\n Status: 429\n Body: rate limited") +} + +func (m *alwaysFailingMockProvider) GetDefaultModel() string { + return "always-failing" +} + // mockCustomTool is a simple mock tool for registration testing type mockCustomTool struct{} @@ -770,6 +788,72 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { } } +func TestProcessMessage_FallbackUsesResolvedProviderConfig(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + primaryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"error":{"message":"rate limited"}}`)) + })) + defer primaryServer.Close() + + fallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("request path = %q, want /chat/completions", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"fallback success"},"finish_reason":"stop"}]}`)) + })) + defer fallbackServer.Close() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "primary-openrouter", + ModelFallbacks: []string{"fallback-openai"}, + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []config.ModelConfig{ + { + ModelName: "primary-openrouter", + Model: "openrouter/auto", + APIKey: "sk-or-test", + APIBase: primaryServer.URL, + }, + { + ModelName: "fallback-openai", + Model: "openai/gpt-4o-mini", + APIKey: "sk-openai-test", + APIBase: fallbackServer.URL, + }, + }, + } + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &alwaysFailingMockProvider{}) + + response, err := al.ProcessDirectWithChannel( + context.Background(), + "hello", + "test-fallback-session", + "telegram", + "chat-1", + ) + if err != nil { + t.Fatalf("ProcessDirectWithChannel() error = %v", err) + } + if response != "fallback success" { + t.Fatalf("response = %q, want %q", response, "fallback success") + } +} + func TestTargetReasoningChannelID_AllChannels(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index a798154cb..fd7bc0c4b 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -12,6 +12,42 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) +// CreateProviderForModelRef resolves either a model_name alias or a fully +// qualified provider/model ref against model_list and creates the matching +// provider instance for that entry. +func CreateProviderForModelRef(appCfg *config.Config, modelRef string) (LLMProvider, string, error) { + if appCfg == nil { + return nil, "", fmt.Errorf("config is nil") + } + + ref := strings.TrimSpace(modelRef) + if ref == "" { + return nil, "", fmt.Errorf("model reference is empty") + } + + if mc, err := appCfg.GetModelConfig(ref); err == nil && mc != nil { + return CreateProviderFromConfig(mc) + } + + refProvider, refModel := ExtractProtocol(ref) + for i := range appCfg.ModelList { + modelCfg := &appCfg.ModelList[i] + candidate := strings.TrimSpace(modelCfg.Model) + if candidate == "" { + continue + } + if candidate == ref { + return CreateProviderFromConfig(modelCfg) + } + candidateProvider, candidateModel := ExtractProtocol(candidate) + if candidateProvider == refProvider && candidateModel == refModel { + return CreateProviderFromConfig(modelCfg) + } + } + + return nil, "", fmt.Errorf("model %q not found in model_list", modelRef) +} + // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. func createClaudeAuthProvider() (LLMProvider, error) { cred, err := getCredential("anthropic") diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 17bc55d25..d7dc2f0bc 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -235,6 +235,79 @@ func TestCreateProviderFromConfig_CodexCLI(t *testing.T) { } } +func TestCreateProviderForModelRef_ResolvesAliasAndFullRef(t *testing.T) { + aliasServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("alias request path = %q, want %q", r.URL.Path, "/chat/completions") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"alias"},"finish_reason":"stop"}]}`)) + })) + defer aliasServer.Close() + + openrouterServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("openrouter request path = %q, want %q", r.URL.Path, "/chat/completions") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"openrouter"},"finish_reason":"stop"}]}`)) + })) + defer openrouterServer.Close() + + cfg := &config.Config{ + ModelList: []config.ModelConfig{ + { + ModelName: "lmstudio-qwen", + Model: "openai/qwen/qwen3.5-9b", + APIKey: "lm-studio", + APIBase: aliasServer.URL, + }, + { + ModelName: "openrouter-auto", + Model: "openrouter/auto", + APIKey: "sk-or-v1-test", + APIBase: openrouterServer.URL, + }, + }, + } + + provider, modelID, err := CreateProviderForModelRef(cfg, "lmstudio-qwen") + if err != nil { + t.Fatalf("CreateProviderForModelRef(alias) error = %v", err) + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("CreateProviderForModelRef(alias) provider = %T, want *HTTPProvider", provider) + } + if modelID != "qwen/qwen3.5-9b" { + t.Fatalf("alias modelID = %q, want %q", modelID, "qwen/qwen3.5-9b") + } + resp, err := provider.Chat(t.Context(), []Message{{Role: "user", Content: "ping"}}, nil, modelID, nil) + if err != nil { + t.Fatalf("alias Chat() error = %v", err) + } + if resp.Content != "alias" { + t.Fatalf("alias response = %q, want %q", resp.Content, "alias") + } + + provider, modelID, err = CreateProviderForModelRef(cfg, "openrouter/auto") + if err != nil { + t.Fatalf("CreateProviderForModelRef(full ref) error = %v", err) + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("CreateProviderForModelRef(full ref) provider = %T, want *HTTPProvider", provider) + } + if modelID != "auto" { + t.Fatalf("full ref modelID = %q, want %q", modelID, "auto") + } + resp, err = provider.Chat(t.Context(), []Message{{Role: "user", Content: "ping"}}, nil, modelID, nil) + if err != nil { + t.Fatalf("full ref Chat() error = %v", err) + } + if resp.Content != "openrouter" { + t.Fatalf("full ref response = %q, want %q", resp.Content, "openrouter") + } +} + func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-no-key",