mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
chore(models): upgrade the opus alias to claude-opus-4-8
MODEL_MAP["opus"] moves off claude-opus-4-6 to the newest Opus tier at the same price; pricing already matched via the claude-opus-4 fragment, and a new test_opus_is_priced guard keeps the alias priced. Fixtures, panel mocks, and docs follow.
This commit is contained in:
@@ -79,7 +79,7 @@ pnpm test
|
||||
| RAG Engine | in-house (asyncpg + pgvector, hybrid retrieval) |
|
||||
| Cache/Queue | Redis |
|
||||
| Container Runtime | Docker + Docker Compose |
|
||||
| Cloud LLM | Claude API (claude-opus-4-6) + xAI Grok (official `grok` CLI, SuperGrok subscription) + OpenAI (official `codex` CLI, ChatGPT subscription) + Google Gemini (official `gemini` CLI, OAuth login) |
|
||||
| Cloud LLM | Claude API (claude-opus-4-8) + xAI Grok (official `grok` CLI, SuperGrok subscription) + OpenAI (official `codex` CLI, ChatGPT subscription) + Google Gemini (official `gemini` CLI, OAuth login) |
|
||||
| Local LLM | Ollama (glm-5.2:cloud for RAG/hybrid retrieval) |
|
||||
| Embeddings | qwen3-embedding:0.6b (1024 dim) |
|
||||
| Frontend | Next.js 16 + TypeScript + Tailwind + Radix UI (in `panel/`) |
|
||||
|
||||
@@ -5134,7 +5134,7 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API,
|
||||
| `OrchestratorAgentState` | StrEnum | runtime.py:15 | offline/starting/active/waiting_short/waiting_long/idle/stopping |
|
||||
| `AgentInstance` | dataclass | runtime.py:70 | Running Claude Code container record + `usage_session_id` |
|
||||
| `SpawnGitContext` | dataclass | runtime.py:28 | Git context for spawn (project_slug, branch, `task_short_id` for worktree) |
|
||||
| `MODEL_MAP` | dict | runtime.py:106 | short-name→full Claude id (opus→claude-opus-4-6, sonnet→claude-sonnet-5, haiku→…) |
|
||||
| `MODEL_MAP` | dict | runtime.py:106 | short-name→full Claude id (opus→claude-opus-4-8, sonnet→claude-sonnet-5, haiku→…) |
|
||||
| `ROLE_MODEL_MAP` | dict | runtime.py:120 | per-role default tier: developer/qa/documenter/cell_pm/main_pm→sonnet, pr_reviewer/auditor/board/ceo/prompter/secretary→opus. **Haiku retired from every delivery-lifecycle role (#680, 2026-07-24)**: qa/documenter moved haiku→sonnet — haiku can't reliably emit the structured envelopes the lifecycle runs on (`pass_review`'s per-AC `criteria_verified`, `delegate`'s `covers_parent_criteria`, the findings ledger), so a haiku QA/PM claims, gets validation-rejected, idles, respawns, and loops without progress. A structured-verb capability floor (`_below_capability_floor`/`_floor_below_capability`, `roboco/services/llm.py`) additionally upgrades ANY below-floor Anthropic assignment to sonnet at resolution time — from a pin, a plain ROLE row, or a future map edit — so the floor can't be silently reintroduced. Non-Anthropic providers are untouched (an Anthropic-tier floor, not a provider policy). |
|
||||
| `ROLE_EFFORT_MAP` | dict | runtime.py | per-role `CLAUDE_CODE_EFFORT_LEVEL` override injected at spawn; **empty/inert by default** (opt-in per role after verifying the level moves usage) |
|
||||
| `MODEL_CATALOG` | tuple | llm_catalog.py:67 | Settings-dropdown source of truth; Anthropic entries derived from `MODEL_MAP` |
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API,
|
||||
| `OrchestratorAgentState` | StrEnum | runtime.py:15 | offline/starting/active/waiting_short/waiting_long/idle/stopping |
|
||||
| `AgentInstance` | dataclass | runtime.py:70 | Running Claude Code container record + `usage_session_id` |
|
||||
| `SpawnGitContext` | dataclass | runtime.py:28 | Git context for spawn (project_slug, branch, `task_short_id` for worktree) |
|
||||
| `MODEL_MAP` | dict | runtime.py:106 | short-name→full Claude id (opus→claude-opus-4-6, sonnet→claude-sonnet-5, haiku→…) |
|
||||
| `MODEL_MAP` | dict | runtime.py:106 | short-name→full Claude id (opus→claude-opus-4-8, sonnet→claude-sonnet-5, haiku→…) |
|
||||
| `ROLE_MODEL_MAP` | dict | runtime.py:120 | per-role default tier: developer/qa/documenter/cell_pm/main_pm→sonnet, pr_reviewer/auditor/board/ceo/prompter/secretary→opus. **Haiku retired from every delivery-lifecycle role (#680, 2026-07-24)**: qa/documenter moved haiku→sonnet — haiku can't reliably emit the structured envelopes the lifecycle runs on (`pass_review`'s per-AC `criteria_verified`, `delegate`'s `covers_parent_criteria`, the findings ledger), so a haiku QA/PM claims, gets validation-rejected, idles, respawns, and loops without progress. A structured-verb capability floor (`_below_capability_floor`/`_floor_below_capability`, `roboco/services/llm.py`) additionally upgrades ANY below-floor Anthropic assignment to sonnet at resolution time — from a pin, a plain ROLE row, or a future map edit — so the floor can't be silently reintroduced. Non-Anthropic providers are untouched (an Anthropic-tier floor, not a provider policy). |
|
||||
| `ROLE_EFFORT_MAP` | dict | runtime.py | per-role `CLAUDE_CODE_EFFORT_LEVEL` override injected at spawn; **empty/inert by default** (opt-in per role after verifying the level moves usage) |
|
||||
| `MODEL_CATALOG` | tuple | llm_catalog.py:67 | Settings-dropdown source of truth; Anthropic entries derived from `MODEL_MAP` |
|
||||
|
||||
@@ -9,10 +9,7 @@ import {
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
import type {
|
||||
ComplexityOverride,
|
||||
RoutingPreset,
|
||||
} from "@/lib/api/providers";
|
||||
import type { ComplexityOverride, RoutingPreset } from "@/lib/api/providers";
|
||||
|
||||
const {
|
||||
catalog,
|
||||
@@ -36,9 +33,9 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
catalog: vi.fn(async () => [
|
||||
{
|
||||
model_name: "claude-opus-4-6",
|
||||
model_name: "claude-opus-4-8",
|
||||
provider_type: "anthropic",
|
||||
display_name: "Claude Opus 4.6",
|
||||
display_name: "Claude Opus 4.8",
|
||||
},
|
||||
{
|
||||
model_name: "grok-build-0.1",
|
||||
@@ -643,9 +640,7 @@ describe("AIRoutingCard", () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Per-agent override (mix mode)");
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Clear all" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Clear all" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("clears persisted pins via an empty per_agent map on confirm", async () => {
|
||||
@@ -654,9 +649,7 @@ describe("AIRoutingCard", () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Per-agent override (mix mode)");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Clear all" }),
|
||||
).toBeEnabled(),
|
||||
expect(screen.getByRole("button", { name: "Clear all" })).toBeEnabled(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear all" }));
|
||||
@@ -673,9 +666,7 @@ describe("AIRoutingCard", () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Per-agent override (mix mode)");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Clear all" }),
|
||||
).toBeEnabled(),
|
||||
expect(screen.getByRole("button", { name: "Clear all" })).toBeEnabled(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear all" }));
|
||||
@@ -702,7 +693,9 @@ describe("AIRoutingCard", () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Complexity overrides");
|
||||
|
||||
const section = screen.getByText("Complexity overrides").closest("section")!;
|
||||
const section = screen
|
||||
.getByText("Complexity overrides")
|
||||
.closest("section")!;
|
||||
for (const label of ["Developer", "QA", "Documenter"]) {
|
||||
expect(within(section).getByText(label)).toBeInTheDocument();
|
||||
}
|
||||
@@ -712,7 +705,9 @@ describe("AIRoutingCard", () => {
|
||||
// mix table's roster).
|
||||
expect(within(section).queryByText("Cell PM")).not.toBeInTheDocument();
|
||||
expect(within(section).queryByText("Main PM")).not.toBeInTheDocument();
|
||||
expect(within(section).queryByText("PR Reviewer")).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(section).queryByText("PR Reviewer"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// 3 roles x 2 (low/high) selects.
|
||||
expect(section.querySelectorAll('[role="combobox"]')).toHaveLength(6);
|
||||
@@ -724,14 +719,14 @@ describe("AIRoutingCard", () => {
|
||||
|
||||
const devLow = screen.getByTestId("complexity-select-developer-low");
|
||||
fireEvent.click(
|
||||
await within(devLow).findByRole("option", { name: "Claude Opus 4.6" }),
|
||||
await within(devLow).findByRole("option", { name: "Claude Opus 4.8" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setComplexityOverride).toHaveBeenCalledWith({
|
||||
role: "developer",
|
||||
complexity: "low",
|
||||
model_name: "claude-opus-4-6",
|
||||
model_name: "claude-opus-4-8",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -769,7 +764,7 @@ describe("AIRoutingCard", () => {
|
||||
|
||||
const devLow = screen.getByTestId("complexity-select-developer-low");
|
||||
fireEvent.click(
|
||||
await within(devLow).findByRole("option", { name: "Claude Opus 4.6" }),
|
||||
await within(devLow).findByRole("option", { name: "Claude Opus 4.8" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setComplexityOverride).toHaveBeenCalled());
|
||||
@@ -805,9 +800,10 @@ describe("AIRoutingCard", () => {
|
||||
// (its combobox reflects the current value via data-value) before
|
||||
// clearing it — otherwise the click races the query resolving.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(documenterLow).getByRole("combobox"),
|
||||
).toHaveAttribute("data-value", "grok-build-0.1"),
|
||||
expect(within(documenterLow).getByRole("combobox")).toHaveAttribute(
|
||||
"data-value",
|
||||
"grok-build-0.1",
|
||||
),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(documenterLow).getByRole("option", { name: "(none)" }),
|
||||
@@ -909,9 +905,7 @@ describe("AIRoutingCard", () => {
|
||||
expect(
|
||||
await within(beDevRow).findByText("Codex (OpenAI)"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(beDevRow).getByText("Gemini (Google)"),
|
||||
).toBeInTheDocument();
|
||||
expect(within(beDevRow).getByText("Gemini (Google)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("excludes Codex and Gemini from the Intake/Secretary/PR Review group, with an inline note", async () => {
|
||||
@@ -1043,9 +1037,9 @@ describe("AIRoutingCard", () => {
|
||||
skipped: ["Skipped role:developer (ghost-model) — Unknown model"],
|
||||
});
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
const presetSection = (await screen.findByText("Routing presets")).closest(
|
||||
"section",
|
||||
)!;
|
||||
const presetSection = (
|
||||
await screen.findByText("Routing presets")
|
||||
).closest("section")!;
|
||||
fireEvent.click(
|
||||
await within(presetSection).findByRole("option", {
|
||||
name: "Cheap Fleet",
|
||||
@@ -1068,9 +1062,9 @@ describe("AIRoutingCard", () => {
|
||||
{ id: "p1", name: "Cheap Fleet", created_at: "2026-07-01T00:00:00Z" },
|
||||
]);
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
const presetSection = (await screen.findByText("Routing presets")).closest(
|
||||
"section",
|
||||
)!;
|
||||
const presetSection = (
|
||||
await screen.findByText("Routing presets")
|
||||
).closest("section")!;
|
||||
fireEvent.click(
|
||||
await within(presetSection).findByRole("option", {
|
||||
name: "Cheap Fleet",
|
||||
|
||||
@@ -105,7 +105,7 @@ export const DEMO_TEAM_USAGE: TeamUsageRow[] = (
|
||||
|
||||
export const DEMO_MODEL_USAGE: ModelUsageSlice[] = (
|
||||
[
|
||||
["claude-opus-4-6", 42.1],
|
||||
["claude-opus-4-8", 42.1],
|
||||
["glm-5.2:cloud", 16.8],
|
||||
["grok-build", 7.64],
|
||||
] as Array<[string, number]>
|
||||
|
||||
@@ -57,7 +57,7 @@ _ANTHROPIC_FRAGMENTS = ("claude", "opus", "sonnet", "haiku")
|
||||
# Cache read is charged at ~10 % of the input price.
|
||||
# Cache write is charged at ~25 % of the input price.
|
||||
#
|
||||
# Match on *substring* of model name so "claude-opus-4-6" and "opus" both
|
||||
# Match on *substring* of model name so "claude-opus-4-8" and "opus" both
|
||||
# resolve to the same tier.
|
||||
# ---------------------------------------------------------------------------
|
||||
_PRICING: list[tuple[str, float, float, float, float]] = [
|
||||
|
||||
@@ -7,7 +7,7 @@ the router maps back to the correct pre-seeded provider row.
|
||||
|
||||
**Anthropic entries derive from `runtime.MODEL_MAP`** — that's the single
|
||||
source of truth for which Claude versions are supported. Bumping a model
|
||||
version there (e.g. `claude-opus-4-6` → `claude-opus-4-7`) updates the
|
||||
version there (e.g. `claude-opus-4-8` → a newer id) updates the
|
||||
catalog automatically without a second edit here.
|
||||
|
||||
Ollama Cloud entries are hand-maintained because Ollama's cloud tags
|
||||
@@ -46,7 +46,7 @@ def _build_anthropic_entries() -> tuple[CatalogEntry, ...]:
|
||||
|
||||
Keeps the UI honest: a user picking "Claude Opus" sees exactly which
|
||||
underlying Claude Code model id will be used at spawn
|
||||
(e.g. "Claude Opus · claude-opus-4-6"), so version bumps in
|
||||
(e.g. "Claude Opus · claude-opus-4-8"), so version bumps in
|
||||
`runtime.MODEL_MAP` are immediately visible.
|
||||
"""
|
||||
entries: list[CatalogEntry] = []
|
||||
|
||||
@@ -110,7 +110,7 @@ class WaitingRecord:
|
||||
|
||||
# Model mapping for cost optimization
|
||||
MODEL_MAP: dict[str, str] = {
|
||||
"opus": "claude-opus-4-6",
|
||||
"opus": "claude-opus-4-8",
|
||||
"sonnet": "claude-sonnet-5",
|
||||
"haiku": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
|
||||
@@ -562,7 +562,14 @@ class TestProviderAwareness:
|
||||
assert cost == _ZERO_COST
|
||||
|
||||
def test_is_anthropic_model_true_for_claude_names(self) -> None:
|
||||
for name in ("claude-opus-4-6", "claude-fable-5", "opus", "sonnet", "haiku"):
|
||||
for name in (
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4-8",
|
||||
"claude-fable-5",
|
||||
"opus",
|
||||
"sonnet",
|
||||
"haiku",
|
||||
):
|
||||
assert _is_anthropic_model(name) is True, name
|
||||
|
||||
def test_is_anthropic_model_false_for_non_claude_names(self) -> None:
|
||||
|
||||
@@ -106,7 +106,7 @@ def test_build_anthropic_entries_skips_missing_models() -> None:
|
||||
# Patch MODEL_MAP to be missing one entry → loop hits the `continue` branch.
|
||||
with patch(
|
||||
"roboco.models.llm_catalog.MODEL_MAP",
|
||||
{"opus": "claude-opus-4-6"}, # sonnet+haiku missing
|
||||
{"opus": "claude-opus-4-8"}, # sonnet+haiku missing
|
||||
):
|
||||
entries = _build_anthropic_entries()
|
||||
# Only one entry survives — opus.
|
||||
|
||||
@@ -12,9 +12,10 @@ def test_sonnet_alias_resolves_to_sonnet_5() -> None:
|
||||
assert MODEL_MAP["sonnet"] == "claude-sonnet-5"
|
||||
|
||||
|
||||
def test_opus_alias_unchanged() -> None:
|
||||
# CEO preference: stay on Opus 4.6 (4.7/4.8 not preferred).
|
||||
assert MODEL_MAP["opus"] == "claude-opus-4-6"
|
||||
def test_opus_alias_resolves_to_opus_4_8() -> None:
|
||||
# CEO upgrade to the newest Opus tier ("Opus 5" does not exist — the
|
||||
# Claude 5 family is Fable/Mythos/Sonnet; Opus tops out at 4.8).
|
||||
assert MODEL_MAP["opus"] == "claude-opus-4-8"
|
||||
|
||||
|
||||
def test_sonnet_5_is_priced() -> None:
|
||||
@@ -23,6 +24,12 @@ def test_sonnet_5_is_priced() -> None:
|
||||
assert cost > 0.0
|
||||
|
||||
|
||||
def test_opus_is_priced() -> None:
|
||||
# Same silent-$0 guard for the opus alias.
|
||||
cost = calculate_cost(MODEL_MAP["opus"], tokens_input=_M, tokens_output=0)
|
||||
assert cost > 0.0
|
||||
|
||||
|
||||
def test_no_lifecycle_role_routes_below_the_structured_verb_floor() -> None:
|
||||
# Retired haiku from lifecycle roles (2026-07-24): a haiku QA/documenter
|
||||
# can't emit the structured review envelopes (criteria_verified, findings)
|
||||
|
||||
@@ -56,7 +56,7 @@ def _spec(**overrides: Any) -> _IntakeRunSpec:
|
||||
},
|
||||
"session_id": "sess-abc",
|
||||
"cwd": "/data/workspaces/roboco/board/intake-1",
|
||||
"cli_model": "claude-opus-4-6",
|
||||
"cli_model": "claude-opus-4-8",
|
||||
"api_url": "http://roboco-orchestrator:8000",
|
||||
"provider_base_url": None,
|
||||
"provider_auth_token": None,
|
||||
@@ -108,7 +108,7 @@ class TestBuildIntakeRunCmd:
|
||||
assert "ROBOCO_WORKSPACE=/data/workspaces/roboco/board/intake-1" in cmd
|
||||
assert "ROBOCO_API_URL=http://roboco-orchestrator:8000" in cmd
|
||||
assert "ROBOCO_AGENT_ID=intake-1" in cmd
|
||||
assert "CLAUDE_CODE_SUBAGENT_MODEL=claude-opus-4-6" in cmd
|
||||
assert "CLAUDE_CODE_SUBAGENT_MODEL=claude-opus-4-8" in cmd
|
||||
|
||||
def test_mounts_prompt_and_workspaces(self) -> None:
|
||||
cmd = AgentOrchestrator._build_intake_run_cmd(_spec())
|
||||
|
||||
@@ -99,7 +99,7 @@ class TestBuildManifestForAgent:
|
||||
mock_settings.manifest_host_dir = str(tmp_path)
|
||||
mock_settings.workspaces_root = str(tmp_path / "workspaces")
|
||||
|
||||
result = _build_manifest_for_agent("be-dev-1", "claude-opus-4-6")
|
||||
result = _build_manifest_for_agent("be-dev-1", "claude-opus-4-8")
|
||||
|
||||
assert result is not None
|
||||
data = json.loads(result.read_text())
|
||||
|
||||
@@ -87,7 +87,7 @@ class TestBuildForRole:
|
||||
role=role,
|
||||
team="board",
|
||||
workspace_path=Path("/tmp/x"),
|
||||
agent_model="claude-opus-4-6",
|
||||
agent_model="claude-opus-4-8",
|
||||
)
|
||||
)
|
||||
assert "read_messages" in m.do_tools
|
||||
|
||||
@@ -182,7 +182,7 @@ async def test_log_agent_event_resolves_slug_to_uuid(
|
||||
event_type="agent.spawned",
|
||||
agent_slug=slug,
|
||||
task_id=task_id,
|
||||
details={"container_id": "abc123def456", "model": "claude-opus-4-6"},
|
||||
details={"container_id": "abc123def456", "model": "claude-opus-4-8"},
|
||||
)
|
||||
|
||||
# Read back the row.
|
||||
|
||||
@@ -945,7 +945,7 @@ class TestGetRecentSessions:
|
||||
row = MagicMock()
|
||||
row.id = sid
|
||||
row.agent_slug = "product-owner"
|
||||
row.model = "claude-opus-4-6"
|
||||
row.model = "claude-opus-4-8"
|
||||
row.started_at = datetime.datetime(2026, 6, 11, 20, 41, tzinfo=datetime.UTC)
|
||||
row.ended_at = datetime.datetime(2026, 6, 11, 20, 42, tzinfo=datetime.UTC)
|
||||
row.tokens_input = exp_in
|
||||
@@ -966,7 +966,7 @@ class TestGetRecentSessions:
|
||||
s = out[0]
|
||||
assert s["id"] == str(sid)
|
||||
assert s["agent_slug"] == "product-owner"
|
||||
assert s["model"] == "claude-opus-4-6"
|
||||
assert s["model"] == "claude-opus-4-8"
|
||||
assert s["tokens_input"] == exp_in
|
||||
assert s["tokens_output"] == exp_out
|
||||
assert s["tokens_cache"] == exp_cr + exp_cw
|
||||
|
||||
Reference in New Issue
Block a user