1+ import { getLogger } from "@intx/log" ;
2+ import { LOG_NAMESPACE_ROOT } from "../branding.js" ;
13import {
24 endpointErrorMessage ,
35 type ModelsEndpointDiscoveryState ,
46} from "./models-endpoint.js" ;
57
8+ const catalogLogger = getLogger ( [ LOG_NAMESPACE_ROOT , "provider" , "catalog" ] ) ;
9+
610export type CatalogDiscoveryState = ModelsEndpointDiscoveryState ;
711
812function declaredCatalogBytes ( response : Response ) : number | undefined {
@@ -18,20 +22,24 @@ async function readBoundedCatalogText(
1822 args : { readonly maxBytes : number ; readonly oversizeMessage : string } ,
1923) : Promise <
2024 | { readonly ok : true ; readonly text : string }
21- | { readonly ok : false ; readonly message : string }
25+ | {
26+ readonly ok : false ;
27+ readonly message : string ;
28+ readonly transport : boolean ;
29+ }
2230> {
2331 const declared = declaredCatalogBytes ( response ) ;
2432 if ( declared !== undefined && declared > args . maxBytes ) {
2533 await response . body ?. cancel ( ) . catch ( ( ) => undefined ) ;
26- return { ok : false , message : args . oversizeMessage } ;
34+ return { ok : false , message : args . oversizeMessage , transport : false } ;
2735 }
2836
2937 try {
3038 const body = response . body ;
3139 if ( body === null ) {
3240 const text = await response . text ( ) ;
3341 if ( new TextEncoder ( ) . encode ( text ) . byteLength > args . maxBytes ) {
34- return { ok : false , message : args . oversizeMessage } ;
42+ return { ok : false , message : args . oversizeMessage , transport : false } ;
3543 }
3644 return { ok : true , text } ;
3745 }
@@ -46,7 +54,7 @@ async function readBoundedCatalogText(
4654 total += value . byteLength ;
4755 if ( total > args . maxBytes ) {
4856 await reader . cancel ( ) . catch ( ( ) => undefined ) ;
49- return { ok : false , message : args . oversizeMessage } ;
57+ return { ok : false , message : args . oversizeMessage , transport : false } ;
5058 }
5159 chunks . push ( value ) ;
5260 }
@@ -59,7 +67,11 @@ async function readBoundedCatalogText(
5967 }
6068 return { ok : true , text : new TextDecoder ( ) . decode ( buffer ) } ;
6169 } catch ( error ) {
62- return { ok : false , message : endpointErrorMessage ( error ) } ;
70+ return {
71+ ok : false ,
72+ message : endpointErrorMessage ( error ) ,
73+ transport : true ,
74+ } ;
6375 }
6476}
6577
@@ -104,11 +116,13 @@ export function createBoundedModelCatalog<
104116 return `${ catalogLabel } catalog exceeds ${ String ( maxModels ) } models` ;
105117 }
106118
107- async function readCatalogJson (
108- response : Response ,
109- ) : Promise <
119+ async function readCatalogJson ( response : Response ) : Promise <
110120 | { readonly ok : true ; readonly value : unknown }
111- | { readonly ok : false ; readonly message : string }
121+ | {
122+ readonly ok : false ;
123+ readonly message : string ;
124+ readonly transport : boolean ;
125+ }
112126 > {
113127 const text = await readBoundedCatalogText ( response , {
114128 maxBytes,
@@ -119,7 +133,11 @@ export function createBoundedModelCatalog<
119133 const value : unknown = JSON . parse ( text . text ) ;
120134 return { ok : true , value } ;
121135 } catch ( error ) {
122- return { ok : false , message : endpointErrorMessage ( error ) } ;
136+ return {
137+ ok : false ,
138+ message : endpointErrorMessage ( error ) ,
139+ transport : false ,
140+ } ;
123141 }
124142 }
125143
@@ -151,7 +169,12 @@ export function createBoundedModelCatalog<
151169
152170 const body = await readCatalogJson ( response ) ;
153171 if ( ! body . ok ) {
154- 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+ } ;
155178 }
156179 return parse ( body . value , {
157180 maxModels,
@@ -169,10 +192,48 @@ export function createBoundedModelCatalog<
169192 // stale-but-live beats empty; a cold failure falls back to the seed.
170193 if ( state . status === "models" ) {
171194 snapshot = state . models ;
195+ } else {
196+ logFallback ( state ) ;
172197 }
173198 return selectableModelIds ( ) ;
174199 }
175200
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+
176237 function prefetchModels ( ) : Promise < Seed | DiscoveredModels > {
177238 if ( inflight !== undefined ) return inflight ;
178239
0 commit comments