@@ -11,6 +11,24 @@ import {
1111 expectUnifiedModelCatalogProviderRegistration ,
1212} from "openclaw/plugin-sdk/provider-test-contracts" ;
1313import { describe , expect , it , vi } from "vitest" ;
14+
15+ const { getOpenRouterModelCapabilitiesMock, loadOpenRouterModelCapabilitiesMock } = vi . hoisted (
16+ ( ) => ( {
17+ getOpenRouterModelCapabilitiesMock : vi . fn ( ) ,
18+ loadOpenRouterModelCapabilitiesMock : vi . fn ( async ( ) => { } ) ,
19+ } ) ,
20+ ) ;
21+
22+ vi . mock ( "openclaw/plugin-sdk/provider-stream-family" , async ( importOriginal ) => {
23+ const actual =
24+ await importOriginal < typeof import ( "openclaw/plugin-sdk/provider-stream-family" ) > ( ) ;
25+ return {
26+ ...actual ,
27+ getOpenRouterModelCapabilities : getOpenRouterModelCapabilitiesMock ,
28+ loadOpenRouterModelCapabilities : loadOpenRouterModelCapabilitiesMock ,
29+ } ;
30+ } ) ;
31+
1432import openrouterPlugin from "./index.js" ;
1533import {
1634 buildOpenrouterProvider ,
@@ -204,6 +222,59 @@ describe("openrouter provider hooks", () => {
204222 expect ( buildOpenrouterProvider ( ) . models ?. map ( ( model ) => model . id ) ) . not . toContain ( "auto" ) ;
205223 } ) ;
206224
225+ it ( "normalizes OpenRouter API ids before capability loading and lookup" , async ( ) => {
226+ getOpenRouterModelCapabilitiesMock . mockReset ( ) ;
227+ loadOpenRouterModelCapabilitiesMock . mockClear ( ) ;
228+ getOpenRouterModelCapabilitiesMock . mockReturnValue ( {
229+ name : "Claude Sonnet 4.6" ,
230+ reasoning : true ,
231+ input : [ "text" , "image" ] ,
232+ supportsTools : true ,
233+ cost : { input : 1 , output : 2 , cacheRead : 0 , cacheWrite : 0 } ,
234+ contextWindow : 200_000 ,
235+ maxTokens : 64_000 ,
236+ } ) ;
237+ const provider = await registerSingleProviderPlugin ( openrouterPlugin ) ;
238+ const modelId = "openrouter/anthropic/claude-sonnet-4.6" ;
239+ const context = {
240+ provider : "openrouter" ,
241+ modelId,
242+ modelRegistry : { find : vi . fn ( ( ) => null ) } ,
243+ } as never ;
244+
245+ await provider . prepareDynamicModel ?.( context ) ;
246+ const model = provider . resolveDynamicModel ?.( context ) ;
247+
248+ expect ( loadOpenRouterModelCapabilitiesMock ) . toHaveBeenCalledWith ( "anthropic/claude-sonnet-4.6" ) ;
249+ expect ( getOpenRouterModelCapabilitiesMock ) . toHaveBeenCalledWith ( "anthropic/claude-sonnet-4.6" ) ;
250+ expect ( model ) . toMatchObject ( {
251+ id : modelId ,
252+ name : "Claude Sonnet 4.6" ,
253+ reasoning : true ,
254+ input : [ "text" , "image" ] ,
255+ compat : { supportsTools : true } ,
256+ contextWindow : 200_000 ,
257+ maxTokens : 64_000 ,
258+ } ) ;
259+ } ) ;
260+
261+ it ( "keeps native OpenRouter namespace ids for capability lookup" , async ( ) => {
262+ getOpenRouterModelCapabilitiesMock . mockReset ( ) ;
263+ loadOpenRouterModelCapabilitiesMock . mockClear ( ) ;
264+ const provider = await registerSingleProviderPlugin ( openrouterPlugin ) ;
265+ const context = {
266+ provider : "openrouter" ,
267+ modelId : "openrouter/auto" ,
268+ modelRegistry : { find : vi . fn ( ( ) => null ) } ,
269+ } as never ;
270+
271+ await provider . prepareDynamicModel ?.( context ) ;
272+ provider . resolveDynamicModel ?.( context ) ;
273+
274+ expect ( loadOpenRouterModelCapabilitiesMock ) . toHaveBeenCalledWith ( "openrouter/auto" ) ;
275+ expect ( getOpenRouterModelCapabilitiesMock ) . toHaveBeenCalledWith ( "openrouter/auto" ) ;
276+ } ) ;
277+
207278 it ( "does not include retired stealth models in the bundled catalog" , ( ) => {
208279 const modelIds = buildOpenrouterProvider ( ) . models ?. map ( ( model ) => model . id ) ?? [ ] ;
209280 expect ( modelIds ) . not . toContain ( "openrouter/hunter-alpha" ) ;
@@ -389,6 +460,61 @@ describe("openrouter provider hooks", () => {
389460 } ,
390461 } as never ) ;
391462 expect ( normalizedHunterModel ?. reasoning ) . toBe ( false ) ;
463+ expect ( normalizedHunterModel ?. id ) . toBe ( "openrouter/hunter-alpha" ) ;
464+
465+ const normalizedAnthropicModel = provider . normalizeResolvedModel ?.( {
466+ provider : "openrouter" ,
467+ model : {
468+ provider : "openrouter" ,
469+ id : "openrouter/anthropic/claude-sonnet-4.6" ,
470+ name : "anthropic/claude-sonnet-4.6" ,
471+ api : "openai-completions" ,
472+ baseUrl : "https://openrouter.ai/api/v1" ,
473+ reasoning : true ,
474+ input : [ "text" , "image" ] ,
475+ cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 } ,
476+ contextWindow : 200_000 ,
477+ maxTokens : 8192 ,
478+ } ,
479+ } as never ) ;
480+ expect ( normalizedAnthropicModel ?. id ) . toBe ( "anthropic/claude-sonnet-4.6" ) ;
481+
482+ expect (
483+ provider . normalizeResolvedModel ?.( {
484+ provider : "openrouter" ,
485+ modelId : "openrouter/auto" ,
486+ model : {
487+ provider : "openrouter" ,
488+ id : "openrouter/auto" ,
489+ name : "OpenRouter Auto" ,
490+ api : "openai-completions" ,
491+ baseUrl : "https://openrouter.ai/api/v1" ,
492+ reasoning : false ,
493+ input : [ "text" , "image" ] ,
494+ cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 } ,
495+ contextWindow : 200_000 ,
496+ maxTokens : 8192 ,
497+ } ,
498+ } as never ) ,
499+ ) . toBeUndefined ( ) ;
500+
501+ const normalizedDuplicatedAutoModel = provider . normalizeResolvedModel ?.( {
502+ provider : "openrouter" ,
503+ modelId : "openrouter/openrouter/auto" ,
504+ model : {
505+ provider : "openrouter" ,
506+ id : "openrouter/openrouter/auto" ,
507+ name : "OpenRouter Auto" ,
508+ api : "openai-completions" ,
509+ baseUrl : "https://openrouter.ai/api/v1" ,
510+ reasoning : false ,
511+ input : [ "text" , "image" ] ,
512+ cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 } ,
513+ contextWindow : 200_000 ,
514+ maxTokens : 8192 ,
515+ } ,
516+ } as never ) ;
517+ expect ( normalizedDuplicatedAutoModel ?. id ) . toBe ( "openrouter/auto" ) ;
392518
393519 expect (
394520 provider . normalizeTransport ?.( {
0 commit comments