From 10872c67112bd07b54d95baca4a126d7b011d892 Mon Sep 17 00:00:00 2001 From: Anteriousis Date: Sat, 5 Sep 2026 10:20:03 -0500 Subject: [PATCH 1/2] Cache Anthropic model capabilities across provider turns --- src/Mod/VibeCAD/VibeCADProvider.py | 98 ++++++++++++--- .../vibecad_tests/test_provider_subprocess.py | 115 ++++++++++++++++++ 2 files changed, 196 insertions(+), 17 deletions(-) diff --git a/src/Mod/VibeCAD/VibeCADProvider.py b/src/Mod/VibeCAD/VibeCADProvider.py index 36947fca..d44fc516 100644 --- a/src/Mod/VibeCAD/VibeCADProvider.py +++ b/src/Mod/VibeCAD/VibeCADProvider.py @@ -1930,6 +1930,46 @@ def run( raise + +# Parent-process cache: provider objects and subprocesses are recreated per turn. +_ANTHROPIC_CAPABILITY_CACHE: dict[tuple[str, str], tuple[float, int]] = {} +_ANTHROPIC_CAPABILITY_CACHE_LOCK = threading.Lock() +_ANTHROPIC_CAPABILITY_CACHE_TTL = 300.0 +_ANTHROPIC_CAPABILITY_CACHE_SIZE = 64 + + +def _anthropic_capability_scope(api_key: str | None, base_url: str | None) -> str: + # Keep credentials and credential-bearing endpoints out of cache keys/events. + identity = [ + base_url or os.environ.get("ANTHROPIC_BASE_URL") or "https://api.anthropic.com", + api_key or os.environ.get("ANTHROPIC_API_KEY", ""), + os.environ.get("ANTHROPIC_AUTH_TOKEN", ""), + ] + return hashlib.sha256(json.dumps(identity).encode("utf-8")).hexdigest() + + +def _anthropic_cached_capabilities(scope: str, models: set[str]) -> dict[str, int]: + with _ANTHROPIC_CAPABILITY_CACHE_LOCK: + now = time.monotonic() + expired = [key for key, (stamp, _) in _ANTHROPIC_CAPABILITY_CACHE.items() + if now - stamp >= _ANTHROPIC_CAPABILITY_CACHE_TTL] + for key in expired: + del _ANTHROPIC_CAPABILITY_CACHE[key] + return {model: _ANTHROPIC_CAPABILITY_CACHE[(scope, model)][1] + for model in models if (scope, model) in _ANTHROPIC_CAPABILITY_CACHE} + + +def _anthropic_cache_capability(scope: str, model: str, maximum: Any) -> None: + if type(maximum) is not int or maximum <= 0: + return + with _ANTHROPIC_CAPABILITY_CACHE_LOCK: + key = (scope, model) + _ANTHROPIC_CAPABILITY_CACHE.pop(key, None) + _ANTHROPIC_CAPABILITY_CACHE[key] = (time.monotonic(), maximum) + while len(_ANTHROPIC_CAPABILITY_CACHE) > _ANTHROPIC_CAPABILITY_CACHE_SIZE: + del _ANTHROPIC_CAPABILITY_CACHE[next(iter(_ANTHROPIC_CAPABILITY_CACHE))] + + class AnthropicProvider(BaseProvider): """Native Anthropic Messages API adapter. @@ -1968,11 +2008,26 @@ def run( progress_callback: ProgressCallback | None = None, ) -> ProviderResult: try: + scope = _anthropic_capability_scope(self.api_key, self.base_url) + models = {self.model, self.compaction_model} provider_context = dict(context) - provider_context["_vibecad_provider_options"] = { + options = dict(context.get("_vibecad_provider_options") or {}) + options.update({ "web_search_enabled": self.web_search_enabled, "compaction_model": self.compaction_model, - } + "model_capabilities": _anthropic_cached_capabilities(scope, models), + }) + provider_context["_vibecad_provider_options"] = options + + def on_progress(event: dict[str, Any]) -> None: + if event.get("event") == "anthropic_model_capability": + if event.get("model") in models: + _anthropic_cache_capability( + scope, event["model"], event.get("max_tokens") + ) + return + if progress_callback is not None: + progress_callback(event) return _run_provider_subprocess( prompt=prompt, context=provider_context, @@ -1984,7 +2039,7 @@ def run( max_turns=self.max_turns, base_url=self.base_url, cancellation_check=cancellation_check, - progress_callback=progress_callback, + progress_callback=on_progress, child_main=_anthropic_child_main, provider_label="Anthropic provider", ) @@ -5872,20 +5927,27 @@ def build_tool_surface( if timeout_seconds is not None and timeout_seconds > 0: client_kwargs["timeout"] = timeout_seconds client = anthropic.Anthropic(**client_kwargs) - max_tokens = _anthropic_model_max_tokens( - client, - model, - sdk_fallback=DEFAULT_ANTHROPIC_MAX_TOKENS, - ) - compaction_max_tokens = ( - max_tokens - if compaction_model == model - else _anthropic_model_max_tokens( - client, - compaction_model, - sdk_fallback=ANTHROPIC_TURN_COMPACTION_MAX_TOKENS, + cached = _provider_option_value(live_context, "model_capabilities") + capabilities = dict(cached) if isinstance(cached, dict) else {} + + def model_max_tokens(model_id: str, fallback: int) -> int: + maximum = capabilities.get(model_id) + if type(maximum) is int and maximum > 0: + return maximum + maximum = _anthropic_model_max_tokens( + client, model_id, sdk_fallback=fallback ) - ) + capabilities[model_id] = maximum + # Older SDK fallback values are not model-reported capabilities. + if callable(getattr(getattr(client, "models", None), "retrieve", None)): + _send_child_progress(conn, { + "event": "anthropic_model_capability", + "model": model_id, + "max_tokens": maximum, + }) + return maximum + + max_tokens = model_max_tokens(model, DEFAULT_ANTHROPIC_MAX_TOKENS) request_kwargs: dict[str, Any] = { "model": model, @@ -6143,7 +6205,9 @@ def _stream_response_with_retries(turn: int) -> Any: debug_context=live_context, base_url=base_url, generation=compaction_count, - max_tokens=compaction_max_tokens, + max_tokens=model_max_tokens( + compaction_model, ANTHROPIC_TURN_COMPACTION_MAX_TOKENS + ), ) messages = [ { diff --git a/src/Mod/VibeCAD/vibecad_tests/test_provider_subprocess.py b/src/Mod/VibeCAD/vibecad_tests/test_provider_subprocess.py index b5615e69..6b88892a 100644 --- a/src/Mod/VibeCAD/vibecad_tests/test_provider_subprocess.py +++ b/src/Mod/VibeCAD/vibecad_tests/test_provider_subprocess.py @@ -2691,3 +2691,118 @@ def test_partdesign_does_not_inject_a_model_manifest_at_turn_start( assert "partdesign" not in context assert "vibescript" not in context assert context["editable_sources"]["domain"] == "partdesign" + +@pytest.mark.parametrize("change", ["none", "model", "endpoint", "auth", "expired"]) +def test_anthropic_capabilities_survive_new_provider_instances(monkeypatch, change): + monkeypatch.setattr(provider, "_ANTHROPIC_CAPABILITY_CACHE", {}, raising=False) + now = [10.0] + monkeypatch.setattr(provider.time, "monotonic", lambda: now[0]) + snapshots = [] + def run(**kwargs): + options = kwargs["context"]["_vibecad_provider_options"] + snapshots.append(options.get("model_capabilities", {})) + kwargs["progress_callback"]({ + "event": "anthropic_model_capability", + "model": kwargs["model"], "max_tokens": 128000, + }) + return provider.ProviderResult(final_output="done", raw=None) + monkeypatch.setattr(provider, "_run_provider_subprocess", run) + config = {"model": "primary", "api_key": "one", "base_url": "https://one.test"} + original = {"_vibecad_provider_options": {"custom": True}} + provider.AnthropicProvider(**config).run("hello", original) + if change == "model": config["model"] = "other" + if change == "endpoint": config["base_url"] = "https://two.test" + if change == "auth": config["api_key"] = "two" + if change == "expired": now[0] += 301 + provider.AnthropicProvider(**config).run("hello", original) + assert snapshots == [{}, {"primary": 128000} if change == "none" else {}] + assert original == {"_vibecad_provider_options": {"custom": True}} + + +@pytest.mark.parametrize("cached", [False, True]) +def test_anthropic_defers_unused_compaction_capabilities(monkeypatch, cached): + requests = [] + def retrieve(model): + requests.append(model) + assert model == "primary", "unused compaction metadata was fetched" + return SimpleNamespace(max_tokens=128000) + messages = _SequenceAnthropicMessages([ + SimpleNamespace(content=[SimpleNamespace(type="text", text="Done")], stop_reason="end_turn") + ]) + monkeypatch.setitem(sys.modules, "anthropic", SimpleNamespace( + Anthropic=lambda **kw: SimpleNamespace( + messages=messages, models=SimpleNamespace(retrieve=retrieve)), + BadRequestError=type("BadRequestError", (Exception,), {}), + )) + monkeypatch.setattr(provider, "_validate_provider_wire_surface", lambda c: None) + conn = _CollectingConnection() + provider._anthropic_child_main(conn, "hello", { + "provider_tool_schemas": [], + "_vibecad_provider_options": { + "compaction_model": "unused", + "model_capabilities": {"primary": 128000} if cached else {}, + }, + }, "primary", "key", None, 1.0, 2, False) + assert conn.messages[-1]["type"] == "done" + assert requests == ([] if cached else ["primary"]) + assert messages.requests[0]["max_tokens"] == 128000 + +@pytest.mark.parametrize("maximum", [None, 0, -1, "broken", True]) +def test_anthropic_capability_cache_rejects_invalid_values(monkeypatch, maximum): + monkeypatch.setattr(provider, "_ANTHROPIC_CAPABILITY_CACHE", {}) + provider._anthropic_cache_capability("scope", "model", maximum) + assert provider._anthropic_cached_capabilities("scope", {"model"}) == {} + + +def test_anthropic_capability_cache_is_bounded_and_does_not_extend_ttl(monkeypatch): + monkeypatch.setattr(provider, "_ANTHROPIC_CAPABILITY_CACHE", {}) + now = [1.0] + monkeypatch.setattr(provider.time, "monotonic", lambda: now[0]) + for i in range(70): + provider._anthropic_cache_capability("scope", str(i), 128000) + assert len(provider._ANTHROPIC_CAPABILITY_CACHE) == 64 + assert provider._anthropic_cached_capabilities("scope", {"0", "69"}) == {"69": 128000} + now[0] = 300 + assert provider._anthropic_cached_capabilities("scope", {"69"}) + now[0] = 301 + assert provider._anthropic_cached_capabilities("scope", {"69"}) == {} + + +def test_anthropic_capability_scope_tracks_environment_without_storing_credentials(monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "first-secret") + first = provider._anthropic_capability_scope(None, None) + monkeypatch.setenv("ANTHROPIC_API_KEY", "second-secret") + second = provider._anthropic_capability_scope(None, None) + assert first != second + explicit = provider._anthropic_capability_scope("explicit-secret", None) + monkeypatch.setenv("ANTHROPIC_API_KEY", "third-secret") + assert provider._anthropic_capability_scope("explicit-secret", None) == explicit + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://another.test") + assert provider._anthropic_capability_scope("explicit-secret", None) != explicit + assert len(first) == len(second) == 64 + assert "secret" not in first + second + + +@pytest.mark.parametrize("kind", ["failed", "invalid", "older_sdk"]) +def test_anthropic_non_capability_values_are_not_reported_for_cache(monkeypatch, kind): + def retrieve(model): + if kind == "failed": + raise RuntimeError("lookup failed") + return SimpleNamespace(max_tokens=0) + messages = _SequenceAnthropicMessages([ + SimpleNamespace(content=[SimpleNamespace(type="text", text="Done")], stop_reason="end_turn") + ]) + client = SimpleNamespace(messages=messages) + if kind != "older_sdk": + client.models = SimpleNamespace(retrieve=retrieve) + monkeypatch.setitem(sys.modules, "anthropic", SimpleNamespace( + Anthropic=lambda **kw: client, + BadRequestError=type("BadRequestError", (Exception,), {}), + )) + monkeypatch.setattr(provider, "_validate_provider_wire_surface", lambda c: None) + conn = _CollectingConnection() + provider._anthropic_child_main(conn, "hello", {"provider_tool_schemas": []}, + "primary", "key", None, 1.0, 2, False) + assert not any(m.get("event", {}).get("event") == "anthropic_model_capability" + for m in conn.messages if m.get("type") == "progress") + assert conn.messages[-1]["type"] == ("done" if kind == "older_sdk" else "error") From 080c99d24e793f1c0d57e840a188e8b900ee81ac Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 18:20:19 -0500 Subject: [PATCH 2/2] Preserve retries for deferred Anthropic metadata lookup --- src/Mod/VibeCAD/VibeCADProvider.py | 14 ++++- .../test_anthropic_retry_budget.py | 63 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/Mod/VibeCAD/VibeCADProvider.py b/src/Mod/VibeCAD/VibeCADProvider.py index 09134937..33015c50 100644 --- a/src/Mod/VibeCAD/VibeCADProvider.py +++ b/src/Mod/VibeCAD/VibeCADProvider.py @@ -5974,9 +5974,17 @@ def model_max_tokens(model_id: str, fallback: int) -> int: maximum = capabilities.get(model_id) if type(maximum) is int and maximum > 0: return maximum - maximum = _anthropic_model_max_tokens( - client, model_id, sdk_fallback=fallback - ) + # Deferred metadata runs between generations on this child-owned + # client. Restore its SDK retry allowance only for the lookup; + # streamed generations retain the outer-loop-only retry policy. + stream_retries = getattr(client, "max_retries", client_kwargs["max_retries"]) + try: + client.max_retries = client_kwargs["max_retries"] + maximum = _anthropic_model_max_tokens( + client, model_id, sdk_fallback=fallback + ) + finally: + client.max_retries = stream_retries capabilities[model_id] = maximum # Older SDK fallback values are not model-reported capabilities. if callable(getattr(getattr(client, "models", None), "retrieve", None)): diff --git a/src/Mod/VibeCAD/vibecad_tests/test_anthropic_retry_budget.py b/src/Mod/VibeCAD/vibecad_tests/test_anthropic_retry_budget.py index 86239bec..586c3e20 100644 --- a/src/Mod/VibeCAD/vibecad_tests/test_anthropic_retry_budget.py +++ b/src/Mod/VibeCAD/vibecad_tests/test_anthropic_retry_budget.py @@ -193,3 +193,66 @@ def test_stream_status_retry_limit_survives_mixed_failures(run_transport): ) assert len(requests) == 5 assert messages[-1]["type"] == "error" + + +def test_deferred_compaction_metadata_retains_retries(monkeypatch): + real_client = anthropic.Anthropic + metadata = [] + generations = [] + clients = [] + + def handle(request): + if request.method == "GET": + model = request.url.path.rsplit("/", 1)[-1] + metadata.append(model) + if model == "compact" and metadata.count(model) < 3: + return httpx.Response(503, json={ + "type": "error", "error": { + "type": "overloaded_error", "message": "Temporary" + }, + }) + return httpx.Response(200, json={ + "id": model, "type": "model", "display_name": model, + "created_at": "2026-01-01T00:00:00Z", "max_tokens": 8192, + }) + generations.append(clients[0].max_retries) + events = b"".join(_response_events()) + if len(generations) == 1: + events = events.replace(b'"end_turn"', b'"max_tokens"') + return httpx.Response(200, headers={"content-type": "text/event-stream"}, + content=events) + + def make_client(**kwargs): + client = real_client( + **kwargs, http_client=httpx.Client(transport=httpx.MockTransport(handle)) + ) + clients.append(client) + return client + + messages = [] + + class Connection: + def send(self, message): + messages.append(message) + + def close(self): + pass + + monkeypatch.setattr(anthropic, "Anthropic", make_client) + monkeypatch.setattr(provider, "_validate_provider_wire_surface", lambda _: None) + monkeypatch.setattr(provider.time, "sleep", lambda _: None) + monkeypatch.setattr(provider, "_anthropic_compact_turn_in_thread", + lambda **_: {"current_request": "Finish the model."}) + try: + provider._anthropic_child_main( + Connection(), "Finish the model.", { + "provider_tool_schemas": [], + "_vibecad_provider_options": {"compaction_model": "compact"}, + }, "primary", "fake-key", None, 1.0, 2, False, + ) + finally: + for client in clients: + client.close() + assert metadata == ["primary", "compact", "compact", "compact"] + assert generations == [0, 0] + assert messages[-1]["type"] == "done"