2026-02-05 16:23:18 -08:00
import { beforeEach , describe , expect , it , vi } from "vitest" ;
2026-01-20 07:53:25 +00:00
2026-01-31 06:40:45 +01:00
vi . mock ( "../pi-model-discovery.js" , ( ) = > ( {
discoverAuthStorage : vi.fn ( ( ) = > ( { mocked : true } ) ) ,
discoverModels : vi.fn ( ( ) = > ( { find : vi.fn ( ( ) = > null ) } ) ) ,
2026-01-27 16:34:27 -05:00
} ) ) ;
2026-03-15 14:18:39 +08:00
import type { OpenRouterModelCapabilities } from "./openrouter-model-capabilities.js" ;
const mockGetOpenRouterModelCapabilities = vi . fn <
( modelId : string ) = > OpenRouterModelCapabilities | undefined
> ( ( ) = > undefined ) ;
const mockLoadOpenRouterModelCapabilities = vi . fn < ( modelId : string ) = > Promise < void > > (
async ( ) = > { } ,
) ;
vi . mock ( "./openrouter-model-capabilities.js" , ( ) = > ( {
getOpenRouterModelCapabilities : ( modelId : string ) = > mockGetOpenRouterModelCapabilities ( modelId ) ,
loadOpenRouterModelCapabilities : ( modelId : string ) = >
mockLoadOpenRouterModelCapabilities ( modelId ) ,
} ) ) ;
2026-01-30 03:15:10 +01:00
import type { OpenClawConfig } from "../../config/config.js" ;
2026-03-15 14:18:39 +08:00
import { buildInlineProviderModels , resolveModel , resolveModelAsync } from "./model.js" ;
2026-02-14 22:06:04 +00:00
import {
2026-02-22 20:01:43 +00:00
buildOpenAICodexForwardCompatExpectation ,
2026-02-14 22:06:04 +00:00
makeModel ,
mockDiscoveredModel ,
2026-02-22 20:01:43 +00:00
mockOpenAICodexTemplateModel ,
2026-02-14 22:06:04 +00:00
resetMockDiscoverModels ,
} from "./model.test-harness.js" ;
2026-01-20 07:53:25 +00:00
2026-02-05 16:23:18 -08:00
beforeEach ( ( ) = > {
2026-02-14 22:06:04 +00:00
resetMockDiscoverModels ( ) ;
2026-03-15 14:18:39 +08:00
mockGetOpenRouterModelCapabilities . mockReset ( ) ;
mockGetOpenRouterModelCapabilities . mockReturnValue ( undefined ) ;
mockLoadOpenRouterModelCapabilities . mockReset ( ) ;
mockLoadOpenRouterModelCapabilities . mockResolvedValue ( ) ;
2026-02-05 16:23:18 -08:00
} ) ;
2026-02-16 14:52:09 +00:00
function buildForwardCompatTemplate ( params : {
id : string ;
name : string ;
provider : string ;
2026-03-06 08:01:37 +03:00
api : "anthropic-messages" | "google-gemini-cli" | "openai-completions" | "openai-responses" ;
2026-02-16 14:52:09 +00:00
baseUrl : string ;
input? : readonly [ "text" ] | readonly [ "text" , "image" ] ;
cost ? : { input : number ; output : number ; cacheRead : number ; cacheWrite : number } ;
contextWindow? : number ;
maxTokens? : number ;
} ) {
return {
id : params.id ,
name : params.name ,
provider : params.provider ,
api : params.api ,
baseUrl : params.baseUrl ,
reasoning : true ,
input : params.input ? ? ( [ "text" , "image" ] as const ) ,
cost : params.cost ? ? { input : 5 , output : 25 , cacheRead : 0.5 , cacheWrite : 6.25 } ,
contextWindow : params.contextWindow ? ? 200000 ,
maxTokens : params.maxTokens ? ? 64000 ,
} ;
}
function expectResolvedForwardCompatFallback ( params : {
provider : string ;
id : string ;
expectedModel : Record < string , unknown > ;
cfg? : OpenClawConfig ;
} ) {
const result = resolveModel ( params . provider , params . id , "/tmp/agent" , params . cfg ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( params . expectedModel ) ;
}
function expectUnknownModelError ( provider : string , id : string ) {
const result = resolveModel ( provider , id , "/tmp/agent" ) ;
expect ( result . model ) . toBeUndefined ( ) ;
expect ( result . error ) . toBe ( ` Unknown model: ${ provider } / ${ id } ` ) ;
}
2026-01-20 07:53:25 +00:00
describe ( "buildInlineProviderModels" , ( ) = > {
it ( "attaches provider ids to inline models" , ( ) = > {
2026-02-17 14:31:40 +09:00
const providers : Parameters < typeof buildInlineProviderModels > [ 0 ] = {
2026-01-27 16:34:27 -05:00
" alpha " : { baseUrl : "http://alpha.local" , models : [ makeModel ( "alpha-model" ) ] } ,
beta : { baseUrl : "http://beta.local" , models : [ makeModel ( "beta-model" ) ] } ,
2026-01-20 07:53:25 +00:00
} ;
const result = buildInlineProviderModels ( providers ) ;
expect ( result ) . toEqual ( [
2026-01-27 16:34:27 -05:00
{
. . . makeModel ( "alpha-model" ) ,
provider : "alpha" ,
baseUrl : "http://alpha.local" ,
api : undefined ,
} ,
{
. . . makeModel ( "beta-model" ) ,
provider : "beta" ,
baseUrl : "http://beta.local" ,
api : undefined ,
} ,
2026-01-20 07:53:25 +00:00
] ) ;
} ) ;
2026-01-27 19:08:35 +07:00
it ( "inherits baseUrl from provider when model does not specify it" , ( ) = > {
2026-02-17 14:31:40 +09:00
const providers : Parameters < typeof buildInlineProviderModels > [ 0 ] = {
2026-01-27 19:08:35 +07:00
custom : {
baseUrl : "http://localhost:8000" ,
models : [ makeModel ( "custom-model" ) ] ,
} ,
} ;
const result = buildInlineProviderModels ( providers ) ;
expect ( result ) . toHaveLength ( 1 ) ;
expect ( result [ 0 ] . baseUrl ) . toBe ( "http://localhost:8000" ) ;
} ) ;
it ( "inherits api from provider when model does not specify it" , ( ) = > {
2026-02-17 14:31:40 +09:00
const providers : Parameters < typeof buildInlineProviderModels > [ 0 ] = {
2026-01-27 19:08:35 +07:00
custom : {
2026-01-27 16:34:27 -05:00
baseUrl : "http://localhost:8000" ,
2026-01-27 19:08:35 +07:00
api : "anthropic-messages" ,
models : [ makeModel ( "custom-model" ) ] ,
} ,
} ;
const result = buildInlineProviderModels ( providers ) ;
expect ( result ) . toHaveLength ( 1 ) ;
expect ( result [ 0 ] . api ) . toBe ( "anthropic-messages" ) ;
} ) ;
it ( "model-level api takes precedence over provider-level api" , ( ) = > {
2026-02-17 14:31:40 +09:00
const providers : Parameters < typeof buildInlineProviderModels > [ 0 ] = {
2026-01-27 19:08:35 +07:00
custom : {
2026-01-27 16:34:27 -05:00
baseUrl : "http://localhost:8000" ,
api : "openai-responses" ,
2026-01-27 19:08:35 +07:00
models : [ { . . . makeModel ( "custom-model" ) , api : "anthropic-messages" as const } ] ,
} ,
} ;
const result = buildInlineProviderModels ( providers ) ;
expect ( result ) . toHaveLength ( 1 ) ;
expect ( result [ 0 ] . api ) . toBe ( "anthropic-messages" ) ;
} ) ;
it ( "inherits both baseUrl and api from provider config" , ( ) = > {
2026-02-17 14:31:40 +09:00
const providers : Parameters < typeof buildInlineProviderModels > [ 0 ] = {
2026-01-27 19:08:35 +07:00
custom : {
baseUrl : "http://localhost:10000" ,
api : "anthropic-messages" ,
models : [ makeModel ( "claude-opus-4.5" ) ] ,
} ,
} ;
const result = buildInlineProviderModels ( providers ) ;
expect ( result ) . toHaveLength ( 1 ) ;
expect ( result [ 0 ] ) . toMatchObject ( {
provider : "custom" ,
baseUrl : "http://localhost:10000" ,
api : "anthropic-messages" ,
name : "claude-opus-4.5" ,
} ) ;
} ) ;
2026-03-05 00:02:29 +08:00
it ( "merges provider-level headers into inline models" , ( ) = > {
const providers : Parameters < typeof buildInlineProviderModels > [ 0 ] = {
proxy : {
baseUrl : "https://proxy.example.com" ,
api : "anthropic-messages" ,
headers : { "User-Agent" : "custom-agent/1.0" } ,
models : [ makeModel ( "claude-sonnet-4-6" ) ] ,
} ,
} ;
const result = buildInlineProviderModels ( providers ) ;
expect ( result ) . toHaveLength ( 1 ) ;
expect ( result [ 0 ] . headers ) . toEqual ( { "User-Agent" : "custom-agent/1.0" } ) ;
} ) ;
it ( "omits headers when neither provider nor model specifies them" , ( ) = > {
const providers : Parameters < typeof buildInlineProviderModels > [ 0 ] = {
plain : {
baseUrl : "http://localhost:8000" ,
models : [ makeModel ( "some-model" ) ] ,
} ,
} ;
const result = buildInlineProviderModels ( providers ) ;
expect ( result ) . toHaveLength ( 1 ) ;
expect ( result [ 0 ] . headers ) . toBeUndefined ( ) ;
} ) ;
2026-03-07 11:28:39 -06:00
2026-03-10 18:46:47 -05:00
it ( "drops SecretRef marker headers in inline provider models" , ( ) = > {
2026-03-07 11:28:39 -06:00
const providers : Parameters < typeof buildInlineProviderModels > [ 0 ] = {
custom : {
headers : {
Authorization : "secretref-env:OPENAI_HEADER_TOKEN" ,
"X-Managed" : "secretref-managed" ,
"X-Static" : "tenant-a" ,
} ,
models : [ makeModel ( "custom-model" ) ] ,
} ,
} ;
const result = buildInlineProviderModels ( providers ) ;
expect ( result ) . toHaveLength ( 1 ) ;
expect ( result [ 0 ] . headers ) . toEqual ( {
"X-Static" : "tenant-a" ,
} ) ;
} ) ;
2026-01-20 07:53:25 +00:00
} ) ;
2026-01-27 16:34:27 -05:00
describe ( "resolveModel" , ( ) = > {
2026-03-11 20:43:59 +08:00
it ( "defaults model input to text when discovery omits input" , ( ) = > {
mockDiscoveredModel ( {
provider : "custom" ,
modelId : "missing-input" ,
templateModel : {
id : "missing-input" ,
name : "missing-input" ,
api : "openai-completions" ,
provider : "custom" ,
baseUrl : "http://localhost:9999" ,
reasoning : false ,
// NOTE: deliberately omit input to simulate buggy/custom catalogs.
cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 } ,
contextWindow : 8192 ,
maxTokens : 1024 ,
} ,
} ) ;
const result = resolveModel ( "custom" , "missing-input" , "/tmp/agent" , {
models : {
providers : {
custom : {
baseUrl : "http://localhost:9999" ,
api : "openai-completions" ,
// Intentionally keep this minimal — the discovered model provides the rest.
models : [ { id : "missing-input" , name : "missing-input" } ] ,
} ,
} ,
} ,
} as unknown as OpenClawConfig ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( Array . isArray ( result . model ? . input ) ) . toBe ( true ) ;
expect ( result . model ? . input ) . toEqual ( [ "text" ] ) ;
} ) ;
2026-01-27 16:34:27 -05:00
it ( "includes provider baseUrl in fallback model" , ( ) = > {
const cfg = {
models : {
providers : {
custom : {
baseUrl : "http://localhost:9000" ,
models : [ ] ,
} ,
} ,
} ,
2026-01-30 03:15:10 +01:00
} as OpenClawConfig ;
2026-01-27 16:34:27 -05:00
const result = resolveModel ( "custom" , "missing-model" , "/tmp/agent" , cfg ) ;
expect ( result . model ? . baseUrl ) . toBe ( "http://localhost:9000" ) ;
expect ( result . model ? . provider ) . toBe ( "custom" ) ;
expect ( result . model ? . id ) . toBe ( "missing-model" ) ;
} ) ;
2026-02-05 16:23:18 -08:00
2026-03-05 00:02:29 +08:00
it ( "includes provider headers in provider fallback model" , ( ) = > {
const cfg = {
models : {
providers : {
custom : {
baseUrl : "http://localhost:9000" ,
headers : { "X-Custom-Auth" : "token-123" } ,
models : [ makeModel ( "listed-model" ) ] ,
} ,
} ,
} ,
} as OpenClawConfig ;
// Requesting a non-listed model forces the providerCfg fallback branch.
const result = resolveModel ( "custom" , "missing-model" , "/tmp/agent" , cfg ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( ( result . model as unknown as { headers? : Record < string , string > } ) . headers ) . toEqual ( {
"X-Custom-Auth" : "token-123" ,
} ) ;
} ) ;
2026-03-10 18:46:47 -05:00
it ( "drops SecretRef marker provider headers in fallback models" , ( ) = > {
2026-03-07 11:28:39 -06:00
const cfg = {
models : {
providers : {
custom : {
baseUrl : "http://localhost:9000" ,
headers : {
Authorization : "secretref-env:OPENAI_HEADER_TOKEN" ,
"X-Managed" : "secretref-managed" ,
"X-Custom-Auth" : "token-123" ,
} ,
models : [ makeModel ( "listed-model" ) ] ,
} ,
} ,
} ,
} as OpenClawConfig ;
const result = resolveModel ( "custom" , "missing-model" , "/tmp/agent" , cfg ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( ( result . model as unknown as { headers? : Record < string , string > } ) . headers ) . toEqual ( {
"X-Custom-Auth" : "token-123" ,
} ) ;
} ) ;
it ( "drops marker headers from discovered models.json entries" , ( ) = > {
mockDiscoveredModel ( {
provider : "custom" ,
modelId : "listed-model" ,
templateModel : {
. . . makeModel ( "listed-model" ) ,
provider : "custom" ,
headers : {
Authorization : "secretref-env:OPENAI_HEADER_TOKEN" ,
"X-Managed" : "secretref-managed" ,
"X-Static" : "tenant-a" ,
} ,
} ,
} ) ;
const result = resolveModel ( "custom" , "listed-model" , "/tmp/agent" ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( ( result . model as unknown as { headers? : Record < string , string > } ) . headers ) . toEqual ( {
"X-Static" : "tenant-a" ,
} ) ;
} ) ;
2026-02-27 17:20:47 -08:00
it ( "prefers matching configured model metadata for fallback token limits" , ( ) = > {
const cfg = {
models : {
providers : {
custom : {
baseUrl : "http://localhost:9000" ,
models : [
{
. . . makeModel ( "model-a" ) ,
contextWindow : 4096 ,
maxTokens : 1024 ,
} ,
{
. . . makeModel ( "model-b" ) ,
contextWindow : 262144 ,
maxTokens : 32768 ,
} ,
] ,
} ,
} ,
} ,
} as OpenClawConfig ;
const result = resolveModel ( "custom" , "model-b" , "/tmp/agent" , cfg ) ;
expect ( result . model ? . contextWindow ) . toBe ( 262144 ) ;
expect ( result . model ? . maxTokens ) . toBe ( 32768 ) ;
} ) ;
2026-02-27 17:38:22 -08:00
it ( "propagates reasoning from matching configured fallback model" , ( ) = > {
const cfg = {
models : {
providers : {
custom : {
baseUrl : "http://localhost:9000" ,
models : [
{
. . . makeModel ( "model-a" ) ,
reasoning : false ,
} ,
{
. . . makeModel ( "model-b" ) ,
reasoning : true ,
} ,
] ,
} ,
} ,
} ,
} as OpenClawConfig ;
const result = resolveModel ( "custom" , "model-b" , "/tmp/agent" , cfg ) ;
expect ( result . model ? . reasoning ) . toBe ( true ) ;
} ) ;
2026-03-11 23:58:48 -04:00
it ( "matches prefixed OpenRouter native ids in configured fallback models" , ( ) = > {
const cfg = {
models : {
providers : {
openrouter : {
baseUrl : "https://openrouter.ai/api/v1" ,
api : "openai-completions" ,
models : [
{
. . . makeModel ( "openrouter/healer-alpha" ) ,
reasoning : true ,
input : [ "text" , "image" ] ,
contextWindow : 262144 ,
maxTokens : 65536 ,
} ,
] ,
} ,
} ,
} ,
} as OpenClawConfig ;
const result = resolveModel ( "openrouter" , "openrouter/healer-alpha" , "/tmp/agent" , cfg ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( {
provider : "openrouter" ,
id : "openrouter/healer-alpha" ,
reasoning : true ,
input : [ "text" , "image" ] ,
contextWindow : 262144 ,
maxTokens : 65536 ,
} ) ;
} ) ;
2026-03-15 14:18:39 +08:00
it ( "uses OpenRouter API capabilities for unknown models when cache is populated" , ( ) = > {
mockGetOpenRouterModelCapabilities . mockReturnValue ( {
name : "Healer Alpha" ,
input : [ "text" , "image" ] ,
reasoning : true ,
contextWindow : 262144 ,
maxTokens : 65536 ,
cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 } ,
} ) ;
const result = resolveModel ( "openrouter" , "openrouter/healer-alpha" , "/tmp/agent" ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( {
provider : "openrouter" ,
id : "openrouter/healer-alpha" ,
name : "Healer Alpha" ,
reasoning : true ,
input : [ "text" , "image" ] ,
contextWindow : 262144 ,
maxTokens : 65536 ,
} ) ;
} ) ;
it ( "falls back to text-only when OpenRouter API cache is empty" , ( ) = > {
mockGetOpenRouterModelCapabilities . mockReturnValue ( undefined ) ;
const result = resolveModel ( "openrouter" , "openrouter/healer-alpha" , "/tmp/agent" ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( {
provider : "openrouter" ,
id : "openrouter/healer-alpha" ,
reasoning : false ,
input : [ "text" ] ,
} ) ;
} ) ;
it ( "preloads OpenRouter capabilities before first async resolve of an unknown model" , async ( ) = > {
mockLoadOpenRouterModelCapabilities . mockImplementation ( async ( modelId ) = > {
if ( modelId === "google/gemini-3.1-flash-image-preview" ) {
mockGetOpenRouterModelCapabilities . mockReturnValue ( {
name : "Google: Nano Banana 2 (Gemini 3.1 Flash Image Preview)" ,
input : [ "text" , "image" ] ,
reasoning : true ,
contextWindow : 65536 ,
maxTokens : 65536 ,
cost : { input : 0.5 , output : 3 , cacheRead : 0 , cacheWrite : 0 } ,
} ) ;
}
} ) ;
const result = await resolveModelAsync (
"openrouter" ,
"google/gemini-3.1-flash-image-preview" ,
"/tmp/agent" ,
) ;
expect ( mockLoadOpenRouterModelCapabilities ) . toHaveBeenCalledWith (
"google/gemini-3.1-flash-image-preview" ,
) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( {
provider : "openrouter" ,
id : "google/gemini-3.1-flash-image-preview" ,
reasoning : true ,
input : [ "text" , "image" ] ,
contextWindow : 65536 ,
maxTokens : 65536 ,
} ) ;
} ) ;
it ( "skips OpenRouter preload for models already present in the registry" , async ( ) = > {
mockDiscoveredModel ( {
provider : "openrouter" ,
modelId : "openrouter/healer-alpha" ,
templateModel : {
id : "openrouter/healer-alpha" ,
name : "Healer Alpha" ,
api : "openai-completions" ,
provider : "openrouter" ,
baseUrl : "https://openrouter.ai/api/v1" ,
reasoning : true ,
input : [ "text" , "image" ] ,
cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 } ,
contextWindow : 262144 ,
maxTokens : 65536 ,
} ,
} ) ;
const result = await resolveModelAsync ( "openrouter" , "openrouter/healer-alpha" , "/tmp/agent" ) ;
expect ( mockLoadOpenRouterModelCapabilities ) . not . toHaveBeenCalled ( ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( {
provider : "openrouter" ,
id : "openrouter/healer-alpha" ,
input : [ "text" , "image" ] ,
} ) ;
} ) ;
2026-03-06 03:30:24 +08:00
it ( "prefers configured provider api metadata over discovered registry model" , ( ) = > {
mockDiscoveredModel ( {
provider : "onehub" ,
modelId : "glm-5" ,
templateModel : {
id : "glm-5" ,
name : "GLM-5 (cached)" ,
provider : "onehub" ,
api : "anthropic-messages" ,
baseUrl : "https://old-provider.example.com/v1" ,
reasoning : false ,
input : [ "text" ] ,
cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 } ,
contextWindow : 8192 ,
maxTokens : 2048 ,
} ,
} ) ;
const cfg = {
models : {
providers : {
onehub : {
baseUrl : "http://new-provider.example.com/v1" ,
api : "openai-completions" ,
models : [
{
. . . makeModel ( "glm-5" ) ,
api : "openai-completions" ,
reasoning : true ,
contextWindow : 198000 ,
maxTokens : 16000 ,
} ,
] ,
} ,
} ,
} ,
} as OpenClawConfig ;
const result = resolveModel ( "onehub" , "glm-5" , "/tmp/agent" , cfg ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( {
provider : "onehub" ,
id : "glm-5" ,
api : "openai-completions" ,
baseUrl : "http://new-provider.example.com/v1" ,
reasoning : true ,
contextWindow : 198000 ,
maxTokens : 16000 ,
} ) ;
} ) ;
it ( "prefers exact provider config over normalized alias match when both keys exist" , ( ) = > {
mockDiscoveredModel ( {
provider : "qwen" ,
modelId : "qwen3-coder-plus" ,
templateModel : {
id : "qwen3-coder-plus" ,
name : "Qwen3 Coder Plus" ,
provider : "qwen" ,
api : "openai-completions" ,
baseUrl : "https://default-provider.example.com/v1" ,
reasoning : false ,
input : [ "text" ] ,
cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 } ,
contextWindow : 8192 ,
maxTokens : 2048 ,
} ,
} ) ;
const cfg = {
models : {
providers : {
"qwen-portal" : {
baseUrl : "https://canonical-provider.example.com/v1" ,
api : "openai-completions" ,
headers : { "X-Provider" : "canonical" } ,
models : [ { . . . makeModel ( "qwen3-coder-plus" ) , reasoning : false } ] ,
} ,
qwen : {
baseUrl : "https://alias-provider.example.com/v1" ,
api : "anthropic-messages" ,
headers : { "X-Provider" : "alias" } ,
models : [
{
. . . makeModel ( "qwen3-coder-plus" ) ,
api : "anthropic-messages" ,
reasoning : true ,
contextWindow : 262144 ,
maxTokens : 32768 ,
} ,
] ,
} ,
} ,
} ,
} as OpenClawConfig ;
const result = resolveModel ( "qwen" , "qwen3-coder-plus" , "/tmp/agent" , cfg ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( {
provider : "qwen" ,
id : "qwen3-coder-plus" ,
api : "anthropic-messages" ,
baseUrl : "https://alias-provider.example.com" ,
reasoning : true ,
contextWindow : 262144 ,
maxTokens : 32768 ,
headers : { "X-Provider" : "alias" } ,
} ) ;
} ) ;
2026-02-05 16:23:18 -08:00
it ( "builds an openai-codex fallback for gpt-5.3-codex" , ( ) = > {
2026-02-22 20:01:43 +00:00
mockOpenAICodexTemplateModel ( ) ;
2026-02-05 16:23:18 -08:00
const result = resolveModel ( "openai-codex" , "gpt-5.3-codex" , "/tmp/agent" ) ;
expect ( result . error ) . toBeUndefined ( ) ;
2026-02-22 20:01:43 +00:00
expect ( result . model ) . toMatchObject ( buildOpenAICodexForwardCompatExpectation ( "gpt-5.3-codex" ) ) ;
2026-02-05 16:23:18 -08:00
} ) ;
2026-03-06 08:01:37 +03:00
it ( "builds an openai-codex fallback for gpt-5.4" , ( ) = > {
mockOpenAICodexTemplateModel ( ) ;
const result = resolveModel ( "openai-codex" , "gpt-5.4" , "/tmp/agent" ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( buildOpenAICodexForwardCompatExpectation ( "gpt-5.4" ) ) ;
} ) ;
2026-03-13 00:51:30 +00:00
it ( "builds an openai-codex fallback for gpt-5.3-codex-spark" , ( ) = > {
mockOpenAICodexTemplateModel ( ) ;
const result = resolveModel ( "openai-codex" , "gpt-5.3-codex-spark" , "/tmp/agent" ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject (
buildOpenAICodexForwardCompatExpectation ( "gpt-5.3-codex-spark" ) ,
) ;
} ) ;
it ( "keeps openai-codex gpt-5.3-codex-spark when discovery provides it" , ( ) = > {
mockDiscoveredModel ( {
provider : "openai-codex" ,
modelId : "gpt-5.3-codex-spark" ,
templateModel : {
. . . buildOpenAICodexForwardCompatExpectation ( "gpt-5.3-codex-spark" ) ,
name : "GPT-5.3 Codex Spark" ,
input : [ "text" ] ,
} ,
} ) ;
const result = resolveModel ( "openai-codex" , "gpt-5.3-codex-spark" , "/tmp/agent" ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( {
provider : "openai-codex" ,
id : "gpt-5.3-codex-spark" ,
api : "openai-codex-responses" ,
baseUrl : "https://chatgpt.com/backend-api" ,
} ) ;
} ) ;
it ( "rejects stale direct openai gpt-5.3-codex-spark discovery rows" , ( ) = > {
mockDiscoveredModel ( {
provider : "openai" ,
modelId : "gpt-5.3-codex-spark" ,
templateModel : buildForwardCompatTemplate ( {
id : "gpt-5.3-codex-spark" ,
name : "GPT-5.3 Codex Spark" ,
provider : "openai" ,
api : "openai-responses" ,
baseUrl : "https://api.openai.com/v1" ,
} ) ,
} ) ;
const result = resolveModel ( "openai" , "gpt-5.3-codex-spark" , "/tmp/agent" ) ;
expect ( result . model ) . toBeUndefined ( ) ;
expect ( result . error ) . toBe (
"Unknown model: openai/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark." ,
) ;
} ) ;
2026-03-06 08:01:37 +03:00
it ( "applies provider overrides to openai gpt-5.4 forward-compat models" , ( ) = > {
mockDiscoveredModel ( {
provider : "openai" ,
modelId : "gpt-5.2" ,
templateModel : buildForwardCompatTemplate ( {
id : "gpt-5.2" ,
name : "GPT-5.2" ,
provider : "openai" ,
api : "openai-responses" ,
baseUrl : "https://api.openai.com/v1" ,
} ) ,
} ) ;
const cfg = {
models : {
providers : {
openai : {
baseUrl : "https://proxy.example.com/v1" ,
headers : { "X-Proxy-Auth" : "token-123" } ,
} ,
} ,
} ,
} as unknown as OpenClawConfig ;
const result = resolveModel ( "openai" , "gpt-5.4" , "/tmp/agent" , cfg ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( {
provider : "openai" ,
id : "gpt-5.4" ,
api : "openai-responses" ,
baseUrl : "https://proxy.example.com/v1" ,
} ) ;
expect ( ( result . model as unknown as { headers? : Record < string , string > } ) . headers ) . toEqual ( {
"X-Proxy-Auth" : "token-123" ,
} ) ;
} ) ;
2026-03-10 20:23:03 +00:00
it ( "normalizes stale native openai gpt-5.4 completions transport to responses" , ( ) = > {
mockDiscoveredModel ( {
provider : "openai" ,
modelId : "gpt-5.4" ,
templateModel : buildForwardCompatTemplate ( {
id : "gpt-5.4" ,
name : "GPT-5.4" ,
provider : "openai" ,
api : "openai-completions" ,
baseUrl : "https://api.openai.com/v1" ,
} ) ,
} ) ;
const result = resolveModel ( "openai" , "gpt-5.4" , "/tmp/agent" ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( {
provider : "openai" ,
id : "gpt-5.4" ,
api : "openai-responses" ,
baseUrl : "https://api.openai.com/v1" ,
} ) ;
} ) ;
it ( "keeps proxied openai completions transport untouched" , ( ) = > {
mockDiscoveredModel ( {
provider : "openai" ,
modelId : "gpt-5.4" ,
templateModel : buildForwardCompatTemplate ( {
id : "gpt-5.4" ,
name : "GPT-5.4" ,
provider : "openai" ,
api : "openai-completions" ,
baseUrl : "https://proxy.example.com/v1" ,
} ) ,
} ) ;
const result = resolveModel ( "openai" , "gpt-5.4" , "/tmp/agent" ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( {
provider : "openai" ,
id : "gpt-5.4" ,
api : "openai-completions" ,
baseUrl : "https://proxy.example.com/v1" ,
} ) ;
} ) ;
2026-02-13 15:54:46 -08:00
it ( "builds an anthropic forward-compat fallback for claude-opus-4-6" , ( ) = > {
2026-02-16 14:52:09 +00:00
mockDiscoveredModel ( {
2026-02-13 15:54:46 -08:00
provider : "anthropic" ,
2026-02-16 14:52:09 +00:00
modelId : "claude-opus-4-5" ,
templateModel : buildForwardCompatTemplate ( {
id : "claude-opus-4-5" ,
name : "Claude Opus 4.5" ,
provider : "anthropic" ,
api : "anthropic-messages" ,
baseUrl : "https://api.anthropic.com" ,
2026-02-13 12:39:22 +01:00
} ) ,
2026-02-16 14:52:09 +00:00
} ) ;
2026-02-13 12:39:22 +01:00
2026-02-16 14:52:09 +00:00
expectResolvedForwardCompatFallback ( {
2026-02-13 15:54:46 -08:00
provider : "anthropic" ,
id : "claude-opus-4-6" ,
2026-02-16 14:52:09 +00:00
expectedModel : {
provider : "anthropic" ,
id : "claude-opus-4-6" ,
api : "anthropic-messages" ,
baseUrl : "https://api.anthropic.com" ,
reasoning : true ,
} ,
2026-02-13 12:39:22 +01:00
} ) ;
} ) ;
2026-02-18 00:00:20 +01:00
it ( "builds an anthropic forward-compat fallback for claude-sonnet-4-6" , ( ) = > {
mockDiscoveredModel ( {
provider : "anthropic" ,
modelId : "claude-sonnet-4-5" ,
templateModel : buildForwardCompatTemplate ( {
id : "claude-sonnet-4-5" ,
name : "Claude Sonnet 4.5" ,
provider : "anthropic" ,
api : "anthropic-messages" ,
baseUrl : "https://api.anthropic.com" ,
} ) ,
} ) ;
expectResolvedForwardCompatFallback ( {
provider : "anthropic" ,
id : "claude-sonnet-4-6" ,
expectedModel : {
provider : "anthropic" ,
id : "claude-sonnet-4-6" ,
api : "anthropic-messages" ,
baseUrl : "https://api.anthropic.com" ,
reasoning : true ,
} ,
} ) ;
} ) ;
2026-02-12 19:16:04 +01:00
it ( "builds a zai forward-compat fallback for glm-5" , ( ) = > {
2026-02-16 14:52:09 +00:00
mockDiscoveredModel ( {
2026-02-12 19:16:04 +01:00
provider : "zai" ,
2026-02-16 14:52:09 +00:00
modelId : "glm-4.7" ,
templateModel : buildForwardCompatTemplate ( {
id : "glm-4.7" ,
name : "GLM-4.7" ,
provider : "zai" ,
api : "openai-completions" ,
baseUrl : "https://api.z.ai/api/paas/v4" ,
input : [ "text" ] ,
cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 } ,
maxTokens : 131072 ,
2026-02-12 19:16:04 +01:00
} ) ,
2026-02-16 14:52:09 +00:00
} ) ;
2026-02-12 19:16:04 +01:00
2026-02-16 14:52:09 +00:00
expectResolvedForwardCompatFallback ( {
2026-02-12 19:16:04 +01:00
provider : "zai" ,
id : "glm-5" ,
2026-02-16 14:52:09 +00:00
expectedModel : {
provider : "zai" ,
id : "glm-5" ,
api : "openai-completions" ,
baseUrl : "https://api.z.ai/api/paas/v4" ,
reasoning : true ,
} ,
2026-02-12 19:16:04 +01:00
} ) ;
} ) ;
2026-02-13 15:54:46 -08:00
it ( "keeps unknown-model errors when no antigravity thinking template exists" , ( ) = > {
2026-02-16 14:52:09 +00:00
expectUnknownModelError ( "google-antigravity" , "claude-opus-4-6-thinking" ) ;
2026-02-05 16:23:18 -08:00
} ) ;
2026-02-13 15:54:46 -08:00
it ( "keeps unknown-model errors when no antigravity non-thinking template exists" , ( ) = > {
2026-02-16 14:52:09 +00:00
expectUnknownModelError ( "google-antigravity" , "claude-opus-4-6" ) ;
2026-02-13 15:54:46 -08:00
} ) ;
it ( "keeps unknown-model errors for non-gpt-5 openai-codex ids" , ( ) = > {
2026-02-16 14:52:09 +00:00
expectUnknownModelError ( "openai-codex" , "gpt-4.1-mini" ) ;
2026-02-13 12:39:22 +01:00
} ) ;
2026-03-13 00:51:30 +00:00
it ( "rejects direct openai gpt-5.3-codex-spark with a codex-only hint" , ( ) = > {
const result = resolveModel ( "openai" , "gpt-5.3-codex-spark" , "/tmp/agent" ) ;
expect ( result . model ) . toBeUndefined ( ) ;
expect ( result . error ) . toBe (
"Unknown model: openai/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark." ,
) ;
} ) ;
2026-03-15 14:18:39 +08:00
it ( "keeps suppressed openai gpt-5.3-codex-spark from falling through provider fallback" , ( ) = > {
const cfg = {
models : {
providers : {
openai : {
baseUrl : "https://api.openai.com/v1" ,
api : "openai-responses" ,
models : [ { . . . makeModel ( "gpt-4.1" ) , api : "openai-responses" } ] ,
} ,
} ,
} ,
} as OpenClawConfig ;
const result = resolveModel ( "openai" , "gpt-5.3-codex-spark" , "/tmp/agent" , cfg ) ;
expect ( result . model ) . toBeUndefined ( ) ;
expect ( result . error ) . toBe (
"Unknown model: openai/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark." ,
) ;
} ) ;
2026-03-13 00:51:30 +00:00
it ( "rejects azure openai gpt-5.3-codex-spark with a codex-only hint" , ( ) = > {
const result = resolveModel ( "azure-openai-responses" , "gpt-5.3-codex-spark" , "/tmp/agent" ) ;
expect ( result . model ) . toBeUndefined ( ) ;
expect ( result . error ) . toBe (
"Unknown model: azure-openai-responses/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark." ,
) ;
} ) ;
2026-02-05 16:23:18 -08:00
it ( "uses codex fallback even when openai-codex provider is configured" , ( ) = > {
// This test verifies the ordering: codex fallback must fire BEFORE the generic providerCfg fallback.
// If ordering is wrong, the generic fallback would use api: "openai-responses" (the default)
// instead of "openai-codex-responses".
const cfg : OpenClawConfig = {
models : {
providers : {
"openai-codex" : {
baseUrl : "https://custom.example.com" ,
// No models array, or models without gpt-5.3-codex
} ,
} ,
} ,
2026-02-17 14:31:40 +09:00
} as unknown as OpenClawConfig ;
2026-02-05 16:23:18 -08:00
2026-02-16 14:52:09 +00:00
expectResolvedForwardCompatFallback ( {
provider : "openai-codex" ,
id : "gpt-5.3-codex" ,
cfg ,
expectedModel : {
api : "openai-codex-responses" ,
id : "gpt-5.3-codex" ,
provider : "openai-codex" ,
} ,
} ) ;
2026-02-05 16:23:18 -08:00
} ) ;
2026-02-16 15:58:49 +01:00
2026-03-08 11:08:37 +00:00
it ( "uses codex fallback when inline model omits api (#39682)" , ( ) = > {
mockOpenAICodexTemplateModel ( ) ;
const cfg : OpenClawConfig = {
models : {
providers : {
"openai-codex" : {
baseUrl : "https://custom.example.com" ,
2026-03-08 13:54:03 +00:00
headers : { "X-Custom-Auth" : "token-123" } ,
2026-03-08 11:08:37 +00:00
models : [ { id : "gpt-5.4" } ] ,
} ,
} ,
} ,
} as unknown as OpenClawConfig ;
const result = resolveModel ( "openai-codex" , "gpt-5.4" , "/tmp/agent" , cfg ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ) . toMatchObject ( {
api : "openai-codex-responses" ,
2026-03-08 13:54:03 +00:00
baseUrl : "https://custom.example.com" ,
headers : { "X-Custom-Auth" : "token-123" } ,
2026-03-08 11:08:37 +00:00
id : "gpt-5.4" ,
provider : "openai-codex" ,
} ) ;
} ) ;
2026-03-07 17:48:17 +08:00
it ( "normalizes openai-codex gpt-5.4 overrides away from /v1/responses" , ( ) = > {
mockOpenAICodexTemplateModel ( ) ;
const cfg : OpenClawConfig = {
models : {
providers : {
"openai-codex" : {
baseUrl : "https://api.openai.com/v1" ,
api : "openai-responses" ,
} ,
} ,
} ,
} as unknown as OpenClawConfig ;
expectResolvedForwardCompatFallback ( {
provider : "openai-codex" ,
id : "gpt-5.4" ,
cfg ,
expectedModel : {
api : "openai-codex-responses" ,
baseUrl : "https://chatgpt.com/backend-api" ,
id : "gpt-5.4" ,
provider : "openai-codex" ,
} ,
} ) ;
} ) ;
it ( "does not rewrite openai baseUrl when openai-codex api stays non-codex" , ( ) = > {
mockOpenAICodexTemplateModel ( ) ;
const cfg : OpenClawConfig = {
models : {
providers : {
"openai-codex" : {
baseUrl : "https://api.openai.com/v1" ,
api : "openai-completions" ,
} ,
} ,
} ,
} as unknown as OpenClawConfig ;
expectResolvedForwardCompatFallback ( {
provider : "openai-codex" ,
id : "gpt-5.4" ,
cfg ,
expectedModel : {
api : "openai-completions" ,
baseUrl : "https://api.openai.com/v1" ,
id : "gpt-5.4" ,
provider : "openai-codex" ,
} ,
} ) ;
} ) ;
2026-02-16 15:58:49 +01:00
it ( "includes auth hint for unknown ollama models (#17328)" , ( ) = > {
// resetMockDiscoverModels() in beforeEach already sets find → null
const result = resolveModel ( "ollama" , "gemma3:4b" , "/tmp/agent" ) ;
expect ( result . model ) . toBeUndefined ( ) ;
expect ( result . error ) . toContain ( "Unknown model: ollama/gemma3:4b" ) ;
expect ( result . error ) . toContain ( "OLLAMA_API_KEY" ) ;
expect ( result . error ) . toContain ( "docs.openclaw.ai/providers/ollama" ) ;
} ) ;
it ( "includes auth hint for unknown vllm models" , ( ) = > {
const result = resolveModel ( "vllm" , "llama-3-70b" , "/tmp/agent" ) ;
expect ( result . model ) . toBeUndefined ( ) ;
expect ( result . error ) . toContain ( "Unknown model: vllm/llama-3-70b" ) ;
expect ( result . error ) . toContain ( "VLLM_API_KEY" ) ;
} ) ;
it ( "does not add auth hint for non-local providers" , ( ) = > {
const result = resolveModel ( "google-antigravity" , "some-model" , "/tmp/agent" ) ;
expect ( result . model ) . toBeUndefined ( ) ;
expect ( result . error ) . toBe ( "Unknown model: google-antigravity/some-model" ) ;
} ) ;
2026-03-05 00:02:29 +08:00
it ( "applies provider baseUrl override to registry-found models" , ( ) = > {
mockDiscoveredModel ( {
provider : "anthropic" ,
modelId : "claude-sonnet-4-5" ,
templateModel : buildForwardCompatTemplate ( {
id : "claude-sonnet-4-5" ,
name : "Claude Sonnet 4.5" ,
provider : "anthropic" ,
api : "anthropic-messages" ,
baseUrl : "https://api.anthropic.com" ,
} ) ,
} ) ;
const cfg = {
models : {
providers : {
anthropic : {
baseUrl : "https://my-proxy.example.com" ,
} ,
} ,
} ,
} as unknown as OpenClawConfig ;
const result = resolveModel ( "anthropic" , "claude-sonnet-4-5" , "/tmp/agent" , cfg ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ? . baseUrl ) . toBe ( "https://my-proxy.example.com" ) ;
} ) ;
it ( "applies provider headers override to registry-found models" , ( ) = > {
mockDiscoveredModel ( {
provider : "anthropic" ,
modelId : "claude-sonnet-4-5" ,
templateModel : buildForwardCompatTemplate ( {
id : "claude-sonnet-4-5" ,
name : "Claude Sonnet 4.5" ,
provider : "anthropic" ,
api : "anthropic-messages" ,
baseUrl : "https://api.anthropic.com" ,
} ) ,
} ) ;
const cfg = {
models : {
providers : {
anthropic : {
headers : { "X-Custom-Auth" : "token-123" } ,
} ,
} ,
} ,
} as unknown as OpenClawConfig ;
const result = resolveModel ( "anthropic" , "claude-sonnet-4-5" , "/tmp/agent" , cfg ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( ( result . model as unknown as { headers? : Record < string , string > } ) . headers ) . toEqual ( {
"X-Custom-Auth" : "token-123" ,
} ) ;
} ) ;
2026-03-12 13:30:07 -04:00
it ( "lets provider config override registry-found kimi user agent headers" , ( ) = > {
mockDiscoveredModel ( {
provider : "kimi-coding" ,
modelId : "k2p5" ,
templateModel : {
. . . buildForwardCompatTemplate ( {
id : "k2p5" ,
name : "Kimi for Coding" ,
provider : "kimi-coding" ,
api : "anthropic-messages" ,
baseUrl : "https://api.kimi.com/coding/" ,
} ) ,
headers : { "User-Agent" : "claude-code/0.1.0" } ,
} ,
} ) ;
const cfg = {
models : {
providers : {
"kimi-coding" : {
headers : {
"User-Agent" : "custom-kimi-client/1.0" ,
"X-Kimi-Tenant" : "tenant-a" ,
} ,
} ,
} ,
} ,
} as unknown as OpenClawConfig ;
const result = resolveModel ( "kimi-coding" , "k2p5" , "/tmp/agent" , cfg ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( ( result . model as unknown as { headers? : Record < string , string > } ) . headers ) . toEqual ( {
"User-Agent" : "custom-kimi-client/1.0" ,
"X-Kimi-Tenant" : "tenant-a" ,
} ) ;
} ) ;
2026-03-05 00:02:29 +08:00
it ( "does not override when no provider config exists" , ( ) = > {
mockDiscoveredModel ( {
provider : "anthropic" ,
modelId : "claude-sonnet-4-5" ,
templateModel : buildForwardCompatTemplate ( {
id : "claude-sonnet-4-5" ,
name : "Claude Sonnet 4.5" ,
provider : "anthropic" ,
api : "anthropic-messages" ,
baseUrl : "https://api.anthropic.com" ,
} ) ,
} ) ;
const result = resolveModel ( "anthropic" , "claude-sonnet-4-5" , "/tmp/agent" ) ;
expect ( result . error ) . toBeUndefined ( ) ;
expect ( result . model ? . baseUrl ) . toBe ( "https://api.anthropic.com" ) ;
} ) ;
2026-01-27 16:34:27 -05:00
} ) ;