@@ -226,3 +226,257 @@ describe("ModelRegistry models.json auth", () => {
226226 expect ( registry . find ( "zai" , "glm-5.1" ) ) . toBeUndefined ( ) ;
227227 } ) ;
228228} ) ;
229+
230+ describe ( "ModelRegistry default-agent catalog inheritance" , ( ) => {
231+ // Model discovery only runs for the default agent at gateway startup, so a freshly
232+ // created secondary agent has an apiKey-only google catalog with no models. Without
233+ // read-through inheritance it fails at runtime with "Unknown model: google/...".
234+ function writeGoogleCatalogAgentDir ( params : { withModels : boolean ; modelName ?: string } ) : string {
235+ return writeModelsJsonWithPluginCatalog ( {
236+ root : { providers : { } } ,
237+ pluginRelativePath : join ( "plugins" , "google" , PLUGIN_MODEL_CATALOG_FILE ) ,
238+ pluginCatalog : {
239+ generatedBy : PLUGIN_MODEL_CATALOG_GENERATED_BY ,
240+ providers : {
241+ google : params . withModels
242+ ? {
243+ baseUrl : "https://generativelanguage.googleapis.com/v1beta" ,
244+ api : "openai-responses" ,
245+ apiKey : "GEMINI_API_KEY" ,
246+ models : [
247+ { id : "gemini-2.5-flash" , name : params . modelName ?? "Gemini 2.5 Flash" } ,
248+ ] ,
249+ }
250+ : { apiKey : "GEMINI_API_KEY" } ,
251+ } ,
252+ } ,
253+ } ) ;
254+ }
255+
256+ it ( "inherits the default agent's generated catalog models for a secondary agent" , ( ) => {
257+ const defaultAgentDir = dirname ( writeGoogleCatalogAgentDir ( { withModels : true } ) ) ;
258+ const secondaryModelsPath = writeGoogleCatalogAgentDir ( { withModels : false } ) ;
259+
260+ const registry = ModelRegistry . create (
261+ AuthStorage . inMemory ( { google : { type : "api_key" , key : "sk-test" } } ) ,
262+ secondaryModelsPath ,
263+ {
264+ pluginMetadataSnapshot : pluginOwnerSnapshot ( "google" , "google" ) ,
265+ inheritedAgentDir : defaultAgentDir ,
266+ } ,
267+ ) ;
268+
269+ expect ( registry . getError ( ) ) . toBeUndefined ( ) ;
270+ expect ( registry . find ( "google" , "gemini-2.5-flash" ) ?. name ) . toBe ( "Gemini 2.5 Flash" ) ;
271+ } ) ;
272+
273+ it ( "does not inherit without an inheritedAgentDir (reproduces the bug)" , ( ) => {
274+ const secondaryModelsPath = writeGoogleCatalogAgentDir ( { withModels : false } ) ;
275+
276+ const registry = ModelRegistry . create (
277+ AuthStorage . inMemory ( { google : { type : "api_key" , key : "sk-test" } } ) ,
278+ secondaryModelsPath ,
279+ { pluginMetadataSnapshot : pluginOwnerSnapshot ( "google" , "google" ) } ,
280+ ) ;
281+
282+ expect ( registry . getError ( ) ) . toBeUndefined ( ) ;
283+ expect ( registry . find ( "google" , "gemini-2.5-flash" ) ) . toBeUndefined ( ) ;
284+ } ) ;
285+
286+ it ( "prefers the secondary agent's own catalog over inherited models" , ( ) => {
287+ const defaultAgentDir = dirname (
288+ writeGoogleCatalogAgentDir ( { withModels : true , modelName : "Gemini 2.5 Flash (default)" } ) ,
289+ ) ;
290+ const secondaryModelsPath = writeGoogleCatalogAgentDir ( {
291+ withModels : true ,
292+ modelName : "Gemini 2.5 Flash (local)" ,
293+ } ) ;
294+
295+ const registry = ModelRegistry . create (
296+ AuthStorage . inMemory ( { google : { type : "api_key" , key : "sk-test" } } ) ,
297+ secondaryModelsPath ,
298+ {
299+ pluginMetadataSnapshot : pluginOwnerSnapshot ( "google" , "google" ) ,
300+ inheritedAgentDir : defaultAgentDir ,
301+ } ,
302+ ) ;
303+
304+ expect ( registry . getError ( ) ) . toBeUndefined ( ) ;
305+ expect ( registry . find ( "google" , "gemini-2.5-flash" ) ?. name ) . toBe ( "Gemini 2.5 Flash (local)" ) ;
306+ } ) ;
307+
308+ it ( "does not let an inherited catalog override a local provider's config/headers or leak sibling models" , async ( ) => {
309+ // Regression: a provider the secondary agent defines in its own models.json must keep
310+ // its local request config (headers/auth) and must NOT gain sibling models from the
311+ // default agent's catalog — otherwise a local model would run with the default agent's
312+ // credentials/transport (a cross-agent boundary violation), and inheritance would no
313+ // longer be "local always wins".
314+ const defaultAgentDir = dirname (
315+ writeModelsJsonWithPluginCatalog ( {
316+ root : { providers : { } } ,
317+ pluginRelativePath : join ( "plugins" , "google" , PLUGIN_MODEL_CATALOG_FILE ) ,
318+ pluginCatalog : {
319+ generatedBy : PLUGIN_MODEL_CATALOG_GENERATED_BY ,
320+ providers : {
321+ google : {
322+ baseUrl : "https://default.example/v1beta" ,
323+ api : "openai-responses" ,
324+ apiKey : "GEMINI_API_KEY" ,
325+ headers : { "x-source" : "default" } ,
326+ models : [
327+ { id : "gemini-2.5-flash" , name : "Flash (default)" } ,
328+ { id : "gemini-3-flash-preview" , name : "3 Flash (default)" } ,
329+ ] ,
330+ } ,
331+ } ,
332+ } ,
333+ } ) ,
334+ ) ;
335+
336+ // Secondary agent owns google via its OWN models.json (not a plugin catalog).
337+ const secondaryModelsPath = writeModelsJson ( {
338+ providers : {
339+ google : {
340+ baseUrl : "https://local.example/v1beta" ,
341+ api : "openai-responses" ,
342+ apiKey : "GEMINI_API_KEY" ,
343+ headers : { "x-source" : "local" } ,
344+ models : [ { id : "gemini-2.5-flash" , name : "Flash (local)" } ] ,
345+ } ,
346+ } ,
347+ } ) ;
348+
349+ const registry = ModelRegistry . create (
350+ AuthStorage . inMemory ( { google : { type : "api_key" , key : "sk-test" } } ) ,
351+ secondaryModelsPath ,
352+ {
353+ pluginMetadataSnapshot : pluginOwnerSnapshot ( "google" , "google" ) ,
354+ inheritedAgentDir : defaultAgentDir ,
355+ } ,
356+ ) ;
357+
358+ expect ( registry . getError ( ) ) . toBeUndefined ( ) ;
359+
360+ const local = registry . find ( "google" , "gemini-2.5-flash" ) ;
361+ expect ( local ?. name ) . toBe ( "Flash (local)" ) ;
362+ // Per-provider gating: the default agent's sibling model must not leak in.
363+ expect ( registry . find ( "google" , "gemini-3-flash-preview" ) ) . toBeUndefined ( ) ;
364+
365+ // Local provider request config (headers) must survive inherited loading.
366+ const resolved = await registry . getApiKeyAndHeaders ( local ! ) ;
367+ if ( ! resolved . ok ) {
368+ throw new Error ( `expected resolved auth, got error: ${ resolved . error } ` ) ;
369+ }
370+ expect ( resolved . headers ?. [ "x-source" ] ) . toBe ( "local" ) ;
371+ } ) ;
372+
373+ it ( "resolves local request config when the local provider comes from a local plugin catalog" , async ( ) => {
374+ // Same guarantee as above, but the local provider is owned via a local plugin catalog
375+ // (not root models.json) — covers the other registration path end-to-end.
376+ const defaultAgentDir = dirname (
377+ writeModelsJsonWithPluginCatalog ( {
378+ root : { providers : { } } ,
379+ pluginRelativePath : join ( "plugins" , "google" , PLUGIN_MODEL_CATALOG_FILE ) ,
380+ pluginCatalog : {
381+ generatedBy : PLUGIN_MODEL_CATALOG_GENERATED_BY ,
382+ providers : {
383+ google : {
384+ baseUrl : "https://default.example/v1beta" ,
385+ api : "openai-responses" ,
386+ apiKey : "GEMINI_API_KEY" ,
387+ headers : { "x-source" : "default" } ,
388+ models : [
389+ { id : "gemini-2.5-flash" , name : "Flash (default)" } ,
390+ { id : "gemini-3-flash-preview" , name : "3 Flash (default)" } ,
391+ ] ,
392+ } ,
393+ } ,
394+ } ,
395+ } ) ,
396+ ) ;
397+
398+ const secondaryModelsPath = writeModelsJsonWithPluginCatalog ( {
399+ root : { providers : { } } ,
400+ pluginRelativePath : join ( "plugins" , "google" , PLUGIN_MODEL_CATALOG_FILE ) ,
401+ pluginCatalog : {
402+ generatedBy : PLUGIN_MODEL_CATALOG_GENERATED_BY ,
403+ providers : {
404+ google : {
405+ baseUrl : "https://local.example/v1beta" ,
406+ api : "openai-responses" ,
407+ apiKey : "GEMINI_API_KEY" ,
408+ headers : { "x-source" : "local" } ,
409+ models : [ { id : "gemini-2.5-flash" , name : "Flash (local)" } ] ,
410+ } ,
411+ } ,
412+ } ,
413+ } ) ;
414+
415+ const registry = ModelRegistry . create (
416+ AuthStorage . inMemory ( { google : { type : "api_key" , key : "sk-test" } } ) ,
417+ secondaryModelsPath ,
418+ {
419+ pluginMetadataSnapshot : pluginOwnerSnapshot ( "google" , "google" ) ,
420+ inheritedAgentDir : defaultAgentDir ,
421+ } ,
422+ ) ;
423+
424+ expect ( registry . getError ( ) ) . toBeUndefined ( ) ;
425+
426+ const local = registry . find ( "google" , "gemini-2.5-flash" ) ;
427+ expect ( local ?. name ) . toBe ( "Flash (local)" ) ;
428+ expect ( registry . find ( "google" , "gemini-3-flash-preview" ) ) . toBeUndefined ( ) ;
429+
430+ const resolved = await registry . getApiKeyAndHeaders ( local ! ) ;
431+ if ( ! resolved . ok ) {
432+ throw new Error ( `expected resolved auth, got error: ${ resolved . error } ` ) ;
433+ }
434+ expect ( resolved . headers ?. [ "x-source" ] ) . toBe ( "local" ) ;
435+ } ) ;
436+
437+ it ( "resolves an inherited-only provider with the inherited baseUrl and headers" , async ( ) => {
438+ // For a provider the secondary agent does not configure at all, inheritance must supply
439+ // the default agent's baseUrl/api and request headers so the model is actually usable.
440+ const defaultAgentDir = dirname (
441+ writeModelsJsonWithPluginCatalog ( {
442+ root : { providers : { } } ,
443+ pluginRelativePath : join ( "plugins" , "google" , PLUGIN_MODEL_CATALOG_FILE ) ,
444+ pluginCatalog : {
445+ generatedBy : PLUGIN_MODEL_CATALOG_GENERATED_BY ,
446+ providers : {
447+ google : {
448+ baseUrl : "https://inherited.example/v1beta" ,
449+ api : "openai-responses" ,
450+ apiKey : "GEMINI_API_KEY" ,
451+ headers : { "x-source" : "inherited" } ,
452+ models : [ { id : "gemini-2.5-flash" , name : "Flash (inherited)" } ] ,
453+ } ,
454+ } ,
455+ } ,
456+ } ) ,
457+ ) ;
458+
459+ // Secondary agent configures no provider of its own (lives on inheritance).
460+ const secondaryModelsPath = writeModelsJson ( { providers : { } } ) ;
461+
462+ const registry = ModelRegistry . create (
463+ AuthStorage . inMemory ( { google : { type : "api_key" , key : "sk-test" } } ) ,
464+ secondaryModelsPath ,
465+ {
466+ pluginMetadataSnapshot : pluginOwnerSnapshot ( "google" , "google" ) ,
467+ inheritedAgentDir : defaultAgentDir ,
468+ } ,
469+ ) ;
470+
471+ expect ( registry . getError ( ) ) . toBeUndefined ( ) ;
472+
473+ const model = registry . find ( "google" , "gemini-2.5-flash" ) ;
474+ expect ( model ?. baseUrl ) . toBe ( "https://inherited.example/v1beta" ) ;
475+
476+ const resolved = await registry . getApiKeyAndHeaders ( model ! ) ;
477+ if ( ! resolved . ok ) {
478+ throw new Error ( `expected resolved auth, got error: ${ resolved . error } ` ) ;
479+ }
480+ expect ( resolved . headers ?. [ "x-source" ] ) . toBe ( "inherited" ) ;
481+ } ) ;
482+ } ) ;
0 commit comments