From 5e6c7514a76651fe4726fee722f1425260476a5c Mon Sep 17 00:00:00 2001 From: bussyjd Date: Thu, 12 Feb 2026 23:48:04 +0400 Subject: [PATCH] fix(openclaw): rename virtual provider from "ollama" to "llmspy" for cloud model routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenClaw requires provider/model format (e.g. "llmspy/claude-sonnet-4-5-20250929") for model resolution. Without a provider prefix, it hardcodes a fallback to the "anthropic" provider — which is disabled in the llmspy-routed overlay, causing chat requests to fail silently. This renames the virtual provider used for cloud model routing from "ollama" to "llmspy", adds the proper provider prefix to AgentModel, and disables the default "ollama" provider when a cloud provider is selected. The default Ollama-only path is unchanged since it genuinely routes Ollama models. --- CLAUDE.md | 27 +++++++--- internal/openclaw/openclaw.go | 16 +++--- internal/openclaw/overlay_test.go | 90 ++++++++++++++++++------------- 3 files changed, 81 insertions(+), 52 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7ea97d1..51f7bd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -616,14 +616,14 @@ The stack uses a two-tier architecture for LLM routing. A cluster-wide proxy (ll When a cloud provider is selected during setup, two things happen simultaneously: 1. **Global tier**: `llm.ConfigureLLMSpy()` patches the cluster-wide llmspy gateway with the API key and enables the provider -2. **Instance tier**: `buildLLMSpyRoutedOverlay()` creates an overlay where a single "ollama" provider points at llmspy, the cloud model is listed under that provider, and `api` is set to `openai-completions` +2. **Instance tier**: `buildLLMSpyRoutedOverlay()` creates an overlay where a "llmspy" provider points at the llmspy gateway, the cloud model is listed under that provider with a `llmspy/` prefix, and `api` is set to `openai-completions`. The default "ollama" provider is disabled. **Result**: The application never talks directly to cloud APIs. All traffic is routed through llmspy. **Data flow**: ``` Application (openclaw.json) - │ model: "ollama/claude-sonnet-4-5-20250929" + │ model: "llmspy/claude-sonnet-4-5-20250929" │ api: "openai-completions" │ baseUrl: http://llmspy.llm.svc.cluster.local:8000/v1 │ @@ -636,24 +636,28 @@ llmspy (llm namespace, port 8000) Anthropic API (or Ollama, OpenAI — depending on provider) ``` -**Overlay example** (`values-obol.yaml`): +**Overlay example** (`values-obol.yaml` for cloud provider path): ```yaml models: - ollama: + llmspy: enabled: true baseUrl: http://llmspy.llm.svc.cluster.local:8000/v1 api: openai-completions - apiKeyEnvVar: OLLAMA_API_KEY - apiKeyValue: ollama-local + apiKeyEnvVar: LLMSPY_API_KEY + apiKeyValue: llmspy-default models: - id: claude-sonnet-4-5-20250929 name: Claude Sonnet 4.5 + ollama: + enabled: false anthropic: enabled: false openai: enabled: false ``` +**Note**: The default Ollama path (no cloud provider) still uses the "ollama" provider name pointing at llmspy, since it genuinely routes Ollama model traffic. + ### Summary Table | Aspect | Tier 1 (llmspy) | Tier 2 (Application instance) | @@ -663,7 +667,7 @@ models: | **Config storage** | ConfigMap `llmspy-config` | ConfigMap `-config` | | **Secrets** | Secret `llms-secrets` | Secret `-secrets` | | **Configure via** | `obol llm configure` | `obol openclaw setup ` | -| **Providers** | Real (Ollama, Anthropic, OpenAI) | Virtual: everything appears as "ollama" pointing at llmspy | +| **Providers** | Real (Ollama, Anthropic, OpenAI) | Cloud: "llmspy" virtual provider; Default: "ollama" pointing at llmspy | | **API field** | N/A (provider-native) | Must be `openai-completions` for llmspy routing | ### Key Source Files @@ -972,3 +976,12 @@ This file should be updated when: - New workflows or development practices are established Always confirm with the user before making updates to maintain accuracy and relevance. + +## Related Codebases (External Resources) + +| Resource | Path | Description | +|----------|------|-------------| +| obol-stack-front-end | `/Users/bussyjd/Development/Obol_Workbench/obol-stack-front-end` | Next.js web dashboard | +| obol-stack-docs | `/Users/bussyjd/Development/Obol_Workbench/obol-stack-docs` | MkDocs documentation site | +| OpenClaw | `/Users/bussyjd/Development/Obol_Workbench/openclaw` | OpenClaw AI assistant (upstream) | +| llmspy | `/Users/bussyjd/Development/R&D/llmspy` | LLM proxy/router (upstream) | diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index 82e33e4..14bc5c0 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -1147,23 +1147,25 @@ func promptForCloudProvider(reader *bufio.Reader, name, display, modelID, modelN } // buildLLMSpyRoutedOverlay creates an ImportResult that routes a cloud model -// through the llmspy proxy. OpenClaw sees a single "ollama" provider pointing -// at llmspy, with the cloud model in its model list. The actual cloud providers -// are disabled in OpenClaw — llmspy handles the routing. +// through the llmspy proxy. OpenClaw sees a "llmspy" provider pointing at the +// cluster-wide llmspy gateway, with the cloud model in its model list. The +// actual cloud providers (and default ollama) are disabled in OpenClaw — llmspy +// handles upstream routing based on the bare model ID. func buildLLMSpyRoutedOverlay(cloud *CloudProviderInfo) *ImportResult { return &ImportResult{ - AgentModel: cloud.ModelID, + AgentModel: "llmspy/" + cloud.ModelID, Providers: []ImportedProvider{ { - Name: "ollama", + Name: "llmspy", BaseURL: "http://llmspy.llm.svc.cluster.local:8000/v1", API: "openai-completions", - APIKeyEnvVar: "OLLAMA_API_KEY", - APIKey: "ollama-local", + APIKeyEnvVar: "LLMSPY_API_KEY", + APIKey: "llmspy-default", Models: []ImportedModel{ {ID: cloud.ModelID, Name: cloud.Display}, }, }, + {Name: "ollama", Disabled: true}, {Name: "anthropic", Disabled: true}, {Name: "openai", Disabled: true}, }, diff --git a/internal/openclaw/overlay_test.go b/internal/openclaw/overlay_test.go index fdeed61..fd84994 100644 --- a/internal/openclaw/overlay_test.go +++ b/internal/openclaw/overlay_test.go @@ -15,42 +15,50 @@ func TestBuildLLMSpyRoutedOverlay_Anthropic(t *testing.T) { result := buildLLMSpyRoutedOverlay(cloud) - // Check agent model uses bare model ID (no provider/ prefix) - if result.AgentModel != "claude-sonnet-4-5-20250929" { - t.Errorf("AgentModel = %q, want %q", result.AgentModel, "claude-sonnet-4-5-20250929") + // Check agent model uses llmspy/ prefix for correct OpenClaw provider routing + if result.AgentModel != "llmspy/claude-sonnet-4-5-20250929" { + t.Errorf("AgentModel = %q, want %q", result.AgentModel, "llmspy/claude-sonnet-4-5-20250929") } - // Check 3 providers: ollama (enabled), anthropic (disabled), openai (disabled) - if len(result.Providers) != 3 { - t.Fatalf("len(Providers) = %d, want 3", len(result.Providers)) + // Check 4 providers: llmspy (enabled), ollama (disabled), anthropic (disabled), openai (disabled) + if len(result.Providers) != 4 { + t.Fatalf("len(Providers) = %d, want 4", len(result.Providers)) } - ollama := result.Providers[0] - if ollama.Name != "ollama" || ollama.Disabled { - t.Errorf("ollama: name=%q disabled=%v, want ollama/false", ollama.Name, ollama.Disabled) + llmspy := result.Providers[0] + if llmspy.Name != "llmspy" || llmspy.Disabled { + t.Errorf("llmspy: name=%q disabled=%v, want llmspy/false", llmspy.Name, llmspy.Disabled) } - if ollama.BaseURL != "http://llmspy.llm.svc.cluster.local:8000/v1" { - t.Errorf("ollama.BaseURL = %q", ollama.BaseURL) + if llmspy.BaseURL != "http://llmspy.llm.svc.cluster.local:8000/v1" { + t.Errorf("llmspy.BaseURL = %q", llmspy.BaseURL) } - if ollama.APIKeyEnvVar != "OLLAMA_API_KEY" { - t.Errorf("ollama.APIKeyEnvVar = %q, want OLLAMA_API_KEY", ollama.APIKeyEnvVar) + if llmspy.APIKeyEnvVar != "LLMSPY_API_KEY" { + t.Errorf("llmspy.APIKeyEnvVar = %q, want LLMSPY_API_KEY", llmspy.APIKeyEnvVar) } - if ollama.APIKey != "ollama-local" { - t.Errorf("ollama.APIKey = %q, want ollama-local", ollama.APIKey) + if llmspy.APIKey != "llmspy-default" { + t.Errorf("llmspy.APIKey = %q, want llmspy-default", llmspy.APIKey) } - if ollama.API != "openai-completions" { - t.Errorf("ollama.API = %q, want openai-completions", ollama.API) + if llmspy.API != "openai-completions" { + t.Errorf("llmspy.API = %q, want openai-completions", llmspy.API) } - if len(ollama.Models) != 1 || ollama.Models[0].ID != "claude-sonnet-4-5-20250929" { - t.Errorf("ollama.Models = %v", ollama.Models) + if len(llmspy.Models) != 1 || llmspy.Models[0].ID != "claude-sonnet-4-5-20250929" { + t.Errorf("llmspy.Models = %v", llmspy.Models) } - // anthropic and openai should be disabled - if !result.Providers[1].Disabled || result.Providers[1].Name != "anthropic" { - t.Errorf("anthropic: disabled=%v name=%q", result.Providers[1].Disabled, result.Providers[1].Name) + // ollama, anthropic and openai should be disabled + for _, idx := range []int{1, 2, 3} { + if !result.Providers[idx].Disabled { + t.Errorf("Providers[%d] (%s) should be disabled", idx, result.Providers[idx].Name) + } + } + if result.Providers[1].Name != "ollama" { + t.Errorf("Providers[1].Name = %q, want ollama", result.Providers[1].Name) + } + if result.Providers[2].Name != "anthropic" { + t.Errorf("Providers[2].Name = %q, want anthropic", result.Providers[2].Name) } - if !result.Providers[2].Disabled || result.Providers[2].Name != "openai" { - t.Errorf("openai: disabled=%v name=%q", result.Providers[2].Disabled, result.Providers[2].Name) + if result.Providers[3].Name != "openai" { + t.Errorf("Providers[3].Name = %q, want openai", result.Providers[3].Name) } } @@ -64,13 +72,13 @@ func TestBuildLLMSpyRoutedOverlay_OpenAI(t *testing.T) { result := buildLLMSpyRoutedOverlay(cloud) - if result.AgentModel != "gpt-5.2" { - t.Errorf("AgentModel = %q, want %q", result.AgentModel, "gpt-5.2") + if result.AgentModel != "llmspy/gpt-5.2" { + t.Errorf("AgentModel = %q, want %q", result.AgentModel, "llmspy/gpt-5.2") } - ollama := result.Providers[0] - if len(ollama.Models) != 1 || ollama.Models[0].ID != "gpt-5.2" { - t.Errorf("ollama model = %v, want gpt-5.2", ollama.Models) + llmspy := result.Providers[0] + if len(llmspy.Models) != 1 || llmspy.Models[0].ID != "gpt-5.2" { + t.Errorf("llmspy model = %v, want gpt-5.2", llmspy.Models) } } @@ -84,23 +92,26 @@ func TestOverlayYAML_LLMSpyRouted(t *testing.T) { result := buildLLMSpyRoutedOverlay(cloud) yaml := TranslateToOverlayYAML(result) - // Agent model should be the bare model ID - if !strings.Contains(yaml, "agentModel: claude-sonnet-4-5-20250929") { + // Agent model should have llmspy/ prefix + if !strings.Contains(yaml, "agentModel: llmspy/claude-sonnet-4-5-20250929") { t.Errorf("YAML missing agentModel, got:\n%s", yaml) } - // ollama should be enabled with llmspy baseUrl + // llmspy should be enabled with llmspy baseUrl + if !strings.Contains(yaml, "llmspy:\n enabled: true") { + t.Errorf("YAML missing enabled llmspy provider, got:\n%s", yaml) + } if !strings.Contains(yaml, "baseUrl: http://llmspy.llm.svc.cluster.local:8000/v1") { t.Errorf("YAML missing llmspy baseUrl, got:\n%s", yaml) } - // apiKeyEnvVar should be set - if !strings.Contains(yaml, "apiKeyEnvVar: OLLAMA_API_KEY") { + // apiKeyEnvVar should be LLMSPY_API_KEY + if !strings.Contains(yaml, "apiKeyEnvVar: LLMSPY_API_KEY") { t.Errorf("YAML missing apiKeyEnvVar, got:\n%s", yaml) } - // apiKeyValue should be ollama-local - if !strings.Contains(yaml, "apiKeyValue: ollama-local") { + // apiKeyValue should be llmspy-default + if !strings.Contains(yaml, "apiKeyValue: llmspy-default") { t.Errorf("YAML missing apiKeyValue, got:\n%s", yaml) } @@ -109,12 +120,15 @@ func TestOverlayYAML_LLMSpyRouted(t *testing.T) { t.Errorf("YAML missing api: openai-completions, got:\n%s", yaml) } - // Cloud model should appear in ollama's model list + // Cloud model should appear in llmspy's model list if !strings.Contains(yaml, "- id: claude-sonnet-4-5-20250929") { t.Errorf("YAML missing cloud model ID, got:\n%s", yaml) } - // anthropic and openai should be disabled + // ollama, anthropic and openai should be disabled + if !strings.Contains(yaml, "ollama:\n enabled: false") { + t.Errorf("YAML missing disabled ollama, got:\n%s", yaml) + } if !strings.Contains(yaml, "anthropic:\n enabled: false") { t.Errorf("YAML missing disabled anthropic, got:\n%s", yaml) }