Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
155 changes: 155 additions & 0 deletions packages/opencode/src/model-costs.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<string, unknown>,
): { 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 },
})
}
Comment thread
iceteaSA marked this conversation as resolved.
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<string, ModelCost> | null {
const root = record(raw)
const openai = record(root?.openai)
const models = record(openai?.models)
if (!models) return null

const costs: Record<string, ModelCost> = {}
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<Record<
string,
ModelCost
> | 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<Record<string, ModelCost> | null> | undefined

export function loadModelsDevCosts(): Promise<Record<
string,
ModelCost
> | 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
}
62 changes: 58 additions & 4 deletions packages/opencode/src/tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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({
Expand All @@ -3829,16 +3838,61 @@ 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
if (!modelsFn) throw new Error('No models hook')
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 },
},
})
})

Expand Down
Loading