@@ -19,7 +19,7 @@ import {
1919 DEFAULT_CONTEXT_LIMIT ,
2020 DEFAULT_OUTPUT_LIMIT ,
2121} from './constants.js' ;
22- import { fetchModels , resolveProviderAliasForMetadata , isProviderAlias } from './models.js' ;
22+ import { fetchModels , resolveProviderAliasForMetadata } from './models.js' ;
2323import { warn , debug } from './logger.js' ;
2424import { sanitizeForLog } from './omniroute-combos.js' ;
2525
@@ -31,6 +31,14 @@ type AuthHook = NonNullable<Hooks['auth']>;
3131type AuthLoader = NonNullable < AuthHook [ 'loader' ] > ;
3232type AuthAccessor = Parameters < AuthLoader > [ 0 ] ;
3333type ProviderDefinition = Parameters < AuthLoader > [ 1 ] ;
34+ const RAW_MODEL_METADATA = Symbol ( 'omniroute.rawModelMetadata' ) ;
35+ const RAW_MODEL_METADATA_OPTION = '__omnirouteRawModelMetadata' ;
36+ const MODELS_GENERATED_BY_PLUGIN = Symbol ( 'omniroute.modelsGeneratedByPlugin' ) ;
37+ const MODELS_GENERATED_BY_PLUGIN_OPTION = '__omnirouteModelsGeneratedByPlugin' ;
38+ type OptionsWithRawModelMetadata = Record < string , unknown > & {
39+ [ RAW_MODEL_METADATA ] ?: unknown ;
40+ [ MODELS_GENERATED_BY_PLUGIN ] ?: unknown ;
41+ } ;
3442
3543export const OmniRouteAuthPlugin : Plugin = async ( _input ) => {
3644 return {
@@ -40,6 +48,7 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => {
4048 const baseUrl = getBaseUrl ( existingProvider ?. options ) ;
4149 const apiMode = getApiMode ( existingProvider ?. options ) ;
4250 const providerApi = resolveProviderApi ( existingProvider ?. api , apiMode ) ;
51+ const rawUserModelMetadata = getRawUserModelMetadata ( existingProvider ?. options ) ;
4352
4453 // Eagerly fetch models for OpenCode <=1.14.48 (which read models from config hook).
4554 // OpenCode >=1.14.49 uses the provider hook below instead.
@@ -55,6 +64,11 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => {
5564 warn ( `Eager model fetch failed, using defaults: ${ error } ` ) ;
5665 }
5766
67+ const effectiveModels = applyModelMetadataOverrides (
68+ models ,
69+ rawUserModelMetadata ,
70+ ) ;
71+
5872 const generatedModelMetadata : Record < string , OmniRouteModelMetadata > = { } ;
5973 for ( const model of models ) {
6074 // Use canonical ID for metadata keys to match user config
@@ -73,26 +87,37 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => {
7387 }
7488
7589 const modelMetadata = mergeModelMetadata (
76- existingProvider ?. options ?. modelMetadata ,
90+ rawUserModelMetadata ,
7791 generatedModelMetadata ,
7892 ) ;
7993
94+ const providerOptions : Record < string , unknown > = {
95+ ...( existingProvider ?. options ?? { } ) ,
96+ baseURL : baseUrl ,
97+ apiMode,
98+ modelMetadata,
99+ } ;
100+ setRawUserModelMetadata ( providerOptions , rawUserModelMetadata ) ;
101+
102+ const hasExistingModels = Boolean (
103+ existingProvider ?. models && Object . keys ( existingProvider . models ) . length > 0 ,
104+ ) ;
105+ const shouldPreserveExistingModels = hasExistingModels && ! getModelsGeneratedByPlugin (
106+ existingProvider ?. options ,
107+ ) ;
108+ const providerModels = shouldPreserveExistingModels
109+ ? existingProvider ?. models
110+ : toProviderModels ( effectiveModels , baseUrl ) ;
111+ setModelsGeneratedByPlugin ( providerOptions , ! shouldPreserveExistingModels ) ;
112+
80113 providers [ OMNIROUTE_PROVIDER_ID ] = {
81114 ...existingProvider ,
82115 name : existingProvider ?. name ?? OMNIROUTE_PROVIDER_NAME ,
83116 api : providerApi ,
84117 npm : existingProvider ?. npm ?? OMNIROUTE_PROVIDER_NPM ,
85118 env : existingProvider ?. env ?? OMNIROUTE_PROVIDER_ENV ,
86- options : {
87- ...( existingProvider ?. options ?? { } ) ,
88- baseURL : baseUrl ,
89- apiMode,
90- modelMetadata,
91- } ,
92- models :
93- existingProvider ?. models && Object . keys ( existingProvider . models ) . length > 0
94- ? existingProvider . models
95- : toProviderModels ( models , baseUrl ) ,
119+ options : providerOptions ,
120+ models : providerModels ,
96121 } ;
97122
98123 config . provider = providers ;
@@ -107,12 +132,20 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => {
107132 if ( ctx . auth ?. type === 'api' && ctx . auth . key ) {
108133 const runtimeConfig = createRuntimeConfig ( provider . options , ctx . auth . key ) ;
109134 const models = await fetchModels ( runtimeConfig , ctx . auth . key , false ) ;
110- return toProviderModels ( models , baseUrl ) ;
135+ const effectiveModels = applyModelMetadataOverrides (
136+ models ,
137+ getRawUserModelMetadata ( provider . options ) ,
138+ ) ;
139+ return toProviderModels ( effectiveModels , baseUrl ) ;
111140 }
112141
113142 // No auth yet (user hasn't /connect'd): return built-in defaults.
114143 // This ensures models have the correct metadata (like api.url) to work with the plugin.
115- return toProviderModels ( OMNIROUTE_DEFAULT_MODELS , baseUrl ) ;
144+ const effectiveModels = applyModelMetadataOverrides (
145+ OMNIROUTE_DEFAULT_MODELS ,
146+ getRawUserModelMetadata ( provider . options ) ,
147+ ) ;
148+ return toProviderModels ( effectiveModels , baseUrl ) ;
116149 } ,
117150 } ,
118151 auth : createAuthHook ( ) ,
@@ -155,7 +188,11 @@ async function loadProviderOptions(
155188 models = OMNIROUTE_DEFAULT_MODELS ;
156189 }
157190
158- replaceProviderModels ( provider , toProviderModels ( models , config . baseUrl ) ) ;
191+ const effectiveModels = applyModelMetadataOverrides (
192+ models ,
193+ getRawUserModelMetadata ( provider . options ) ,
194+ ) ;
195+ replaceProviderModels ( provider , toProviderModels ( effectiveModels , config . baseUrl ) ) ;
159196 if ( isRecord ( provider . models ) ) {
160197 debug ( `Provider models hydrated: ${ Object . keys ( provider . models ) . length } ` ) ;
161198 }
@@ -340,6 +377,72 @@ function getModelMetadataConfig(
340377 return undefined ;
341378}
342379
380+ function getRawUserModelMetadata ( options : Record < string , unknown > | undefined ) : unknown {
381+ if ( ! options ) return undefined ;
382+ const optionsWithRaw = options as OptionsWithRawModelMetadata ;
383+ // Preserve raw user-authored modelMetadata separately from generated compatibility
384+ // metadata. The non-enumerable Symbol is the in-memory fast path; if OpenCode
385+ // clones/serializes options between lifecycle hooks, the internal option field
386+ // survives and distinguishes "no raw metadata" (null) from generated metadata.
387+ if ( RAW_MODEL_METADATA in optionsWithRaw ) {
388+ return optionsWithRaw [ RAW_MODEL_METADATA ] ;
389+ }
390+ if ( RAW_MODEL_METADATA_OPTION in options ) {
391+ return options [ RAW_MODEL_METADATA_OPTION ] === null
392+ ? undefined
393+ : options [ RAW_MODEL_METADATA_OPTION ] ;
394+ }
395+ return options . modelMetadata ;
396+ }
397+
398+ function setRawUserModelMetadata ( options : Record < string , unknown > , rawUserConfig : unknown ) : void {
399+ options [ RAW_MODEL_METADATA_OPTION ] = serializeRawModelMetadataForOption ( rawUserConfig ) ?? null ;
400+ Object . defineProperty ( options , RAW_MODEL_METADATA , {
401+ value : rawUserConfig ,
402+ enumerable : false ,
403+ configurable : true ,
404+ writable : true ,
405+ } ) ;
406+ }
407+
408+ function serializeRawModelMetadataForOption ( raw : unknown ) : unknown {
409+ if ( ! Array . isArray ( raw ) ) return raw ;
410+
411+ return raw . map ( ( block ) => {
412+ if ( ! isRecord ( block ) || ! isRegExp ( block . match ) ) return block ;
413+
414+ return {
415+ ...block ,
416+ match : {
417+ source : block . match . source ,
418+ flags : block . match . flags ,
419+ } ,
420+ } ;
421+ } ) ;
422+ }
423+
424+ function getModelsGeneratedByPlugin ( options : Record < string , unknown > | undefined ) : boolean {
425+ if ( ! options ) return false ;
426+ const optionsWithMarker = options as OptionsWithRawModelMetadata ;
427+ if ( MODELS_GENERATED_BY_PLUGIN in optionsWithMarker ) {
428+ return optionsWithMarker [ MODELS_GENERATED_BY_PLUGIN ] === true ;
429+ }
430+ return options [ MODELS_GENERATED_BY_PLUGIN_OPTION ] === true ;
431+ }
432+
433+ function setModelsGeneratedByPlugin (
434+ options : Record < string , unknown > ,
435+ generatedByPlugin : boolean ,
436+ ) : void {
437+ options [ MODELS_GENERATED_BY_PLUGIN_OPTION ] = generatedByPlugin ? true : null ;
438+ Object . defineProperty ( options , MODELS_GENERATED_BY_PLUGIN , {
439+ value : generatedByPlugin ,
440+ enumerable : false ,
441+ configurable : true ,
442+ writable : true ,
443+ } ) ;
444+ }
445+
343446function getStringRecord ( value : unknown ) : Record < string , string > | undefined {
344447 if ( ! isRecord ( value ) ) return undefined ;
345448
@@ -402,6 +505,141 @@ function mergeModelMetadata(
402505 return generated ;
403506}
404507
508+ function applyModelMetadataOverrides (
509+ models : OmniRouteModel [ ] ,
510+ rawUserConfig : unknown ,
511+ ) : OmniRouteModel [ ] {
512+ const userConfig = getModelMetadataConfig ( { modelMetadata : rawUserConfig } ) ;
513+ if ( ! userConfig ) return models ;
514+
515+ if ( Array . isArray ( userConfig ) ) {
516+ const validBlocks = userConfig . filter ( ( block ) => {
517+ const validation = isValidModelMetadata ( block ) ;
518+ if ( ! validation . valid ) {
519+ warn ( `Invalid metadata block for match "${ sanitizeForLog ( String ( block . match ) ) } " (field: ${ sanitizeForLog ( validation . field ?? '' ) } ), skipping` ) ;
520+ return false ;
521+ }
522+ return true ;
523+ } ) ;
524+
525+ const modelsWithOverrides = models . map ( ( model ) => {
526+ const canonicalId = resolveProviderAliasForMetadata ( model . id ) ;
527+ const block = validBlocks . find ( ( candidate ) =>
528+ metadataBlockMatches ( candidate . match , model . id , canonicalId ) ,
529+ ) ;
530+ if ( ! block ) return model ;
531+
532+ return {
533+ ...model ,
534+ ...extractModelMetadata ( block ) ,
535+ } ;
536+ } ) ;
537+
538+ const existingModels = modelsWithOverrides . map ( ( model ) => ( {
539+ id : model . id ,
540+ canonicalId : resolveProviderAliasForMetadata ( model . id ) ,
541+ } ) ) ;
542+ const missingModels : OmniRouteModel [ ] = [ ] ;
543+ for ( const block of validBlocks ) {
544+ if ( block . addIfMissing !== true || typeof block . match !== 'string' ) continue ;
545+ if ( ! isConcreteModelIdMatch ( block . match ) ) continue ;
546+
547+ const id = resolveProviderAliasForMetadata ( block . match ) ;
548+ const alreadyExists = existingModels . some ( ( model ) =>
549+ metadataBlockMatches ( block . match , model . id , model . canonicalId ) ,
550+ ) || missingModels . some ( ( model ) => model . id === id ) ;
551+ if ( alreadyExists ) continue ;
552+
553+ missingModels . push ( {
554+ id,
555+ name : block . name ?? id ,
556+ ...extractModelMetadata ( block ) ,
557+ } ) ;
558+ }
559+
560+ return [ ...modelsWithOverrides , ...missingModels ] ;
561+ }
562+
563+ const overrides : Record < string , OmniRouteModelMetadata > = { } ;
564+ for ( const [ id , metadata ] of Object . entries ( userConfig ) ) {
565+ const validation = isValidModelMetadata ( metadata ) ;
566+ if ( ! validation . valid ) {
567+ warn ( `Invalid metadata for model "${ sanitizeForLog ( id ) } " (field: ${ sanitizeForLog ( validation . field ?? '' ) } ), skipping` ) ;
568+ continue ;
569+ }
570+
571+ const canonicalId = resolveProviderAliasForMetadata ( id ) ;
572+ overrides [ canonicalId ] = {
573+ ...( overrides [ canonicalId ] ?? { } ) ,
574+ ...extractModelMetadata ( metadata ) ,
575+ } ;
576+ }
577+
578+ return models . map ( ( model ) => {
579+ const canonicalId = resolveProviderAliasForMetadata ( model . id ) ;
580+ const metadata = overrides [ canonicalId ] ;
581+ if ( ! metadata ) return model ;
582+
583+ return {
584+ ...model ,
585+ ...metadata ,
586+ } ;
587+ } ) ;
588+ }
589+
590+ function metadataBlockMatches ( match : unknown , modelId : string , canonicalId : string ) : boolean {
591+ if ( typeof match === 'string' ) {
592+ const canonicalMatch = resolveProviderAliasForMetadata ( match ) ;
593+ return (
594+ match === modelId ||
595+ match === canonicalId ||
596+ canonicalMatch === modelId ||
597+ canonicalMatch === canonicalId ||
598+ metadataMatcherMatches ( match , modelId ) ||
599+ metadataMatcherMatches ( match , canonicalId )
600+ ) ;
601+ }
602+
603+ return metadataMatcherMatches ( match , modelId ) || metadataMatcherMatches ( match , canonicalId ) ;
604+ }
605+
606+ function metadataMatcherMatches ( match : unknown , modelId : string ) : boolean {
607+ if ( typeof match === 'string' ) {
608+ if ( match === modelId ) return true ;
609+
610+ try {
611+ return new RegExp ( match ) . test ( modelId ) ;
612+ } catch {
613+ return false ;
614+ }
615+ }
616+
617+ const regexp = coerceRegExp ( match ) ;
618+ if ( ! regexp ) return false ;
619+ regexp . lastIndex = 0 ;
620+ return regexp . test ( modelId ) ;
621+ }
622+
623+ function isConcreteModelIdMatch ( match : string ) : boolean {
624+ return ! / [ \\ ^ $ * + ? ( ) [ \] { } | ] / u. test ( match ) ;
625+ }
626+
627+ function extractModelMetadata ( value : OmniRouteModelMetadata ) : OmniRouteModelMetadata {
628+ return {
629+ ...( 'name' in value ? { name : value . name } : { } ) ,
630+ ...( 'description' in value ? { description : value . description } : { } ) ,
631+ ...( 'contextWindow' in value ? { contextWindow : value . contextWindow } : { } ) ,
632+ ...( 'maxTokens' in value ? { maxTokens : value . maxTokens } : { } ) ,
633+ ...( 'supportsStreaming' in value ? { supportsStreaming : value . supportsStreaming } : { } ) ,
634+ ...( 'supportsVision' in value ? { supportsVision : value . supportsVision } : { } ) ,
635+ ...( 'supportsTools' in value ? { supportsTools : value . supportsTools } : { } ) ,
636+ ...( 'supportsTemperature' in value ? { supportsTemperature : value . supportsTemperature } : { } ) ,
637+ ...( 'supportsReasoning' in value ? { supportsReasoning : value . supportsReasoning } : { } ) ,
638+ ...( 'supportsAttachment' in value ? { supportsAttachment : value . supportsAttachment } : { } ) ,
639+ ...( 'pricing' in value ? { pricing : value . pricing } : { } ) ,
640+ } ;
641+ }
642+
405643function isRegExp ( value : unknown ) : value is RegExp {
406644 return Object . prototype . toString . call ( value ) === '[object RegExp]' ;
407645}
0 commit comments