1+ import { getLogger } from "@intx/log" ;
2+ import { LOG_NAMESPACE_ROOT } from "../branding.js" ;
13import {
24 endpointErrorMessage ,
3- parseModelsData ,
45 type ModelsEndpointDiscoveryState ,
5- requestModelsEndpoint ,
66} from "./models-endpoint.js" ;
77
8+ const catalogLogger = getLogger ( [ LOG_NAMESPACE_ROOT , "provider" , "catalog" ] ) ;
9+
810export type CatalogDiscoveryState = ModelsEndpointDiscoveryState ;
911
1012function declaredCatalogBytes ( response : Response ) : number | undefined {
@@ -20,20 +22,24 @@ async function readBoundedCatalogText(
2022 args : { readonly maxBytes : number ; readonly oversizeMessage : string } ,
2123) : Promise <
2224 | { readonly ok : true ; readonly text : string }
23- | { readonly ok : false ; readonly message : string }
25+ | {
26+ readonly ok : false ;
27+ readonly message : string ;
28+ readonly transport : boolean ;
29+ }
2430> {
2531 const declared = declaredCatalogBytes ( response ) ;
2632 if ( declared !== undefined && declared > args . maxBytes ) {
2733 await response . body ?. cancel ( ) . catch ( ( ) => undefined ) ;
28- return { ok : false , message : args . oversizeMessage } ;
34+ return { ok : false , message : args . oversizeMessage , transport : false } ;
2935 }
3036
3137 try {
3238 const body = response . body ;
3339 if ( body === null ) {
3440 const text = await response . text ( ) ;
3541 if ( new TextEncoder ( ) . encode ( text ) . byteLength > args . maxBytes ) {
36- return { ok : false , message : args . oversizeMessage } ;
42+ return { ok : false , message : args . oversizeMessage , transport : false } ;
3743 }
3844 return { ok : true , text } ;
3945 }
@@ -48,7 +54,7 @@ async function readBoundedCatalogText(
4854 total += value . byteLength ;
4955 if ( total > args . maxBytes ) {
5056 await reader . cancel ( ) . catch ( ( ) => undefined ) ;
51- return { ok : false , message : args . oversizeMessage } ;
57+ return { ok : false , message : args . oversizeMessage , transport : false } ;
5258 }
5359 chunks . push ( value ) ;
5460 }
@@ -61,29 +67,47 @@ async function readBoundedCatalogText(
6167 }
6268 return { ok : true , text : new TextDecoder ( ) . decode ( buffer ) } ;
6369 } catch ( error ) {
64- return { ok : false , message : endpointErrorMessage ( error ) } ;
70+ return {
71+ ok : false ,
72+ message : endpointErrorMessage ( error ) ,
73+ transport : true ,
74+ } ;
6575 }
6676}
6777
68- export function createBoundedModelCatalog ( args : {
69- baseURL : string ;
70- seedIds : readonly string [ ] ;
78+ type DiscoveredModels = Extract <
79+ CatalogDiscoveryState ,
80+ { status : "models" }
81+ > [ "models" ] ;
82+
83+ export function createBoundedModelCatalog <
84+ Seed extends readonly string [ ] ,
85+ > ( args : {
86+ request : ( opts : {
87+ timeoutMs ?: number ;
88+ signal ?: AbortSignal ;
89+ } ) => Promise < Response > ;
90+ parse : (
91+ raw : unknown ,
92+ cap : { readonly maxModels : number ; readonly oversizeMessage : string } ,
93+ ) => CatalogDiscoveryState ;
94+ seedIds : Seed ;
7195 catalogLabel : string ;
7296 maxBytes : number ;
7397 maxModels : number ;
7498} ) : {
75- discoverModels : ( args ?: {
99+ discoverModels : ( opts ?: {
76100 timeoutMs ?: number ;
77101 signal ?: AbortSignal ;
78102 } ) => Promise < CatalogDiscoveryState > ;
79- selectableModelIds : ( ) => readonly string [ ] ;
80- prefetchModels : ( ) => Promise < readonly string [ ] > ;
103+ selectableModelIds : ( ) => Seed | DiscoveredModels ;
104+ prefetchModels : ( ) => Promise < Seed | DiscoveredModels > ;
81105 resetDiscoveryForTests : ( ) => void ;
82106} {
83- const { baseURL , seedIds, catalogLabel, maxBytes, maxModels } = args ;
107+ const { request , parse , seedIds, catalogLabel, maxBytes, maxModels } = args ;
84108
85- let inflight : Promise < readonly string [ ] > | undefined ;
86- let snapshot : readonly string [ ] | undefined ;
109+ let inflight : Promise < Seed | DiscoveredModels > | undefined ;
110+ let snapshot : DiscoveredModels | undefined ;
87111
88112 function oversizeMessage ( kind : "bytes" | "models" ) : string {
89113 if ( kind === "bytes" ) {
@@ -92,11 +116,13 @@ export function createBoundedModelCatalog(args: {
92116 return `${ catalogLabel } catalog exceeds ${ String ( maxModels ) } models` ;
93117 }
94118
95- async function readCatalogJson (
96- response : Response ,
97- ) : Promise <
119+ async function readCatalogJson ( response : Response ) : Promise <
98120 | { readonly ok : true ; readonly value : unknown }
99- | { readonly ok : false ; readonly message : string }
121+ | {
122+ readonly ok : false ;
123+ readonly message : string ;
124+ readonly transport : boolean ;
125+ }
100126 > {
101127 const text = await readBoundedCatalogText ( response , {
102128 maxBytes,
@@ -107,20 +133,25 @@ export function createBoundedModelCatalog(args: {
107133 const value : unknown = JSON . parse ( text . text ) ;
108134 return { ok : true , value } ;
109135 } catch ( error ) {
110- return { ok : false , message : endpointErrorMessage ( error ) } ;
136+ return {
137+ ok : false ,
138+ message : endpointErrorMessage ( error ) ,
139+ transport : false ,
140+ } ;
111141 }
112142 }
113143
114- async function discoverModels ( args ?: {
144+ async function discoverModels ( opts ?: {
115145 timeoutMs ?: number ;
116146 signal ?: AbortSignal ;
117147 } ) : Promise < CatalogDiscoveryState > {
118148 let response : Response ;
119149 try {
120- response = await requestModelsEndpoint ( {
121- baseURL,
122- ...( args ?. timeoutMs !== undefined ? { timeoutMs : args . timeoutMs } : { } ) ,
123- ...( args ?. signal !== undefined ? { signal : args . signal } : { } ) ,
150+ // Forward only the transport knobs so a caller's extra keys can never
151+ // reach the request and override its URL or headers.
152+ response = await request ( {
153+ ...( opts ?. timeoutMs !== undefined ? { timeoutMs : opts . timeoutMs } : { } ) ,
154+ ...( opts ?. signal !== undefined ? { signal : opts . signal } : { } ) ,
124155 } ) ;
125156 } catch ( error ) {
126157 return {
@@ -138,29 +169,72 @@ export function createBoundedModelCatalog(args: {
138169
139170 const body = await readCatalogJson ( response ) ;
140171 if ( ! body . ok ) {
141- return { status : "malformed" , message : body . message } ;
172+ // A connection that drops or times out mid-body never delivered an
173+ // answer; only a body that arrived and fails the contract is malformed.
174+ return {
175+ status : body . transport ? "unavailable" : "malformed" ,
176+ message : body . message ,
177+ } ;
142178 }
143- return parseModelsData ( body . value , {
179+ return parse ( body . value , {
144180 maxModels,
145181 oversizeMessage : oversizeMessage ( "models" ) ,
146182 } ) ;
147183 }
148184
149- function selectableModelIds ( ) : readonly string [ ] {
185+ function selectableModelIds ( ) : Seed | DiscoveredModels {
150186 return snapshot ?? seedIds ;
151187 }
152188
153- async function runPrefetch ( ) : Promise < readonly string [ ] > {
189+ async function runPrefetch ( ) : Promise < Seed | DiscoveredModels > {
154190 const state = await discoverModels ( ) ;
155191 // Empty/unavailable/malformed leave the successful snapshot in place:
156192 // stale-but-live beats empty; a cold failure falls back to the seed.
157193 if ( state . status === "models" ) {
158194 snapshot = state . models ;
195+ } else {
196+ logFallback ( state ) ;
159197 }
160198 return selectableModelIds ( ) ;
161199 }
162200
163- function prefetchModels ( ) : Promise < readonly string [ ] > {
201+ // An unreachable catalog is routine (offline, transient outage), so it
202+ // stays at debug. An empty or malformed answer means the endpoint's
203+ // contract moved, which only shows up here, so it warns.
204+ function logFallback (
205+ state : Exclude < CatalogDiscoveryState , { status : "models" } > ,
206+ ) : void {
207+ const fallback =
208+ snapshot === undefined ? "the built-in list" : "the last live list" ;
209+ // HTTP and size failures already name the catalog; transport and parse
210+ // errors do not, so the catalog is named exactly once either way.
211+ const reason = ( message : string ) : string =>
212+ message . startsWith ( catalogLabel )
213+ ? message
214+ : `${ catalogLabel } : ${ message } ` ;
215+ switch ( state . status ) {
216+ case "unavailable" :
217+ catalogLogger . debug (
218+ "Model catalog unavailable ({reason}); keeping {fallback}" ,
219+ { reason : reason ( state . message ) , fallback } ,
220+ ) ;
221+ return ;
222+ case "empty" :
223+ catalogLogger . warn (
224+ "Model catalog listed no models ({catalog}); keeping {fallback}" ,
225+ { catalog : catalogLabel , fallback } ,
226+ ) ;
227+ return ;
228+ case "malformed" :
229+ catalogLogger . warn (
230+ "Model catalog response rejected ({reason}); keeping {fallback}" ,
231+ { reason : reason ( state . message ) , fallback } ,
232+ ) ;
233+ return ;
234+ }
235+ }
236+
237+ function prefetchModels ( ) : Promise < Seed | DiscoveredModels > {
164238 if ( inflight !== undefined ) return inflight ;
165239
166240 const pending = runPrefetch ( ) ;
0 commit comments