@@ -4,15 +4,22 @@ export interface GatewayModel {
44 context_window : number ;
55 supports_streaming : boolean ;
66 supports_vision : boolean ;
7+ // Free-tier model gate: authenticated fetches mark models outside the
8+ // caller's plan `allowed: false`. Anonymous fetches and older gateways
9+ // don't mark, so absence means allowed.
10+ allowed : boolean ;
11+ restriction_reason ?: string | null ;
712}
813
914interface GatewayModelsResponse {
1015 object : "list" ;
11- data : GatewayModel [ ] ;
16+ data : Array < Omit < GatewayModel , "allowed" > & { allowed ?: boolean } > ;
1217}
1318
1419export interface FetchGatewayModelsOptions {
1520 gatewayUrl : string ;
21+ /** Bearer token; required for accurate free-tier marks. */
22+ authToken ?: string ;
1623}
1724
1825export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-8" ;
@@ -42,12 +49,19 @@ export function isBlockedModelId(modelId: string): boolean {
4249 return BLOCKED_MODELS . has ( modelId . toLowerCase ( ) ) ;
4350}
4451
52+ interface ModelsListEntry {
53+ id ?: string ;
54+ owned_by ?: string ;
55+ allowed ?: boolean ;
56+ restriction_reason ?: string | null ;
57+ }
58+
4559type ModelsListResponse =
4660 | {
47- data ?: Array < { id ?: string ; owned_by ?: string } > ;
48- models ?: Array < { id ?: string ; owned_by ?: string } > ;
61+ data ?: ModelsListEntry [ ] ;
62+ models ?: ModelsListEntry [ ] ;
4963 }
50- | Array < { id ?: string ; owned_by ?: string } > ;
64+ | ModelsListEntry [ ] ;
5165
5266const CACHE_TTL = 10 * 60 * 1000 ; // 10 minutes
5367
@@ -57,11 +71,29 @@ const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
5771// the callers fall through to `return []`.
5872const GATEWAY_FETCH_TIMEOUT_MS = 10_000 ;
5973
60- let gatewayModelsCache : {
61- models : GatewayModel [ ] ;
74+ // Authed and anonymous responses differ (free-tier marks are authed-only),
75+ // so cache entries are keyed on auth presence.
76+ interface ModelsCache < T > {
77+ models : T [ ] ;
6278 expiry : number ;
6379 url : string ;
64- } | null = null ;
80+ authed : boolean ;
81+ }
82+
83+ function readModelsCache < T > (
84+ cache : ModelsCache < T > | null ,
85+ url : string ,
86+ authed : boolean ,
87+ ) : T [ ] | null {
88+ if ( ! cache || cache . url !== url || cache . authed !== authed ) return null ;
89+ return Date . now ( ) < cache . expiry ? cache . models : null ;
90+ }
91+
92+ function authHeaders ( authToken ?: string ) : Record < string , string > | undefined {
93+ return authToken ? { Authorization : `Bearer ${ authToken } ` } : undefined ;
94+ }
95+
96+ let gatewayModelsCache : ModelsCache < GatewayModel > | null = null ;
6597
6698export async function fetchGatewayModels (
6799 options ?: FetchGatewayModelsOptions ,
@@ -71,18 +103,15 @@ export async function fetchGatewayModels(
71103 return [ ] ;
72104 }
73105
74- if (
75- gatewayModelsCache &&
76- gatewayModelsCache . url === gatewayUrl &&
77- Date . now ( ) < gatewayModelsCache . expiry
78- ) {
79- return gatewayModelsCache . models ;
80- }
106+ const authed = Boolean ( options ?. authToken ) ;
107+ const cached = readModelsCache ( gatewayModelsCache , gatewayUrl , authed ) ;
108+ if ( cached ) return cached ;
81109
82110 const modelsUrl = `${ gatewayUrl } /v1/models` ;
83111
84112 try {
85113 const response = await fetch ( modelsUrl , {
114+ headers : authHeaders ( options ?. authToken ) ,
86115 signal : AbortSignal . timeout ( GATEWAY_FETCH_TIMEOUT_MS ) ,
87116 } ) ;
88117
@@ -91,11 +120,14 @@ export async function fetchGatewayModels(
91120 }
92121
93122 const data = ( await response . json ( ) ) as GatewayModelsResponse ;
94- const models = ( data . data ?? [ ] ) . filter ( ( m ) => ! isBlockedModelId ( m . id ) ) ;
123+ const models = ( data . data ?? [ ] )
124+ . filter ( ( m ) => ! isBlockedModelId ( m . id ) )
125+ . map ( ( m ) => ( { ...m , allowed : m . allowed !== false } ) ) ;
95126 gatewayModelsCache = {
96127 models,
97128 expiry : Date . now ( ) + CACHE_TTL ,
98129 url : gatewayUrl ,
130+ authed,
99131 } ;
100132 return models ;
101133 } catch {
@@ -136,13 +168,11 @@ export function isCloudflareModel(model: GatewayModel): boolean {
136168export interface ModelInfo {
137169 id : string ;
138170 owned_by ?: string ;
171+ allowed : boolean ;
172+ restriction_reason ?: string | null ;
139173}
140174
141- let modelsListCache : {
142- models : ModelInfo [ ] ;
143- expiry : number ;
144- url : string ;
145- } | null = null ;
175+ let modelsListCache : ModelsCache < ModelInfo > | null = null ;
146176
147177export async function fetchModelsList (
148178 options ?: FetchGatewayModelsOptions ,
@@ -152,17 +182,14 @@ export async function fetchModelsList(
152182 return [ ] ;
153183 }
154184
155- if (
156- modelsListCache &&
157- modelsListCache . url === gatewayUrl &&
158- Date . now ( ) < modelsListCache . expiry
159- ) {
160- return modelsListCache . models ;
161- }
185+ const authed = Boolean ( options ?. authToken ) ;
186+ const cached = readModelsCache ( modelsListCache , gatewayUrl , authed ) ;
187+ if ( cached ) return cached ;
162188
163189 try {
164190 const modelsUrl = `${ gatewayUrl } /v1/models` ;
165191 const response = await fetch ( modelsUrl , {
192+ headers : authHeaders ( options ?. authToken ) ,
166193 signal : AbortSignal . timeout ( GATEWAY_FETCH_TIMEOUT_MS ) ,
167194 } ) ;
168195 if ( ! response . ok ) {
@@ -177,19 +204,48 @@ export async function fetchModelsList(
177204 const id = model ?. id ? String ( model . id ) : "" ;
178205 if ( ! id ) continue ;
179206 if ( isBlockedModelId ( id ) ) continue ;
180- results . push ( { id, owned_by : model ?. owned_by } ) ;
207+ results . push ( {
208+ id,
209+ owned_by : model ?. owned_by ,
210+ allowed : model ?. allowed !== false ,
211+ restriction_reason : model ?. restriction_reason ?? null ,
212+ } ) ;
181213 }
182214 modelsListCache = {
183215 models : results ,
184216 expiry : Date . now ( ) + CACHE_TTL ,
185217 url : gatewayUrl ,
218+ authed,
186219 } ;
187220 return results ;
188221 } catch {
189222 return [ ] ;
190223 }
191224}
192225
226+ /**
227+ * The model a session should start on: the preferred id when present and
228+ * allowed, else the newest allowed model — a free-tier org must not default
229+ * onto a model that 403s its first message. Falls back to the preferred id
230+ * when the list is empty (fetch failed) or nothing is allowed (all locked —
231+ * the picker gate communicates that state better than a silent swap).
232+ */
233+ export function pickAllowedModel (
234+ models : ReadonlyArray < Pick < GatewayModel , "id" | "allowed" > > ,
235+ preferred : string ,
236+ ) : string {
237+ if ( models . length === 0 ) return preferred ;
238+ const preferredEntry = models . find ( ( m ) => m . id === preferred ) ;
239+ if ( ! preferredEntry || preferredEntry . allowed ) return preferred ;
240+ const allowed = models . filter ( ( m ) => m . allowed ) ;
241+ if ( allowed . length === 0 ) return preferred ;
242+ return allowed . reduce ( ( best , candidate ) =>
243+ getClaudeModelRecency ( candidate . id ) >= getClaudeModelRecency ( best . id )
244+ ? candidate
245+ : best ,
246+ ) . id ;
247+ }
248+
193249const PROVIDER_NAMES : Record < string , string > = {
194250 anthropic : "Anthropic" ,
195251 openai : "OpenAI" ,
0 commit comments