33from __future__ import annotations
44
55import json
6+ import sys
67import threading
8+ import time
79from http import HTTPStatus
810from urllib .parse import parse_qsl , urlencode , urlsplit , urlunsplit
911
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
1923class _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+
90167class _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
109208def 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
0 commit comments