Skip to content

Commit 87bd21f

Browse files
committed
Cache Claude gateway model discovery
1 parent 2ad807d commit 87bd21f

5 files changed

Lines changed: 243 additions & 4 deletions

File tree

src/ucode/agents/claude.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,7 @@ def _launch_gateway(state: dict, binary: str, tool_args: list[str]) -> None:
10441044
0,
10451045
token_header=AUTHORIZATION_HEADER,
10461046
force_refresh_near_expiry=True,
1047+
prefetch_models=True,
10471048
)
10481049
token = cache.token
10491050
os.environ["OAUTH_TOKEN"] = token

src/ucode/anthropic_model_discovery_proxy.py

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
from __future__ import annotations
44

55
import json
6+
import sys
67
import threading
8+
import time
79
from http import HTTPStatus
810
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
911

@@ -14,6 +16,8 @@
1416
_MODEL_ALIAS_PREFIX = "anthropic-aigw-"
1517
_ANTHROPIC_MODELS_PATH = "/v1/models"
1618
_ANTHROPIC_MESSAGES_PATH = "/v1/messages"
19+
_MODEL_DISCOVERY_LIMIT = 1000
20+
_MODEL_CACHE_REFRESH_S = 600
1721

1822

1923
class _AnthropicModelAliases:
@@ -87,8 +91,82 @@ def rewrite_body(self, path: str, body: bytes | None) -> bytes | None:
8791
return json.dumps(payload, separators=(",", ":")).encode()
8892

8993

94+
class _ModelCache:
95+
"""Caches the complete model list so Claude's discovery request is local."""
96+
97+
def __init__(self, aliases: _AnthropicModelAliases) -> None:
98+
self._aliases = aliases
99+
self._body: bytes | None = None
100+
self._lock = threading.Lock()
101+
102+
def refresh(self, client: httpx.Client, token: str, token_header: str) -> None:
103+
headers = {
104+
token_header: f"Bearer {token}",
105+
"anthropic-version": "2023-06-01",
106+
}
107+
models: list[object] = []
108+
first_page: dict[str, object] | None = None
109+
last_page: dict[str, object] | None = None
110+
after_id: str | None = None
111+
seen_cursors: set[str] = set()
112+
113+
while True:
114+
params: dict[str, str | int] = {"limit": _MODEL_DISCOVERY_LIMIT}
115+
if after_id is not None:
116+
params["after_id"] = after_id
117+
response = client.get("v1/models", headers=headers, params=params)
118+
response.raise_for_status()
119+
payload = response.json()
120+
if not isinstance(payload, dict) or not isinstance(payload.get("data"), list):
121+
raise ValueError("invalid model discovery response")
122+
if first_page is None:
123+
first_page = payload
124+
last_page = payload
125+
models.extend(payload["data"])
126+
if not payload.get("has_more"):
127+
break
128+
after_id = payload.get("last_id")
129+
if not isinstance(after_id, str) or after_id in seen_cursors:
130+
raise ValueError("invalid model discovery cursor")
131+
seen_cursors.add(after_id)
132+
133+
combined = dict(first_page or {})
134+
combined["data"] = models
135+
combined["has_more"] = False
136+
if last_page is not None:
137+
combined["last_id"] = last_page.get("last_id")
138+
body = self._aliases.prefix_model_ids(json.dumps(combined, separators=(",", ":")).encode())
139+
with self._lock:
140+
self._body = body
141+
142+
def get(self, method: str, path: str) -> bytes | None:
143+
parsed = urlsplit(path)
144+
if method != "GET" or parsed.path != _ANTHROPIC_MODELS_PATH:
145+
return None
146+
if any(
147+
key in {"after_id", "before_id"}
148+
for key, _value in parse_qsl(parsed.query, keep_blank_values=True)
149+
):
150+
return None
151+
with self._lock:
152+
return self._body
153+
154+
def run_refresher(
155+
self,
156+
client: httpx.Client,
157+
token_cache: gateway_proxy._TokenCache,
158+
token_header: str,
159+
) -> None:
160+
while not token_cache.wait_until_stopped(_MODEL_CACHE_REFRESH_S):
161+
try:
162+
self.refresh(client, token_cache.token, token_header)
163+
except Exception: # noqa: BLE001 - refresh failure must not kill the thread
164+
continue
165+
166+
90167
class _AnthropicModelDiscoveryHandler(gateway_proxy._ProxyHandler):
91168
anthropic_model_aliases: _AnthropicModelAliases
169+
model_cache: _ModelCache
92170

93171
def _transform_request(self, body: bytes | None) -> tuple[str, bytes | None]:
94172
body = self.anthropic_model_aliases.rewrite_body(self.path, body)
@@ -105,20 +183,62 @@ def _transform_response(self, resp: httpx.Response) -> bytes | None:
105183
return None
106184
return self.anthropic_model_aliases.prefix_model_ids(resp.read())
107185

