Skip to content

Commit bdca1cb

Browse files
committed
fix: harden OAuth model cost restoration
1 parent a504213 commit bdca1cb

3 files changed

Lines changed: 171 additions & 17 deletions

File tree

packages/opencode/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,7 @@ export async function CodexAuthPlugin(
763763
if (!zeroCosts && !catalog && !warnedCostCatalogUnavailable) {
764764
warnedCostCatalogUnavailable = true
765765
logModels.warn(
766-
'models.dev catalog unavailable; preserving incoming OAuth model costs',
766+
'models.dev catalog unavailable; OAuth model costs could not be restored',
767767
)
768768
}
769769

packages/opencode/src/model-costs.ts

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { join } from 'node:path'
44

55
const MODELS_DEV_URL = 'https://models.dev/api.json'
66
const MODELS_CACHE_ENV = 'OPENCODE_OPENAI_AUTH_MODELS_CACHE'
7+
const OPENCODE_MODELS_PATH_ENV = 'OPENCODE_MODELS_PATH'
8+
const OPENCODE_DISABLE_MODELS_FETCH_ENV = 'OPENCODE_DISABLE_MODELS_FETCH'
79
const FETCH_TIMEOUT_MS = 10_000
10+
const CATALOG_CACHE_TTL_MS = 5 * 60_000
811

912
type CacheCost = { read: number; write: number }
1013

@@ -33,8 +36,11 @@ function record(value: unknown): Record<string, unknown> | null {
3336
: null
3437
}
3538

36-
function finiteNumber(value: unknown): number {
37-
return typeof value === 'number' && Number.isFinite(value) ? value : 0
39+
function optionalPrice(value: unknown): number | null {
40+
if (value === undefined) return 0
41+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
42+
? value
43+
: null
3844
}
3945

4046
// Restoring a zero price for a direction the catalog actually charges for
@@ -45,14 +51,19 @@ function strictPrices(
4551
raw: Record<string, unknown>,
4652
): { input: number; output: number; cache: CacheCost } | null {
4753
const { input, output } = raw
48-
if (typeof input !== 'number' || !Number.isFinite(input)) return null
49-
if (typeof output !== 'number' || !Number.isFinite(output)) return null
54+
if (typeof input !== 'number' || !Number.isFinite(input) || input < 0)
55+
return null
56+
if (typeof output !== 'number' || !Number.isFinite(output) || output < 0)
57+
return null
58+
const cacheRead = optionalPrice(raw.cache_read)
59+
const cacheWrite = optionalPrice(raw.cache_write)
60+
if (cacheRead === null || cacheWrite === null) return null
5061
return {
5162
input,
5263
output,
5364
cache: {
54-
read: finiteNumber(raw.cache_read),
55-
write: finiteNumber(raw.cache_write),
65+
read: cacheRead,
66+
write: cacheWrite,
5667
},
5768
}
5869
}
@@ -76,6 +87,7 @@ export function toSdkCost(raw: unknown): ModelCost | null {
7687
if (!tier) continue
7788
const tierRule = record(tier.tier)
7889
const size = tierRule?.size
90+
if (tierRule?.type !== 'context') continue
7991
if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0)
8092
continue
8193
const prices = strictPrices(tier)
@@ -107,14 +119,27 @@ function catalogCosts(raw: unknown): Record<string, ModelCost> | null {
107119
const cost = toSdkCost(model?.cost)
108120
if (cost) costs[modelID] = cost
109121
}
110-
return costs
122+
return Object.keys(costs).length > 0 ? costs : null
111123
}
112124

113125
function modelsCachePath(): string {
114-
return (
115-
process.env[MODELS_CACHE_ENV]?.trim() ||
116-
join(homedir(), '.cache', 'opencode', 'models.json')
117-
)
126+
const explicit = process.env[MODELS_CACHE_ENV]?.trim()
127+
if (explicit) return explicit
128+
const hostOverride = process.env[OPENCODE_MODELS_PATH_ENV]?.trim()
129+
if (hostOverride) return hostOverride
130+
131+
const cacheRoot =
132+
process.env.XDG_CACHE_HOME?.trim() ||
133+
(process.platform === 'win32'
134+
? process.env.LOCALAPPDATA?.trim()
135+
: undefined) ||
136+
join(homedir(), '.cache')
137+
return join(cacheRoot, 'opencode', 'models.json')
138+
}
139+
140+
function hostModelsFetchDisabled(): boolean {
141+
const value = process.env[OPENCODE_DISABLE_MODELS_FETCH_ENV]?.toLowerCase()
142+
return value === 'true' || value === '1'
118143
}
119144

120145
async function resolveModelsDevCosts(): Promise<Record<
@@ -128,6 +153,8 @@ async function resolveModelsDevCosts(): Promise<Record<
128153
if (cachedCatalog) return cachedCatalog
129154
} catch {}
130155

156+
if (hostModelsFetchDisabled()) return null
157+
131158
try {
132159
const response = await fetch(MODELS_DEV_URL, {
133160
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -139,14 +166,29 @@ async function resolveModelsDevCosts(): Promise<Record<
139166
}
140167
}
141168

142-
let cachedCosts: Promise<Record<string, ModelCost> | null> | undefined
169+
let cachedCosts:
170+
| {
171+
expiresAt: number
172+
promise: Promise<Record<string, ModelCost> | null>
173+
}
174+
| undefined
143175

144176
export function loadModelsDevCosts(): Promise<Record<
145177
string,
146178
ModelCost
147179
> | null> {
148-
cachedCosts ??= resolveModelsDevCosts()
149-
return cachedCosts
180+
const now = Date.now()
181+
if (cachedCosts && cachedCosts.expiresAt > now) return cachedCosts.promise
182+
183+
const entry = {
184+
expiresAt: now + CATALOG_CACHE_TTL_MS,
185+
promise: resolveModelsDevCosts(),
186+
}
187+
cachedCosts = entry
188+
void entry.promise.then((costs) => {
189+
if (costs === null && cachedCosts === entry) cachedCosts = undefined
190+
})
191+
return entry.promise
150192
}
151193

152194
/** Test-only: drop the memoized catalog so a later load re-reads its source. */

packages/opencode/src/tests/model-costs.test.ts

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { 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'
33
import { tmpdir } from 'node:os'
44
import { join } from 'node:path'
55
import {
@@ -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

Comments
 (0)