2026-02-08 18:41:35 +09:00
declare const require : ( name : string ) = > any
const { describe , it , expect , beforeEach , afterEach , beforeAll } = require ( "bun:test" )
2026-01-23 14:09:37 +09:00
import { mkdtempSync , writeFileSync , rmSync } from "fs"
import { tmpdir } from "os"
import { join } from "path"
2026-02-08 18:00:19 +09:00
2026-02-08 18:41:35 +09:00
let __resetModelCache : ( ) = > void
let fetchAvailableModels : ( client? : unknown , options ? : { connectedProviders? : string [ ] | null } ) = > Promise < Set < string > >
let fuzzyMatchModel : ( target : string , available : Set < string > , providers? : string [ ] ) = > string | null
let isModelAvailable : ( targetModel : string , availableModels : Set < string > ) = > boolean
let getConnectedProviders : ( client : unknown ) = > Promise < string [ ] >
2026-02-17 10:29:48 +09:00
let isAnyFallbackModelAvailable : (
fallbackChain : Array < { providers : string [ ] ; model : string } > ,
availableModels : Set < string > ,
) = > boolean
let resolveFirstAvailableFallback : (
fallbackChain : Array < { providers : string [ ] ; model : string } > ,
availableModels : Set < string > ,
) = > { provider : string ; model : string } | null
2026-02-08 18:41:35 +09:00
beforeAll ( async ( ) = > {
; ( {
__resetModelCache ,
fetchAvailableModels ,
fuzzyMatchModel ,
isModelAvailable ,
getConnectedProviders ,
} = await import ( "./model-availability" ) )
2026-02-17 10:29:48 +09:00
; ( {
isAnyFallbackModelAvailable ,
resolveFirstAvailableFallback ,
} = await import ( "./fallback-model-availability" ) )
2026-02-08 18:41:35 +09:00
} )
2026-01-22 22:43:10 +09:00
describe ( "fetchAvailableModels" , ( ) = > {
2026-01-23 14:09:37 +09:00
let tempDir : string
2026-02-08 18:41:35 +09:00
let originalXdgCache : string | undefined
2026-02-08 18:00:19 +09:00
2026-01-22 22:43:10 +09:00
beforeEach ( ( ) = > {
__resetModelCache ( )
2026-01-23 14:09:37 +09:00
tempDir = mkdtempSync ( join ( tmpdir ( ) , "opencode-test-" ) )
2026-02-08 18:41:35 +09:00
originalXdgCache = process . env . XDG_CACHE_HOME
process . env . XDG_CACHE_HOME = tempDir
2026-01-22 22:43:10 +09:00
} )
2026-01-23 14:09:37 +09:00
afterEach ( ( ) = > {
2026-02-08 18:41:35 +09:00
if ( originalXdgCache !== undefined ) {
process . env . XDG_CACHE_HOME = originalXdgCache
} else {
delete process . env . XDG_CACHE_HOME
}
2026-01-23 14:09:37 +09:00
rmSync ( tempDir , { recursive : true , force : true } )
} )
function writeModelsCache ( data : Record < string , any > ) {
const cacheDir = join ( tempDir , "opencode" )
require ( "fs" ) . mkdirSync ( cacheDir , { recursive : true } )
writeFileSync ( join ( cacheDir , "models.json" ) , JSON . stringify ( data ) )
}
2026-01-22 22:43:10 +09:00
2026-01-26 11:53:41 +09:00
it ( "#given cache file with models #when fetchAvailableModels called with connectedProviders #then returns Set of model IDs" , async ( ) = > {
2026-01-23 14:09:37 +09:00
writeModelsCache ( {
openai : { id : "openai" , models : { "gpt-5.2" : { id : "gpt-5.2" } } } ,
2026-02-06 14:21:15 +09:00
anthropic : { id : "anthropic" , models : { "claude-opus-4-6" : { id : "claude-opus-4-6" } } } ,
2026-01-23 14:09:37 +09:00
google : { id : "google" , models : { "gemini-3-pro" : { id : "gemini-3-pro" } } } ,
} )
2026-01-26 11:53:41 +09:00
const result = await fetchAvailableModels ( undefined , {
connectedProviders : [ "openai" , "anthropic" , "google" ]
} )
2026-01-22 22:43:10 +09:00
expect ( result ) . toBeInstanceOf ( Set )
expect ( result . size ) . toBe ( 3 )
expect ( result . has ( "openai/gpt-5.2" ) ) . toBe ( true )
2026-02-06 14:21:15 +09:00
expect ( result . has ( "anthropic/claude-opus-4-6" ) ) . toBe ( true )
2026-01-22 22:43:10 +09:00
expect ( result . has ( "google/gemini-3-pro" ) ) . toBe ( true )
} )
2026-01-26 11:53:41 +09:00
it ( "#given connectedProviders unknown #when fetchAvailableModels called without options #then returns empty Set" , async ( ) = > {
writeModelsCache ( {
openai : { id : "openai" , models : { "gpt-5.2" : { id : "gpt-5.2" } } } ,
} )
2026-01-23 14:09:37 +09:00
const result = await fetchAvailableModels ( )
2026-01-22 22:43:10 +09:00
expect ( result ) . toBeInstanceOf ( Set )
expect ( result . size ) . toBe ( 0 )
} )
2026-01-30 16:57:13 +09:00
it ( "#given connectedProviders unknown but client can list #when fetchAvailableModels called with client #then returns models from API filtered by connected providers" , async ( ) = > {
const client = {
provider : {
list : async ( ) = > ( { data : { connected : [ "openai" ] } } ) ,
} ,
model : {
list : async ( ) = > ( {
data : [
2026-02-06 14:20:05 +09:00
{ id : "gpt-5.3-codex" , provider : "openai" } ,
2026-01-30 16:57:13 +09:00
{ id : "gemini-3-pro" , provider : "google" } ,
] ,
} ) ,
} ,
}
const result = await fetchAvailableModels ( client )
expect ( result ) . toBeInstanceOf ( Set )
2026-02-06 14:20:05 +09:00
expect ( result . has ( "openai/gpt-5.3-codex" ) ) . toBe ( true )
2026-01-30 16:57:13 +09:00
expect ( result . has ( "google/gemini-3-pro" ) ) . toBe ( false )
} )
2026-01-26 11:53:41 +09:00
it ( "#given cache file not found #when fetchAvailableModels called with connectedProviders #then returns empty Set" , async ( ) = > {
const result = await fetchAvailableModels ( undefined , { connectedProviders : [ "openai" ] } )
expect ( result ) . toBeInstanceOf ( Set )
expect ( result . size ) . toBe ( 0 )
} )
2026-01-30 16:57:13 +09:00
it ( "#given cache missing but client can list #when fetchAvailableModels called with connectedProviders #then returns models from API" , async ( ) = > {
const client = {
provider : {
list : async ( ) = > ( { data : { connected : [ "openai" , "google" ] } } ) ,
} ,
model : {
list : async ( ) = > ( {
data : [
2026-02-06 14:20:05 +09:00
{ id : "gpt-5.3-codex" , provider : "openai" } ,
2026-01-30 16:57:13 +09:00
{ id : "gemini-3-pro" , provider : "google" } ,
] ,
} ) ,
} ,
}
const result = await fetchAvailableModels ( client , { connectedProviders : [ "openai" , "google" ] } )
expect ( result ) . toBeInstanceOf ( Set )
2026-02-06 14:20:05 +09:00
expect ( result . has ( "openai/gpt-5.3-codex" ) ) . toBe ( true )
2026-01-30 16:57:13 +09:00
expect ( result . has ( "google/gemini-3-pro" ) ) . toBe ( true )
} )
2026-01-26 11:53:41 +09:00
it ( "#given cache read twice #when second call made with same providers #then reads fresh each time" , async ( ) = > {
2026-01-23 14:09:37 +09:00
writeModelsCache ( {
openai : { id : "openai" , models : { "gpt-5.2" : { id : "gpt-5.2" } } } ,
2026-02-06 14:21:15 +09:00
anthropic : { id : "anthropic" , models : { "claude-opus-4-6" : { id : "claude-opus-4-6" } } } ,
2026-01-23 14:09:37 +09:00
} )
2026-01-22 22:43:10 +09:00
2026-01-26 11:53:41 +09:00
const result1 = await fetchAvailableModels ( undefined , { connectedProviders : [ "openai" ] } )
const result2 = await fetchAvailableModels ( undefined , { connectedProviders : [ "openai" ] } )
2026-01-22 22:43:10 +09:00
2026-01-26 11:53:41 +09:00
expect ( result1 . size ) . toBe ( result2 . size )
2026-01-22 22:43:10 +09:00
expect ( result1 . has ( "openai/gpt-5.2" ) ) . toBe ( true )
} )
2026-01-26 11:53:41 +09:00
it ( "#given empty providers in cache #when fetchAvailableModels called with connectedProviders #then returns empty Set" , async ( ) = > {
2026-01-23 14:09:37 +09:00
writeModelsCache ( { } )
2026-01-22 22:43:10 +09:00
2026-01-26 11:53:41 +09:00
const result = await fetchAvailableModels ( undefined , { connectedProviders : [ "openai" ] } )
2026-01-22 22:43:10 +09:00
expect ( result ) . toBeInstanceOf ( Set )
expect ( result . size ) . toBe ( 0 )
} )
2026-01-26 11:53:41 +09:00
it ( "#given cache file with various providers #when fetchAvailableModels called with all providers #then extracts all IDs correctly" , async ( ) = > {
2026-01-23 14:09:37 +09:00
writeModelsCache ( {
2026-02-06 14:20:05 +09:00
openai : { id : "openai" , models : { "gpt-5.3-codex" : { id : "gpt-5.3-codex" } } } ,
2026-02-18 15:51:24 +09:00
anthropic : { id : "anthropic" , models : { "claude-sonnet-4-6" : { id : "claude-sonnet-4-6" } } } ,
2026-01-23 14:09:37 +09:00
google : { id : "google" , models : { "gemini-3-flash" : { id : "gemini-3-flash" } } } ,
2026-01-24 15:30:35 +09:00
opencode : { id : "opencode" , models : { "gpt-5-nano" : { id : "gpt-5-nano" } } } ,
2026-01-23 14:09:37 +09:00
} )
2026-01-22 22:43:10 +09:00
2026-01-26 11:53:41 +09:00
const result = await fetchAvailableModels ( undefined , {
connectedProviders : [ "openai" , "anthropic" , "google" , "opencode" ]
} )
2026-01-22 22:43:10 +09:00
expect ( result . size ) . toBe ( 4 )
2026-02-06 14:20:05 +09:00
expect ( result . has ( "openai/gpt-5.3-codex" ) ) . toBe ( true )
2026-02-18 15:51:24 +09:00
expect ( result . has ( "anthropic/claude-sonnet-4-6" ) ) . toBe ( true )
2026-01-22 22:43:10 +09:00
expect ( result . has ( "google/gemini-3-flash" ) ) . toBe ( true )
2026-01-24 15:30:35 +09:00
expect ( result . has ( "opencode/gpt-5-nano" ) ) . toBe ( true )
2026-01-22 22:43:10 +09:00
} )
} )
describe ( "fuzzyMatchModel" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given available models from multiple providers
// when searching for a substring match
// then return the matching model
2026-01-22 22:43:10 +09:00
it ( "should match substring in model name" , ( ) = > {
const available = new Set ( [
"openai/gpt-5.2" ,
2026-02-06 14:20:05 +09:00
"openai/gpt-5.3-codex" ,
2026-02-06 14:21:15 +09:00
"anthropic/claude-opus-4-6" ,
2026-01-22 22:43:10 +09:00
] )
const result = fuzzyMatchModel ( "gpt-5.2" , available )
expect ( result ) . toBe ( "openai/gpt-5.2" )
} )
2026-02-01 16:47:50 +09:00
// given available model with preview suffix
// when searching with provider-prefixed base model
// then return preview model
2026-01-30 16:57:13 +09:00
it ( "should match preview suffix for gemini-3-flash" , ( ) = > {
const available = new Set ( [ "google/gemini-3-flash-preview" ] )
const result = fuzzyMatchModel (
"google/gemini-3-flash" ,
available ,
[ "google" ] ,
)
expect ( result ) . toBe ( "google/gemini-3-flash-preview" )
} )
2026-02-01 16:47:50 +09:00
// given available models with partial matches
// when searching for a substring
// then return exact match if it exists
2026-01-22 22:43:10 +09:00
it ( "should prefer exact match over substring match" , ( ) = > {
const available = new Set ( [
"openai/gpt-5.2" ,
2026-02-06 14:20:05 +09:00
"openai/gpt-5.3-codex" ,
2026-01-22 22:43:10 +09:00
"openai/gpt-5.2-ultra" ,
] )
const result = fuzzyMatchModel ( "gpt-5.2" , available )
expect ( result ) . toBe ( "openai/gpt-5.2" )
} )
2026-02-01 16:47:50 +09:00
// given available models with multiple substring matches
// when searching for a substring
// then return the shorter model name (more specific)
2026-01-22 22:43:10 +09:00
it ( "should prefer shorter model name when multiple matches exist" , ( ) = > {
const available = new Set ( [
"openai/gpt-5.2-ultra" ,
"openai/gpt-5.2-ultra-mega" ,
] )
const result = fuzzyMatchModel ( "gpt-5.2" , available )
expect ( result ) . toBe ( "openai/gpt-5.2-ultra" )
} )
2026-02-01 16:47:50 +09:00
// given available models with claude variants
// when searching for claude-opus
// then return matching claude-opus model
2026-02-06 14:21:15 +09:00
it ( "should match claude-opus to claude-opus-4-6" , ( ) = > {
2026-01-22 22:43:10 +09:00
const available = new Set ( [
2026-02-06 14:21:15 +09:00
"anthropic/claude-opus-4-6" ,
2026-02-18 15:51:24 +09:00
"anthropic/claude-sonnet-4-6" ,
2026-01-22 22:43:10 +09:00
] )
const result = fuzzyMatchModel ( "claude-opus" , available )
2026-02-06 14:21:15 +09:00
expect ( result ) . toBe ( "anthropic/claude-opus-4-6" )
2026-01-22 22:43:10 +09:00
} )
2026-02-17 01:27:10 +09:00
// given github-copilot serves claude versions with dot notation
// when fallback chain uses hyphen notation in requested model
// then normalize both forms and match github-copilot model
it ( "should match github-copilot claude-opus-4-6 to claude-opus-4.6" , ( ) = > {
const available = new Set ( [
"github-copilot/claude-opus-4.6" ,
"opencode/glm-4.7-free" ,
] )
const result = fuzzyMatchModel ( "claude-opus-4-6" , available , [ "github-copilot" ] )
expect ( result ) . toBe ( "github-copilot/claude-opus-4.6" )
} )
// given claude models can evolve to newer version numbers
// when matching across dot and hyphen version separators
// then normalize generically without hardcoding specific versions
it ( "should normalize claude version separators for future versions" , ( ) = > {
const available = new Set ( [ "github-copilot/claude-sonnet-5.1" ] )
const result = fuzzyMatchModel ( "claude-sonnet-5-1" , available , [ "github-copilot" ] )
expect ( result ) . toBe ( "github-copilot/claude-sonnet-5.1" )
} )
2026-02-01 16:47:50 +09:00
// given available models from multiple providers
// when providers filter is specified
// then only search models from specified providers
2026-01-22 22:43:10 +09:00
it ( "should filter by provider when providers array is given" , ( ) = > {
const available = new Set ( [
"openai/gpt-5.2" ,
2026-02-06 14:21:15 +09:00
"anthropic/claude-opus-4-6" ,
2026-01-22 22:43:10 +09:00
"google/gemini-3" ,
] )
const result = fuzzyMatchModel ( "gpt" , available , [ "openai" ] )
expect ( result ) . toBe ( "openai/gpt-5.2" )
} )
2026-02-01 16:47:50 +09:00
// given available models from multiple providers
// when providers filter excludes matching models
// then return null
2026-01-22 22:43:10 +09:00
it ( "should return null when provider filter excludes all matches" , ( ) = > {
const available = new Set ( [
"openai/gpt-5.2" ,
2026-02-06 14:21:15 +09:00
"anthropic/claude-opus-4-6" ,
2026-01-22 22:43:10 +09:00
] )
const result = fuzzyMatchModel ( "claude" , available , [ "openai" ] )
expect ( result ) . toBeNull ( )
} )
2026-02-01 16:47:50 +09:00
// given available models
// when no substring match exists
// then return null
2026-01-22 22:43:10 +09:00
it ( "should return null when no match found" , ( ) = > {
const available = new Set ( [
"openai/gpt-5.2" ,
2026-02-06 14:21:15 +09:00
"anthropic/claude-opus-4-6" ,
2026-01-22 22:43:10 +09:00
] )
const result = fuzzyMatchModel ( "gemini" , available )
expect ( result ) . toBeNull ( )
} )
2026-02-01 16:47:50 +09:00
// given available models with different cases
// when searching with different case
// then match case-insensitively
2026-01-22 22:43:10 +09:00
it ( "should match case-insensitively" , ( ) = > {
const available = new Set ( [
"openai/gpt-5.2" ,
2026-02-06 14:21:15 +09:00
"anthropic/claude-opus-4-6" ,
2026-01-22 22:43:10 +09:00
] )
const result = fuzzyMatchModel ( "GPT-5.2" , available )
expect ( result ) . toBe ( "openai/gpt-5.2" )
} )
2026-02-01 16:47:50 +09:00
// given available models with exact match and longer variants
// when searching for exact match
// then return exact match first
2026-01-22 22:43:10 +09:00
it ( "should prioritize exact match over longer variants" , ( ) = > {
const available = new Set ( [
2026-02-06 14:21:15 +09:00
"anthropic/claude-opus-4-6" ,
"anthropic/claude-opus-4-6-extended" ,
2026-01-22 22:43:10 +09:00
] )
2026-02-06 14:21:15 +09:00
const result = fuzzyMatchModel ( "claude-opus-4-6" , available )
expect ( result ) . toBe ( "anthropic/claude-opus-4-6" )
2026-01-22 22:43:10 +09:00
} )
2026-02-04 11:25:59 +09:00
// given available models with similar model IDs (e.g., glm-4.7 and glm-4.7-free)
// when searching for the longer variant (glm-4.7-free)
// then return exact model ID match, not the shorter one
it ( "should prefer exact model ID match over shorter substring match" , ( ) = > {
const available = new Set ( [
"zai-coding-plan/glm-4.7" ,
"zai-coding-plan/glm-4.7-free" ,
] )
const result = fuzzyMatchModel ( "glm-4.7-free" , available )
expect ( result ) . toBe ( "zai-coding-plan/glm-4.7-free" )
} )
// given available models with similar model IDs
// when searching for the shorter variant
// then return the shorter match (existing behavior preserved)
it ( "should still prefer shorter match when searching for shorter variant" , ( ) = > {
const available = new Set ( [
"zai-coding-plan/glm-4.7" ,
"zai-coding-plan/glm-4.7-free" ,
] )
const result = fuzzyMatchModel ( "glm-4.7" , available )
expect ( result ) . toBe ( "zai-coding-plan/glm-4.7" )
} )
// given same model ID from multiple providers
// when searching for exact model ID
// then return shortest full string (preserves tie-break behavior)
it ( "should use shortest tie-break when multiple providers have same model ID" , ( ) = > {
const available = new Set ( [
"opencode/gpt-5.2" ,
"openai/gpt-5.2" ,
] )
const result = fuzzyMatchModel ( "gpt-5.2" , available )
expect ( result ) . toBe ( "openai/gpt-5.2" )
} )
2026-02-01 16:47:50 +09:00
// given available models with multiple providers
// when multiple providers are specified
// then search all specified providers
2026-01-22 22:43:10 +09:00
it ( "should search all specified providers" , ( ) = > {
const available = new Set ( [
"openai/gpt-5.2" ,
2026-02-06 14:21:15 +09:00
"anthropic/claude-opus-4-6" ,
2026-01-22 22:43:10 +09:00
"google/gemini-3" ,
] )
const result = fuzzyMatchModel ( "gpt" , available , [ "openai" , "google" ] )
expect ( result ) . toBe ( "openai/gpt-5.2" )
} )
2026-02-01 16:47:50 +09:00
// given available models with provider prefix
// when searching with provider filter
// then only match models with correct provider prefix
2026-01-22 22:43:10 +09:00
it ( "should only match models with correct provider prefix" , ( ) = > {
const available = new Set ( [
"openai/gpt-5.2" ,
"anthropic/gpt-something" ,
] )
const result = fuzzyMatchModel ( "gpt" , available , [ "openai" ] )
expect ( result ) . toBe ( "openai/gpt-5.2" )
} )
2026-02-01 16:47:50 +09:00
// given empty available set
// when searching
// then return null
2026-01-22 22:43:10 +09:00
it ( "should return null for empty available set" , ( ) = > {
const available = new Set < string > ( )
const result = fuzzyMatchModel ( "gpt" , available )
expect ( result ) . toBeNull ( )
} )
} )
2026-01-26 11:53:41 +09:00
describe ( "getConnectedProviders" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given SDK client with connected providers
// when provider.list returns data
// then returns connected array
2026-01-26 11:53:41 +09:00
it ( "should return connected providers from SDK" , async ( ) = > {
const mockClient = {
provider : {
list : async ( ) = > ( {
data : { connected : [ "anthropic" , "opencode" , "google" ] }
} )
}
}
const result = await getConnectedProviders ( mockClient )
expect ( result ) . toEqual ( [ "anthropic" , "opencode" , "google" ] )
} )
2026-02-01 16:47:50 +09:00
// given SDK client
// when provider.list throws error
// then returns empty array
2026-01-26 11:53:41 +09:00
it ( "should return empty array on SDK error" , async ( ) = > {
const mockClient = {
provider : {
list : async ( ) = > { throw new Error ( "Network error" ) }
}
}
const result = await getConnectedProviders ( mockClient )
expect ( result ) . toEqual ( [ ] )
} )
2026-02-01 16:47:50 +09:00
// given SDK client with empty connected array
// when provider.list returns empty
// then returns empty array
2026-01-26 11:53:41 +09:00
it ( "should return empty array when no providers connected" , async ( ) = > {
const mockClient = {
provider : {
list : async ( ) = > ( { data : { connected : [ ] } } )
}
}
const result = await getConnectedProviders ( mockClient )
expect ( result ) . toEqual ( [ ] )
} )
2026-02-01 16:47:50 +09:00
// given SDK client without provider.list method
// when getConnectedProviders called
// then returns empty array
2026-01-26 11:53:41 +09:00
it ( "should return empty array when client.provider.list not available" , async ( ) = > {
const mockClient = { }
const result = await getConnectedProviders ( mockClient )
expect ( result ) . toEqual ( [ ] )
} )
2026-02-01 16:47:50 +09:00
// given null client
// when getConnectedProviders called
// then returns empty array
2026-01-26 11:53:41 +09:00
it ( "should return empty array for null client" , async ( ) = > {
const result = await getConnectedProviders ( null )
expect ( result ) . toEqual ( [ ] )
} )
2026-02-01 16:47:50 +09:00
// given SDK client with missing data.connected
// when provider.list returns without connected field
// then returns empty array
2026-01-26 11:53:41 +09:00
it ( "should return empty array when data.connected is undefined" , async ( ) = > {
const mockClient = {
provider : {
list : async ( ) = > ( { data : { } } )
}
}
const result = await getConnectedProviders ( mockClient )
expect ( result ) . toEqual ( [ ] )
} )
} )
describe ( "fetchAvailableModels with connected providers filtering" , ( ) = > {
let tempDir : string
let originalXdgCache : string | undefined
beforeEach ( ( ) = > {
__resetModelCache ( )
tempDir = mkdtempSync ( join ( tmpdir ( ) , "opencode-test-" ) )
originalXdgCache = process . env . XDG_CACHE_HOME
process . env . XDG_CACHE_HOME = tempDir
} )
afterEach ( ( ) = > {
if ( originalXdgCache !== undefined ) {
process . env . XDG_CACHE_HOME = originalXdgCache
} else {
delete process . env . XDG_CACHE_HOME
}
rmSync ( tempDir , { recursive : true , force : true } )
} )
function writeModelsCache ( data : Record < string , any > ) {
const cacheDir = join ( tempDir , "opencode" )
require ( "fs" ) . mkdirSync ( cacheDir , { recursive : true } )
writeFileSync ( join ( cacheDir , "models.json" ) , JSON . stringify ( data ) )
}
2026-02-01 16:47:50 +09:00
// given cache with multiple providers
// when connectedProviders specifies one provider
// then only returns models from that provider
2026-01-26 11:53:41 +09:00
it ( "should filter models by connected providers" , async ( ) = > {
writeModelsCache ( {
openai : { models : { "gpt-5.2" : { id : "gpt-5.2" } } } ,
2026-02-06 14:21:15 +09:00
anthropic : { models : { "claude-opus-4-6" : { id : "claude-opus-4-6" } } } ,
2026-01-26 11:53:41 +09:00
google : { models : { "gemini-3-pro" : { id : "gemini-3-pro" } } } ,
} )
const result = await fetchAvailableModels ( undefined , {
connectedProviders : [ "anthropic" ]
} )
expect ( result . size ) . toBe ( 1 )
2026-02-06 14:21:15 +09:00
expect ( result . has ( "anthropic/claude-opus-4-6" ) ) . toBe ( true )
2026-01-26 11:53:41 +09:00
expect ( result . has ( "openai/gpt-5.2" ) ) . toBe ( false )
expect ( result . has ( "google/gemini-3-pro" ) ) . toBe ( false )
} )
2026-02-01 16:47:50 +09:00
// given cache with multiple providers
// when connectedProviders specifies multiple providers
// then returns models from all specified providers
2026-01-26 11:53:41 +09:00
it ( "should filter models by multiple connected providers" , async ( ) = > {
writeModelsCache ( {
openai : { models : { "gpt-5.2" : { id : "gpt-5.2" } } } ,
2026-02-06 14:21:15 +09:00
anthropic : { models : { "claude-opus-4-6" : { id : "claude-opus-4-6" } } } ,
2026-01-26 11:53:41 +09:00
google : { models : { "gemini-3-pro" : { id : "gemini-3-pro" } } } ,
} )
const result = await fetchAvailableModels ( undefined , {
connectedProviders : [ "anthropic" , "google" ]
} )
expect ( result . size ) . toBe ( 2 )
2026-02-06 14:21:15 +09:00
expect ( result . has ( "anthropic/claude-opus-4-6" ) ) . toBe ( true )
2026-01-26 11:53:41 +09:00
expect ( result . has ( "google/gemini-3-pro" ) ) . toBe ( true )
expect ( result . has ( "openai/gpt-5.2" ) ) . toBe ( false )
} )
2026-02-01 16:47:50 +09:00
// given cache with models
// when connectedProviders is empty array
// then returns empty set
2026-01-26 11:53:41 +09:00
it ( "should return empty set when connectedProviders is empty" , async ( ) = > {
writeModelsCache ( {
openai : { models : { "gpt-5.2" : { id : "gpt-5.2" } } } ,
2026-02-06 14:21:15 +09:00
anthropic : { models : { "claude-opus-4-6" : { id : "claude-opus-4-6" } } } ,
2026-01-26 11:53:41 +09:00
} )
const result = await fetchAvailableModels ( undefined , {
connectedProviders : [ ]
} )
expect ( result . size ) . toBe ( 0 )
} )
2026-02-01 16:47:50 +09:00
// given cache with models
// when connectedProviders is undefined (no options)
// then returns empty set (triggers fallback in resolver)
2026-01-26 11:53:41 +09:00
it ( "should return empty set when connectedProviders not specified" , async ( ) = > {
writeModelsCache ( {
openai : { models : { "gpt-5.2" : { id : "gpt-5.2" } } } ,
2026-02-06 14:21:15 +09:00
anthropic : { models : { "claude-opus-4-6" : { id : "claude-opus-4-6" } } } ,
2026-01-26 11:53:41 +09:00
} )
const result = await fetchAvailableModels ( )
expect ( result . size ) . toBe ( 0 )
} )
2026-02-01 16:47:50 +09:00
// given cache with models
// when connectedProviders contains provider not in cache
// then returns empty set for that provider
2026-01-26 11:53:41 +09:00
it ( "should handle provider not in cache gracefully" , async ( ) = > {
writeModelsCache ( {
openai : { models : { "gpt-5.2" : { id : "gpt-5.2" } } } ,
} )
const result = await fetchAvailableModels ( undefined , {
connectedProviders : [ "azure" ]
} )
expect ( result . size ) . toBe ( 0 )
} )
2026-02-01 16:47:50 +09:00
// given cache with models and mixed connected providers
// when some providers exist in cache and some don't
// then returns models only from matching providers
2026-01-26 11:53:41 +09:00
it ( "should return models from providers that exist in both cache and connected list" , async ( ) = > {
writeModelsCache ( {
openai : { models : { "gpt-5.2" : { id : "gpt-5.2" } } } ,
2026-02-06 14:21:15 +09:00
anthropic : { models : { "claude-opus-4-6" : { id : "claude-opus-4-6" } } } ,
2026-01-26 11:53:41 +09:00
} )
const result = await fetchAvailableModels ( undefined , {
connectedProviders : [ "anthropic" , "azure" , "unknown" ]
} )
expect ( result . size ) . toBe ( 1 )
2026-02-06 14:21:15 +09:00
expect ( result . has ( "anthropic/claude-opus-4-6" ) ) . toBe ( true )
2026-01-26 11:53:41 +09:00
} )
2026-02-01 16:47:50 +09:00
// given filtered fetch
// when called twice with different filters
// then does NOT use cache (dynamic per-session)
2026-01-26 11:53:41 +09:00
it ( "should not cache filtered results" , async ( ) = > {
writeModelsCache ( {
openai : { models : { "gpt-5.2" : { id : "gpt-5.2" } } } ,
2026-02-06 14:21:15 +09:00
anthropic : { models : { "claude-opus-4-6" : { id : "claude-opus-4-6" } } } ,
2026-01-26 11:53:41 +09:00
} )
// First call with anthropic
const result1 = await fetchAvailableModels ( undefined , {
connectedProviders : [ "anthropic" ]
} )
expect ( result1 . size ) . toBe ( 1 )
// Second call with openai - should work, not cached
const result2 = await fetchAvailableModels ( undefined , {
connectedProviders : [ "openai" ]
} )
expect ( result2 . size ) . toBe ( 1 )
expect ( result2 . has ( "openai/gpt-5.2" ) ) . toBe ( true )
} )
2026-02-01 16:47:50 +09:00
// given connectedProviders unknown
// when called twice without connectedProviders
// then always returns empty set (triggers fallback)
2026-01-26 11:53:41 +09:00
it ( "should return empty set when connectedProviders unknown" , async ( ) = > {
writeModelsCache ( {
openai : { models : { "gpt-5.2" : { id : "gpt-5.2" } } } ,
} )
const result1 = await fetchAvailableModels ( )
const result2 = await fetchAvailableModels ( )
expect ( result1 . size ) . toBe ( 0 )
expect ( result2 . size ) . toBe ( 0 )
} )
} )
describe ( "fetchAvailableModels with provider-models cache (whitelist-filtered)" , ( ) = > {
let tempDir : string
let originalXdgCache : string | undefined
beforeEach ( ( ) = > {
__resetModelCache ( )
tempDir = mkdtempSync ( join ( tmpdir ( ) , "opencode-test-" ) )
originalXdgCache = process . env . XDG_CACHE_HOME
process . env . XDG_CACHE_HOME = tempDir
} )
afterEach ( ( ) = > {
if ( originalXdgCache !== undefined ) {
process . env . XDG_CACHE_HOME = originalXdgCache
} else {
delete process . env . XDG_CACHE_HOME
}
rmSync ( tempDir , { recursive : true , force : true } )
} )
2026-02-05 15:32:08 +09:00
function writeProviderModelsCache ( data : { models : Record < string , string [ ] | any [ ] > ; connected : string [ ] } ) {
2026-01-26 11:53:41 +09:00
const cacheDir = join ( tempDir , "oh-my-opencode" )
require ( "fs" ) . mkdirSync ( cacheDir , { recursive : true } )
writeFileSync ( join ( cacheDir , "provider-models.json" ) , JSON . stringify ( {
. . . data ,
updatedAt : new Date ( ) . toISOString ( )
} ) )
}
function writeModelsCache ( data : Record < string , any > ) {
const cacheDir = join ( tempDir , "opencode" )
require ( "fs" ) . mkdirSync ( cacheDir , { recursive : true } )
writeFileSync ( join ( cacheDir , "models.json" ) , JSON . stringify ( data ) )
}
2026-02-01 16:47:50 +09:00
// given provider-models cache exists (whitelist-filtered)
// when fetchAvailableModels called
// then uses provider-models cache instead of models.json
2026-01-26 11:53:41 +09:00
it ( "should prefer provider-models cache over models.json" , async ( ) = > {
writeProviderModelsCache ( {
models : {
2026-01-30 13:44:04 +09:00
opencode : [ "glm-4.7-free" , "gpt-5-nano" ] ,
2026-02-06 14:21:15 +09:00
anthropic : [ "claude-opus-4-6" ]
2026-01-26 11:53:41 +09:00
} ,
connected : [ "opencode" , "anthropic" ]
} )
writeModelsCache ( {
2026-01-30 13:44:04 +09:00
opencode : { models : { "glm-4.7-free" : { } , "gpt-5-nano" : { } , "gpt-5.2" : { } } } ,
2026-02-18 15:51:24 +09:00
anthropic : { models : { "claude-opus-4-6" : { } , "claude-sonnet-4-6" : { } } }
2026-01-26 11:53:41 +09:00
} )
const result = await fetchAvailableModels ( undefined , {
connectedProviders : [ "opencode" , "anthropic" ]
} )
expect ( result . size ) . toBe ( 3 )
2026-01-30 13:44:04 +09:00
expect ( result . has ( "opencode/glm-4.7-free" ) ) . toBe ( true )
2026-01-26 11:53:41 +09:00
expect ( result . has ( "opencode/gpt-5-nano" ) ) . toBe ( true )
2026-02-06 14:21:15 +09:00
expect ( result . has ( "anthropic/claude-opus-4-6" ) ) . toBe ( true )
2026-01-26 11:53:41 +09:00
expect ( result . has ( "opencode/gpt-5.2" ) ) . toBe ( false )
2026-02-18 15:51:24 +09:00
expect ( result . has ( "anthropic/claude-sonnet-4-6" ) ) . toBe ( false )
2026-01-26 11:53:41 +09:00
} )
2026-02-01 16:47:50 +09:00
// given provider-models cache exists but has no models (API failure)
// when fetchAvailableModels called
// then falls back to models.json so fuzzy matching can still work
2026-01-30 16:57:13 +09:00
it ( "should fall back to models.json when provider-models cache is empty" , async ( ) = > {
writeProviderModelsCache ( {
models : {
} ,
connected : [ "google" ] ,
} )
writeModelsCache ( {
google : { models : { "gemini-3-flash-preview" : { } } } ,
} )
const availableModels = await fetchAvailableModels ( undefined , {
connectedProviders : [ "google" ] ,
} )
const match = fuzzyMatchModel ( "google/gemini-3-flash" , availableModels , [ "google" ] )
expect ( match ) . toBe ( "google/gemini-3-flash-preview" )
} )
2026-02-01 16:47:50 +09:00
// given only models.json exists (no provider-models cache)
// when fetchAvailableModels called
// then falls back to models.json (no whitelist filtering)
2026-01-26 11:53:41 +09:00
it ( "should fallback to models.json when provider-models cache not found" , async ( ) = > {
writeModelsCache ( {
2026-01-30 13:44:04 +09:00
opencode : { models : { "glm-4.7-free" : { } , "gpt-5-nano" : { } , "gpt-5.2" : { } } } ,
2026-01-26 11:53:41 +09:00
} )
const result = await fetchAvailableModels ( undefined , {
connectedProviders : [ "opencode" ]
} )
expect ( result . size ) . toBe ( 3 )
2026-01-30 13:44:04 +09:00
expect ( result . has ( "opencode/glm-4.7-free" ) ) . toBe ( true )
2026-01-26 11:53:41 +09:00
expect ( result . has ( "opencode/gpt-5-nano" ) ) . toBe ( true )
expect ( result . has ( "opencode/gpt-5.2" ) ) . toBe ( true )
} )
2026-02-01 16:47:50 +09:00
// given provider-models cache with whitelist
// when connectedProviders filters to subset
// then only returns models from connected providers
2026-01-26 11:53:41 +09:00
it ( "should filter by connectedProviders even with provider-models cache" , async ( ) = > {
writeProviderModelsCache ( {
models : {
2026-01-30 13:44:04 +09:00
opencode : [ "glm-4.7-free" ] ,
2026-02-06 14:21:15 +09:00
anthropic : [ "claude-opus-4-6" ] ,
2026-01-26 11:53:41 +09:00
google : [ "gemini-3-pro" ]
} ,
connected : [ "opencode" , "anthropic" , "google" ]
} )
const result = await fetchAvailableModels ( undefined , {
connectedProviders : [ "opencode" ]
} )
expect ( result . size ) . toBe ( 1 )
2026-01-30 13:44:04 +09:00
expect ( result . has ( "opencode/glm-4.7-free" ) ) . toBe ( true )
2026-02-06 14:21:15 +09:00
expect ( result . has ( "anthropic/claude-opus-4-6" ) ) . toBe ( false )
2026-01-26 11:53:41 +09:00
expect ( result . has ( "google/gemini-3-pro" ) ) . toBe ( false )
} )
2026-02-05 15:32:08 +09:00
it ( "should handle object[] format with metadata (Ollama-style)" , async ( ) = > {
writeProviderModelsCache ( {
models : {
ollama : [
{ id : "ministral-3:14b-32k-agent" , provider : "ollama" , context : 32768 , output : 8192 } ,
{ id : "qwen3-coder:32k-agent" , provider : "ollama" , context : 32768 , output : 8192 }
]
} ,
connected : [ "ollama" ]
} )
const result = await fetchAvailableModels ( undefined , {
connectedProviders : [ "ollama" ]
} )
expect ( result . size ) . toBe ( 2 )
expect ( result . has ( "ollama/ministral-3:14b-32k-agent" ) ) . toBe ( true )
expect ( result . has ( "ollama/qwen3-coder:32k-agent" ) ) . toBe ( true )
} )
it ( "should handle mixed string[] and object[] formats across providers" , async ( ) = > {
writeProviderModelsCache ( {
models : {
2026-02-18 15:51:24 +09:00
anthropic : [ "claude-opus-4-6" , "claude-sonnet-4-6" ] ,
2026-02-05 15:32:08 +09:00
ollama : [
{ id : "ministral-3:14b-32k-agent" , provider : "ollama" } ,
{ id : "qwen3-coder:32k-agent" , provider : "ollama" }
]
} ,
connected : [ "anthropic" , "ollama" ]
} )
const result = await fetchAvailableModels ( undefined , {
connectedProviders : [ "anthropic" , "ollama" ]
} )
expect ( result . size ) . toBe ( 4 )
2026-02-06 14:21:15 +09:00
expect ( result . has ( "anthropic/claude-opus-4-6" ) ) . toBe ( true )
2026-02-18 15:51:24 +09:00
expect ( result . has ( "anthropic/claude-sonnet-4-6" ) ) . toBe ( true )
2026-02-05 15:32:08 +09:00
expect ( result . has ( "ollama/ministral-3:14b-32k-agent" ) ) . toBe ( true )
expect ( result . has ( "ollama/qwen3-coder:32k-agent" ) ) . toBe ( true )
} )
it ( "should skip invalid entries in object[] format" , async ( ) = > {
writeProviderModelsCache ( {
models : {
ollama : [
{ id : "valid-model" , provider : "ollama" } ,
{ provider : "ollama" } ,
{ id : "" , provider : "ollama" } ,
null ,
"string-model"
]
} ,
connected : [ "ollama" ]
} )
const result = await fetchAvailableModels ( undefined , {
connectedProviders : [ "ollama" ]
} )
expect ( result . size ) . toBe ( 2 )
expect ( result . has ( "ollama/valid-model" ) ) . toBe ( true )
expect ( result . has ( "ollama/string-model" ) ) . toBe ( true )
} )
2026-01-26 11:53:41 +09:00
} )
2026-01-30 15:11:32 +09:00
describe ( "isModelAvailable" , ( ) = > {
it ( "returns true when model exists via fuzzy match" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-02-06 14:21:15 +09:00
const available = new Set ( [ "openai/gpt-5.3-codex" , "anthropic/claude-opus-4-6" ] )
2026-01-30 15:11:32 +09:00
2026-02-01 16:47:50 +09:00
// when
2026-02-06 14:20:05 +09:00
const result = isModelAvailable ( "gpt-5.3-codex" , available )
2026-01-30 15:11:32 +09:00
2026-02-01 16:47:50 +09:00
// then
2026-01-30 15:11:32 +09:00
expect ( result ) . toBe ( true )
} )
it ( "returns false when model not found" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-02-06 14:21:15 +09:00
const available = new Set ( [ "anthropic/claude-opus-4-6" ] )
2026-01-30 15:11:32 +09:00
2026-02-01 16:47:50 +09:00
// when
2026-02-06 14:20:05 +09:00
const result = isModelAvailable ( "gpt-5.3-codex" , available )
2026-01-30 15:11:32 +09:00
2026-02-01 16:47:50 +09:00
// then
2026-01-30 15:11:32 +09:00
expect ( result ) . toBe ( false )
} )
it ( "returns false for empty available set" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-30 15:11:32 +09:00
const available = new Set < string > ( )
2026-02-01 16:47:50 +09:00
// when
2026-02-06 14:20:05 +09:00
const result = isModelAvailable ( "gpt-5.3-codex" , available )
2026-01-30 15:11:32 +09:00
2026-02-01 16:47:50 +09:00
// then
2026-01-30 15:11:32 +09:00
expect ( result ) . toBe ( false )
} )
} )
2026-02-17 10:29:48 +09:00
describe ( "fallback model availability" , ( ) = > {
let tempDir : string
let originalXdgCache : string | undefined
beforeEach ( ( ) = > {
// given
tempDir = mkdtempSync ( join ( tmpdir ( ) , "opencode-test-" ) )
originalXdgCache = process . env . XDG_CACHE_HOME
process . env . XDG_CACHE_HOME = tempDir
} )
afterEach ( ( ) = > {
if ( originalXdgCache !== undefined ) {
process . env . XDG_CACHE_HOME = originalXdgCache
} else {
delete process . env . XDG_CACHE_HOME
}
rmSync ( tempDir , { recursive : true , force : true } )
} )
function writeConnectedProvidersCache ( connected : string [ ] ) : void {
const cacheDir = join ( tempDir , "oh-my-opencode" )
require ( "fs" ) . mkdirSync ( cacheDir , { recursive : true } )
writeFileSync (
join ( cacheDir , "connected-providers.json" ) ,
JSON . stringify ( { connected , updatedAt : new Date ( ) . toISOString ( ) } ) ,
)
}
it ( "returns null for completely unknown model" , ( ) = > {
// given
const available = new Set ( [ "openai/gpt-5.2" , "anthropic/claude-opus-4-6" ] )
// when
const result = fuzzyMatchModel ( "non-existent-model-family" , available )
// then
expect ( result ) . toBeNull ( )
} )
it ( "returns true when models do not match but provider is connected" , ( ) = > {
// given
const fallbackChain = [ { providers : [ "openai" ] , model : "gpt-5.2" } ]
const availableModels = new Set ( [ "anthropic/claude-opus-4-6" ] )
writeConnectedProvidersCache ( [ "openai" ] )
// when
const result = isAnyFallbackModelAvailable ( fallbackChain , availableModels )
// then
expect ( result ) . toBe ( true )
} )
it ( "returns first resolved fallback model from chain" , ( ) = > {
// given
const fallbackChain = [
{ providers : [ "openai" ] , model : "gpt-5.2" } ,
{ providers : [ "anthropic" ] , model : "claude-opus-4-6" } ,
]
const availableModels = new Set ( [
"anthropic/claude-opus-4-6" ,
"openai/gpt-5.2-preview" ,
] )
// when
const result = resolveFirstAvailableFallback ( fallbackChain , availableModels )
// then
expect ( result ) . toEqual ( { provider : "openai" , model : "openai/gpt-5.2-preview" } )
} )
it ( "returns null when no fallback model resolves" , ( ) = > {
// given
const fallbackChain = [
{ providers : [ "openai" ] , model : "gpt-5.2" } ,
{ providers : [ "anthropic" ] , model : "claude-opus-4-6" } ,
]
const availableModels = new Set ( [ "google/gemini-3-pro" ] )
// when
const result = resolveFirstAvailableFallback ( fallbackChain , availableModels )
// then
expect ( result ) . toBeNull ( )
} )
} )