186+
def _handle_cached_response(self, diagnostic_id: str, started: float) -> bool:
187+
cached_models = self.model_cache.get(self.command, self.path)
188+
if cached_models is None:
189+
return False
190+
try:
191+
self.send_response(HTTPStatus.OK)
192+
self.send_header("Content-Type", "application/json")
193+
self.send_header("Content-Length", str(len(cached_models)))
194+
self.end_headers()
195+
self.wfile.write(cached_models)
196+
self.wfile.flush()
197+
gateway_proxy._diagnostic_log(
198+
"model_cache_hit",
199+
request_id=diagnostic_id,
200+
bytes=len(cached_models),
201+
elapsed_ms=round((time.monotonic() - started) * 1000),
202+
)
203+
except (BrokenPipeError, ConnectionResetError):
204+
pass
205+
return True
206+
108207

109208
def start_proxy(
110209
workspace: str,
111210
profile: str | None,
112211
port: int,
113212
token_header: str,
114213
force_refresh_near_expiry: bool,
214+
prefetch_models: bool = False,
115215
):
116-
return gateway_proxy._start_proxy(
216+
aliases = _AnthropicModelAliases()
217+
model_cache = _ModelCache(aliases)
218+
server, token_cache, client = gateway_proxy._start_proxy(
117219
workspace,
118220
profile,
119221
port,
120222
token_header,
121223
force_refresh_near_expiry,
122224
handler_class=_AnthropicModelDiscoveryHandler,
123-
handler_attributes={"anthropic_model_aliases": _AnthropicModelAliases()},
225+
handler_attributes={
226+
"anthropic_model_aliases": aliases,
227+
"model_cache": model_cache,
228+
},
124229
)
230+
if prefetch_models:
231+
try:
232+
model_cache.refresh(client, token_cache.token, token_header)
233+
except (httpx.HTTPError, TypeError, ValueError) as exc:
234+
sys.stderr.write(
235+
"[ucode] Claude model prefetch failed "
236+
f"({type(exc).__name__}); falling back to live discovery.\n"
237+
)
238+
model_refresher = threading.Thread(
239+
target=model_cache.run_refresher,
240+
args=(client, token_cache, token_header),
241+
daemon=True,
242+
)
243+
model_refresher.start()
244+
return server, token_cache, client

src/ucode/gateway_proxy.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ def run_refresher(self) -> None:
183183
def stop(self) -> None:
184184
self._stop.set()
185185

186+
def wait_until_stopped(self, timeout: float) -> bool:
187+
return self._stop.wait(timeout)
188+
186189

187190
def _forwarded_request_headers(
188191
handler: BaseHTTPRequestHandler,
@@ -220,6 +223,9 @@ def _transform_request(self, body: bytes | None) -> tuple[str, bytes | None]:
220223
def _transform_response(self, resp: httpx.Response) -> bytes | None:
221224
return None
222225

226+
def _handle_cached_response(self, diagnostic_id: str, started: float) -> bool:
227+
return False
228+
223229
def _handle(self) -> None:
224230
diagnostic_id = uuid.uuid4().hex[:12]
225231
started = time.monotonic()
@@ -232,6 +238,8 @@ def _handle(self) -> None:
232238
method=self.command,
233239
path=self.path.split("?", 1)[0],
234240
)
241+
if self._handle_cached_response(diagnostic_id, started):
242+
return
235243
try:
236244
# First attempt with the current token.
237245
headers = _forwarded_request_headers(self, self.cache.token, self.token_header)

tests/test_agent_claude.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,14 @@ def __init__(self, argv):
695695
def wait(self):
696696
return 0
697697

698-
def start_proxy(workspace, profile, port, token_header, force_refresh_near_expiry):
698+
def start_proxy(
699+
workspace,
700+
profile,
701+
port,
702+
token_header,
703+
force_refresh_near_expiry,
704+
prefetch_models,
705+
):
699706
calls.append(
700707
(
701708
"proxy",
@@ -704,6 +711,7 @@ def start_proxy(workspace, profile, port, token_header, force_refresh_near_expir
704711
port,
705712
token_header,
706713
force_refresh_near_expiry,
714+
prefetch_models,
707715
)
708716
)
709717
return Server(), Cache(), Client()
@@ -725,7 +733,7 @@ def start_proxy(workspace, profile, port, token_header, force_refresh_near_expir
725733
assert os.environ["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:12345"
726734
assert os.environ["CLAUDE_CODE_USE_GATEWAY"] == "1"
727735
assert calls[:2] == [
728-
("proxy", WS, "test", 0, claude.AUTHORIZATION_HEADER, True),
736+
("proxy", WS, "test", 0, claude.AUTHORIZATION_HEADER, True, True),
729737
("serve",),
730738
]
731739
assert calls[2][0] == "popen"

tests/test_anthropic_model_discovery_proxy.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ def _handler(wfile, path="/v1/models", command="GET"):
6565
handler.path = path
6666
handler._headers_buffer = []
6767
handler.anthropic_model_aliases = anthropic_model_discovery_proxy._AnthropicModelAliases()
68+
handler.model_cache = anthropic_model_discovery_proxy._ModelCache(
69+
handler.anthropic_model_aliases
70+
)
6871
return handler
6972

7073

@@ -147,6 +150,83 @@ def test_leaves_malformed_discovery_response_unchanged(self):
147150
assert aliases.prefix_model_ids(b"not-json") == b"not-json"
148151

149152

153+
class _DiscoveryResponse:
154+
def __init__(self, payload: dict):
155+
self._payload = payload
156+
157+
def raise_for_status(self):
158+
return None
159+
160+
def json(self):
161+
return self._payload
162+
163+
164+
class _DiscoveryClient:
165+
def __init__(self, pages: list[dict]):
166+
self._pages = iter(pages)
167+
self.requests: list[tuple[str, dict[str, str], dict[str, str | int]]] = []
168+
169+
def get(self, path, *, headers, params):
170+
self.requests.append((path, headers, params))
171+
return _DiscoveryResponse(next(self._pages))
172+
173+
174+
class TestModelCache:
175+
def test_prefetches_all_pages_and_serves_one_aliased_response(self):
176+
aliases = anthropic_model_discovery_proxy._AnthropicModelAliases()
177+
cache = anthropic_model_discovery_proxy._ModelCache(aliases)
178+
client = _DiscoveryClient(
179+
[
180+
{
181+
"data": [{"id": "catalog.schema.one"}],
182+
"has_more": True,
183+
"first_id": "catalog.schema.one",
184+
"last_id": "catalog.schema.one",
185+
},
186+
{
187+
"data": [{"id": "system.ai.claude-sonnet"}],
188+
"has_more": False,
189+
"first_id": "system.ai.claude-sonnet",
190+
"last_id": "system.ai.claude-sonnet",
191+
},
192+
]
193+
)
194+
195+
cache.refresh(
196+
client,
197+
"token",
198+
anthropic_model_discovery_proxy.gateway_proxy.AUTHORIZATION_HEADER,
199+
)
200+
201+
payload = json.loads(cache.get("GET", "/v1/models?limit=1000"))
202+
assert [model["id"] for model in payload["data"]] == [
203+
"anthropic-aigw-catalog.schema.one",
204+
"system.ai.claude-sonnet",
205+
]
206+
assert payload["has_more"] is False
207+
assert payload["last_id"] == "system.ai.claude-sonnet"
208+
assert [request[2] for request in client.requests] == [
209+
{"limit": 1000},
210+
{"limit": 1000, "after_id": "catalog.schema.one"},
211+
]
212+
213+
def test_only_serves_first_page_get_requests(self):
214+
cache = anthropic_model_discovery_proxy._ModelCache(
215+
anthropic_model_discovery_proxy._AnthropicModelAliases()
216+
)
217+
cache.refresh(
218+
_DiscoveryClient([{"data": [], "has_more": False}]),
219+
"token",
220+
anthropic_model_discovery_proxy.gateway_proxy.AUTHORIZATION_HEADER,
221+
)
222+
223+
assert cache.get("GET", "/v1/models") is not None
224+
assert cache.get("POST", "/v1/models") is None
225+
assert cache.get("GET", "/v1/models?after_id=cursor") is None
226+
assert cache.get("GET", "/v1/models?before_id=cursor") is None
227+
assert cache.get("GET", "/v1/messages") is None
228+
229+
150230
class TestAnthropicModelDiscoveryHandler:
151231
def test_inherits_relayed_auth_and_prefixes_models(self):
152232
out = _Collect()
@@ -164,6 +244,24 @@ def test_inherits_relayed_auth_and_prefixes_models(self):
164244
assert headers["X-Databricks-AI-Gateway-Token"] == "Bearer databricks-token"
165245
assert b"anthropic-aigw-custom-model" in bytes(out.data)
166246

247+
def test_serves_cached_models_without_upstream_request(self):
248+
out = _Collect()
249+
handler = _handler(out)
250+
handler.headers = {}
251+
handler.rfile = io.BytesIO()
252+
handler.cache = _FakeCache()
253+
handler.client = _FakeClient(None)
254+
handler.model_cache.refresh(
255+
_DiscoveryClient([{"data": [{"id": "system.ai.claude"}], "has_more": False}]),
256+
"token",
257+
anthropic_model_discovery_proxy.gateway_proxy.AUTHORIZATION_HEADER,
258+
)
259+
260+
handler._handle()
261+
262+
assert handler.client.request is None
263+
assert b"system.ai.claude" in bytes(out.data)
264+
167265
def test_prefixes_successful_model_response_and_drops_content_encoding(self):
168266
out = _Collect()
169267
handler = _handler(out)
@@ -222,3 +320,7 @@ def start(*args, **kwargs):
222320
call["kwargs"]["handler_attributes"]["anthropic_model_aliases"],
223321
anthropic_model_discovery_proxy._AnthropicModelAliases,
224322
)
323+
assert isinstance(
324+
call["kwargs"]["handler_attributes"]["model_cache"],
325+
anthropic_model_discovery_proxy._ModelCache,
326+
)

0 commit comments

Comments
 (0)