diff --git a/apps/api/models.json b/apps/api/models.json index 92437960..200e2c93 100644 --- a/apps/api/models.json +++ b/apps/api/models.json @@ -86,16 +86,16 @@ "endpoint": "https://api.anthropic.com/v1", "api_path": "/messages", "models": { - "default": "claude-3-haiku-20240307", - "rca": "claude-3-haiku-20240307", - "summary": "claude-3-haiku-20240307" + "default": "claude-haiku-4-5-20251001", + "rca": "claude-haiku-4-5-20251001", + "summary": "claude-haiku-4-5-20251001" }, "options": { "max_tokens": 2048 }, "timeout_seconds": 30, "cost": { - "per_1k_tokens": 0.008, + "per_1k_tokens": 0.005, "currency": "USD" }, "auth": { diff --git a/apps/api/src/services/action_parser.py b/apps/api/src/services/action_parser.py new file mode 100644 index 00000000..4bb16fca --- /dev/null +++ b/apps/api/src/services/action_parser.py @@ -0,0 +1,373 @@ +""" +Structured safety parser for remediation actions. + +SPF-2 replaces the old single-regex kubectl whitelist with a small token parser. +The parser intentionally supports only the kubectl forms AWOOOI can safely +auto-execute; anything outside that grammar falls back to human review. +""" + +from __future__ import annotations + +import shlex +from dataclasses import dataclass, field +from enum import StrEnum + +KUBECTL_MAX_LEN = 500 + +_FORBIDDEN_RAW_CHARS = frozenset("\n\r\t\f\v;&|<>`$") +_SAFE_TOKEN_CHARS = frozenset( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789" + "-_./:=,@" +) + +_RESOURCE_ALIASES: dict[str, str] = { + "deploy": "deployment", + "deployment": "deployment", + "deployments": "deployment", + "ds": "daemonset", + "daemonset": "daemonset", + "daemonsets": "daemonset", + "pod": "pod", + "pods": "pod", + "po": "pod", + "service": "service", + "services": "service", + "svc": "service", + "statefulset": "statefulset", + "statefulsets": "statefulset", + "sts": "statefulset", + "configmap": "configmap", + "configmaps": "configmap", + "cm": "configmap", + "node": "node", + "nodes": "node", +} + +_ROLLING_RESOURCES = frozenset({"deployment", "statefulset", "daemonset"}) +_SCALABLE_RESOURCES = frozenset({"deployment", "statefulset"}) +_READONLY_VERBS = frozenset({"get", "describe", "logs", "top", "version"}) +_MUTATING_VERBS = frozenset({"rollout", "scale", "delete"}) + + +class ActionKind(StrEnum): + """High-level parsed action kind.""" + + READONLY = "readonly" + ROLLOUT = "rollout" + SCALE = "scale" + DELETE_POD = "delete_pod" + + +@dataclass(frozen=True) +class ParsedKubectlAction: + """Parsed kubectl action with safety decision.""" + + ok: bool + reason: str + kind: ActionKind | None = None + verb: str | None = None + subverb: str | None = None + resource_type: str | None = None + resource_name: str | None = None + namespace: str | None = None + flags: tuple[str, ...] = field(default_factory=tuple) + + +def is_safe_kubectl_action(command: str) -> bool: + """Return True when the command is in the allowed kubectl action grammar.""" + + return parse_kubectl_action(command).ok + + +def parse_kubectl_action(command: str) -> ParsedKubectlAction: + """Parse and validate a kubectl command for auto-execute safety. + + The grammar is intentionally narrow: + - readonly: get/describe/logs/top/version with bounded, known-safe flags + - rollout: rollout restart/undo on workload resources + - scale: scale deployment/statefulset to a positive replica count + - delete: delete one pod by name only + """ + + command = (command or "").strip() + if not command: + return _reject("empty") + if len(command) > KUBECTL_MAX_LEN: + return _reject("too_long") + if any(ch in command for ch in _FORBIDDEN_RAW_CHARS): + return _reject("forbidden_shell_metachar") + if any(ord(ch) < 32 or ord(ch) > 126 for ch in command): + return _reject("non_ascii_or_control") + + try: + tokens = shlex.split(command, posix=True) + except ValueError: + return _reject("invalid_shell_syntax") + + if not tokens or tokens[0] != "kubectl": + return _reject("not_kubectl") + if any(not token or not set(token) <= _SAFE_TOKEN_CHARS for token in tokens): + return _reject("invalid_token_chars") + + body = tokens[1:] + namespace, body, namespace_flags = _consume_namespace_flags(body) + if not body: + return _reject("missing_verb") + + verb = body[0] + rest = body[1:] + if verb in _READONLY_VERBS: + return _parse_readonly(verb, rest, namespace, namespace_flags) + if verb == "rollout": + return _parse_rollout(rest, namespace, namespace_flags) + if verb == "scale": + return _parse_scale(rest, namespace, namespace_flags) + if verb == "delete": + return _parse_delete(rest, namespace, namespace_flags) + return _reject("unsupported_verb") + + +def _reject(reason: str) -> ParsedKubectlAction: + return ParsedKubectlAction(ok=False, reason=reason) + + +def _consume_namespace_flags(tokens: list[str]) -> tuple[str | None, list[str], list[str]]: + namespace: str | None = None + remaining: list[str] = [] + namespace_flags: list[str] = [] + i = 0 + while i < len(tokens): + token = tokens[i] + if token in {"-n", "--namespace"}: + if i + 1 >= len(tokens): + remaining.append(token) + i += 1 + continue + namespace = tokens[i + 1] + namespace_flags.extend([token, tokens[i + 1]]) + i += 2 + continue + if token.startswith("--namespace="): + namespace = token.split("=", 1)[1] + namespace_flags.append(token) + i += 1 + continue + remaining.append(token) + i += 1 + return namespace, remaining, namespace_flags + + +def _normalize_resource(value: str) -> str: + return _RESOURCE_ALIASES.get(value.lower(), value.lower()) + + +def _split_resource_ref(tokens: list[str]) -> tuple[str | None, str | None, list[str]]: + if not tokens: + return None, None, [] + + first = tokens[0] + if "/" in first: + resource_type, resource_name = first.split("/", 1) + return _normalize_resource(resource_type), resource_name, tokens[1:] + + resource_type = _normalize_resource(first) + if len(tokens) >= 2 and not tokens[1].startswith("-"): + return resource_type, tokens[1], tokens[2:] + return resource_type, None, tokens[1:] + + +def _parse_readonly( + verb: str, + tokens: list[str], + namespace: str | None, + namespace_flags: list[str], +) -> ParsedKubectlAction: + if verb == "version": + if tokens: + return _reject("version_disallows_args") + return ParsedKubectlAction( + ok=True, + reason="ok", + kind=ActionKind.READONLY, + verb=verb, + namespace=namespace, + flags=tuple(namespace_flags), + ) + + resource_type, resource_name, rest = _split_resource_ref(tokens) + if not resource_type: + return _reject("missing_readonly_resource") + + allowed_flags = { + "--all-namespaces", + "--no-headers", + "--output", + "-o", + "--previous", + "--show-labels", + "--since", + "--since-time", + "--tail", + "--timestamps", + "--selector", + "-l", + "--container", + "-c", + "--watch", + "-w", + } + if not _flags_allowed(rest, allowed_flags): + return _reject("unsupported_readonly_flag") + + return ParsedKubectlAction( + ok=True, + reason="ok", + kind=ActionKind.READONLY, + verb=verb, + resource_type=resource_type, + resource_name=resource_name, + namespace=namespace, + flags=tuple(namespace_flags + rest), + ) + + +def _parse_rollout( + tokens: list[str], + namespace: str | None, + namespace_flags: list[str], +) -> ParsedKubectlAction: + if len(tokens) < 2: + return _reject("rollout_missing_args") + subverb = tokens[0] + if subverb not in {"restart", "undo"}: + return _reject("unsupported_rollout_subverb") + + resource_type, resource_name, rest = _split_resource_ref(tokens[1:]) + if resource_type not in _ROLLING_RESOURCES or not resource_name: + return _reject("invalid_rollout_resource") + if rest: + return _reject("unsupported_rollout_flag") + + return ParsedKubectlAction( + ok=True, + reason="ok", + kind=ActionKind.ROLLOUT, + verb="rollout", + subverb=subverb, + resource_type=resource_type, + resource_name=resource_name, + namespace=namespace, + flags=tuple(namespace_flags), + ) + + +def _parse_scale( + tokens: list[str], + namespace: str | None, + namespace_flags: list[str], +) -> ParsedKubectlAction: + resource_type, resource_name, rest = _split_resource_ref(tokens) + if resource_type not in _SCALABLE_RESOURCES or not resource_name: + return _reject("invalid_scale_resource") + + replicas: int | None = None + remaining_flags: list[str] = [] + i = 0 + while i < len(rest): + token = rest[i] + if token == "--replicas": + if i + 1 >= len(rest): + return _reject("replicas_missing_value") + replicas = _parse_positive_int(rest[i + 1]) + remaining_flags.extend([token, rest[i + 1]]) + i += 2 + continue + if token.startswith("--replicas="): + replicas = _parse_positive_int(token.split("=", 1)[1]) + remaining_flags.append(token) + i += 1 + continue + return _reject("unsupported_scale_flag") + + if replicas is None: + return _reject("replicas_required") + if replicas < 1: + return _reject("replicas_must_be_positive") + + return ParsedKubectlAction( + ok=True, + reason="ok", + kind=ActionKind.SCALE, + verb="scale", + resource_type=resource_type, + resource_name=resource_name, + namespace=namespace, + flags=tuple(namespace_flags + remaining_flags), + ) + + +def _parse_delete( + tokens: list[str], + namespace: str | None, + namespace_flags: list[str], +) -> ParsedKubectlAction: + resource_type, resource_name, rest = _split_resource_ref(tokens) + if resource_type != "pod" or not resource_name: + return _reject("delete_only_allows_single_pod") + if rest: + return _reject("unsupported_delete_flag") + if resource_name in {"--all", "all"}: + return _reject("delete_all_disallowed") + + return ParsedKubectlAction( + ok=True, + reason="ok", + kind=ActionKind.DELETE_POD, + verb="delete", + resource_type=resource_type, + resource_name=resource_name, + namespace=namespace, + flags=tuple(namespace_flags), + ) + + +def _parse_positive_int(value: str) -> int: + if not value.isdigit(): + return -1 + return int(value) + + +def _flags_allowed(tokens: list[str], allowed_flags: set[str]) -> bool: + i = 0 + while i < len(tokens): + token = tokens[i] + if not token.startswith("-"): + return False + + flag = token.split("=", 1)[0] + if flag not in allowed_flags: + return False + if "=" in token: + i += 1 + continue + + requires_value = flag in { + "--output", + "-o", + "--since", + "--since-time", + "--tail", + "--selector", + "-l", + "--container", + "-c", + } + if requires_value: + if i + 1 >= len(tokens) or tokens[i + 1].startswith("-"): + return False + i += 2 + continue + i += 1 + return True diff --git a/apps/api/src/services/ai_providers/claude.py b/apps/api/src/services/ai_providers/claude.py index 6efe8d6a..b09e1546 100644 --- a/apps/api/src/services/ai_providers/claude.py +++ b/apps/api/src/services/ai_providers/claude.py @@ -1,7 +1,7 @@ """ Claude Provider - Phase 24 ADR-052 ==================================== -Anthropic Claude API (claude-3-haiku, Tool Use 強制 JSON) +Anthropic Claude API (Claude Haiku 4.5, Tool Use 強制 JSON) 搬移自: openclaw.py _call_claude (L474-550) 特性: 最強推理、昂貴、CRITICAL/DELETE 強制使用 @@ -18,7 +18,7 @@ import httpx import structlog from src.core.config import get_settings -from src.services.ai_providers.interfaces import AIProvider, AIResult, is_provider_enabled_by_env +from src.services.ai_providers.interfaces import AIResult, is_provider_enabled_by_env from src.services.model_registry import get_model_registry logger = structlog.get_logger(__name__) @@ -125,8 +125,8 @@ class ClaudeProvider: input_tokens = usage.get("input_tokens", 0) output_tokens = usage.get("output_tokens", 0) total_tokens = input_tokens + output_tokens - # Claude Haiku: Input $0.25/1M, Output $1.25/1M - cost_usd = (input_tokens * 0.00000025) + (output_tokens * 0.00000125) + # Claude Haiku 4.5: Input $1/1M, Output $5/1M + cost_usd = (input_tokens * 0.000001) + (output_tokens * 0.000005) # 從 Tool Use 回應中提取 JSON for block in data.get("content", []): diff --git a/apps/api/src/services/decision_manager.py b/apps/api/src/services/decision_manager.py index fda6ccfe..6db38e13 100644 --- a/apps/api/src/services/decision_manager.py +++ b/apps/api/src/services/decision_manager.py @@ -33,6 +33,7 @@ from src.core.config import settings from src.core.redis_client import get_redis from src.models.incident import Incident from src.models.playbook import SymptomPattern +from src.services.action_parser import parse_kubectl_action from src.services.auto_approve import get_auto_approve_policy from src.services.openclaw import get_openclaw from src.services.playbook_service import get_playbook_service @@ -52,19 +53,6 @@ def _fire_and_forget(coro) -> asyncio.Task: task.add_done_callback(_background_tasks.discard) return task -# P1 fix 2026-04-11: kubectl action dangerous char whitelist -# 2026-04-25 ogt + Claude Sonnet 4.6: 補允許 K8s resource type keyword -# LLM 常輸出 "kubectl rollout restart deployment clickhouse" (含 deployment 關鍵字) -# 原 pattern 只允許 verb 後直接接名稱,導致 _action_safe=False → 全部被攔截 → 飛輪 0 -# 修法:在名稱前加可選的 resource type group (deployment/pod/service/...) -import re as _re_module -_ALLOWED_KUBECTL_PATTERN = _re_module.compile( - r"^kubectl\s+(rollout restart|rollout undo|scale|delete pod|get|describe|logs)" - r"\s+(?:(?:deployment|pod|pods|service|services|statefulset|sts|daemonset|ds|svc|configmap|cm)\s+)?" - r"[a-zA-Z0-9_./-]+(\s+(-n|--namespace)\s+[a-zA-Z0-9_-]+)?$", - _re_module.ASCII, -) - # ============================================================================= # Phase 31 (ADR-067 2026-04-10): Log 異常摘要 — NemoTron deepseek-r1:14b @@ -1944,15 +1932,17 @@ class DecisionManager: # 另外:若 target 等於 alertname,代表 LLM 把告警名稱填入 deployment_name,也拒絕 _alertname = incident.signals[0].labels.get("alertname", "") if incident.signals else "" _target_is_alertname = bool(_alertname and _target == _alertname) - # P1 fix 2026-04-11: kubectl action 危險字元白名單 — 防止 && || ; > | 注入 - _action_safe = bool(_ALLOWED_KUBECTL_PATTERN.match(action.strip())) + # SPF-2: structured kubectl parser replaces the old single-regex guard. + _action_parse = parse_kubectl_action(action.strip()) + _action_safe = _action_parse.ok if "unknown" in action or _re.search(r"[<{][^>}]+[>}]", action) or _target_is_alertname or not _action_safe: logger.warning( "auto_execute_blocked_unresolved_placeholder", incident_id=incident.incident_id, action=action, target=_target, - reason="action 含未解析的 placeholder、unknown、target==alertname、或危險字元,拒絕執行", + parser_reason=_action_parse.reason, + reason="action 含未解析的 placeholder、unknown、target==alertname、或未通過 kubectl action parser,拒絕執行", ) # Safety guard 攔截 → 降級為人工審核,而非「修復失敗」 # 2026-04-11 Claude Sonnet 4.6: 不發 ❌ 失敗訊息,改發人工審核卡片 diff --git a/apps/api/src/services/model_registry.py b/apps/api/src/services/model_registry.py index 2d89fa66..03e9321e 100644 --- a/apps/api/src/services/model_registry.py +++ b/apps/api/src/services/model_registry.py @@ -137,9 +137,9 @@ class ModelRegistry: }, "claude": { "models": { - "default": "claude-3-haiku-20240307", - "rca": "claude-3-haiku-20240307", - "summary": "claude-3-haiku-20240307", + "default": "claude-haiku-4-5-20251001", + "rca": "claude-haiku-4-5-20251001", + "summary": "claude-haiku-4-5-20251001", } }, # 2026-03-29 ogt: P2-3 加入 NVIDIA (ADR-036) @@ -191,7 +191,7 @@ class ModelRegistry: fallback_map = { "ollama": "qwen2.5:7b-instruct", "gemini": "gemini-2.0-flash", - "claude": "claude-3-haiku-20240307", + "claude": "claude-haiku-4-5-20251001", } model = fallback_map.get(provider, provider) logger.warning( diff --git a/apps/api/src/services/model_version_probe.py b/apps/api/src/services/model_version_probe.py index 827299d7..3ab08427 100644 --- a/apps/api/src/services/model_version_probe.py +++ b/apps/api/src/services/model_version_probe.py @@ -135,7 +135,7 @@ async def probe_gemini_version() -> ProviderVersionInfo: # ============================================================================= async def probe_claude_version() -> ProviderVersionInfo: - """Claude:model name 即版本識別(例如 "claude-sonnet-4-6") + """Claude:model name 即版本識別(例如 "claude-haiku-4-5-20251001") Anthropic 沒有 list models endpoint(截至 2026-04), 以設定中的 claude model name 作為版本字串。 @@ -151,9 +151,7 @@ async def probe_claude_version() -> ProviderVersionInfo: if not api_key: raise RuntimeError("CLAUDE_API_KEY not configured") - # Claude model name 從 AI_FALLBACK_ORDER 的 claude provider 取 - # 直接使用已知 model name 作為版本(Claude 不提供公開版本 API) - model_name = "claude-sonnet-4-6" # 與 settings 中 ai_router 的 claude model 對齊 + model_name = "claude-haiku-4-5-20251001" return ProviderVersionInfo( provider="claude", @@ -249,7 +247,7 @@ async def probe_all_providers() -> list[ProviderVersionInfo]: results: list[ProviderVersionInfo] = [] provider_labels = ["ollama", "ollama_188", "gemini", "claude", "openclaw_nemo"] - for label, outcome in zip(provider_labels, raw): + for label, outcome in zip(provider_labels, raw, strict=True): if isinstance(outcome, ProviderVersionInfo): results.append(outcome) else: diff --git a/apps/api/src/services/ollama_failover_manager.py b/apps/api/src/services/ollama_failover_manager.py index b9aa722c..6da11393 100644 --- a/apps/api/src/services/ollama_failover_manager.py +++ b/apps/api/src/services/ollama_failover_manager.py @@ -124,7 +124,7 @@ _GEMINI_ENDPOINT = OllamaEndpoint( _CLAUDE_ENDPOINT = OllamaEndpoint( url="", provider_name="claude", - model="claude-3-5-haiku-20241022", + model="claude-haiku-4-5-20251001", ) diff --git a/apps/api/src/services/openclaw.py b/apps/api/src/services/openclaw.py index 278514fd..31b8a54b 100644 --- a/apps/api/src/services/openclaw.py +++ b/apps/api/src/services/openclaw.py @@ -552,7 +552,7 @@ class OpenClawService: "content-type": "application/json", }, json={ - "model": "claude-3-haiku-20240307", + "model": get_model_registry().get_model("claude", "rca"), "max_tokens": 2048, "messages": [{"role": "user", "content": prompt}], "tools": [{ diff --git a/apps/api/src/services/token_counter.py b/apps/api/src/services/token_counter.py index 1e2f4be2..3a566824 100644 --- a/apps/api/src/services/token_counter.py +++ b/apps/api/src/services/token_counter.py @@ -52,7 +52,7 @@ logger = structlog.get_logger(__name__) COST_PER_1K_TOKENS = { "ollama": 0.0, # 本地免費 "gemini": 0.001, # Gemini 1.5 Flash - "claude": 0.008, # Claude 3 Haiku + "claude": 0.005, # Claude Haiku 4.5 conservative output-side estimate } # 預算閾值 (from models.json monitoring.alerts) diff --git a/apps/api/tests/test_action_parser_safety.py b/apps/api/tests/test_action_parser_safety.py new file mode 100644 index 00000000..66cb128a --- /dev/null +++ b/apps/api/tests/test_action_parser_safety.py @@ -0,0 +1,67 @@ +""" +SPF-2 structured action parser safety tests. + +These tests cover the auto-execute kubectl grammar without mocking services. +""" + +import pytest + +from src.services.action_parser import ( + ActionKind, + is_safe_kubectl_action, + parse_kubectl_action, +) + + +@pytest.mark.parametrize("cmd", [ + "kubectl rollout restart deployment/awoooi-api -n awoooi-prod", + "kubectl rollout restart deployment awoooi-api -n awoooi-prod", + "kubectl -n awoooi-prod rollout restart deploy/awoooi-api", + "kubectl scale deployment awoooi-api --replicas=3 -n awoooi-prod", + "kubectl delete pod awoooi-api-7d6b776f78-4sgjl -n awoooi-prod", + "kubectl get pods -n awoooi-prod", + "kubectl describe node k3s-node-01", + "kubectl logs -n awoooi-prod --tail=100 --selector=app=awoooi-api", + "kubectl top pods -n awoooi-prod", +]) +def test_safe_kubectl_actions_pass(cmd): + assert is_safe_kubectl_action(cmd) is True + + +@pytest.mark.parametrize("cmd", [ + "kubectl get pods\nrm -rf /", + "kubectl get pods -n prod $(echo injected)", + "kubectl rollout restart deployment/$(cat /etc/passwd)", + "kubectl rollout restart deployment/awoooi-api; rm -rf / -n prod", + "kubectl get pods -n prod && curl http://attacker.invalid", + "kubectl delete deployment awoooi-api -n awoooi-prod", + "kubectl delete pods --all -n awoooi-prod", + "kubectl scale deployment awoooi-api --replicas=0 -n awoooi-prod", + "kubectl patch deployment awoooi-api -p spec -n awoooi-prod", + "ssh 192.168.0.188 docker restart openclaw", +]) +def test_unsafe_kubectl_actions_fail(cmd): + parsed = parse_kubectl_action(cmd) + assert parsed.ok is False, parsed + + +def test_parser_returns_structured_fields(): + parsed = parse_kubectl_action( + "kubectl -n awoooi-prod rollout restart deployment/awoooi-api" + ) + + assert parsed.ok is True + assert parsed.kind is ActionKind.ROLLOUT + assert parsed.verb == "rollout" + assert parsed.subverb == "restart" + assert parsed.resource_type == "deployment" + assert parsed.resource_name == "awoooi-api" + assert parsed.namespace == "awoooi-prod" + + +def test_parser_rejects_newline_without_regex_backtracking(): + cmd = "kubectl get pods" + (" " * 400) + "\nrm -rf /" + parsed = parse_kubectl_action(cmd) + + assert parsed.ok is False + assert parsed.reason == "forbidden_shell_metachar" diff --git a/apps/api/tests/test_ai_router_failover_integration.py b/apps/api/tests/test_ai_router_failover_integration.py index 8cab0543..5cef7c8e 100644 --- a/apps/api/tests/test_ai_router_failover_integration.py +++ b/apps/api/tests/test_ai_router_failover_integration.py @@ -117,7 +117,7 @@ async def test_router_failover_fallback_chain_converted(): fallback=[ ("ollama_188", "qwen2.5:7b-instruct"), ("nemotron", "nvidia/nemotron-mini-4b-instruct"), - ("claude", "claude-3-5-haiku-20241022"), + ("claude", "claude-haiku-4-5-20251001"), ], ) ) diff --git a/apps/api/tests/test_model_version_probe.py b/apps/api/tests/test_model_version_probe.py index bf1a438e..72f32c75 100644 --- a/apps/api/tests/test_model_version_probe.py +++ b/apps/api/tests/test_model_version_probe.py @@ -14,7 +14,6 @@ model_version_probe 單元測試 """ from __future__ import annotations -import json from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch @@ -329,7 +328,7 @@ class TestProbeAllProviders: ProviderVersionInfo(provider="ollama", model="qwen2.5:7b-instruct", version="v1"), ProviderVersionInfo(provider="ollama_188", model="qwen2.5:7b-instruct", version="v1"), ProviderVersionInfo(provider="gemini", model="gemini-1.5-flash", version="gemini-1.5-flash"), - ProviderVersionInfo(provider="claude", model="claude-sonnet-4-6", version="claude-sonnet-4-6"), + ProviderVersionInfo(provider="claude", model="claude-haiku-4-5-20251001", version="claude-haiku-4-5-20251001"), ProviderVersionInfo(provider="openclaw_nemo", model="deepseek-r1:14b", version="v1"), ] @@ -365,7 +364,7 @@ class TestProbeAllProviders: with patch("src.services.model_version_probe.probe_ollama_version", side_effect=_fail_ollama), \ patch("src.services.model_version_probe.probe_gemini_version", side_effect=_fail), \ patch("src.services.model_version_probe.probe_claude_version", return_value=ProviderVersionInfo( - provider="claude", model="claude-sonnet-4-6", version="claude-sonnet-4-6" + provider="claude", model="claude-haiku-4-5-20251001", version="claude-haiku-4-5-20251001" )), \ patch("src.services.model_version_probe.probe_openclaw_nemo_version", return_value=ProviderVersionInfo( provider="openclaw_nemo", model="deepseek-r1:14b", version="v1"