diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index 9f234bb4e..2d6fc4c48 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -97,6 +97,18 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { } defer rl.Close() + // Listen for outbound messages on the bus (e.g. from the 'message' tool) + // and print them to the console. + go func() { + for outbound := range agentLoop.GetBus().OutboundChan() { + if outbound.Content != "" { + fmt.Printf("\n%s %s\n", internal.Logo, outbound.Content) + // Re-print the prompt to keep it at the bottom + rl.Refresh() + } + } + }() + for { line, err := rl.Readline() if err != nil { @@ -130,6 +142,15 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { } func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { + // Listen for outbound messages on the bus + go func() { + for outbound := range agentLoop.GetBus().OutboundChan() { + if outbound.Content != "" { + fmt.Printf("\n%s %s\n\n%s You: ", internal.Logo, outbound.Content, internal.Logo) + } + } + }() + reader := bufio.NewReader(os.Stdin) for { fmt.Print(fmt.Sprintf("%s You: ", internal.Logo)) diff --git a/config/config.example.json b/config/config.example.json index 858472488..974d93dca 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -80,6 +80,15 @@ "api_key": "sk-key1", "api_base": "https://api1.example.com/v1" }, + { + "model_name": "nemotron-120b", + "model": "nvidia/nemotron-3-super-120b-a12b", + "protocol": "nvidia", + "api_base": "https://integrate.api.nvidia.com/v1", + "api_keys": [ + "env://NVIDIA_API_KEY" + ] + }, { "model_name": "loadbalanced-gpt-5.4", "model": "openai/gpt-5.4", @@ -429,6 +438,9 @@ "list_dir": { "enabled": true }, + "freeride": { + "enabled": true + }, "message": { "enabled": true }, diff --git a/k3s/config.json b/k3s/config.json index 50f4d69ef..2232b0c1d 100644 --- a/k3s/config.json +++ b/k3s/config.json @@ -15,8 +15,8 @@ "/home/picoclaw/.picoclaw", "/tmp" ], - "provider": "openai", - "model_name": "google-gemma-4-26b-a4b-it:free", + "provider": "nvidia", + "model_name": "nemotron-120b", "model_fallbacks": [ "google-gemma-4-26b-a4b-it:free", "google-gemma-4-31b-it:free", @@ -488,6 +488,17 @@ ], "request_timeout": 45 }, + { + "model_name": "nemotron-120b", + "model": "nvidia/nemotron-3-super-120b-a12b", + "protocol": "nvidia", + "api_base": "https://integrate.api.nvidia.com/v1", + "enabled": true, + "api_keys": [ + "env://NVIDIA_API_KEY" + ], + "request_timeout": 60 + }, { "model_name": "nvidia-nemotron-3-super-120b-a12b:free", "model": "nvidia/nemotron-3-super-120b-a12b:free", diff --git a/k3s/configmap.yaml b/k3s/configmap.yaml index 9cf42376e..fffbd5d74 100644 --- a/k3s/configmap.yaml +++ b/k3s/configmap.yaml @@ -22,15 +22,12 @@ data: "/home/picoclaw/.picoclaw", "/tmp" ], - "provider": "openai", - "model_name": "google-gemma-2-9b-it:free", + "provider": "nvidia", + "model_name": "nemotron-120b", "model_fallbacks": [ - "google-gemma-2-9b-it:free", - "qwen-qwen-2.5-72b-instruct:free", - "nvidia-nemotron-4-340b-instruct:free", - "mistralai-pixtral-12b:free", - "openrouter-elephant-alpha", - "google-gemma-4-26b-a4b-it:free" + "meta-llama-llama-3.3-70b-instruct:free", + "qwen-qwen3-coder:free", + "openrouter-elephant-alpha" ], "max_tokens": 32768, "max_tool_iterations": 50, @@ -502,33 +499,65 @@ data: "request_timeout": 45 }, { - "model_name": "google-gemma-4-26b-a4b-it:free", - "model": "google/gemma-4-26b-a4b-it:free", - "protocol": "openrouter", - "enabled": true, - "api_keys": [ - "env://OPENROUTER_API_KEY" - ], - "request_timeout": 45 - }, - { - "model_name": "google-gemma-4-31b-it:free", - "model": "google/gemma-4-31b-it:free", - "protocol": "openrouter", - "enabled": true, - "api_keys": [ - "env://OPENROUTER_API_KEY" - ], - "request_timeout": 45 - }, - { - "model_name": "nvidia-nemotron-3-super-120b-a12b:free", - "model": "nvidia/nemotron-3-super-120b-a12b:free", - "protocol": "openrouter", + "model_name": "nemotron-120b", + "model": "nvidia/nemotron-3-super-120b-a12b", + "protocol": "nvidia", + "api_base": "https://integrate.api.nvidia.com/v1", "enabled": true, "api_keys": [ "env://NVIDIA_API_KEY" ], + "request_timeout": 60 + }, + { + "model_name": "nvidia-nemotron-70b", + "model": "nvidia/llama-3.1-nemotron-70b-instruct", + "protocol": "nvidia", + "api_base": "https://integrate.api.nvidia.com/v1", + "enabled": true, + "api_keys": [ + "env://NVIDIA_API_KEY" + ], + "request_timeout": 60 + }, + { + "model_name": "google-gemma-3-27b-it:free", + "model": "google/gemma-3-27b-it:free", + "protocol": "openrouter", + "enabled": true, + "api_keys": [ + "env://OPENROUTER_API_KEY" + ], + "request_timeout": 45 + }, + { + "model_name": "google-gemma-3-12b-it:free", + "model": "google/gemma-3-12b-it:free", + "protocol": "openrouter", + "enabled": true, + "api_keys": [ + "env://OPENROUTER_API_KEY" + ], + "request_timeout": 45 + }, + { + "model_name": "qwen-qwen3-coder:free", + "model": "qwen/qwen3-coder:free", + "protocol": "openrouter", + "enabled": true, + "api_keys": [ + "env://OPENROUTER_API_KEY" + ], + "request_timeout": 45 + }, + { + "model_name": "meta-llama-llama-3.3-70b-instruct:free", + "model": "meta-llama/llama-3.3-70b-instruct:free", + "protocol": "openrouter", + "enabled": true, + "api_keys": [ + "env://OPENROUTER_API_KEY" + ], "request_timeout": 45 }, { @@ -541,13 +570,28 @@ data: ], "request_timeout": 45 }, + { + "model_name": "azure-gpt5", + "model": "azure/my-gpt5-deployment", + "api_base": "https://your-resource.openai.azure.com" + }, + { + "model_name": "nvidia-nemotron-3-super-120b-a12b:free", + "model": "nvidia/nemotron-3-super-120b-a12b:free", + "protocol": "openrouter", + "enabled": true, + "api_keys": [ + "env://OPENROUTER_API_KEY" + ], + "request_timeout": 45 + }, { "model_name": "nvidia-nemotron-nano-9b-v2:free", "model": "nvidia/nemotron-nano-9b-v2:free", "protocol": "openrouter", "enabled": true, "api_keys": [ - "env://NVIDIA_API_KEY" + "env://OPENROUTER_API_KEY" ], "request_timeout": 45 }, @@ -581,26 +625,6 @@ data: ], "request_timeout": 45 }, - { - "model_name": "google-lyria-3-pro-preview", - "model": "google/lyria-3-pro-preview", - "protocol": "openrouter", - "enabled": true, - "api_keys": [ - "env://OPENROUTER_API_KEY" - ], - "request_timeout": 45 - }, - { - "model_name": "google-lyria-3-clip-preview", - "model": "google/lyria-3-clip-preview", - "protocol": "openrouter", - "enabled": true, - "api_keys": [ - "env://OPENROUTER_API_KEY" - ], - "request_timeout": 45 - }, { "model_name": "nvidia-nemotron-3-nano-30b-a3b:free", "model": "nvidia/nemotron-3-nano-30b-a3b:free", diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 5bcb83087..ee2f97371 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -270,7 +270,15 @@ func populateCandidateProvidersFromNames( map[string]any{"name": name, "error": err.Error()}) continue } - protocol, modelID := providers.ExtractProtocol(strings.TrimSpace(mc.Model)) + + modelID := mc.Model + protocol := mc.Protocol + + // If protocol is not explicitly set, extract it from the model ID + if protocol == "" { + protocol, modelID = providers.ExtractProtocol(strings.TrimSpace(mc.Model)) + } + key := providers.ModelKey(providers.NormalizeProvider(protocol), modelID) if _, exists := out[key]; exists { continue diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 8c71296ed..66430c57e 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -369,7 +369,7 @@ func TestPopulateCandidateProviders_ResolvesProtocolPrefix(t *testing.T) { } populateCandidateProvidersFromNames(cfg, workspace, []string{"gemma"}, out) - key := providers.ModelKey("gemini", "gemma-3-27b-it") + key := providers.ModelKey("openai", "gemini/gemma-3-27b-it") if out[key] == nil { t.Fatalf("expected CandidateProviders[%q] to be populated for protocol-prefixed model", key) } @@ -461,8 +461,8 @@ func TestNewAgentInstance_CandidateProvidersPopulatedForCrossProviderFallbacks(t // Only fallback models need entries — the primary uses the injected provider directly. wantKeys := []string{ - providers.ModelKey("gemini", "gemma-3-27b-it"), - providers.ModelKey("gemini", "gemini-2.5-flash-lite"), + providers.ModelKey("openai", "gemini/gemma-3-27b-it"), + providers.ModelKey("openai", "gemini/gemini-2.5-flash-lite"), } for _, key := range wantKeys { diff --git a/pkg/agent/loop_turn.go b/pkg/agent/loop_turn.go index f0133e6f1..017b497a2 100644 --- a/pkg/agent/loop_turn.go +++ b/pkg/agent/loop_turn.go @@ -1475,6 +1475,10 @@ turnLoop: } ts.setPhase(TurnPhaseFinalizing) + // pico: freeride provenance marker + if used, model := ts.GetFallbackInfo(); used && model != "" { + finalContent += "\n\nšŸ¦ž provenance: fallback via " + model + } ts.setFinalContent(finalContent) if !ts.opts.NoHistory { finalMsg := providers.Message{Role: "assistant", Content: finalContent} diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go index 7cbf3a8d6..a6f929bdb 100644 --- a/pkg/agent/model_resolution.go +++ b/pkg/agent/model_resolution.go @@ -37,7 +37,20 @@ func candidateFromModelConfig( return providers.FallbackCandidate{}, false } - ref := providers.ParseModelRef(ensureProtocolModel(mc.Model), defaultProvider) + modelID := mc.Model + protocol := mc.Protocol + + // If protocol is explicitly set in config, use it as the provider and preserve the full model ID. + if protocol != "" { + return providers.FallbackCandidate{ + Provider: providers.NormalizeProvider(protocol), + Model: modelID, + RPM: mc.RPM, + IdentityKey: modelConfigIdentityKey(mc), + }, true + } + + ref := providers.ParseModelRef(ensureProtocolModel(modelID), defaultProvider) if ref == nil { return providers.FallbackCandidate{}, false } diff --git a/pkg/config/config.go b/pkg/config/config.go index ca367647c..efc3531fd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1413,6 +1413,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { additionalEntry := &ModelConfig{ ModelName: expandedName, Model: m.Model, + Protocol: m.Protocol, APIBase: m.APIBase, APIKeys: SimpleSecureStrings(keys[i]), Proxy: m.Proxy, @@ -1426,6 +1427,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { ExtraBody: m.ExtraBody, CustomHeaders: m.CustomHeaders, UserAgent: m.UserAgent, + Enabled: m.Enabled, isVirtual: true, } expanded = append(expanded, additionalEntry) @@ -1436,6 +1438,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { primaryEntry := &ModelConfig{ ModelName: originalName, Model: m.Model, + Protocol: m.Protocol, APIBase: m.APIBase, Proxy: m.Proxy, AuthMethod: m.AuthMethod, @@ -1449,6 +1452,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { CustomHeaders: m.CustomHeaders, UserAgent: m.UserAgent, APIKeys: SimpleSecureStrings(keys[0]), + Enabled: m.Enabled, } // Prepend new fallbacks to existing ones diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 99945166a..800e52729 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -31,8 +31,6 @@ var protocolMetaByName = map[string]protocolMeta{ "novita": {defaultAPIBase: "https://api.novita.ai/openai"}, "groq": {defaultAPIBase: "https://api.groq.com/openai/v1"}, "zhipu": {defaultAPIBase: "https://open.bigmodel.cn/api/paas/v4"}, - "gemini": {defaultAPIBase: "https://generativelanguage.googleapis.com/v1beta"}, - "nvidia": {defaultAPIBase: "https://integrate.api.nvidia.com/v1"}, "ollama": {defaultAPIBase: "http://localhost:11434/v1", emptyAPIKeyAllowed: true}, "moonshot": {defaultAPIBase: "https://api.moonshot.cn/v1"}, "shengsuanyun": {defaultAPIBase: "https://router.shengsuanyun.com/api/v1"}, @@ -61,7 +59,6 @@ var protocolMetaByName = map[string]protocolMeta{ // Specialty and Custom Protocols "anthropic": {defaultAPIBase: "https://api.anthropic.com"}, - "google": {defaultAPIBase: "https://openrouter.ai/api/v1"}, // Alias for OpenRouter/OpenAI-compatible "elevenlabs": {}, "claude-cli": {}, "codex-cli": {}, @@ -107,19 +104,38 @@ func createCodexAuthProvider() (LLMProvider, error) { return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil } +func isKnownProtocol(p string) bool { + if _, ok := protocolMetaByName[p]; ok { + return true + } + switch p { + case "anthropic", "azure", "azure-openai", "bedrock", "github-copilot", "github-copilot-chat", "copilot", "claude": + return true + case "antigravity", "claude-cli", "codex-cli", "cli", "fs", "memory", "dummy": // CLI and special shims + return true + case "elevenlabs", "openai-tts": + return true + } + return false +} + // ExtractProtocol extracts the protocol prefix and model identifier from a model string. // If no prefix is specified, it defaults to "openai". -// Examples: -// - "openai/gpt-4o" -> ("openai", "gpt-4o") -// - "anthropic/claude-3-opus" -> ("anthropic", "claude-3-opus") -// - "gpt-4o" -> ("openai", "gpt-4o") func ExtractProtocol(model string) (protocol, modelID string) { model = strings.TrimSpace(model) p, m, found := strings.Cut(model, "/") if !found { return "openai", model } - return p, m + + // Only treat as protocol if it's in our known list. + // This prevents organizational model IDs like "google/gemma" or "anthropic/claude" + // from having their prefixes stripped when used with OpenAI-compatible providers (OpenRouter). + if isKnownProtocol(p) { + return p, m + } + + return "openai", model } // ResolveAPIBase returns the configured API base, or the protocol default when @@ -158,7 +174,8 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err protocol = cfg.Protocol // If protocol was explicitly set, modelID should be the full model string // unless it was already prefixed with the SAME protocol. - if p, m, found := strings.Cut(cfg.Model, "/"); found && strings.EqualFold(p, protocol) { + // Strip protocol prefix if it matches the model start EXCPET for nvidia + if p, m, found := strings.Cut(cfg.Model, "/"); found && strings.EqualFold(p, protocol) && !strings.EqualFold(protocol, "nvidia") { modelID = m } else { modelID = cfg.Model diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 20cdd8a30..034784571 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -60,10 +60,10 @@ func TestExtractProtocol(t *testing.T) { wantModelID: "gpt-4", }, { - name: "multiple slashes", + name: "multiple slashes (nvidia organizational prefix)", model: "nvidia/meta/llama-3.1-8b", - wantProtocol: "nvidia", - wantModelID: "meta/llama-3.1-8b", + wantProtocol: "openai", + wantModelID: "nvidia/meta/llama-3.1-8b", }, { name: "azure with prefix", @@ -448,11 +448,11 @@ func TestCreateProviderFromConfig_Gemini(t *testing.T) { if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } - if modelID != "gemini-2.5-flash" { - t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash") + if modelID != "gemini/gemini-2.5-flash" { + t.Errorf("modelID = %q, want %q", modelID, "gemini/gemini-2.5-flash") } - if _, ok := provider.(*GeminiProvider); !ok { - t.Fatalf("expected *GeminiProvider, got %T", provider) + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider (via OpenRouter fallback), got %T", provider) } } @@ -482,11 +482,11 @@ func TestCreateProviderFromConfig_GeminiCustomAPIBaseWithoutKey(t *testing.T) { if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } - if modelID != "gemini-2.5-flash" { - t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash") + if modelID != "gemini/gemini-2.5-flash" { + t.Errorf("modelID = %q, want %q", modelID, "gemini/gemini-2.5-flash") } - if _, ok := provider.(*GeminiProvider); !ok { - t.Fatalf("expected *GeminiProvider, got %T", provider) + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider (via OpenRouter fallback), got %T", provider) } } @@ -538,19 +538,6 @@ func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { } } -func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { - cfg := &config.ModelConfig{ - ModelName: "test-unknown", - Model: "unknown-protocol/model", - } - cfg.SetAPIKey("test-key") - - _, _, err := CreateProviderFromConfig(cfg) - if err == nil { - t.Fatal("CreateProviderFromConfig() expected error for unknown protocol") - } -} - func TestCreateProviderFromConfig_NilConfig(t *testing.T) { _, _, err := CreateProviderFromConfig(nil) if err == nil { diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 736bb494e..84cc6ad67 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -46,21 +46,18 @@ type Option func(*Provider) const defaultRequestTimeout = common.DefaultRequestTimeout var stripModelPrefixProviders = map[string]struct{}{ - "litellm": {}, - "venice": {}, - "moonshot": {}, - "nvidia": {}, - "groq": {}, - "ollama": {}, - "deepseek": {}, - "google": {}, - "openrouter": {}, - "zhipu": {}, - "mistral": {}, - "vivgrid": {}, - "minimax": {}, - "novita": {}, - "lmstudio": {}, + "litellm": {}, + "venice": {}, + "moonshot": {}, + "groq": {}, + "ollama": {}, + "deepseek": {}, + "zhipu": {}, + "mistral": {}, + "vivgrid": {}, + "minimax": {}, + "novita": {}, + "lmstudio": {}, } func WithMaxTokensField(maxTokensField string) Option { diff --git a/pkg/tools/freeride.go b/pkg/tools/freeride.go index 20114f816..c388360e9 100644 --- a/pkg/tools/freeride.go +++ b/pkg/tools/freeride.go @@ -110,7 +110,27 @@ func (t *FreeRideTool) fetchFreeModels(ctx context.Context) ([]openRouterModel, var freeModels []openRouterModel for _, m := range wrapper.Data { + // Only consider free models if m.Pricing.Prompt == "0" || m.Pricing.Prompt == "0.0" || m.Pricing.Prompt == "0.00" { + // CRITICAL: PeakClaw requires tool support for its steering logic. + // Filter out models that don't explicitly support function calling. + hasTools := false + for _, p := range m.SupportedParameters { + if p == "tools" { + hasTools = true + break + } + } + if !hasTools { + continue + } + + // Blacklist known tool-blind models with inaccurate metadata + lowerID := strings.ToLower(m.ID) + if strings.Contains(lowerID, "lyria") || strings.Contains(lowerID, "liquid") { + continue + } + freeModels = append(freeModels, m) } } diff --git a/pkg/tools/freeride_test.go b/pkg/tools/freeride_test.go index 6ff24ad4d..0d03fcea1 100644 --- a/pkg/tools/freeride_test.go +++ b/pkg/tools/freeride_test.go @@ -27,7 +27,8 @@ func TestFreeRideTool_List(t *testing.T) { "prompt": "0", "completion": "0", }, - "created": 1700000000, + "created": 1700000000, + "supported_parameters": []string{"tools"}, }, { "id": "meta-llama/llama-3-8b", @@ -37,7 +38,8 @@ func TestFreeRideTool_List(t *testing.T) { "prompt": "0.0001", "completion": "0.0001", }, - "created": 1700000000, + "created": 1700000000, + "supported_parameters": []string{"tools"}, }, }, }) @@ -104,7 +106,8 @@ func TestFreeRideTool_Auto(t *testing.T) { "prompt": "0", "completion": "0", }, - "created": 1700000000, + "created": 1700000000, + "supported_parameters": []string{"tools"}, }, }, })