Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 81 additions & 17 deletions src/Mod/VibeCAD/VibeCADProvider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand All @@ -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",
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [
{
Expand Down
115 changes: 115 additions & 0 deletions src/Mod/VibeCAD/vibecad_tests/test_provider_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading