fix(agent): resolve fallback providers from model refs
This commit is contained in:
parent
ac9aea43c8
commit
156aaf5e9b
4 changed files with 199 additions and 1 deletions
|
|
@ -1044,7 +1044,12 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
ctx,
|
ctx,
|
||||||
activeCandidates,
|
activeCandidates,
|
||||||
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
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 {
|
if fbErr != nil {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package agent
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
@ -342,6 +344,22 @@ func (m *countingMockProvider) GetDefaultModel() string {
|
||||||
return "counting-mock-model"
|
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
|
// mockCustomTool is a simple mock tool for registration testing
|
||||||
type mockCustomTool struct{}
|
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) {
|
func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,42 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"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.
|
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
||||||
func createClaudeAuthProvider() (LLMProvider, error) {
|
func createClaudeAuthProvider() (LLMProvider, error) {
|
||||||
cred, err := getCredential("anthropic")
|
cred, err := getCredential("anthropic")
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-no-key",
|
ModelName: "test-no-key",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue