11import { afterEach , beforeEach , describe , expect , it } from 'bun:test'
2- import { mkdtempSync , rmSync , writeFileSync } from 'node:fs'
2+ import { mkdirSync , mkdtempSync , rmSync , writeFileSync } from 'node:fs'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
55import {
@@ -48,11 +48,19 @@ describe('models.dev costs', () => {
4848 let dir : string
4949 let cachePath : string
5050 let restoreModelsCache : string
51+ let restoreModelsPath : string | undefined
52+ let restoreDisableFetch : string | undefined
53+ let restoreXdgCacheHome : string | undefined
54+ let restoreLocalAppData : string | undefined
5155 let restoreFetch : typeof globalThis . fetch
5256
5357 beforeEach ( ( ) => {
5458 restoreModelsCache =
5559 process . env . OPENCODE_OPENAI_AUTH_MODELS_CACHE ?? FLOOR_MODELS_CACHE
60+ restoreModelsPath = process . env . OPENCODE_MODELS_PATH
61+ restoreDisableFetch = process . env . OPENCODE_DISABLE_MODELS_FETCH
62+ restoreXdgCacheHome = process . env . XDG_CACHE_HOME
63+ restoreLocalAppData = process . env . LOCALAPPDATA
5664 restoreFetch = globalThis . fetch
5765 dir = mkdtempSync ( join ( tmpdir ( ) , 'oai-model-costs-' ) )
5866 cachePath = join ( dir , 'models.json' )
@@ -62,6 +70,15 @@ describe('models.dev costs', () => {
6270
6371 afterEach ( ( ) => {
6472 process . env . OPENCODE_OPENAI_AUTH_MODELS_CACHE = restoreModelsCache
73+ if ( restoreModelsPath === undefined ) delete process . env . OPENCODE_MODELS_PATH
74+ else process . env . OPENCODE_MODELS_PATH = restoreModelsPath
75+ if ( restoreDisableFetch === undefined )
76+ delete process . env . OPENCODE_DISABLE_MODELS_FETCH
77+ else process . env . OPENCODE_DISABLE_MODELS_FETCH = restoreDisableFetch
78+ if ( restoreXdgCacheHome === undefined ) delete process . env . XDG_CACHE_HOME
79+ else process . env . XDG_CACHE_HOME = restoreXdgCacheHome
80+ if ( restoreLocalAppData === undefined ) delete process . env . LOCALAPPDATA
81+ else process . env . LOCALAPPDATA = restoreLocalAppData
6582 globalThis . fetch = restoreFetch
6683 resetModelCostsForTest ( )
6784 rmSync ( dir , { recursive : true , force : true } )
@@ -101,6 +118,45 @@ describe('models.dev costs', () => {
101118 } )
102119 } )
103120
121+ it ( 'honors the host models path when no plugin override is set' , async ( ) => {
122+ const hostPath = join ( dir , 'host-models.json' )
123+ writeFileSync (
124+ hostPath ,
125+ JSON . stringify ( {
126+ openai : {
127+ models : { 'gpt-5.6-sol' : { cost : REAL_CATALOG_COST } } ,
128+ } ,
129+ } ) ,
130+ )
131+ delete process . env . OPENCODE_OPENAI_AUTH_MODELS_CACHE
132+ process . env . OPENCODE_MODELS_PATH = hostPath
133+ globalThis . fetch = failingFetch ( 'network must not be needed' )
134+ resetModelCostsForTest ( )
135+
136+ expect ( ( await loadModelsDevCosts ( ) ) ?. [ 'gpt-5.6-sol' ] ?. input ) . toBe ( 5 )
137+ } )
138+
139+ it ( 'honors XDG_CACHE_HOME for the default host cache path' , async ( ) => {
140+ const xdgCache = join ( dir , 'xdg-cache' )
141+ const hostCacheDir = join ( xdgCache , 'opencode' )
142+ mkdirSync ( hostCacheDir , { recursive : true } )
143+ writeFileSync (
144+ join ( hostCacheDir , 'models.json' ) ,
145+ JSON . stringify ( {
146+ openai : {
147+ models : { 'gpt-5.6-sol' : { cost : REAL_CATALOG_COST } } ,
148+ } ,
149+ } ) ,
150+ )
151+ delete process . env . OPENCODE_OPENAI_AUTH_MODELS_CACHE
152+ delete process . env . OPENCODE_MODELS_PATH
153+ process . env . XDG_CACHE_HOME = xdgCache
154+ globalThis . fetch = failingFetch ( 'network must not be needed' )
155+ resetModelCostsForTest ( )
156+
157+ expect ( ( await loadModelsDevCosts ( ) ) ?. [ 'gpt-5.6-sol' ] ?. input ) . toBe ( 5 )
158+ } )
159+
104160 it ( 'maps flat cache, tier, and over-200k fields into the SDK shape' , ( ) => {
105161 expect ( toSdkCost ( REAL_CATALOG_COST ) ) . toEqual ( {
106162 input : 5 ,
@@ -136,6 +192,10 @@ describe('models.dev costs', () => {
136192 expect ( toSdkCost ( { input : 5 , output : Number . POSITIVE_INFINITY } ) ) . toBeNull ( )
137193 expect ( toSdkCost ( { input : '5' , output : 30 } ) ) . toBeNull ( )
138194 expect ( toSdkCost ( { output : 30 } ) ) . toBeNull ( )
195+ expect ( toSdkCost ( { input : - 1 , output : 30 } ) ) . toBeNull ( )
196+ expect ( toSdkCost ( { input : 5 , output : - 1 } ) ) . toBeNull ( )
197+ expect ( toSdkCost ( { input : 5 , output : 30 , cache_read : - 1 } ) ) . toBeNull ( )
198+ expect ( toSdkCost ( { input : 5 , output : 30 , cache_write : - 1 } ) ) . toBeNull ( )
139199 } )
140200
141201 it ( 'drops malformed tier entries instead of defaulting their size to zero' , ( ) => {
@@ -154,7 +214,12 @@ describe('models.dev costs', () => {
154214 input : 10 ,
155215 output : 45 ,
156216 cache_read : 1 ,
157- tier : { size : 272_000 } ,
217+ tier : { type : 'context' , size : 272_000 } ,
218+ } ,
219+ {
220+ input : 10 ,
221+ output : 45 ,
222+ tier : { type : 'tokens' , size : 272_000 } ,
158223 } ,
159224 ] ,
160225 } ) ,
@@ -217,4 +282,51 @@ describe('models.dev costs', () => {
217282
218283 await expect ( loadModelsDevCosts ( ) ) . resolves . toBeNull ( )
219284 } )
285+
286+ it ( 'respects the host setting that disables models.dev fetches' , async ( ) => {
287+ let fetchCalls = 0
288+ globalThis . fetch = Object . assign (
289+ async ( ) => {
290+ fetchCalls += 1
291+ return new Response ( '{}' )
292+ } ,
293+ { preconnect : ( ) => { } } ,
294+ )
295+ process . env . OPENCODE_DISABLE_MODELS_FETCH = '1'
296+
297+ await expect ( loadModelsDevCosts ( ) ) . resolves . toBeNull ( )
298+ expect ( fetchCalls ) . toBe ( 0 )
299+ } )
300+
301+ it ( 'retries after a transient catalog miss without a process restart' , async ( ) => {
302+ globalThis . fetch = failingFetch ( 'network unavailable' )
303+ await expect ( loadModelsDevCosts ( ) ) . resolves . toBeNull ( )
304+
305+ writeFileSync (
306+ cachePath ,
307+ JSON . stringify ( {
308+ openai : {
309+ models : { 'gpt-5.6-sol' : { cost : REAL_CATALOG_COST } } ,
310+ } ,
311+ } ) ,
312+ )
313+
314+ expect ( ( await loadModelsDevCosts ( ) ) ?. [ 'gpt-5.6-sol' ] ?. input ) . toBe ( 5 )
315+ } )
316+
317+ it ( 'treats a catalog with no valid prices as unavailable' , async ( ) => {
318+ writeFileSync (
319+ cachePath ,
320+ JSON . stringify ( {
321+ openai : {
322+ models : {
323+ broken : { cost : { input : - 1 , output : 30 } } ,
324+ } ,
325+ } ,
326+ } ) ,
327+ )
328+ process . env . OPENCODE_DISABLE_MODELS_FETCH = 'true'
329+
330+ await expect ( loadModelsDevCosts ( ) ) . resolves . toBeNull ( )
331+ } )
220332} )
0 commit comments