diff --git a/README.md b/README.md index 8a69cf4d..b7cb1859 100644 --- a/README.md +++ b/README.md @@ -180,8 +180,8 @@ FROM openclaw:latest CLAW_TYPE openclaw AGENT AGENTS.md # behavioral contract — bind-mounted read-only -MODEL primary openrouter/anthropic/claude-sonnet-4 -MODEL fallback anthropic/claude-haiku-3-5 +MODEL primary openrouter/anthropic/claude-sonnet-5 +MODEL fallback anthropic/claude-haiku-4-5 CLLAMA passthrough # governance proxy — credential starvation + cost tracking @@ -574,8 +574,8 @@ $ claw audit --since 24h Pod: research-pod Events: 460 CLAW REQ RESP ERR INT TOOLS TOOL_ERR TOK_IN TOK_OUT COST_USD MODELS -analyst 142 142 0 3 18 0 284011 39402 1.8742 anthropic/claude-sonnet-4 -researcher 88 87 1 0 5 0 151233 20118 0.9931 anthropic/claude-sonnet-4 +analyst 142 142 0 3 18 0 284011 39402 1.8742 anthropic/claude-sonnet-5 +researcher 88 87 1 0 5 0 151233 20118 0.9931 anthropic/claude-sonnet-5 Totals: req=230 resp=229 err=1 int=3 tools=23/0 tokens=435244/59520 cost=$2.8673 ``` diff --git a/cllama b/cllama index bb4a1fb9..f36bdbb7 160000 --- a/cllama +++ b/cllama @@ -1 +1 @@ -Subproject commit bb4a1fb978701c9e54419b112bbb9571b626c9a3 +Subproject commit f36bdbb7cd8545d95900c596963c0b8acf937af8 diff --git a/cmd/claw/compose_up.go b/cmd/claw/compose_up.go index 95311de9..f267d5cc 100644 --- a/cmd/claw/compose_up.go +++ b/cmd/claw/compose_up.go @@ -3316,8 +3316,11 @@ func mergeResolvedSkills(imageSkills, podSkills []driver.ResolvedSkill) []driver } // mergeModelSlots overlays pod-declared model slots onto image-declared slots. -// Image-only slots are preserved; pod entries replace image entries by key. -// Empty or nil pod maps suppress pod defaults only; image labels still apply. +// Image-only slots are preserved; pod entries replace image entries by key, +// except the fallback family (fallback, fallback-2, ...), which replaces +// atomically: any pod-declared fallback chain drops the image's entire chain +// so the two can never interleave. Empty or nil pod maps suppress pod defaults +// only; image labels still apply. func mergeModelSlots(image, pod map[string]string) map[string]string { out := cloneStringMap(image) if len(pod) == 0 { @@ -3326,7 +3329,24 @@ func mergeModelSlots(image, pod map[string]string) map[string]string { if out == nil { out = make(map[string]string, len(pod)) } + podDeclaresFallback := false + for key := range pod { + if cllama.FallbackSlotOrdinal(key) > 0 { + podDeclaresFallback = true + break + } + } + if podDeclaresFallback { + for key := range out { + if cllama.FallbackSlotOrdinal(key) > 0 { + delete(out, key) + } + } + } for key, value := range pod { + if cllama.FallbackSlotOrdinal(key) > 0 && strings.TrimSpace(value) == "" { + continue + } out[key] = value } return out diff --git a/cmd/claw/compose_up_test.go b/cmd/claw/compose_up_test.go index c3937922..8ff83c3b 100644 --- a/cmd/claw/compose_up_test.go +++ b/cmd/claw/compose_up_test.go @@ -96,6 +96,30 @@ func TestMergeModelSlots(t *testing.T) { pod: nil, want: nil, }, + { + name: "pod fallback replaces entire image fallback family", + image: map[string]string{"primary": "image-primary", "fallback": "image-fb", "fallback-2": "image-fb2"}, + pod: map[string]string{"fallback": "pod-fb"}, + want: map[string]string{"primary": "image-primary", "fallback": "pod-fb"}, + }, + { + name: "pod fallback chain replaces image scalar fallback", + image: map[string]string{"primary": "image-primary", "fallback": "image-fb"}, + pod: map[string]string{"fallback": "pod-fb", "fallback-2": "pod-fb2"}, + want: map[string]string{"primary": "image-primary", "fallback": "pod-fb", "fallback-2": "pod-fb2"}, + }, + { + name: "pod empty fallback list clears image fallback family", + image: map[string]string{"primary": "image-primary", "fallback": "image-fb", "fallback-2": "image-fb2"}, + pod: map[string]string{"fallback": ""}, + want: map[string]string{"primary": "image-primary"}, + }, + { + name: "image fallback family preserved when pod declares none", + image: map[string]string{"primary": "image-primary", "fallback": "image-fb", "fallback-2": "image-fb2"}, + pod: map[string]string{"primary": "pod-primary"}, + want: map[string]string{"primary": "pod-primary", "fallback": "image-fb", "fallback-2": "image-fb2"}, + }, } for _, tt := range tests { @@ -153,6 +177,39 @@ services: t.Fatalf("%s: expected image-declared slots to remain after pod-default suppression, got %v", serviceName, got) } } + + const clearDefaultsYAML = ` +x-claw: + pod: clear-model-defaults + models-defaults: + primary: pod-default-primary + fallback: + - pod-default-fallback + - pod-default-fallback-2 + +services: + clear_fallbacks: + image: clear-fallbacks:latest + x-claw: + agent: ./AGENTS.md + models: + fallback: [] +` + + parsed, err = pod.Parse(strings.NewReader(clearDefaultsYAML)) + if err != nil { + t.Fatalf("Parse clear defaults: %v", err) + } + service := parsed.Services["clear_fallbacks"] + got := mergeModelSlots(map[string]string{ + "primary": "image-primary", + "fallback": "image-fallback", + "fallback-2": "image-fallback-2", + }, service.Claw.Models) + want := map[string]string{"primary": "pod-default-primary"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("explicit empty fallback list must clear inherited and image fallback chains: got %v, want %v", got, want) + } } func TestBuiltinClawAPIDescriptorUsesShortFleetAlertsFeedWindow(t *testing.T) { @@ -2901,7 +2958,7 @@ func TestMergeProviderSeedsNoKeylessProviderWithoutModelRef(t *testing.T) { claws := map[string]*driver.ResolvedClaw{ "analyst": { Cllama: []string{"passthrough"}, - Models: map[string]string{"primary": "openrouter/google/gemini-2.5-flash"}, + Models: map[string]string{"primary": "openrouter/google/gemini-3.6-flash"}, }, } if err := mergeProviderSeeds(dir, p, claws); err != nil { diff --git a/cmd/claw/scaffold_helpers.go b/cmd/claw/scaffold_helpers.go index abdd30bb..c7a2d29d 100644 --- a/cmd/claw/scaffold_helpers.go +++ b/cmd/claw/scaffold_helpers.go @@ -18,7 +18,7 @@ import ( const ( defaultAgentName = "assistant" defaultClawType = "openclaw" - defaultModel = "openrouter/anthropic/claude-sonnet-4" + defaultModel = "openrouter/anthropic/claude-sonnet-5" defaultPlatform = "discord" defaultCllamaType = "passthrough" ) diff --git a/cmd/claw/skill_data/SKILL.md b/cmd/claw/skill_data/SKILL.md index a52262f4..986bebec 100644 --- a/cmd/claw/skill_data/SKILL.md +++ b/cmd/claw/skill_data/SKILL.md @@ -87,8 +87,8 @@ FROM openclaw:latest CLAW_TYPE openclaw # REQUIRED: selects runtime driver AGENT AGENTS.md # behavioral contract — must exist on host -MODEL primary openrouter/anthropic/claude-sonnet-4 -MODEL fallback anthropic/claude-haiku-3-5 +MODEL primary openrouter/anthropic/claude-sonnet-5 +MODEL fallback anthropic/claude-haiku-4-5 CLLAMA passthrough # governance proxy type PERSONA ./personas/trader # identity materialization (local or OCI) @@ -391,9 +391,9 @@ The proxy sits between agents and LLM providers. Agents get bearer tokens, proxy | Provider | Auth | Model format | |----------|------|-------------| -| OpenAI | Bearer | `openai/gpt-4o` | -| Anthropic | X-Api-Key | `anthropic/claude-sonnet-4` | -| OpenRouter | Bearer | `openrouter/anthropic/claude-sonnet-4` | +| OpenAI | Bearer | `openai/gpt-5.6` | +| Anthropic | X-Api-Key | `anthropic/claude-sonnet-5` | +| OpenRouter | Bearer | `openrouter/anthropic/claude-sonnet-5` | | xAI | Bearer | `xai/grok-3` | | Ollama | None | `ollama/llama3` | diff --git a/cmd/claw/spike_model_failover_test.go b/cmd/claw/spike_model_failover_test.go new file mode 100644 index 00000000..2eb67165 --- /dev/null +++ b/cmd/claw/spike_model_failover_test.go @@ -0,0 +1,252 @@ +//go:build spike + +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// TestSpikeOrderedModelFailover runs the compiled Clawdapus policy through a +// real cllama image and a fake OpenAI-compatible provider. The primary is a +// Responses-only model, the first fallback also fails, and only the second +// fallback succeeds. This locks both the provider-boundary adapter and the +// ordered multi-fallback contract to the metadata Clawdapus emits. +// +// Run with: +// +// go test -tags spike -v -run TestSpikeOrderedModelFailover -timeout 10m ./cmd/claw/... +func TestSpikeOrderedModelFailover(t *testing.T) { + if err := exec.Command("docker", "info").Run(); err != nil { + t.Skipf("docker not available: %v", err) + } + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot, err := filepath.Abs(filepath.Join(filepath.Dir(thisFile), "..", "..")) + if err != nil { + t.Fatalf("resolve repo root: %v", err) + } + + workDir := t.TempDir() + projectName := "model-failover-spike" + generatedPath := filepath.Join(workDir, "compose.generated.yml") + networkName := projectName + "_claw-internal" + spikeCleanupProject(projectName, generatedPath) + t.Cleanup(func() { spikeCleanupProject(projectName, generatedPath) }) + t.Cleanup(func() { + if !t.Failed() { + return + } + out, _ := exec.Command("docker", "compose", "-f", generatedPath, "logs", "--tail", "200").CombinedOutput() + t.Logf("compose logs:\n%s", out) + }) + + pythonImage := "python:3.12-alpine" + agentImage := fmt.Sprintf("model-failover-spike-agent:%d", time.Now().UnixNano()) + spikeEnsurePulledImage(t, pythonImage) + spikeBuildImage(t, filepath.Join(repoRoot, "testdata", "openclaw-stub"), agentImage, "Clawfile") + spikeEnsureRepoInfraImages(t, repoRoot, infraComponentClawdash) + spikeEnsureCllamaPassthroughImage(t, repoRoot) + t.Cleanup(func() { + _, _ = exec.Command("docker", "image", "rm", "-f", agentImage).CombinedOutput() + }) + + capturesDir := filepath.Join(workDir, "captures") + if err := os.MkdirAll(capturesDir, 0o777); err != nil { + t.Fatalf("create captures dir: %v", err) + } + spikeWriteFile(t, filepath.Join(workDir, "AGENTS.md"), "# Model Failover Spike Agent\n\nUse the configured model.\n") + spikeWriteFile(t, filepath.Join(workDir, "fake_provider.py"), orderedFailoverFakeProviderScript()) + spikeWriteFile(t, filepath.Join(workDir, "claw-pod.yml"), fmt.Sprintf(`name: model-failover-spike + +x-claw: + pod: model-failover-spike + cllama-defaults: + proxy: [passthrough] + env: + OPENAI_API_KEY: sk-local-fake + OPENAI_BASE_URL: http://fake-provider:8080/v1 + models-defaults: + primary: openai/gpt-5.6 + fallback: + - openai/gpt-4.1 + - openai/gpt-4.1-mini + +services: + agent: + image: %s + x-claw: + agent: ./AGENTS.md + + fake-provider: + image: %s + command: ["python", "/app/fake_provider.py"] + volumes: + - ./fake_provider.py:/app/fake_provider.py:ro + - ./captures:/captures:rw + expose: + - "8080" + networks: + - claw-internal +`, agentImage, pythonImage)) + + t.Setenv("CLLAMA_UI_PORT", spikeFreePort(t)) + t.Setenv("CLAWDASH_ADDR", ":"+spikeFreePort(t)) + + prevDetach := composeUpDetach + composeUpDetach = true + defer func() { composeUpDetach = prevDetach }() + + if err := runComposeUp(filepath.Join(workDir, "claw-pod.yml")); err != nil { + t.Fatalf("runComposeUp: %v", err) + } + for _, svc := range []string{"agent", "cllama", "fake-provider"} { + spikeWaitRunning(t, spikeComposeContainerID(t, generatedPath, svc), 30*time.Second) + } + spikeWaitHealthy(t, spikeComposeContainerID(t, generatedPath, "cllama"), 45*time.Second) + + metadataPath := filepath.Join(workDir, ".claw-runtime", "context", "agent", "metadata.json") + assertOrderedFallbackMetadata(t, metadataPath) + token := spikeReadAgentToken(t, metadataPath) + + out, err := spikeDockerProbe(networkName, pythonImage, orderedFailoverProbeScript, token) + if err != nil { + t.Fatalf("cllama failover probe failed: %v\n%s", err, out) + } + if !strings.Contains(out, "chatcmpl-second-fallback") || !strings.Contains(out, "second fallback reached") { + t.Fatalf("expected second fallback response, got:\n%s", out) + } + + capturePath := filepath.Join(capturesDir, "requests.jsonl") + deadline := time.Now().Add(10 * time.Second) + for { + if data, readErr := os.ReadFile(capturePath); readErr == nil && strings.Count(strings.TrimSpace(string(data)), "\n")+1 >= 3 { + assertOrderedFallbackCaptures(t, data) + break + } + if time.Now().After(deadline) { + t.Fatalf("provider did not capture all three candidates before timeout") + } + time.Sleep(100 * time.Millisecond) + } +} + +func assertOrderedFallbackMetadata(t *testing.T, path string) { + t.Helper() + var metadata struct { + ModelPolicy struct { + Allowed []struct { + Slot string `json:"slot"` + Ref string `json:"ref"` + } `json:"allowed"` + } `json:"model_policy"` + } + if err := json.Unmarshal([]byte(spikeReadFile(t, path)), &metadata); err != nil { + t.Fatalf("parse metadata.json: %v", err) + } + wantSlots := []string{"primary", "fallback", "fallback"} + wantRefs := []string{"openai/gpt-5.6", "openai/gpt-4.1", "openai/gpt-4.1-mini"} + if len(metadata.ModelPolicy.Allowed) != len(wantRefs) { + t.Fatalf("model policy allowed = %+v, want %v", metadata.ModelPolicy.Allowed, wantRefs) + } + for i, entry := range metadata.ModelPolicy.Allowed { + if entry.Slot != wantSlots[i] || entry.Ref != wantRefs[i] { + t.Fatalf("model policy allowed[%d] = %+v, want slot=%q ref=%q", i, entry, wantSlots[i], wantRefs[i]) + } + } +} + +func assertOrderedFallbackCaptures(t *testing.T, data []byte) { + t.Helper() + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 3 { + t.Fatalf("captured requests = %d, want 3:\n%s", len(lines), data) + } + wantModels := []string{"gpt-5.6", "gpt-4.1", "gpt-4.1-mini"} + wantPaths := []string{"/v1/responses", "/v1/chat/completions", "/v1/chat/completions"} + for i, line := range lines { + var got struct { + Path string `json:"path"` + Model string `json:"model"` + } + if err := json.Unmarshal([]byte(line), &got); err != nil { + t.Fatalf("parse capture[%d]: %v", i, err) + } + if got.Model != wantModels[i] || got.Path != wantPaths[i] { + t.Fatalf("capture[%d] = %+v, want path=%q model=%q", i, got, wantPaths[i], wantModels[i]) + } + } +} + +const orderedFailoverProbeScript = ` +import json, sys, urllib.request +payload = {"model":"openai/gpt-5.6","messages":[{"role":"user","content":"Prove the complete fallback chain."}]} +body = json.dumps(payload).encode("utf-8") +req = urllib.request.Request( + "http://cllama:8080/v1/chat/completions", + data=body, + headers={"Content-Type":"application/json","Authorization":"Bearer " + sys.argv[1]}, +) +with urllib.request.urlopen(req, timeout=30) as resp: + sys.stdout.write(resp.read().decode("utf-8")) +` + +func orderedFailoverFakeProviderScript() string { + return `import json +from http.server import BaseHTTPRequestHandler, HTTPServer + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/health": + body = b"ok" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + self.send_response(404) + self.end_headers() + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length) or b"{}") + model = payload.get("model", "") + with open("/captures/requests.jsonl", "a", encoding="utf-8") as capture: + capture.write(json.dumps({"path": self.path, "model": model}) + "\n") + + if model != "gpt-4.1-mini": + body = json.dumps({"error": {"message": "force next candidate"}}).encode("utf-8") + self.send_response(503) + else: + body = json.dumps({ + "id": "chatcmpl-second-fallback", + "object": "chat.completion", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "second fallback reached"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14} + }).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + return + +HTTPServer(("0.0.0.0", 8080), Handler).serve_forever() +` +} diff --git a/cmd/claw/spike_openclaw_additive_tools_live_test.go b/cmd/claw/spike_openclaw_additive_tools_live_test.go index 9e2bd158..44d24fd7 100644 --- a/cmd/claw/spike_openclaw_additive_tools_live_test.go +++ b/cmd/claw/spike_openclaw_additive_tools_live_test.go @@ -234,7 +234,7 @@ func openClawAdditiveProxyRequest(t *testing.T, env map[string]string) rollcallP cfg.CllamaEnv["XAI_API_KEY"] = xaiKey case anthropicKey != "": cfg.APIFormat = "anthropic" - cfg.Model = "anthropic/claude-sonnet-4-6" + cfg.Model = "anthropic/claude-sonnet-5" cfg.CllamaEnv["ANTHROPIC_API_KEY"] = anthropicKey default: t.Fatal("openclaw additive spike requires at least one real provider key") diff --git a/cmd/claw/spike_rollcall_test.go b/cmd/claw/spike_rollcall_test.go index a91b00de..e78acf4f 100644 --- a/cmd/claw/spike_rollcall_test.go +++ b/cmd/claw/spike_rollcall_test.go @@ -162,7 +162,7 @@ func TestSpikeRollCall(t *testing.T) { name: "oc-roll", runtime: "openclaw", subtestName: "openclaw_anthropic_surface", - modelOverride: "anthropic/claude-sonnet-4-6", + modelOverride: "anthropic/claude-sonnet-5", expectedSurface: "anthropic-messages", requireKeys: []string{"ANTHROPIC_API_KEY"}, }, @@ -175,7 +175,7 @@ func TestSpikeRollCall(t *testing.T) { name: "oc-roll", runtime: "openclaw", subtestName: "openclaw_google_surface", - modelOverride: "google/gemini-2.5-flash", + modelOverride: "google/gemini-3.6-flash", expectedSurface: "openai-chat-completions", requireKeys: []string{"GEMINI_API_KEY"}, }, @@ -189,7 +189,7 @@ func TestSpikeRollCall(t *testing.T) { name: "nb-roll", runtime: "nanobot", proxyFormat: "anthropic", - proxyModel: "anthropic/claude-sonnet-4-6", + proxyModel: "anthropic/claude-sonnet-5", expectedSurface: "anthropic-messages", requireKeys: []string{"ANTHROPIC_API_KEY"}, }, @@ -199,7 +199,7 @@ func TestSpikeRollCall(t *testing.T) { name: "pc-roll", runtime: "picoclaw", proxyFormat: "anthropic", - proxyModel: "anthropic/claude-sonnet-4-6", + proxyModel: "anthropic/claude-sonnet-5", expectedSurface: "anthropic-messages", requireKeys: []string{"ANTHROPIC_API_KEY"}, }, diff --git a/docs/decisions/019-model-policy-authority-and-declared-failover.md b/docs/decisions/019-model-policy-authority-and-declared-failover.md index 8bce2d35..70ddc92c 100644 --- a/docs/decisions/019-model-policy-authority-and-declared-failover.md +++ b/docs/decisions/019-model-policy-authority-and-declared-failover.md @@ -110,3 +110,39 @@ Operators declare the models their agents are allowed to use. If cllama were to - The implied contract that a runner can self-select its model is broken. Runners that rely on sending arbitrary model strings will be clamped silently. This is correct behavior but may surprise operators who have not read this ADR. - `dispatchWithRetry` in cllama requires structural changes to support cross-provider candidate traversal. The current function is provider-scoped; the new design is candidate-list-scoped. - Clawfiles with no `MODEL` directives produce an empty policy. cllama treats an empty policy as unconstrained (legacy behavior). Operators who expect enforcement must declare at least one `MODEL` slot. + +## Amendment (2026-08-03): Ordered Multi-Fallback Chains + +cllama's declared failover originally consumed a single `fallback` slot. As of +cllama's managed read-failover work, `FailoverRefs` walks **every** allowed +entry whose slot is `fallback`, in declared order. Clawdapus now compiles full +chains: + +- **Pod surface.** `x-claw.models.fallback` accepts a scalar (unchanged) or an + ordered list. List entries normalize to reserved internal slot keys + `fallback`, `fallback-2`, `fallback-3`, ... in declared order. Declaring the + ordinal keys directly is rejected; the list is the only authoring surface. +- **Policy emission.** Every chain link is emitted into `model_policy.allowed` + with slot name `fallback`, ordered primary → chain → other slots. Older + cllama versions use only the first fallback entry and treat the rest as + allowed models — graceful degradation, no compatibility break. +- **Atomic family merge.** The fallback family merges as one unit everywhere: + a service-level `fallback` declaration (scalar or list) replaces the entire + `models-defaults` chain, and a pod-declared chain replaces the entire + image-label fallback family. Chains never interleave across layers. +- **Images stay scalar.** `MODEL fallback` in a Clawfile declares at most one + fallback; repeated declarations fail with guidance toward the pod surface. + Failover chains are deployment policy, not image authorship: the image + author cannot know which providers a pod holds keys for. If image-declared + chains become a real need, indexed labels (`claw.model.fallback-2`) are the + planned encoding — deferred until a producer exists. +- **Slots stay purposeful.** Only the fallback family participates in + failover. A model declared under any other slot (`analysis`, `cheap`, ...) + is allowed but never a failover target, matching cllama's contract. +- **The runner ingress still bounds reachability.** An OpenAI-format runner + enters through `/v1/chat/completions`; its candidates must be directly + OpenAI-compatible, or an Anthropic ref must be bridged through a configured + OpenRouter provider. An Anthropic-format runner enters through + `/v1/messages`, where every candidate must be `anthropic/...`. Ordered + policy does not imply arbitrary request-shape conversion. cllama fails + closed when a chain cannot be encoded for the active ingress. diff --git a/docs/decisions/020-cllama-compiled-tool-mediation.md b/docs/decisions/020-cllama-compiled-tool-mediation.md index 468df09e..bdc2cc47 100644 --- a/docs/decisions/020-cllama-compiled-tool-mediation.md +++ b/docs/decisions/020-cllama-compiled-tool-mediation.md @@ -606,3 +606,16 @@ The capability-evolution wave (this ADR + ADR-021) landed together. Current stat - Phase 6: `parallel_safe` annotation, dynamic filtering, native mode graduation See `docs/plans/2026-03-30-memory-plane-and-pluggable-recall.md` for the companion implementation-status document covering both ADR-020 and ADR-021. + +## Amendment (2026-08-03): Terminal-on-Success Annotation Validation + +cllama's managed mediation now supports terminal-on-success tools: a tool +annotated `"x-claw.terminalOnSuccess": true` in its descriptor ends the +mediated turn after a successful call instead of requesting another model +round. The annotation flows through the existing generic annotations +projection (descriptor → tool registry → generated `tools.json`) untouched. + +Clawdapus adds compile-time validation only: cllama ignores and logs non-bool +values at runtime, so `claw up` fails closed when a descriptor declares the +key with any non-boolean value. Unknown annotation keys continue to pass +through unvalidated — the namespace remains open for service authors. diff --git a/examples/budget-spike/Clawfile b/examples/budget-spike/Clawfile index 93a243e5..842559ee 100644 --- a/examples/budget-spike/Clawfile +++ b/examples/budget-spike/Clawfile @@ -2,4 +2,4 @@ FROM openclaw:latest CLAW_TYPE openclaw AGENT AGENTS.md -MODEL primary openai/gpt-4o +MODEL primary openai/gpt-5.6 diff --git a/examples/budget-spike/claw-pod.yml b/examples/budget-spike/claw-pod.yml index d9c740b3..ce326757 100644 --- a/examples/budget-spike/claw-pod.yml +++ b/examples/budget-spike/claw-pod.yml @@ -14,7 +14,7 @@ x-claw: OPENAI_API_KEY: sk-local-fake OPENAI_BASE_URL: http://fake-provider:8080/v1 models-defaults: - primary: openai/gpt-4o + primary: openai/gpt-5.6 services: analyst: diff --git a/examples/budget-spike/fake_provider.py b/examples/budget-spike/fake_provider.py index d7f31cfd..15dc12b9 100644 --- a/examples/budget-spike/fake_provider.py +++ b/examples/budget-spike/fake_provider.py @@ -17,18 +17,33 @@ def do_GET(self): def do_POST(self): length = int(self.headers.get("Content-Length", "0")) self.rfile.read(length) - response = { - "id": "chatcmpl-budget-spike", - "object": "chat.completion", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": "ok"}, - "finish_reason": "stop", - } - ], - "usage": {"prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20}, - } + if self.path.startswith("/v1/responses"): + response = { + "id": "resp-budget-spike", + "object": "response", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "ok"}], + } + ], + "usage": {"input_tokens": 10, "output_tokens": 10, "total_tokens": 20}, + } + else: + response = { + "id": "chatcmpl-budget-spike", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20}, + } encoded = json.dumps(response).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") diff --git a/examples/master-claw/Clawfile b/examples/master-claw/Clawfile index 532255d7..f51a0303 100644 --- a/examples/master-claw/Clawfile +++ b/examples/master-claw/Clawfile @@ -2,6 +2,6 @@ FROM ghcr.io/mostlydev/openclaw:latest CLAW_TYPE openclaw -MODEL primary openrouter/anthropic/claude-sonnet-4 +MODEL primary openrouter/anthropic/claude-sonnet-5 HANDLE discord diff --git a/examples/master-claw/claw-pod.yml b/examples/master-claw/claw-pod.yml index 6c90deb1..32dada56 100644 --- a/examples/master-claw/claw-pod.yml +++ b/examples/master-claw/claw-pod.yml @@ -5,9 +5,15 @@ x-claw: proxy: [passthrough] env: OPENROUTER_API_KEY: "${OPENROUTER_API_KEY}" + OPENAI_API_KEY: "${OPENAI_API_KEY}" + GEMINI_API_KEY: "${GEMINI_API_KEY}" models-defaults: - primary: openrouter/anthropic/claude-sonnet-4 - fallback: anthropic/claude-haiku-4-5 + primary: openrouter/anthropic/claude-sonnet-5 + # Ordered failover chain: cllama walks these in order when a provider + # is exhausted. A service-level fallback replaces the whole chain. + fallback: + - openai/gpt-5.6 + - google/gemini-3.6-flash services: worker-a: @@ -46,7 +52,7 @@ services: x-claw: agent: ./agents/GOVERNOR.md models: - primary: openrouter/anthropic/claude-opus-4-1 + primary: openrouter/anthropic/claude-opus-5 feeds: [fleet-alerts] surfaces: - "service://claw-api" diff --git a/examples/mcp-stdio/Clawfile b/examples/mcp-stdio/Clawfile index 10a07fce..2447369e 100644 --- a/examples/mcp-stdio/Clawfile +++ b/examples/mcp-stdio/Clawfile @@ -2,5 +2,5 @@ FROM openclaw:latest CLAW_TYPE openclaw AGENT AGENTS.md -MODEL primary openrouter/openai/gpt-4o-mini +MODEL primary openrouter/openai/gpt-4.1-mini CLLAMA passthrough diff --git a/examples/multi-claw/Clawfile b/examples/multi-claw/Clawfile index 3e833564..092d211d 100644 --- a/examples/multi-claw/Clawfile +++ b/examples/multi-claw/Clawfile @@ -3,7 +3,7 @@ FROM openclaw:latest CLAW_TYPE openclaw # Model defaults — overridable per-deployment via MODEL in pod or subclass -MODEL primary anthropic/claude-sonnet-4-6 +MODEL primary anthropic/claude-sonnet-5 TRACK apt npm diff --git a/examples/nanobot/Clawfile b/examples/nanobot/Clawfile index 63db8e07..aa4cc490 100644 --- a/examples/nanobot/Clawfile +++ b/examples/nanobot/Clawfile @@ -3,7 +3,7 @@ FROM nanobot:latest CLAW_TYPE nanobot AGENT AGENTS.md -MODEL primary openrouter/anthropic/claude-sonnet-4 +MODEL primary openrouter/anthropic/claude-sonnet-5 CLLAMA passthrough HANDLE discord diff --git a/examples/openclaw/Clawfile b/examples/openclaw/Clawfile index e6a0e590..d07ed457 100644 --- a/examples/openclaw/Clawfile +++ b/examples/openclaw/Clawfile @@ -6,7 +6,7 @@ RUN npm install -g openclaw@2026.4.9 CLAW_TYPE openclaw AGENT AGENTS.md -MODEL primary openrouter/anthropic/claude-sonnet-4 +MODEL primary openrouter/anthropic/claude-sonnet-5 CONFIGURE openclaw config set agents.defaults.heartbeat.every 30m CONFIGURE openclaw config set agents.defaults.heartbeat.target none diff --git a/examples/picoclaw/Clawfile b/examples/picoclaw/Clawfile index a89056ff..63640283 100644 --- a/examples/picoclaw/Clawfile +++ b/examples/picoclaw/Clawfile @@ -3,7 +3,7 @@ FROM docker.io/sipeed/picoclaw:latest CLAW_TYPE picoclaw AGENT AGENTS.md -MODEL primary openrouter/anthropic/claude-sonnet-4 +MODEL primary openrouter/anthropic/claude-sonnet-5 CLLAMA passthrough HANDLE discord diff --git a/examples/quickstart/agents/assistant/Clawfile b/examples/quickstart/agents/assistant/Clawfile index 7376168e..2ce382ed 100644 --- a/examples/quickstart/agents/assistant/Clawfile +++ b/examples/quickstart/agents/assistant/Clawfile @@ -3,7 +3,7 @@ FROM openclaw:latest CLAW_TYPE openclaw AGENT AGENTS.md -MODEL primary openrouter/anthropic/claude-sonnet-4 +MODEL primary openrouter/anthropic/claude-sonnet-5 CLLAMA passthrough diff --git a/examples/quickstart/claw-pod.yml b/examples/quickstart/claw-pod.yml index f863312b..fc9d2dc9 100644 --- a/examples/quickstart/claw-pod.yml +++ b/examples/quickstart/claw-pod.yml @@ -1,7 +1,7 @@ x-claw: pod: quickstart models-defaults: - primary: openrouter/anthropic/claude-sonnet-4 + primary: openrouter/anthropic/claude-sonnet-5 fallback: anthropic/claude-haiku-4-5 services: assistant: @@ -12,7 +12,7 @@ services: agent: ./agents/assistant/AGENTS.md cllama: passthrough models: - primary: openrouter/google/gemini-2.5-flash + primary: openrouter/google/gemini-3.6-flash cllama-env: OPENROUTER_API_KEY: "${OPENROUTER_API_KEY}" handles: diff --git a/examples/rollcall/agents/hm-roll/Clawfile b/examples/rollcall/agents/hm-roll/Clawfile index 543cafba..e304585c 100644 --- a/examples/rollcall/agents/hm-roll/Clawfile +++ b/examples/rollcall/agents/hm-roll/Clawfile @@ -2,5 +2,5 @@ FROM hermes:latest CLAW_TYPE hermes AGENT AGENTS.md -MODEL primary openrouter/anthropic/claude-sonnet-4 +MODEL primary openrouter/anthropic/claude-sonnet-5 HANDLE discord diff --git a/examples/rollcall/agents/nb-roll/Clawfile b/examples/rollcall/agents/nb-roll/Clawfile index a8c91f4e..e8b4bd95 100644 --- a/examples/rollcall/agents/nb-roll/Clawfile +++ b/examples/rollcall/agents/nb-roll/Clawfile @@ -2,5 +2,5 @@ FROM nanobot:latest CLAW_TYPE nanobot AGENT AGENTS.md -MODEL primary anthropic/claude-sonnet-4 +MODEL primary anthropic/claude-sonnet-5 HANDLE discord diff --git a/examples/rollcall/agents/oc-roll/Clawfile b/examples/rollcall/agents/oc-roll/Clawfile index 03821056..64f3199e 100644 --- a/examples/rollcall/agents/oc-roll/Clawfile +++ b/examples/rollcall/agents/oc-roll/Clawfile @@ -2,6 +2,6 @@ FROM openclaw:latest CLAW_TYPE openclaw AGENT AGENTS.md -MODEL primary openrouter/anthropic/claude-sonnet-4 +MODEL primary openrouter/anthropic/claude-sonnet-5 HANDLE discord CONFIGURE openclaw config set channels.discord.groupPolicy "open" diff --git a/examples/rollcall/agents/pc-roll/Clawfile b/examples/rollcall/agents/pc-roll/Clawfile index 33b41eaf..34128dca 100644 --- a/examples/rollcall/agents/pc-roll/Clawfile +++ b/examples/rollcall/agents/pc-roll/Clawfile @@ -2,5 +2,5 @@ FROM picoclaw:latest CLAW_TYPE picoclaw AGENT AGENTS.md -MODEL primary openrouter/anthropic/claude-sonnet-4 +MODEL primary openrouter/anthropic/claude-sonnet-5 HANDLE discord diff --git a/examples/rollcall/discord-responder.sh b/examples/rollcall/discord-responder.sh index fa0f1633..ff49c638 100755 --- a/examples/rollcall/discord-responder.sh +++ b/examples/rollcall/discord-responder.sh @@ -15,7 +15,7 @@ CHANNEL_ID="${ROLLCALL_CHANNEL_ID:-}" RUNTIME="${CLAW_RUNTIME:-unknown}" CLLAMA="${CLLAMA_TOKEN:-}" CLLAMA_FORMAT="${ROLLCALL_CLLAMA_API_FORMAT:-openai}" -CLLAMA_MODEL="${ROLLCALL_CLLAMA_MODEL:-anthropic/claude-sonnet-4}" +CLLAMA_MODEL="${ROLLCALL_CLLAMA_MODEL:-anthropic/claude-sonnet-5}" REPLY_MODE="${ROLLCALL_REPLY_MODE:-tool_only}" UA="DiscordBot (https://github.com/mostlydev/clawdapus, 1.0)" diff --git a/examples/trading-desk/Clawfile b/examples/trading-desk/Clawfile index 05874185..09d36b37 100644 --- a/examples/trading-desk/Clawfile +++ b/examples/trading-desk/Clawfile @@ -3,7 +3,7 @@ FROM openclaw:latest CLAW_TYPE openclaw MODEL primary openrouter/moonshotai/kimi-k2.5 -MODEL fallback anthropic/claude-sonnet-4-6 +MODEL fallback anthropic/claude-sonnet-5 HANDLE discord diff --git a/examples/trading-desk/Clawfile.hermes b/examples/trading-desk/Clawfile.hermes index 7ed34af3..26ce0bc5 100644 --- a/examples/trading-desk/Clawfile.hermes +++ b/examples/trading-desk/Clawfile.hermes @@ -3,7 +3,7 @@ FROM alpine:3.20 CLAW_TYPE hermes AGENT AGENTS.md -MODEL primary anthropic/claude-sonnet-4 +MODEL primary anthropic/claude-sonnet-5 HANDLE discord diff --git a/examples/trading-desk/Clawfile.nanobot b/examples/trading-desk/Clawfile.nanobot index 8364f3a9..27c0f657 100644 --- a/examples/trading-desk/Clawfile.nanobot +++ b/examples/trading-desk/Clawfile.nanobot @@ -3,7 +3,7 @@ FROM alpine:3.20 CLAW_TYPE nanobot AGENT AGENTS.md -MODEL primary anthropic/claude-sonnet-4 +MODEL primary anthropic/claude-sonnet-5 HANDLE discord diff --git a/internal/clawfile/parser.go b/internal/clawfile/parser.go index 36b4d66e..f3a32d2a 100644 --- a/internal/clawfile/parser.go +++ b/internal/clawfile/parser.go @@ -79,6 +79,9 @@ func Parse(r io.Reader) (*ParseResult, error) { } slot := args[0] if _, exists := config.Models[slot]; exists { + if slot == "fallback" { + return nil, fmt.Errorf("line %d: duplicate MODEL slot %q: images declare a single fallback; declare ordered fallback chains at pod level (x-claw.models.fallback: [ref, ref, ...])", node.StartLine, slot) + } return nil, fmt.Errorf("line %d: duplicate MODEL slot %q", node.StartLine, slot) } config.Models[slot] = strings.TrimSpace(strings.TrimPrefix(remainder, slot)) diff --git a/internal/clawfile/parser_test.go b/internal/clawfile/parser_test.go index 32448b36..5f2041fc 100644 --- a/internal/clawfile/parser_test.go +++ b/internal/clawfile/parser_test.go @@ -268,3 +268,13 @@ func TestParseHandleNotPresentMeansEmpty(t *testing.T) { t.Errorf("expected 0 handles, got %d", len(result.Config.Handles)) } } + +func TestParseDuplicateFallbackModelPointsAtPodChains(t *testing.T) { + _, err := Parse(strings.NewReader("FROM alpine\nCLAW_TYPE hermes\nMODEL fallback openai/gpt-5.1\nMODEL fallback anthropic/claude-sonnet-5\n")) + if err == nil { + t.Fatal("expected duplicate MODEL fallback to fail") + } + if !strings.Contains(err.Error(), "x-claw.models.fallback") { + t.Fatalf("error should point at pod-level fallback chains, got %v", err) + } +} diff --git a/internal/cllama/context_test.go b/internal/cllama/context_test.go index 438edaaf..3e19d016 100644 --- a/internal/cllama/context_test.go +++ b/internal/cllama/context_test.go @@ -398,3 +398,52 @@ func TestGenerateContextDirNilToolPolicyUsesDefault(t *testing.T) { t.Fatalf("tools.json policy: got %+v, want default %+v", manifest.Policy, DefaultToolPolicy) } } + +// The x-claw.terminalOnSuccess annotation must survive the full projection +// into the generated tools.json manifest — cllama's mediation reads it there. +func TestGenerateContextDirPreservesTerminalOnSuccessAnnotation(t *testing.T) { + dir := t.TempDir() + agents := []AgentContextInput{{ + AgentID: "octopus", + AgentsMD: "# contract", + Metadata: map[string]any{"token": "tok"}, + Tools: []ToolManifestEntry{{ + Name: "hand_off", + Description: "Hand off to a human", + InputSchema: map[string]interface{}{"type": "object"}, + Annotations: map[string]interface{}{ + "x-claw.terminalOnSuccess": true, + "vendor.custom": map[string]interface{}{"keep": "me"}, + }, + Execution: ToolExecution{Transport: "http", Service: "svc", BaseURL: "http://svc", Method: "POST", Path: "/hand-off"}, + }}, + }} + + if err := GenerateContextDir(dir, agents); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(filepath.Join(dir, "context", "octopus", "tools.json")) + if err != nil { + t.Fatal(err) + } + var manifest struct { + Tools []struct { + Annotations map[string]interface{} `json:"annotations"` + } `json:"tools"` + } + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + if len(manifest.Tools) != 1 { + t.Fatalf("unexpected manifest: %s", raw) + } + annotations := manifest.Tools[0].Annotations + if annotations["x-claw.terminalOnSuccess"] != true { + t.Fatalf("terminalOnSuccess lost in projection: %v", annotations) + } + custom, ok := annotations["vendor.custom"].(map[string]interface{}) + if !ok || custom["keep"] != "me" { + t.Fatalf("unknown annotations must pass through untouched: %v", annotations) + } +} diff --git a/internal/cllama/modelpolicy.go b/internal/cllama/modelpolicy.go index ca8375e1..cb76ac39 100644 --- a/internal/cllama/modelpolicy.go +++ b/internal/cllama/modelpolicy.go @@ -2,6 +2,7 @@ package cllama import ( "sort" + "strconv" "strings" ) @@ -49,17 +50,19 @@ func orderedAllowedModels(models map[string]string) []AllowedModel { return nil } + // Slot keys in policy order: primary, the fallback chain (fallback, + // fallback-2, ... in ordinal order), then remaining slots sorted by name. + // Every chain link is emitted with slot name "fallback" because cllama's + // failover walks each fallback-slot entry in declared order. slots := make([]string, 0, len(models)) if ref := strings.TrimSpace(models["primary"]); ref != "" { slots = append(slots, "primary") } - if ref := strings.TrimSpace(models["fallback"]); ref != "" { - slots = append(slots, "fallback") - } + slots = append(slots, orderedFallbackSlots(models)...) otherSlots := make([]string, 0, len(models)) for slot, ref := range models { - if slot == "primary" || slot == "fallback" || strings.TrimSpace(ref) == "" { + if slot == "primary" || FallbackSlotOrdinal(slot) > 0 || strings.TrimSpace(ref) == "" { continue } otherSlots = append(otherSlots, slot) @@ -78,10 +81,57 @@ func orderedAllowedModels(models map[string]string) []AllowedModel { continue } seen[ref] = struct{}{} + name := slot + if FallbackSlotOrdinal(slot) > 0 { + name = "fallback" + } allowed = append(allowed, AllowedModel{ - Slot: slot, + Slot: name, Ref: ref, }) } return allowed } + +// FallbackSlotOrdinal returns the 1-based chain position for fallback-family +// slot keys ("fallback" -> 1, "fallback-2" -> 2, ...) and 0 for other slots. +func FallbackSlotOrdinal(slot string) int { + if slot == "fallback" { + return 1 + } + rest, ok := strings.CutPrefix(slot, "fallback-") + if !ok || rest == "" { + return 0 + } + n, err := strconv.Atoi(rest) + if err != nil || n < 2 || strconv.Itoa(n) != rest { + return 0 + } + return n +} + +// FallbackChain returns the declared fallback refs in chain order (fallback, +// fallback-2, ...), with blanks skipped. +func FallbackChain(models map[string]string) []string { + slots := orderedFallbackSlots(models) + chain := make([]string, 0, len(slots)) + for _, slot := range slots { + if ref := strings.TrimSpace(models[slot]); ref != "" { + chain = append(chain, ref) + } + } + return chain +} + +func orderedFallbackSlots(models map[string]string) []string { + family := make([]string, 0, 2) + for slot := range models { + if FallbackSlotOrdinal(slot) > 0 { + family = append(family, slot) + } + } + sort.Slice(family, func(i, j int) bool { + return FallbackSlotOrdinal(family[i]) < FallbackSlotOrdinal(family[j]) + }) + return family +} diff --git a/internal/cllama/modelpolicy_test.go b/internal/cllama/modelpolicy_test.go index 11e899ef..0027c1c7 100644 --- a/internal/cllama/modelpolicy_test.go +++ b/internal/cllama/modelpolicy_test.go @@ -1,6 +1,9 @@ package cllama -import "testing" +import ( + "strconv" + "testing" +) func TestCompileModelPolicyOrdersPrimaryFallbackThenSortedRemainder(t *testing.T) { policy := CompileModelPolicy(map[string]string{ @@ -81,3 +84,104 @@ func TestInjectCompiledModelPolicyClonesMetadataAndAddsPolicy(t *testing.T) { t.Fatalf("unexpected compiled policy: %#v", policy) } } + +// Ordered fallback chains: normalized fallback-N slots compile into multiple +// slot=="fallback" entries in chain order, because cllama's FailoverRefs +// consumes every fallback-slot entry in declared order (cllama ADR / #28). +func TestCompileModelPolicyEmitsFallbackChainInOrderWithFallbackSlot(t *testing.T) { + policy := CompileModelPolicy(map[string]string{ + "fallback-10": "openrouter/meta-llama/llama-4-maverick", + "primary": "openai/gpt-5.6", + "fallback-2": "anthropic/claude-sonnet-5", + "fallback": "openai/gpt-5.1", + "cheap": "anthropic/claude-haiku-4-5", + }) + if policy == nil { + t.Fatal("expected non-nil policy") + } + want := []AllowedModel{ + {Slot: "primary", Ref: "openai/gpt-5.6"}, + {Slot: "fallback", Ref: "openai/gpt-5.1"}, + {Slot: "fallback", Ref: "anthropic/claude-sonnet-5"}, + {Slot: "fallback", Ref: "openrouter/meta-llama/llama-4-maverick"}, + {Slot: "cheap", Ref: "anthropic/claude-haiku-4-5"}, + } + if len(policy.Allowed) != len(want) { + t.Fatalf("allowed = %#v, want %#v", policy.Allowed, want) + } + for i, entry := range want { + if policy.Allowed[i] != entry { + t.Fatalf("allowed[%d] = %#v, want %#v", i, policy.Allowed[i], entry) + } + } +} + +func TestCompileModelPolicyFallbackChainSkipsBlankAndDuplicateLinks(t *testing.T) { + policy := CompileModelPolicy(map[string]string{ + "primary": "openai/gpt-5.6", + "fallback": "anthropic/claude-sonnet-5", + "fallback-2": " ", + "fallback-3": "anthropic/claude-sonnet-5", + "fallback-4": "anthropic/claude-haiku-4-5", + }) + if policy == nil { + t.Fatal("expected non-nil policy") + } + want := []AllowedModel{ + {Slot: "primary", Ref: "openai/gpt-5.6"}, + {Slot: "fallback", Ref: "anthropic/claude-sonnet-5"}, + {Slot: "fallback", Ref: "anthropic/claude-haiku-4-5"}, + } + if len(policy.Allowed) != len(want) { + t.Fatalf("allowed = %#v, want %#v", policy.Allowed, want) + } + for i, entry := range want { + if policy.Allowed[i] != entry { + t.Fatalf("allowed[%d] = %#v, want %#v", i, policy.Allowed[i], entry) + } + } +} + +func TestFallbackSlotOrdinalAcceptsOnlyCanonicalPositiveOrdinals(t *testing.T) { + tests := map[string]int{ + "fallback": 1, + "fallback-2": 2, + "fallback-10": 10, + "fallback-0": 0, + "fallback-1": 0, + "fallback-01": 0, + "fallback-02": 0, + "fallback--2": 0, + "fallback-x": 0, + "other": 0, + "fallback-" + strconv.FormatUint(^uint64(0), 10): 0, + } + for slot, want := range tests { + if got := FallbackSlotOrdinal(slot); got != want { + t.Errorf("FallbackSlotOrdinal(%q) = %d, want %d", slot, got, want) + } + } +} + +func TestCompileModelPolicyTreatsNonCanonicalFallbackKeysAsOrdinarySlots(t *testing.T) { + policy := CompileModelPolicy(map[string]string{ + "primary": "openai/gpt-5.6", + "fallback": "openai/gpt-5.1", + "fallback-01": "legacy/noncanonical", + "fallback-2": "anthropic/claude-sonnet-5", + }) + want := []AllowedModel{ + {Slot: "primary", Ref: "openai/gpt-5.6"}, + {Slot: "fallback", Ref: "openai/gpt-5.1"}, + {Slot: "fallback", Ref: "anthropic/claude-sonnet-5"}, + {Slot: "fallback-01", Ref: "legacy/noncanonical"}, + } + if len(policy.Allowed) != len(want) { + t.Fatalf("allowed = %#v, want %#v", policy.Allowed, want) + } + for i, entry := range want { + if policy.Allowed[i] != entry { + t.Fatalf("allowed[%d] = %#v, want %#v", i, policy.Allowed[i], entry) + } + } +} diff --git a/internal/describe/descriptor.go b/internal/describe/descriptor.go index 154b3e64..ec7d9d76 100644 --- a/internal/describe/descriptor.go +++ b/internal/describe/descriptor.go @@ -235,6 +235,11 @@ func validateTools(tools []ToolDescriptor, mcp *MCPDescriptor) error { if strings.ToLower(strings.TrimSpace(schemaType)) != "object" { return fmt.Errorf("tools[%d]: inputSchema.type must be \"object\"", i) } + if value, declared := tool.Annotations["x-claw.terminalOnSuccess"]; declared { + if _, ok := value.(bool); !ok { + return fmt.Errorf("tools[%d]: annotation \"x-claw.terminalOnSuccess\" must be a JSON boolean, got %T", i, value) + } + } if mcp != nil && tool.HTTP != nil { return fmt.Errorf("tools[%d]: http must not be set when descriptor mcp is set", i) } diff --git a/internal/describe/descriptor_test.go b/internal/describe/descriptor_test.go index 1255b874..1713fdf0 100644 --- a/internal/describe/descriptor_test.go +++ b/internal/describe/descriptor_test.go @@ -1,6 +1,10 @@ package describe -import "testing" +import ( + "fmt" + "strings" + "testing" +) func TestParseDescriptorValidatesAndNormalizes(t *testing.T) { data := []byte(`{ @@ -226,3 +230,50 @@ func TestParseDescriptorRejectsInvalidV2CapabilityShape(t *testing.T) { }) } } + +// x-claw.terminalOnSuccess is a known annotation key consumed by cllama's +// managed-tool mediation; cllama silently ignores non-boolean values, so claw +// up fails closed at compile time instead. Unknown annotation keys pass +// through untouched. +func TestParseDescriptorValidatesTerminalOnSuccessAnnotation(t *testing.T) { + template := `{ + "version": 2, + "description": "svc", + "tools": [{ + "name": "hand_off", + "description": "Hand off", + "inputSchema": {"type": "object"}, + "http": {"method": "post", "path": "/hand-off"}, + "annotations": %s + }] + }` + + valid := []string{ + `{"x-claw.terminalOnSuccess": true}`, + `{"x-claw.terminalOnSuccess": false}`, + `{"x-claw.terminalOnSuccess": true, "custom.key": {"nested": 1}}`, + `{"unknown.annotation": "any-shape"}`, + } + for _, annotations := range valid { + if _, err := Parse([]byte(fmt.Sprintf(template, annotations))); err != nil { + t.Errorf("annotations %s should parse, got %v", annotations, err) + } + } + + invalid := []string{ + `{"x-claw.terminalOnSuccess": "true"}`, + `{"x-claw.terminalOnSuccess": 1}`, + `{"x-claw.terminalOnSuccess": null}`, + `{"x-claw.terminalOnSuccess": {"enabled": true}}`, + } + for _, annotations := range invalid { + _, err := Parse([]byte(fmt.Sprintf(template, annotations))) + if err == nil { + t.Errorf("annotations %s should be rejected", annotations) + continue + } + if !strings.Contains(err.Error(), "x-claw.terminalOnSuccess") || !strings.Contains(err.Error(), "boolean") { + t.Errorf("error should name the key and require a boolean, got %v", err) + } + } +} diff --git a/internal/driver/openclaw/config.go b/internal/driver/openclaw/config.go index 7e291cda..db67c73b 100644 --- a/internal/driver/openclaw/config.go +++ b/internal/driver/openclaw/config.go @@ -30,12 +30,16 @@ func GenerateConfig(rc *driver.ResolvedClaw) ([]byte, error) { return nil, fmt.Errorf("config generation: %w", err) } - // Apply MODEL directives. openclaw uses "fallbacks" ([]string), not "fallback" (string). + // Apply MODEL directives. openclaw uses "fallbacks" ([]string), not + // "fallback" (string); the whole fallback family (fallback, fallback-2, + // ...) projects into that one array in chain order. + if chain := cllama.FallbackChain(rc.Models); len(chain) > 0 { + if err := setPath(config, "agents.defaults.model.fallbacks", chain); err != nil { + return nil, fmt.Errorf("config generation: %w", err) + } + } for slot, model := range rc.Models { - if slot == "fallback" { - if err := setPath(config, "agents.defaults.model.fallbacks", []string{model}); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } + if cllama.FallbackSlotOrdinal(slot) > 0 { continue } if err := setPath(config, "agents.defaults.model."+slot, model); err != nil { diff --git a/internal/driver/openclaw/config_test.go b/internal/driver/openclaw/config_test.go index 256df4eb..b9cd0f00 100644 --- a/internal/driver/openclaw/config_test.go +++ b/internal/driver/openclaw/config_test.go @@ -1345,3 +1345,45 @@ func TestGenerateConfigHandleNilMeansNoChannels(t *testing.T) { t.Error("expected no channels key when Handles is nil") } } + +func TestGenerateConfigModelFallbackChainKeepsDeclaredOrder(t *testing.T) { + rc := &driver.ResolvedClaw{ + Models: map[string]string{ + "primary": "openai/gpt-5.6", + "fallback": "openai/gpt-5.1", + "fallback-2": "anthropic/claude-sonnet-5", + "fallback-10": "anthropic/claude-haiku-4-5", + }, + Configures: []string{}, + } + + data, err := GenerateConfig(rc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var config map[string]interface{} + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + model := config["agents"].(map[string]interface{})["defaults"].(map[string]interface{})["model"].(map[string]interface{}) + fallbacks, ok := model["fallbacks"].([]interface{}) + if !ok { + t.Fatalf("expected fallbacks array, got %T: %v", model["fallbacks"], model["fallbacks"]) + } + want := []string{"openai/gpt-5.1", "anthropic/claude-sonnet-5", "anthropic/claude-haiku-4-5"} + if len(fallbacks) != len(want) { + t.Fatalf("fallbacks = %v, want %v", fallbacks, want) + } + for i, ref := range want { + if fallbacks[i] != ref { + t.Errorf("fallbacks[%d] = %v, want %q", i, fallbacks[i], ref) + } + } + for _, key := range []string{"fallback", "fallback-2", "fallback-10"} { + if _, exists := model[key]; exists { + t.Errorf("agents.defaults.model.%s must not leak as a config key", key) + } + } +} diff --git a/internal/driver/picoclaw/config.go b/internal/driver/picoclaw/config.go index 74ae9d9b..c51f1a06 100644 --- a/internal/driver/picoclaw/config.go +++ b/internal/driver/picoclaw/config.go @@ -225,15 +225,26 @@ func sortedModelSlots(models map[string]string) []string { out = append(out, "primary") } + // The fallback family orders numerically (fallback, fallback-2, ..., + // fallback-10) so model_list entries preserve declared chain order. + family := make([]string, 0, len(models)) others := make([]string, 0, len(models)) for slot, ref := range models { if slot == "primary" || strings.TrimSpace(ref) == "" { continue } + if cllama.FallbackSlotOrdinal(slot) > 0 { + family = append(family, slot) + continue + } others = append(others, slot) } + sort.Slice(family, func(i, j int) bool { + return cllama.FallbackSlotOrdinal(family[i]) < cllama.FallbackSlotOrdinal(family[j]) + }) sort.Strings(others) + out = append(out, family...) return append(out, others...) } diff --git a/internal/driver/picoclaw/config_test.go b/internal/driver/picoclaw/config_test.go index f884951a..04f0877e 100644 --- a/internal/driver/picoclaw/config_test.go +++ b/internal/driver/picoclaw/config_test.go @@ -248,3 +248,24 @@ func TestGenerateConfigConfigureOverride(t *testing.T) { t.Fatalf("expected discord enabled override from CONFIGURE, got %v", v) } } + +// Fallback-family slots must order numerically (fallback, fallback-2, ..., +// fallback-10), not lexically, so picoclaw's model_list preserves chain order. +func TestSortedModelSlotsOrdersFallbackChainNumerically(t *testing.T) { + got := sortedModelSlots(map[string]string{ + "fallback-10": "j", + "analysis": "x", + "fallback-2": "b", + "primary": "p", + "fallback": "a", + }) + want := []string{"primary", "fallback", "fallback-2", "fallback-10", "analysis"} + if len(got) != len(want) { + t.Fatalf("slots = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("slots = %v, want %v", got, want) + } + } +} diff --git a/internal/initimport/emit.go b/internal/initimport/emit.go index 2bd22abd..4866dace 100644 --- a/internal/initimport/emit.go +++ b/internal/initimport/emit.go @@ -75,9 +75,11 @@ func renderClawfile(plan Plan) string { b.WriteString("MODEL primary ") b.WriteString(plan.Model.String()) b.WriteString("\n") - for _, fallback := range plan.Fallback { + // Images declare at most one fallback; longer chains are emitted at pod + // level (x-claw.models.fallback), which replaces the image family anyway. + if len(plan.Fallback) > 0 { b.WriteString("MODEL fallback ") - b.WriteString(fallback.String()) + b.WriteString(plan.Fallback[0].String()) b.WriteString("\n") } if plan.Cllama { @@ -114,6 +116,15 @@ func renderPod(plan Plan) string { b.WriteString(" agent: ./agents/") b.WriteString(plan.AgentName) b.WriteString("/AGENTS.md\n") + if len(plan.Fallback) > 1 { + b.WriteString(" models:\n") + b.WriteString(" fallback:\n") + for _, fallback := range plan.Fallback { + b.WriteString(" - ") + b.WriteString(fallback.String()) + b.WriteString("\n") + } + } if plan.Cllama { b.WriteString(" cllama: passthrough\n") if len(plan.CllamaEnv) > 0 { diff --git a/internal/initimport/hermes.go b/internal/initimport/hermes.go index 83a5f691..d115782a 100644 --- a/internal/initimport/hermes.go +++ b/internal/initimport/hermes.go @@ -58,7 +58,7 @@ func readHermes(configPath string) (Descriptor, error) { apiKey, _ := model["api_key"].(string) desc.Models.Primary = hermesModelRef(provider, defaultModel, baseURL, apiKey) if desc.Models.Primary.Provider == "" { - desc.Models.Primary = ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-4"} + desc.Models.Primary = ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-5"} } if strings.TrimSpace(baseURL) != "" { desc.Cllama = true diff --git a/internal/initimport/initimport_test.go b/internal/initimport/initimport_test.go index 1e12e4f3..78dc5bb4 100644 --- a/internal/initimport/initimport_test.go +++ b/internal/initimport/initimport_test.go @@ -10,7 +10,7 @@ import ( func TestDetectAmbiguousSourceRequiresOverride(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "openclaw.json"), `{"channels":{}}`) - mustWrite(t, filepath.Join(dir, "config.yaml"), "model:\n provider: openrouter\n default: anthropic/claude-sonnet-4\n") + mustWrite(t, filepath.Join(dir, "config.yaml"), "model:\n provider: openrouter\n default: anthropic/claude-sonnet-5\n") if _, err := Detect(dir, ""); err == nil { t.Fatal("expected ambiguous source to fail") @@ -28,7 +28,7 @@ func TestTranslateOpenClawSlackRoutingWritesActionNote(t *testing.T) { src := Descriptor{ Kind: SourceOpenClaw, AgentName: "assistant", - Models: ModelSlots{Primary: ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-4"}}, + Models: ModelSlots{Primary: ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-5"}}, Channels: Channels{Slack: &SlackChannel{ BotToken: "${SLACK_BOT_TOKEN}", AppToken: "${SLACK_APP_TOKEN}", @@ -52,7 +52,7 @@ func TestTranslateProxyModelEmitsCllama(t *testing.T) { AgentName: "assistant", Models: ModelSlots{Primary: ModelRef{ Provider: "openrouter", - Model: "anthropic/claude-sonnet-4", + Model: "anthropic/claude-sonnet-5", BaseURL: "http://cllama:8080/v1", }}, } @@ -70,7 +70,7 @@ func TestTranslateRejectsCllamaNoWithProxySource(t *testing.T) { Kind: SourceOpenClaw, Models: ModelSlots{Primary: ModelRef{ Provider: "openrouter", - Model: "anthropic/claude-sonnet-4", + Model: "anthropic/claude-sonnet-5", BaseURL: "http://proxy.example/v1", }}, } @@ -88,7 +88,7 @@ func TestTranslateCronIsMigrationAction(t *testing.T) { src := Descriptor{ Kind: SourceHermes, CronDir: "/tmp/source-cron", - Models: ModelSlots{Primary: ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-4"}}, + Models: ModelSlots{Primary: ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-5"}}, Channels: Channels{Discord: &DiscordChannel{ Token: "${DISCORD_BOT_TOKEN}", BotID: "${DISCORD_BOT_ID}", @@ -124,9 +124,9 @@ func TestTranslateFallbackModelsEmitClawfileLines(t *testing.T) { src := Descriptor{ Kind: SourceOpenClaw, Models: ModelSlots{ - Primary: ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-4"}, + Primary: ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-5"}, Fallback: []ModelRef{ - {Provider: "anthropic", Model: "claude-haiku-3-5"}, + {Provider: "anthropic", Model: "claude-haiku-4-5"}, {Provider: "openai", Model: "gpt-4.1-mini"}, }, }, @@ -137,21 +137,28 @@ func TestTranslateFallbackModelsEmitClawfileLines(t *testing.T) { t.Fatalf("unexpected translate error: %v", err) } clawfile := renderClawfile(plan) - if !strings.Contains(clawfile, "MODEL fallback anthropic/claude-haiku-3-5") { - t.Fatalf("expected fallback model in Clawfile, got:\n%s", clawfile) + if !strings.Contains(clawfile, "MODEL fallback anthropic/claude-haiku-4-5") { + t.Fatalf("expected first fallback model in Clawfile, got:\n%s", clawfile) } - if strings.Contains(clawfile, "fallback_2") { - t.Fatalf("expected additional fallbacks to stay out of Clawfile, got:\n%s", clawfile) + if strings.Contains(clawfile, "gpt-4.1-mini") { + t.Fatalf("chain tail belongs at pod level, not in the Clawfile, got:\n%s", clawfile) + } + pod := renderPod(plan) + if !strings.Contains(pod, "models:") || !strings.Contains(pod, "fallback:") { + t.Fatalf("expected pod-level fallback chain, got:\n%s", pod) + } + if !strings.Contains(pod, "- anthropic/claude-haiku-4-5") || !strings.Contains(pod, "- openai/gpt-4.1-mini") { + t.Fatalf("expected full ordered chain in pod models block, got:\n%s", pod) } if got := plan.Environment["ANTHROPIC_API_KEY"]; got != "${ANTHROPIC_API_KEY}" { t.Fatalf("expected fallback provider key placeholder, got %q", got) } - if _, ok := plan.Environment["OPENAI_API_KEY"]; ok { - t.Fatal("did not expect placeholder for additional fallback that current runtimes ignore") + if got := plan.Environment["OPENAI_API_KEY"]; got != "${OPENAI_API_KEY}" { + t.Fatalf("expected chain-tail provider key placeholder, got %q", got) } migration := renderMigration(plan) - if !strings.Contains(migration, "additional source fallback models") || !strings.Contains(migration, "openai/gpt-4.1-mini") { - t.Fatalf("expected additional fallback migration note, got:\n%s", migration) + if strings.Contains(migration, "additional source fallback models are not emitted") { + t.Fatalf("chain is preserved now; stale truncation note found:\n%s", migration) } } @@ -159,7 +166,7 @@ func TestTranslateUnsupportedFallbackProviderIsNotEmitted(t *testing.T) { src := Descriptor{ Kind: SourceOpenClaw, Models: ModelSlots{ - Primary: ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-4"}, + Primary: ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-5"}, Fallback: []ModelRef{{Provider: "mistral-ai", Model: "large"}}, }, } @@ -210,7 +217,7 @@ func TestReadHermesFoldsEnvIdentityWithSoulAndNotesToolsets(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "config.yaml"), `model: provider: openrouter - default: anthropic/claude-sonnet-4 + default: anthropic/claude-sonnet-5 platform_toolsets: slack: true `) @@ -244,7 +251,7 @@ func TestEmitCanonicalLayoutAndCronReferences(t *testing.T) { ProjectName: "demo", AgentName: "assistant", BaseImage: "hermes-base:test", - Model: ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-4"}, + Model: ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-5"}, Handles: []HandlePlan{{Platform: "slack", IDEnv: "SLACK_BOT_ID", Username: "assistant"}}, Environment: map[string]string{"SLACK_BOT_TOKEN": "${SLACK_BOT_TOKEN}", "SLACK_APP_TOKEN": "${SLACK_APP_TOKEN}", "SLACK_BOT_ID": "${SLACK_BOT_ID}"}, AgentContract: "# Agent Contract\n", diff --git a/internal/initimport/openclaw.go b/internal/initimport/openclaw.go index 77c6b259..fbcca732 100644 --- a/internal/initimport/openclaw.go +++ b/internal/initimport/openclaw.go @@ -66,7 +66,7 @@ func readOpenClaw(configPath string) (Descriptor, error) { } } if desc.Models.Primary.Provider == "" { - desc.Models.Primary = ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-4"} + desc.Models.Primary = ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-5"} } if providers, ok := nestedMap(raw, "models", "providers"); ok { if providerCfg, ok := providers[desc.Models.Primary.Provider].(map[string]any); ok { diff --git a/internal/initimport/translate.go b/internal/initimport/translate.go index a8f75926..7edee8eb 100644 --- a/internal/initimport/translate.go +++ b/internal/initimport/translate.go @@ -27,11 +27,10 @@ func Translate(src Descriptor, opts Options) (Plan, error) { } } if model.Provider == "" { - model = ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-4"} + model = ModelRef{Provider: "openrouter", Model: "anthropic/claude-sonnet-5"} } if len(fallbacks) > 1 { - notes.Action = append(notes.Action, fmt.Sprintf("additional source fallback models are not emitted because current runtimes use only MODEL fallback: %s", strings.Join(modelRefStrings(fallbacks[1:]), ", "))) - fallbacks = fallbacks[:1] + notes.Action = append(notes.Action, fmt.Sprintf("source fallback chain preserved at pod level via x-claw.models.fallback: %s", strings.Join(modelRefStrings(fallbacks), ", "))) } if isCllamaDisabled(opts.CllamaOverride) && model.BaseURL != "" { return Plan{}, fmt.Errorf("--cllama=no cannot import source model base_url %q; pass --model to use a native route or omit --cllama=no", model.BaseURL) diff --git a/internal/pod/models.go b/internal/pod/models.go new file mode 100644 index 00000000..ec040782 --- /dev/null +++ b/internal/pod/models.go @@ -0,0 +1,104 @@ +package pod + +import ( + "fmt" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +// fallbackOrdinalPattern matches the reserved normalized chain keys +// (fallback-2, fallback-3, ...). Authors declare chains with list form; +// the ordinal keys exist only as the internal normalized representation. +var fallbackOrdinalPattern = regexp.MustCompile(`^fallback-\d+$`) + +// ModelSlots is the YAML surface for x-claw.models. Every slot takes a scalar +// provider/model ref; the fallback slot additionally accepts an ordered list, +// normalized to fallback, fallback-2, fallback-3, ... in declared order so the +// rest of the pipeline keeps operating on a flat slot map (ADR-019). +type ModelSlots map[string]string + +func (m *ModelSlots) UnmarshalYAML(node *yaml.Node) error { + if node.Tag == "!!null" { + *m = nil + return nil + } + if node.Kind != yaml.MappingNode { + return fmt.Errorf("models: expected a map of slot -> provider/model") + } + + out := make(map[string]string, len(node.Content)/2) + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + slot := strings.TrimSpace(keyNode.Value) + + if fallbackOrdinalPattern.MatchString(slot) { + return fmt.Errorf("models: slot %q is reserved; declare fallback chains with list form (fallback: [ref, ref, ...])", slot) + } + if _, exists := out[slot]; exists { + return fmt.Errorf("models: duplicate slot %q", slot) + } + + switch valueNode.Kind { + case yaml.ScalarNode: + var ref string + if err := valueNode.Decode(&ref); err != nil { + return fmt.Errorf("models: slot %q: %w", slot, err) + } + out[slot] = ref + case yaml.SequenceNode: + if slot != "fallback" { + return fmt.Errorf("models: slot %q does not accept a list; only fallback declares an ordered chain", slot) + } + refs, err := decodeFallbackChain(valueNode) + if err != nil { + return err + } + // Preserve an explicit empty chain as a tombstone. The compose-time + // merge uses it to clear fallback refs inherited from pod defaults or + // image labels, then removes the blank marker from resolved output. + if len(refs) == 0 { + out["fallback"] = "" + continue + } + for idx, ref := range refs { + out[fallbackSlotName(idx)] = ref + } + default: + return fmt.Errorf("models: slot %q: expected a provider/model ref", slot) + } + } + + *m = out + return nil +} + +func decodeFallbackChain(node *yaml.Node) ([]string, error) { + refs := make([]string, 0, len(node.Content)) + seen := make(map[string]struct{}, len(node.Content)) + for _, entry := range node.Content { + var ref string + if err := entry.Decode(&ref); err != nil { + return nil, fmt.Errorf("models: fallback chain: %w", err) + } + ref = strings.TrimSpace(ref) + if ref == "" { + return nil, fmt.Errorf("models: fallback chain must not contain blank entries") + } + if _, dup := seen[ref]; dup { + return nil, fmt.Errorf("models: fallback chain declares %q twice", ref) + } + seen[ref] = struct{}{} + refs = append(refs, ref) + } + return refs, nil +} + +func fallbackSlotName(index int) string { + if index == 0 { + return "fallback" + } + return fmt.Sprintf("fallback-%d", index+1) +} diff --git a/internal/pod/parser.go b/internal/pod/parser.go index dd6bffde..8f46ed9b 100644 --- a/internal/pod/parser.go +++ b/internal/pod/parser.go @@ -66,7 +66,7 @@ type rawClawBlock struct { Persona string `yaml:"persona"` DescribeFile string `yaml:"describe-file"` Cllama interface{} `yaml:"cllama"` - Models map[string]string `yaml:"models"` + Models ModelSlots `yaml:"models"` CllamaEnv map[string]string `yaml:"cllama-env"` Count int `yaml:"count"` Handles map[string]interface{} `yaml:"handles"` diff --git a/internal/pod/parser_models_fallback_test.go b/internal/pod/parser_models_fallback_test.go new file mode 100644 index 00000000..ddf1615c --- /dev/null +++ b/internal/pod/parser_models_fallback_test.go @@ -0,0 +1,228 @@ +package pod + +import ( + "strings" + "testing" +) + +// Ordered fallback chains: x-claw.models.fallback accepts a scalar (unchanged +// behavior) or an ordered list. List entries normalize to reserved slot keys +// fallback, fallback-2, fallback-3, ... in declared order. See ADR-019. + +func parseOne(t *testing.T, yaml string) *Pod { + t.Helper() + p, err := Parse(strings.NewReader(yaml)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + return p +} + +const fallbackChainPodYAML = ` +x-claw: + pod: chain-test +services: + agent: + image: example/agent + x-claw: + agent: ./AGENTS.md + models: + primary: openai/gpt-5.6 + fallback: + - openai/gpt-5.1 + - anthropic/claude-sonnet-5 + - openrouter/meta-llama/llama-4-maverick +` + +func TestParseModelsFallbackListNormalizesToOrderedSlots(t *testing.T) { + p := parseOne(t, fallbackChainPodYAML) + models := p.Services["agent"].Claw.Models + + want := map[string]string{ + "primary": "openai/gpt-5.6", + "fallback": "openai/gpt-5.1", + "fallback-2": "anthropic/claude-sonnet-5", + "fallback-3": "openrouter/meta-llama/llama-4-maverick", + } + if len(models) != len(want) { + t.Fatalf("models = %v, want %v", models, want) + } + for slot, ref := range want { + if models[slot] != ref { + t.Errorf("models[%q] = %q, want %q", slot, models[slot], ref) + } + } +} + +func TestParseModelsFallbackSingleElementListEqualsScalar(t *testing.T) { + p := parseOne(t, ` +x-claw: + pod: chain-test +services: + agent: + image: example/agent + x-claw: + agent: ./AGENTS.md + models: + primary: openai/gpt-5.6 + fallback: [anthropic/claude-haiku-4-5] +`) + models := p.Services["agent"].Claw.Models + if got := models["fallback"]; got != "anthropic/claude-haiku-4-5" { + t.Fatalf("fallback = %q", got) + } + if _, ok := models["fallback-2"]; ok { + t.Fatal("single-element list must not create fallback-2") + } +} + +func TestParseModelsFallbackEmptyListMeansNoFallback(t *testing.T) { + p := parseOne(t, ` +x-claw: + pod: chain-test +services: + agent: + image: example/agent + x-claw: + agent: ./AGENTS.md + models: + primary: openai/gpt-5.6 + fallback: [] +`) + models := p.Services["agent"].Claw.Models + if got, ok := models["fallback"]; !ok || got != "" { + t.Fatalf("empty list must preserve an explicit fallback-clear marker, got %v", models) + } +} + +func TestParseModelsListRejectedForNonFallbackSlots(t *testing.T) { + _, err := Parse(strings.NewReader(` +x-claw: + pod: chain-test +services: + agent: + image: example/agent + x-claw: + agent: ./AGENTS.md + models: + primary: [openai/gpt-5.6, openai/gpt-5.1] +`)) + if err == nil { + t.Fatal("expected list-form primary to be rejected") + } + if !strings.Contains(err.Error(), "primary") || !strings.Contains(err.Error(), "fallback") { + t.Fatalf("error should name the offending slot and point at fallback, got %v", err) + } +} + +func TestParseModelsRejectsReservedFallbackOrdinalKeys(t *testing.T) { + _, err := Parse(strings.NewReader(` +x-claw: + pod: chain-test +services: + agent: + image: example/agent + x-claw: + agent: ./AGENTS.md + models: + primary: openai/gpt-5.6 + fallback-2: anthropic/claude-sonnet-5 +`)) + if err == nil { + t.Fatal("expected explicit fallback-2 key to be rejected") + } + if !strings.Contains(err.Error(), "fallback-2") || !strings.Contains(err.Error(), "list") { + t.Fatalf("error should name the reserved key and point at list form, got %v", err) + } +} + +func TestParseModelsFallbackListRejectsBlankEntries(t *testing.T) { + _, err := Parse(strings.NewReader(` +x-claw: + pod: chain-test +services: + agent: + image: example/agent + x-claw: + agent: ./AGENTS.md + models: + primary: openai/gpt-5.6 + fallback: ["openai/gpt-5.1", " "] +`)) + if err == nil { + t.Fatal("expected blank chain entry to be rejected") + } +} + +func TestParseModelsFallbackListRejectsDuplicateRefs(t *testing.T) { + _, err := Parse(strings.NewReader(` +x-claw: + pod: chain-test +services: + agent: + image: example/agent + x-claw: + agent: ./AGENTS.md + models: + primary: openai/gpt-5.6 + fallback: [openai/gpt-5.1, openai/gpt-5.1] +`)) + if err == nil { + t.Fatal("expected duplicate chain refs to be rejected") + } +} + +// A service-level fallback declaration (scalar or list) replaces the entire +// default chain — never a positional merge. +func TestParseModelsDefaultsFallbackChainReplacedAtomically(t *testing.T) { + p := parseOne(t, ` +x-claw: + pod: chain-test + models-defaults: + primary: openai/gpt-5.6 + fallback: + - openai/gpt-5.1 + - anthropic/claude-sonnet-5 +services: + inheritor: + image: example/agent + x-claw: + agent: ./AGENTS.md + scalar-override: + image: example/agent + x-claw: + agent: ./AGENTS.md + models: + fallback: anthropic/claude-haiku-4-5 + list-override: + image: example/agent + x-claw: + agent: ./AGENTS.md + models: + fallback: [openrouter/meta-llama/llama-4-maverick] +`) + + inherited := p.Services["inheritor"].Claw.Models + if inherited["fallback"] != "openai/gpt-5.1" || inherited["fallback-2"] != "anthropic/claude-sonnet-5" { + t.Fatalf("inheritor should get the full default chain, got %v", inherited) + } + + scalar := p.Services["scalar-override"].Claw.Models + if scalar["fallback"] != "anthropic/claude-haiku-4-5" { + t.Fatalf("scalar override fallback = %q", scalar["fallback"]) + } + if _, ok := scalar["fallback-2"]; ok { + t.Fatalf("scalar override must replace the whole default chain, got %v", scalar) + } + if scalar["primary"] != "openai/gpt-5.6" { + t.Fatalf("primary should still inherit, got %q", scalar["primary"]) + } + + list := p.Services["list-override"].Claw.Models + if list["fallback"] != "openrouter/meta-llama/llama-4-maverick" { + t.Fatalf("list override fallback = %q", list["fallback"]) + } + if _, ok := list["fallback-2"]; ok { + t.Fatalf("list override must replace the whole default chain, got %v", list) + } +} diff --git a/site/changelog.md b/site/changelog.md index 6bb6b805..c254551b 100644 --- a/site/changelog.md +++ b/site/changelog.md @@ -29,6 +29,11 @@ outline: deep ## Unreleased +- **Ordered fallback model chains** ([ADR-019 amendment](https://github.com/mostlydev/clawdapus/blob/master/docs/decisions/019-model-policy-authority-and-declared-failover.md), [#359](https://github.com/mostlydev/clawdapus/issues/359)) -- `x-claw.models.fallback` (and `models-defaults`) now accepts an ordered list. The compiled model policy emits every chain link as a `fallback`-slot entry, so cllama's declared failover traverses the full chain in order when providers are exhausted. The fallback family merges atomically across defaults, service overrides, and image labels -- chains never interleave. OpenClaw receives the whole chain natively (`agents.defaults.model.fallbacks`); `claw init import` now preserves full source fallback chains instead of truncating to one. +- **Responses-only model support via [cllama v0.9.0](https://github.com/mostlydev/cllama/releases/tag/v0.9.0)** -- cllama translates responses-only OpenAI models (`gpt-5.6*`, `gpt-5-pro*` built in) at the provider boundary while agents keep the chat/completions contract; every governance surface observes the unchanged shape. Extend coverage with `CLLAMA_RESPONSES_API_MODELS` or disable with `CLLAMA_RESPONSES_API_DISABLED` via `x-claw.cllama-defaults.env`. Documented in the [cllama guide](/guide/cllama#responses-only-models). A new spike (`TestSpikeOrderedModelFailover`) proves the compiled two-fallback chain end-to-end: red on cllama v0.7.8, green on v0.9.0. +- **`x-claw.terminalOnSuccess` validated at compile time** -- the managed-tool annotation must be a JSON boolean; `claw up` now fails closed on other types instead of letting cllama silently ignore the annotation at runtime. Unknown annotation keys still pass through untouched. +- **Examples and docs refreshed to current provider model shapes** -- stale references (`gpt-4o`, `claude-sonnet-4`, `claude-haiku-3-5`, `claude-opus-4-1`) move to current models priced by cllama v0.9.0 (`gpt-5.6`, `claude-sonnet-5`, `claude-opus-5`, `claude-haiku-4-5`, `gemini-3.6-flash`). + - **NanoClaw, MicroClaw, and NullClaw drivers retired** ([ADR-026](https://github.com/mostlydev/clawdapus/blob/master/docs/decisions/026-runner-adoption-and-retirement.md), [#353](https://github.com/mostlydev/clawdapus/issues/353)) -- a reproducible upstream-adoption audit (`scripts/runner-adoption-snapshot`, dated evidence under `docs/evidence/`) exposed three ambiguous maintenance cases, and the maintainer chose to stop carrying them. Retired `CLAW_TYPE`s now fail `claw up` with a migration error pointing to Hermes instead of a generic unknown-driver message. The retained set is `openclaw`, `hermes`, `nanobot`, and `picoclaw`; the rollcall conformance pod and trading-desk example were revised to keep full coverage across all four. - **Slow scheduled wakes no longer block unrelated targets** -- claw-api dispatches due targets concurrently while serializing wakes per runner, coalesces overlapping slots without regressing next-fire state, rejects duplicate manual fires with a conflict, and drains active scheduler dispatches cleanly on shutdown. Coalesced slots are now recorded in schedule state (`suppressed_slots`, `last_suppressed_at`) and surfaced on the clawdash schedule card, so a schedule whose wake outruns its own cadence no longer reads as perfectly healthy. Closes [#347](https://github.com/mostlydev/clawdapus/issues/347). - **Manual schedule fires honor runner wake budgets** -- `claw api schedule fire` now gives the in-container request 2 minutes 5 seconds and its outer compose transport 2 minutes 10 seconds, enough to return the final result of the longest supported runner wake. Other schedule operations retain their short defaults, and an explicit `--exec-timeout` still overrides the outer transport. Closes [#348](https://github.com/mostlydev/clawdapus/issues/348). diff --git a/site/guide/clawfile.md b/site/guide/clawfile.md index f7fca0ed..8d1d1483 100644 --- a/site/guide/clawfile.md +++ b/site/guide/clawfile.md @@ -14,8 +14,8 @@ FROM openclaw:latest CLAW_TYPE openclaw AGENT AGENTS.md # behavioral contract — bind-mounted read-only -MODEL primary openrouter/anthropic/claude-sonnet-4 -MODEL fallback anthropic/claude-haiku-3-5 +MODEL primary openrouter/anthropic/claude-sonnet-5 +MODEL fallback anthropic/claude-haiku-4-5 CLLAMA passthrough # governance proxy — credential starvation + cost tracking @@ -154,7 +154,7 @@ Composes contracts at pod level with three inclusion modes: The Clawfile is not interpreted at runtime. `claw build` produces a standard Dockerfile, and `docker build` produces a standard OCI image. The extended directives become image labels that `claw up` reads at deployment time. ::: -For example, `CLAW_TYPE openclaw` becomes a label on the image. `MODEL primary openrouter/anthropic/claude-sonnet-4` becomes a label encoding the model binding. `claw up` reads these labels when composing the pod and generates the appropriate runtime configuration for the selected driver. +For example, `CLAW_TYPE openclaw` becomes a label on the image. `MODEL primary openrouter/anthropic/claude-sonnet-5` becomes a label encoding the model binding. `claw up` reads these labels when composing the pod and generates the appropriate runtime configuration for the selected driver. ## CLAW_TYPE and Drivers @@ -174,15 +174,15 @@ The `CLAW_TYPE` directive selects which runtime driver handles the agent. All dr The `MODEL` directive binds named slots to provider/model pairs: ```dockerfile -MODEL primary openrouter/anthropic/claude-sonnet-4 -MODEL fallback anthropic/claude-haiku-3-5 -MODEL summarizer openrouter/google/gemini-flash-2.0 +MODEL primary openrouter/anthropic/claude-sonnet-5 +MODEL fallback anthropic/claude-haiku-4-5 +MODEL summarizer openrouter/google/gemini-3.6-flash ``` When cllama is enabled, the proxy can silently downgrade a requested model (e.g., from a primary to a fallback) and meter usage without exposing provider credentials to the agent. Hard budget caps and proxy-level rate limits are tracked as future enforcement work. ::: tip Retarget Without Rebuilding -Clawfile `MODEL` labels are the base slot map, but `claw-pod.yml` can retarget slots at deploy time via service-level `x-claw.models` or pod-level `x-claw.models-defaults`. Pod slots overlay image slots additively per key, so you can override `primary` without losing `fallback`. See [Model Slot Precedence](/guide/pod-yaml#model-slot-precedence) for the full rules. +Clawfile `MODEL` labels are the base slot map, but `claw-pod.yml` can retarget slots at deploy time via service-level `x-claw.models` or pod-level `x-claw.models-defaults`. Pod slots overlay image slots additively per key, so you can override `primary` without losing `fallback` — except the fallback family itself, which replaces atomically: a pod-declared fallback (scalar or ordered list) replaces the image's fallback entirely. See [Model Slot Precedence](/guide/pod-yaml#model-slot-precedence) for the full rules. ::: ## CONFIGURE for Runtime Overrides diff --git a/site/guide/cli.md b/site/guide/cli.md index d79a7c1f..0bf3cfd7 100644 --- a/site/guide/cli.md +++ b/site/guide/cli.md @@ -281,8 +281,8 @@ $ claw inspect trading-desk-analyst:latest Claw Type: openclaw Agent: AGENTS.md Cllama: passthrough -Model[primary]: openrouter/anthropic/claude-sonnet-4 -Model[fallback]: anthropic/claude-haiku-3-5 +Model[primary]: openrouter/anthropic/claude-sonnet-5 +Model[fallback]: anthropic/claude-haiku-4-5 Surface: service://trading-api Surface: volume://shared-research read-write ``` @@ -458,8 +458,8 @@ $ claw audit --since 24h Pod: trading-desk Events: 847 CLAW REQ RESP ERR INT TOOLS TOOL_ERR TOK_IN TOK_OUT COST_USD MODELS -analyst 312 310 2 0 18 1 482101 89402 1.2340 claude-sonnet-4(312) -scanner 535 535 0 3 0 0 201440 45200 0.5120 claude-haiku-3-5(535) +analyst 312 310 2 0 18 1 482101 89402 1.2340 claude-sonnet-5(312) +scanner 535 535 0 3 0 0 201440 45200 0.5120 claude-haiku-4-5(535) Totals: req=847 resp=845 err=2 int=3 tools=18/1 tokens=683541/134602 cost=$1.7460 ``` diff --git a/site/guide/cllama.md b/site/guide/cllama.md index a0a92ece..6bac2223 100644 --- a/site/guide/cllama.md +++ b/site/guide/cllama.md @@ -279,6 +279,36 @@ When the aggregate cap does drop a feed, cllama no longer fails silently: the mo The aggregate cap drops whole feeds in manifest order once the budget is exhausted; there is no per-feed priority or reservation yet. If a large feed earlier in the manifest can starve a later one, raise `CLLAMA_FEED_MAX_TOTAL_BYTES` rather than relying on ordering. ::: +### Responses-Only Models + +Some of OpenAI's newest models reject function tools on `/v1/chat/completions` +and are reachable only through the Responses API. cllama translates for them at +the **provider boundary**: agents and runners keep speaking chat/completions +(or Anthropic Messages), and cllama re-encodes the outbound request as a +Responses call and translates the reply back. Every governance surface — audit, +session history, budgets, tool mediation, declared failover — keeps observing +the unchanged shape. Requests routed this way emit an `intervention` audit +event with reason `responses_api_adapter`. + +The built-in list covers `openai/gpt-5.6*` and `openai/gpt-5-pro*`. When +OpenAI moves a model before the built-in list catches up, cllama detects the +upstream rejection and retries once through the adapter automatically. Two +knobs tune the behavior through the proxy environment: + +```yaml +x-claw: + cllama-defaults: + proxy: [passthrough] + env: + # Extra provider-scoped model prefixes to route through the adapter + CLLAMA_RESPONSES_API_MODELS: "openai/gpt-6" + # Escape hatch: disable the adapter entirely + # CLLAMA_RESPONSES_API_DISABLED: "1" +``` + +No runner or Clawfile changes are needed — declare the model in a slot like +any other, and cllama handles the dialect. + ## Pod Configuration ### Declaring a cllama Proxy @@ -332,7 +362,7 @@ services: agent: analyst cllama: passthrough models: - primary: google/gemini-2.5-flash + primary: google/gemini-3.6-flash cllama-env: GEMINI_API_KEY: ${GEMINI_API_KEY} # optional override for proxies or alternate endpoints @@ -355,7 +385,7 @@ services: agent: analyst cllama: passthrough models: - primary: vercel/anthropic/claude-sonnet-4.6 + primary: vercel/anthropic/claude-sonnet-5 cllama-env: AI_GATEWAY_API_KEY: ${AI_GATEWAY_API_KEY} # optional override for proxies or alternate endpoints @@ -363,7 +393,7 @@ services: ``` The OpenAI-compatible `/v1/chat/completions` path forwards -`anthropic/claude-sonnet-4.6` to Vercel as the upstream model. The Anthropic +`anthropic/claude-sonnet-5` to Vercel as the upstream model. The Anthropic `/v1/messages` path remains native Anthropic-only. ### Count Expansion with cllama @@ -394,7 +424,7 @@ $ claw audit --since 24h --claw analyst-0 Pod: trading-desk Events: 128 CLAW REQ RESP ERR INT TOOLS TOOL_ERR TOK_IN TOK_OUT COST_USD MODELS -analyst-0 64 64 0 1 9 0 81204 18402 0.2130 claude-sonnet-4(64) +analyst-0 64 64 0 1 9 0 81204 18402 0.2130 claude-sonnet-5(64) ``` ## Telemetry and Audit diff --git a/site/guide/compilation-principles.md b/site/guide/compilation-principles.md index 06baa908..aec4806c 100644 --- a/site/guide/compilation-principles.md +++ b/site/guide/compilation-principles.md @@ -79,7 +79,7 @@ x-claw: env: OPENROUTER_API_KEY: "${OPENROUTER_API_KEY}" models-defaults: - primary: openrouter/anthropic/claude-sonnet-4 + primary: openrouter/anthropic/claude-sonnet-5 fallback: anthropic/claude-haiku-4-5 surfaces-defaults: - "service://trading-api" diff --git a/site/guide/pod-yaml.md b/site/guide/pod-yaml.md index b3b30a56..972fc247 100644 --- a/site/guide/pod-yaml.md +++ b/site/guide/pod-yaml.md @@ -14,7 +14,7 @@ x-claw: OPENROUTER_API_KEY: "${OPENROUTER_API_KEY}" ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY}" models-defaults: - primary: openrouter/anthropic/claude-sonnet-4 + primary: openrouter/anthropic/claude-sonnet-5 fallback: anthropic/claude-haiku-4-5 surfaces-defaults: - "service://operations-api" @@ -195,7 +195,7 @@ Image `MODEL` labels still define the base slot map, but pod YAML can retarget s ```yaml x-claw: models-defaults: - primary: openrouter/anthropic/claude-sonnet-4 + primary: openrouter/anthropic/claude-sonnet-5 fallback: anthropic/claude-haiku-4-5 services: @@ -204,7 +204,7 @@ services: x-claw: agent: ./agents/analyst/AGENTS.md models: - primary: openrouter/google/gemini-2.5-flash + primary: openrouter/google/gemini-3.6-flash ``` Precedence is: @@ -215,6 +215,41 @@ Precedence is: `x-claw.models` merges additively over `models-defaults`, so overriding `primary` still inherits `fallback` unless you explicitly suppress pod defaults. `models: {}` and `models: null` both suppress pod defaults only; image-declared slots still apply. +### Ordered Fallback Chains + +The `fallback` slot accepts an ordered list. cllama walks the chain in +declared order when a provider is exhausted: + +```yaml +x-claw: + models-defaults: + primary: openai/gpt-5.6 + fallback: + - openai/gpt-5.1 + - openrouter/anthropic/claude-sonnet-5 + - google/gemini-3.6-flash +``` + +Chain rules: + +- The fallback family replaces **atomically**: any service-level `fallback` + declaration (scalar or list) replaces the entire default chain — chains + never interleave across layers. The same rule applies to image `MODEL + fallback` labels: a pod-declared chain replaces the image's fallback. +- Only `fallback` accepts a list. Other slots (`primary`, `analysis`, ...) + are scalar, and non-fallback slots never participate in failover. +- Clawfile images declare at most one `MODEL fallback`; longer chains are + pod-level deployment policy. +- Every candidate must be reachable through the runner's request format. + OpenAI-format runners use `/v1/chat/completions`; an `anthropic/...` ref on + that path is bridged through OpenRouter only when OpenRouter is configured. + Anthropic-format runners use `/v1/messages`, where every candidate must be + `anthropic/...`. For cross-vendor failover from an OpenAI-format runner, use + explicit OpenAI-compatible refs such as `openrouter/anthropic/...` and seed + every provider key in `x-claw.cllama-env`. + +See ADR-019 for the full failover contract. + ## Mixed Cognitive and Non-Cognitive Services A pod is a mixed cluster. Regular API containers, databases, and message queues participate as first-class pod members alongside agents. Non-cognitive services do not need `x-claw` blocks but still benefit from the pod: diff --git a/site/guide/what-is-clawdapus.md b/site/guide/what-is-clawdapus.md index 6a724896..8d5b821d 100644 --- a/site/guide/what-is-clawdapus.md +++ b/site/guide/what-is-clawdapus.md @@ -66,8 +66,8 @@ $ claw audit --since 24h Pod: research-pod Events: 460 CLAW REQ RESP ERR INT TOOLS TOOL_ERR TOK_IN TOK_OUT COST_USD MODELS -analyst 142 142 0 3 18 0 284011 39402 1.8742 anthropic/claude-sonnet-4 -researcher 88 87 1 0 5 0 151233 20118 0.9931 anthropic/claude-sonnet-4 +analyst 142 142 0 3 18 0 284011 39402 1.8742 anthropic/claude-sonnet-5 +researcher 88 87 1 0 5 0 151233 20118 0.9931 anthropic/claude-sonnet-5 Totals: req=230 resp=229 err=1 int=3 tools=23/23 tokens=435244/59520 cost=$2.8673 ``` diff --git a/site/index.md b/site/index.md index d93bd8ed..0d10bfbf 100644 --- a/site/index.md +++ b/site/index.md @@ -49,7 +49,7 @@ FROM openclaw:latest CLAW_TYPE openclaw AGENT AGENTS.md -MODEL primary openrouter/anthropic/claude-sonnet-4 +MODEL primary openrouter/anthropic/claude-sonnet-5 CLLAMA passthrough HANDLE discord diff --git a/site/manifesto.md b/site/manifesto.md index 46fc222c..e3c31ba7 100644 --- a/site/manifesto.md +++ b/site/manifesto.md @@ -84,7 +84,7 @@ cllama is an open standard for a context-aware, bidirectional proxy -- a separat It sits between the runner and the LLM provider. Outbound, it evaluates prompts before the LLM sees them to prevent policy violations. Inbound, it evaluates responses before the runner sees them, dropping output that drifts from purpose. The runner never knows the proxy exists; it thinks it's talking directly to the model. **Intelligent Authorization & Compute Metering:** -Clawdapus injects compiled contract context into shared proxy mounts and resolves caller identity from bearer tokens (`:`). Because it is context-aware, the proxy acts as a dynamic governance enforcement point. It can drop specific managed tool calls based on the agent's identity. Furthermore, because it acts as the central router, it meters compute usage, can silently downgrade a requested model (e.g., from `gpt-4o` to `claude-3-haiku`), and enforces per-agent budget caps and request-rate limits before provider dispatch. +Clawdapus injects compiled contract context into shared proxy mounts and resolves caller identity from bearer tokens (`:`). Because it is context-aware, the proxy acts as a dynamic governance enforcement point. It can drop specific managed tool calls based on the agent's identity. Furthermore, because it acts as the central router, it meters compute usage, can silently downgrade a requested model (e.g., from `gpt-5.6` to `claude-haiku-4-5`), and enforces per-agent budget caps and request-rate limits before provider dispatch. **Enforcement via Credential Starvation:** Isolation is achieved by strictly separating secrets. The proxy holds the real LLM provider API keys. The agent container is provisioned with a unique Bearer Token. Because the agent lacks the credentials to call providers directly, all successful inference *must* pass through the proxy, even if a malicious prompt tricks the agent into ignoring its configured base URL. diff --git a/skills/clawdapus/SKILL.md b/skills/clawdapus/SKILL.md index a52262f4..986bebec 100644 --- a/skills/clawdapus/SKILL.md +++ b/skills/clawdapus/SKILL.md @@ -87,8 +87,8 @@ FROM openclaw:latest CLAW_TYPE openclaw # REQUIRED: selects runtime driver AGENT AGENTS.md # behavioral contract — must exist on host -MODEL primary openrouter/anthropic/claude-sonnet-4 -MODEL fallback anthropic/claude-haiku-3-5 +MODEL primary openrouter/anthropic/claude-sonnet-5 +MODEL fallback anthropic/claude-haiku-4-5 CLLAMA passthrough # governance proxy type PERSONA ./personas/trader # identity materialization (local or OCI) @@ -391,9 +391,9 @@ The proxy sits between agents and LLM providers. Agents get bearer tokens, proxy | Provider | Auth | Model format | |----------|------|-------------| -| OpenAI | Bearer | `openai/gpt-4o` | -| Anthropic | X-Api-Key | `anthropic/claude-sonnet-4` | -| OpenRouter | Bearer | `openrouter/anthropic/claude-sonnet-4` | +| OpenAI | Bearer | `openai/gpt-5.6` | +| Anthropic | X-Api-Key | `anthropic/claude-sonnet-5` | +| OpenRouter | Bearer | `openrouter/anthropic/claude-sonnet-5` | | xAI | Bearer | `xai/grok-3` | | Ollama | None | `ollama/llama3` |