diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 6ac1e15..9b400c8 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -79,6 +79,7 @@ import { translateHostedWebSearchResponse, } from './hosted-web-search' import { createLogger, setLogLevel } from './logger' +import { loadModelsDevCosts } from './model-costs' import { resolvePromptContext } from './prompt-context' import { normalizeQuotaHeaders } from './quota-normalize' import { @@ -138,6 +139,9 @@ const DEFAULT_MID_STREAM_RATE_LIMIT_RESET_MS = 60_000 const HANDLED_SENTINEL = '__OPENCODE_OPENAI_AUTH_COMMAND_HANDLED__' let bootQuotaSeedStarted = false +const logModels = createLogger('models') +let loggedCostRestoration = false +let warnedCostCatalogUnavailable = false export class AuthPersistError extends Error { readonly code = 'OPENAI_AUTH_PERSIST_FAILED' @@ -588,6 +592,17 @@ export async function CodexAuthPlugin( const storage = await loadAccounts(getConfigPath()) const zeroCosts = !storage || isCostZeroingEnabled(storage) + const catalog = zeroCosts ? null : await loadModelsDevCosts() + if (!zeroCosts && catalog && !loggedCostRestoration) { + loggedCostRestoration = true + logModels.debug('restoring OAuth model costs from models.dev catalog') + } + if (!zeroCosts && !catalog && !warnedCostCatalogUnavailable) { + warnedCostCatalogUnavailable = true + logModels.warn( + 'models.dev catalog unavailable; preserving incoming OAuth model costs', + ) + } return Object.fromEntries( Object.entries(provider.models) @@ -604,7 +619,9 @@ export async function CodexAuthPlugin( ...model, cost: zeroCosts ? { input: 0, output: 0, cache: { read: 0, write: 0 } } - : model.cost, + : (catalog?.[model.api.id] ?? + catalog?.[modelID] ?? + model.cost), limit: model.id.includes('gpt-5.5') ? { context: 400_000, diff --git a/packages/opencode/src/model-costs.ts b/packages/opencode/src/model-costs.ts new file mode 100644 index 0000000..191e840 --- /dev/null +++ b/packages/opencode/src/model-costs.ts @@ -0,0 +1,155 @@ +import { readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const MODELS_DEV_URL = 'https://models.dev/api.json' +const MODELS_CACHE_ENV = 'OPENCODE_OPENAI_AUTH_MODELS_CACHE' +const FETCH_TIMEOUT_MS = 10_000 + +type CacheCost = { read: number; write: number } + +type TierCost = { + input: number + output: number + cache: CacheCost + tier: { type: 'context'; size: number } +} + +export type ModelCost = { + input: number + output: number + cache: CacheCost + tiers?: TierCost[] + experimentalOver200K?: { + input: number + output: number + cache: CacheCost + } +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function finiteNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +// Restoring a zero price for a direction the catalog actually charges for +// would silently under-record costs — the exact failure this module exists +// to prevent. So a price block is only accepted when BOTH directions are +// finite numbers; optional cache prices default to zero. +function strictPrices( + raw: Record, +): { input: number; output: number; cache: CacheCost } | null { + const { input, output } = raw + if (typeof input !== 'number' || !Number.isFinite(input)) return null + if (typeof output !== 'number' || !Number.isFinite(output)) return null + return { + input, + output, + cache: { + read: finiteNumber(raw.cache_read), + write: finiteNumber(raw.cache_write), + }, + } +} + +export function toSdkCost(raw: unknown): ModelCost | null { + const source = record(raw) + if (!source) return null + + const base = strictPrices(source) + if (!base) return null + + const cost: ModelCost = base + if (Array.isArray(source.tiers)) { + // A malformed tier is dropped rather than defaulted: a tier without a + // real positive size would match every request in the host's tier + // selection (contextTokens > size), and zero-defaulted tier prices + // would override the base pricing once the threshold is crossed. + const tiers: TierCost[] = [] + for (const rawTier of source.tiers) { + const tier = record(rawTier) + if (!tier) continue + const tierRule = record(tier.tier) + const size = tierRule?.size + if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0) + continue + const prices = strictPrices(tier) + if (!prices) continue + tiers.push({ + ...prices, + tier: { type: 'context', size }, + }) + } + if (tiers.length > 0) cost.tiers = tiers + } + + const over200K = record(source.context_over_200k) + const overPrices = over200K ? strictPrices(over200K) : null + if (overPrices) cost.experimentalOver200K = overPrices + + return cost +} + +function catalogCosts(raw: unknown): Record | null { + const root = record(raw) + const openai = record(root?.openai) + const models = record(openai?.models) + if (!models) return null + + const costs: Record = {} + for (const [modelID, rawModel] of Object.entries(models)) { + const model = record(rawModel) + const cost = toSdkCost(model?.cost) + if (cost) costs[modelID] = cost + } + return costs +} + +function modelsCachePath(): string { + return ( + process.env[MODELS_CACHE_ENV]?.trim() || + join(homedir(), '.cache', 'opencode', 'models.json') + ) +} + +async function resolveModelsDevCosts(): Promise | null> { + try { + const cachedCatalog = catalogCosts( + JSON.parse(await readFile(modelsCachePath(), 'utf8')), + ) + if (cachedCatalog) return cachedCatalog + } catch {} + + try { + const response = await fetch(MODELS_DEV_URL, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + if (!response.ok) return null + return catalogCosts(await response.json()) + } catch { + return null + } +} + +let cachedCosts: Promise | null> | undefined + +export function loadModelsDevCosts(): Promise | null> { + cachedCosts ??= resolveModelsDevCosts() + return cachedCosts +} + +/** Test-only: drop the memoized catalog so a later load re-reads its source. */ +export function resetModelCostsForTest(): void { + cachedCosts = undefined +} diff --git a/packages/opencode/src/tests/integration.test.ts b/packages/opencode/src/tests/integration.test.ts index 4b56044..a26d97d 100644 --- a/packages/opencode/src/tests/integration.test.ts +++ b/packages/opencode/src/tests/integration.test.ts @@ -14,6 +14,7 @@ import { MAIN_REFRESH_LEASE_TTL_MS, MAIN_REFRESH_LOCK_TTL_MS, } from '../index.ts' +import { resetModelCostsForTest } from '../model-costs.ts' import { ResponseStreamError } from '../response-stream-error' import { drainSidebarWrites, @@ -23,6 +24,7 @@ import { import { FLOOR_AUTH_FILE, FLOOR_LOG_FILE, + FLOOR_MODELS_CACHE, FLOOR_SIDEBAR_STATE_FILE, FLOOR_STATE_FILE, } from './setup-env.ts' @@ -304,6 +306,7 @@ describe('integration: HTTP quota push', () => { process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = logFile process.env.NODE_ENV = 'test' process.env.OPENCODE_CONFIG_DIR = configDir + resetModelCostsForTest() }) afterEach(async () => { @@ -3725,6 +3728,7 @@ describe('integration: no real config read', () => { describe('integration: models cost-zeroing', () => { let configDir: string let configFile: string + let modelsCacheFile: string let stateFile: string function mockProvider() { @@ -3790,19 +3794,24 @@ describe('integration: models cost-zeroing', () => { beforeEach(() => { configDir = tempDir('oai-int-models-') configFile = join(configDir, 'openai-auth.json') + modelsCacheFile = join(configDir, 'models.json') stateFile = join(configDir, 'openai-auth-state.json') process.env.OPENCODE_OPENAI_AUTH_FILE = configFile + process.env.OPENCODE_OPENAI_AUTH_MODELS_CACHE = modelsCacheFile process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = stateFile process.env.NODE_ENV = 'test' process.env.OPENCODE_CONFIG_DIR = configDir + resetModelCostsForTest() }) afterEach(() => { // Restore path envs to floor (not delete) — keeps in-flight writes away from live defaults. process.env.OPENCODE_OPENAI_AUTH_FILE = FLOOR_AUTH_FILE + process.env.OPENCODE_OPENAI_AUTH_MODELS_CACHE = FLOOR_MODELS_CACHE process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = FLOOR_STATE_FILE delete process.env.OPENCODE_CONFIG_DIR delete process.env.NODE_ENV + resetModelCostsForTest() }) it('OAuth + no costZeroing key → costs ZEROED (default-on preserved)', async () => { @@ -3820,7 +3829,7 @@ describe('integration: models cost-zeroing', () => { }) }) - it('OAuth + costZeroing.enabled === false → original cost PRESERVED', async () => { + it('OAuth + costZeroing.enabled === false → catalog cost RESTORED', async () => { writeFileSync( configFile, JSON.stringify({ @@ -3829,6 +3838,38 @@ describe('integration: models cost-zeroing', () => { costZeroing: { enabled: false }, }), ) + writeFileSync( + modelsCacheFile, + JSON.stringify({ + openai: { + models: { + 'gpt-5.5': { + cost: { + input: 5, + output: 30, + cache_read: 0.5, + cache_write: 6.25, + tiers: [ + { + input: 10, + output: 45, + cache_read: 1, + cache_write: 12.5, + tier: { type: 'context', size: 272_000 }, + }, + ], + context_over_200k: { + input: 10, + output: 45, + cache_read: 1, + cache_write: 12.5, + }, + }, + }, + }, + }, + }), + ) const input = createMockPluginInput() const hooks = await CodexAuthPlugin(input) const modelsFn = hooks.provider?.models @@ -3836,9 +3877,22 @@ describe('integration: models cost-zeroing', () => { const result = await modelsFn(mockProvider(), oauthCtx()) const model = result['gpt-5.5']! expect(model.cost).toEqual({ - input: 15, - output: 60, - cache: { read: 7.5, write: 15 }, + input: 5, + output: 30, + cache: { read: 0.5, write: 6.25 }, + tiers: [ + { + input: 10, + output: 45, + cache: { read: 1, write: 12.5 }, + tier: { type: 'context', size: 272_000 }, + }, + ], + experimentalOver200K: { + input: 10, + output: 45, + cache: { read: 1, write: 12.5 }, + }, }) }) diff --git a/packages/opencode/src/tests/model-costs.test.ts b/packages/opencode/src/tests/model-costs.test.ts new file mode 100644 index 0000000..28006b2 --- /dev/null +++ b/packages/opencode/src/tests/model-costs.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + loadModelsDevCosts, + resetModelCostsForTest, + toSdkCost, +} from '../model-costs' +import { FLOOR_MODELS_CACHE } from './setup-env' + +const REAL_CATALOG_COST = { + input: 5, + output: 30, + cache_read: 0.5, + cache_write: 6.25, + tiers: [ + { + input: 10, + output: 45, + cache_read: 1, + cache_write: 12.5, + tier: { type: 'context', size: 272_000 }, + }, + ], + context_over_200k: { + input: 10, + output: 45, + cache_read: 1, + cache_write: 12.5, + }, +} + +function failingFetch(message: string): typeof globalThis.fetch { + return Object.assign( + async () => { + throw new Error(message) + }, + { + preconnect: ( + ..._args: Parameters + ) => {}, + }, + ) +} + +describe('models.dev costs', () => { + let dir: string + let cachePath: string + let restoreModelsCache: string + let restoreFetch: typeof globalThis.fetch + + beforeEach(() => { + restoreModelsCache = + process.env.OPENCODE_OPENAI_AUTH_MODELS_CACHE ?? FLOOR_MODELS_CACHE + restoreFetch = globalThis.fetch + dir = mkdtempSync(join(tmpdir(), 'oai-model-costs-')) + cachePath = join(dir, 'models.json') + process.env.OPENCODE_OPENAI_AUTH_MODELS_CACHE = cachePath + resetModelCostsForTest() + }) + + afterEach(() => { + process.env.OPENCODE_OPENAI_AUTH_MODELS_CACHE = restoreModelsCache + globalThis.fetch = restoreFetch + resetModelCostsForTest() + rmSync(dir, { recursive: true, force: true }) + }) + + it('loads OpenAI prices from the overridden host cache path', async () => { + writeFileSync( + cachePath, + JSON.stringify({ + openai: { + models: { + 'gpt-5.6-sol': { cost: REAL_CATALOG_COST }, + }, + }, + }), + ) + + const catalog = await loadModelsDevCosts() + + expect(catalog?.['gpt-5.6-sol']).toEqual({ + input: 5, + output: 30, + cache: { read: 0.5, write: 6.25 }, + tiers: [ + { + input: 10, + output: 45, + cache: { read: 1, write: 12.5 }, + tier: { type: 'context', size: 272_000 }, + }, + ], + experimentalOver200K: { + input: 10, + output: 45, + cache: { read: 1, write: 12.5 }, + }, + }) + }) + + it('maps flat cache, tier, and over-200k fields into the SDK shape', () => { + expect(toSdkCost(REAL_CATALOG_COST)).toEqual({ + input: 5, + output: 30, + cache: { read: 0.5, write: 6.25 }, + tiers: [ + { + input: 10, + output: 45, + cache: { read: 1, write: 12.5 }, + tier: { type: 'context', size: 272_000 }, + }, + ], + experimentalOver200K: { + input: 10, + output: 45, + cache: { read: 1, write: 12.5 }, + }, + }) + }) + + it('defaults missing cache prices to zero', () => { + expect(toSdkCost({ input: 5, output: 30 })).toEqual({ + input: 5, + output: 30, + cache: { read: 0, write: 0 }, + }) + }) + + it('rejects a model when either required price is missing or non-finite', () => { + expect(toSdkCost({ input: Number.NaN, output: undefined })).toBeNull() + expect(toSdkCost({ input: 5 })).toBeNull() + expect(toSdkCost({ input: 5, output: Number.POSITIVE_INFINITY })).toBeNull() + expect(toSdkCost({ input: '5', output: 30 })).toBeNull() + expect(toSdkCost({ output: 30 })).toBeNull() + }) + + it('drops malformed tier entries instead of defaulting their size to zero', () => { + expect( + toSdkCost({ + input: 5, + output: 30, + tiers: [ + 'garbage', + { input: 10, output: 45, tier: { size: Number.NaN } }, + { input: 10, output: 45, tier: { size: 0 } }, + { input: 10, output: 45, tier: { size: -5 } }, + { input: 10, tier: { size: 300_000 } }, + { input: 10, output: Number.NaN, tier: { size: 300_000 } }, + { + input: 10, + output: 45, + cache_read: 1, + tier: { size: 272_000 }, + }, + ], + }), + ).toEqual({ + input: 5, + output: 30, + cache: { read: 0, write: 0 }, + tiers: [ + { + input: 10, + output: 45, + cache: { read: 1, write: 0 }, + tier: { type: 'context', size: 272_000 }, + }, + ], + }) + }) + + it('omits tiers entirely when every entry is malformed', () => { + expect( + toSdkCost({ + input: 5, + output: 30, + tiers: [{ input: 10, output: 45, tier: { size: 0 } }], + }), + ).toEqual({ + input: 5, + output: 30, + cache: { read: 0, write: 0 }, + }) + }) + + it('omits over-200k pricing when its required prices are malformed', () => { + expect( + toSdkCost({ + input: 5, + output: 30, + context_over_200k: { input: 10 }, + }), + ).toEqual({ + input: 5, + output: 30, + cache: { read: 0, write: 0 }, + }) + expect( + toSdkCost({ + input: 5, + output: 30, + context_over_200k: { input: 10, output: Number.NaN }, + }), + ).toEqual({ + input: 5, + output: 30, + cache: { read: 0, write: 0 }, + }) + }) + + it('returns null without throwing when cache and network are unavailable', async () => { + globalThis.fetch = failingFetch('network unavailable') + + await expect(loadModelsDevCosts()).resolves.toBeNull() + }) +}) diff --git a/packages/opencode/src/tests/model-filter.test.ts b/packages/opencode/src/tests/model-filter.test.ts index ff84d2d..143bd3d 100644 --- a/packages/opencode/src/tests/model-filter.test.ts +++ b/packages/opencode/src/tests/model-filter.test.ts @@ -1,9 +1,15 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { PluginInput } from '@opencode-ai/plugin' import { CodexAuthPlugin } from '../index' +import { type ModelCost, resetModelCostsForTest } from '../model-costs' +import { + FLOOR_AUTH_FILE, + FLOOR_MODELS_CACHE, + FLOOR_STATE_FILE, +} from './setup-env' // Exercises the provider.models hook: which OpenAI models surface to OAuth // users and what context limits they carry. The suffix-less gpt-5.6 (and its @@ -29,14 +35,23 @@ function createMockPluginInput(): PluginInput { type MockModel = { id: string api: { id: string } - cost: { - input: number - output: number - cache: { read: number; write: number } - } + cost: ModelCost limit: { context: number; input: number; output: number } } +function failingFetch(message: string): typeof globalThis.fetch { + return Object.assign( + async () => { + throw new Error(message) + }, + { + preconnect: ( + ..._args: Parameters + ) => {}, + }, + ) +} + function model(id: string, apiId: string): MockModel { return { id, @@ -81,27 +96,242 @@ async function surfacedModels() { } describe('provider.models filter', () => { - let restoreFile: string | undefined - let restoreState: string | undefined + let dir: string + let configPath: string + let modelsCachePath: string + let restoreFile: string + let restoreState: string + let restoreModelsCache: string + let restoreFetch: typeof globalThis.fetch beforeEach(() => { - restoreFile = process.env.OPENCODE_OPENAI_AUTH_FILE - restoreState = process.env.OPENCODE_OPENAI_AUTH_STATE_FILE - const dir = mkdtempSync(join(tmpdir(), 'oai-modelfilter-')) + restoreFile = process.env.OPENCODE_OPENAI_AUTH_FILE ?? FLOOR_AUTH_FILE + restoreState = + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE ?? FLOOR_STATE_FILE + restoreModelsCache = + process.env.OPENCODE_OPENAI_AUTH_MODELS_CACHE ?? FLOOR_MODELS_CACHE + restoreFetch = globalThis.fetch + dir = mkdtempSync(join(tmpdir(), 'oai-modelfilter-')) + configPath = join(dir, 'openai-auth.json') + modelsCachePath = join(dir, 'models.json') // Point at nonexistent files so loadAccounts returns null (cost-zeroing on). - process.env.OPENCODE_OPENAI_AUTH_FILE = join(dir, 'openai-auth.json') + process.env.OPENCODE_OPENAI_AUTH_FILE = configPath process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = join( dir, 'openai-auth-state.json', ) + process.env.OPENCODE_OPENAI_AUTH_MODELS_CACHE = modelsCachePath + resetModelCostsForTest() }) afterEach(() => { - if (restoreFile === undefined) delete process.env.OPENCODE_OPENAI_AUTH_FILE - else process.env.OPENCODE_OPENAI_AUTH_FILE = restoreFile - if (restoreState === undefined) - delete process.env.OPENCODE_OPENAI_AUTH_STATE_FILE - else process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = restoreState + process.env.OPENCODE_OPENAI_AUTH_FILE = restoreFile + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = restoreState + process.env.OPENCODE_OPENAI_AUTH_MODELS_CACHE = restoreModelsCache + globalThis.fetch = restoreFetch + resetModelCostsForTest() + rmSync(dir, { recursive: true, force: true }) + }) + + it('restores catalog prices when incoming OAuth costs were pre-zeroed', async () => { + writeFileSync( + configPath, + JSON.stringify({ + version: 1, + costZeroing: { enabled: false }, + accounts: [], + }), + ) + writeFileSync( + modelsCachePath, + JSON.stringify({ + openai: { + models: { + 'gpt-5.6-sol': { + cost: { + input: 5, + output: 30, + cache_read: 0.5, + cache_write: 6.25, + tiers: [ + { + input: 10, + output: 45, + cache_read: 1, + cache_write: 12.5, + tier: { type: 'context', size: 272_000 }, + }, + ], + context_over_200k: { + input: 10, + output: 45, + cache_read: 1, + cache_write: 12.5, + }, + }, + }, + }, + }, + }), + ) + + const hooks = await CodexAuthPlugin(createMockPluginInput(), { + experimentalWebSockets: false, + }) + const modelsHook = hooks.provider?.models + if (!modelsHook) throw new Error('No provider.models hook') + const incoming = model('gpt-5.6-sol', 'gpt-5.6-sol') + incoming.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } } + + const result = (await modelsHook( + { models: { 'gpt-5.6-sol': incoming } } as never, + { auth: { type: 'oauth' } } as never, + )) as Record + + expect(result['gpt-5.6-sol']?.cost).toEqual({ + input: 5, + output: 30, + cache: { read: 0.5, write: 6.25 }, + tiers: [ + { + input: 10, + output: 45, + cache: { read: 1, write: 12.5 }, + tier: { type: 'context', size: 272_000 }, + }, + ], + experimentalOver200K: { + input: 10, + output: 45, + cache: { read: 1, write: 12.5 }, + }, + }) + }) + + it('keeps zeroing enabled even when catalog prices are available', async () => { + writeFileSync( + configPath, + JSON.stringify({ + version: 1, + costZeroing: { enabled: true }, + accounts: [], + }), + ) + writeFileSync( + modelsCachePath, + JSON.stringify({ + openai: { + models: { + 'gpt-5.6-sol': { + cost: { + input: 5, + output: 30, + cache_read: 0.5, + cache_write: 6.25, + }, + }, + }, + }, + }), + ) + + const hooks = await CodexAuthPlugin(createMockPluginInput(), { + experimentalWebSockets: false, + }) + const modelsHook = hooks.provider?.models + if (!modelsHook) throw new Error('No provider.models hook') + const result = (await modelsHook( + { + models: { 'gpt-5.6-sol': model('gpt-5.6-sol', 'gpt-5.6-sol') }, + } as never, + { auth: { type: 'oauth' } } as never, + )) as Record + + expect(result['gpt-5.6-sol']?.cost).toEqual({ + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }) + }) + + it('preserves incoming costs when the catalog is unavailable', async () => { + writeFileSync( + configPath, + JSON.stringify({ + version: 1, + costZeroing: { enabled: false }, + accounts: [], + }), + ) + globalThis.fetch = failingFetch('network unavailable') + + const hooks = await CodexAuthPlugin(createMockPluginInput(), { + experimentalWebSockets: false, + }) + const modelsHook = hooks.provider?.models + if (!modelsHook) throw new Error('No provider.models hook') + const incoming = model('gpt-5.6-sol', 'gpt-5.6-sol') + + const result = (await modelsHook( + { models: { 'gpt-5.6-sol': incoming } } as never, + { auth: { type: 'oauth' } } as never, + )) as Record + + expect(result['gpt-5.6-sol']?.cost).toEqual(incoming.cost) + }) + + it('looks up catalog costs by API id before falling back to model id', async () => { + writeFileSync( + configPath, + JSON.stringify({ + version: 1, + costZeroing: { enabled: false }, + accounts: [], + }), + ) + writeFileSync( + modelsCachePath, + JSON.stringify({ + openai: { + models: { + 'gpt-5.6-sol': { + cost: { + input: 5, + output: 30, + cache_read: 0.5, + cache_write: 6.25, + }, + }, + 'gpt-5.6-terra': { + cost: { + input: 7, + output: 31, + cache_read: 0.7, + cache_write: 7.25, + }, + }, + }, + }, + }), + ) + + const hooks = await CodexAuthPlugin(createMockPluginInput(), { + experimentalWebSockets: false, + }) + const modelsHook = hooks.provider?.models + if (!modelsHook) throw new Error('No provider.models hook') + const result = (await modelsHook( + { + models: { + 'gpt-5.6-sol-fast': model('gpt-5.6-sol-fast', 'gpt-5.6-sol'), + 'gpt-5.6-terra': model('gpt-5.6-terra', 'gpt-5.6-terra-catalog-miss'), + }, + } as never, + { auth: { type: 'oauth' } } as never, + )) as Record + + expect(result['gpt-5.6-sol-fast']?.cost.input).toBe(5) + expect(result['gpt-5.6-terra']?.cost.input).toBe(7) }) it('drops the suffix-less gpt-5.6 and its -fast/-pro synthetics', async () => { diff --git a/packages/opencode/src/tests/setup-env.ts b/packages/opencode/src/tests/setup-env.ts index a8d0a89..fa0ec0c 100644 --- a/packages/opencode/src/tests/setup-env.ts +++ b/packages/opencode/src/tests/setup-env.ts @@ -24,6 +24,7 @@ export const FLOOR_SIDEBAR_STATE_FILE = join(FLOOR_DIR, 'sidebar-state.json') export const FLOOR_AUTH_FILE = join(FLOOR_DIR, 'openai-auth.json') export const FLOOR_STATE_FILE = join(FLOOR_DIR, 'openai-auth-state.json') export const FLOOR_LOG_FILE = join(FLOOR_DIR, 'openai-auth.log') +export const FLOOR_MODELS_CACHE = join(FLOOR_DIR, 'models.json') // Set the floor values only if the env is not already set (a parent process // or CI may have pre-configured them intentionally). @@ -39,6 +40,9 @@ if (!process.env.OPENCODE_OPENAI_AUTH_STATE_FILE) { if (!process.env.OPENCODE_OPENAI_AUTH_LOG_FILE) { process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = FLOOR_LOG_FILE } +if (!process.env.OPENCODE_OPENAI_AUTH_MODELS_CACHE) { + process.env.OPENCODE_OPENAI_AUTH_MODELS_CACHE = FLOOR_MODELS_CACHE +} // Belt-and-suspenders: remove the floor temp dir when the test process exits // so each run doesn't leak a directory under /tmp.