Skip to content

Commit e203a73

Browse files
authored
fix(overlay): collapse empty cache to unknown availability status (#378)
When the API call fails (timeout, throw, or error envelope) and the local `models.json` cache is readable but produces a zero-size set, `readFallbackCache` was returning `{ status: 'cache', models: <empty Set> }`. The downstream gate `availability.status !== 'unknown'` admitted this, `availabilitySet` became the empty Set, and `resolveSourceModel` fell through to the 'last-resort: first provider's first model' path — pinning bundled agents to a provider the user cannot call. Symmetric with the empty-API collapse from PR #372: both branches in `readFallbackCache` now check `result.size > 0` before returning `{ status: 'cache', ... }`, so an empty cache funnels through `emptyAvailability()` (status: 'unknown') just like the empty-API case. Downstream consumers stay on a single gate rule rather than `status !== 'unknown' && models.size > 0` everywhere. Three regression tests cover the three empty-cache shapes: schema-valid empty object, providers-with-empty-models, and OPENCODE_MODELS_URL-derived empty cache. The DiscoveryStatus JSDoc for 'cache' is updated to reflect the non-empty contract. Closes #373
1 parent 828efd0 commit e203a73

2 files changed

Lines changed: 88 additions & 6 deletions

File tree

src/lib/model-availability.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ export interface OpencodeClientLike {
3535
* operational consequence is identical and downstream consumers should treat
3636
* both cases the same way.
3737
* - `cache`: The API call failed (error envelope, thrown, or timed out) and
38-
* the local `models.json` cache was readable. `models` reflects whatever
39-
* OpenCode last wrote to disk. The cache may itself be empty; callers that
40-
* need to distinguish "cached empty" from "cached with content" should
41-
* inspect `models.size`.
38+
* the local `models.json` cache was readable AND non-empty. `models`
39+
* reflects whatever OpenCode last wrote to disk. A cache that loads
40+
* successfully but produces a zero-size set collapses to `'unknown'` for
41+
* the same operational reason an empty API response does.
4242
* - `unknown`: Either both the API call and the cache fallback failed (cache
4343
* missing, unreadable, corrupt, or schema-mismatched), OR the API call
4444
* succeeded with zero usable models. Resolution should degrade gracefully —
@@ -183,6 +183,15 @@ function readFallbackCache(): ModelAvailability {
183183
const cacheDir = resolveCacheDir()
184184
const openCodeModelsUrl = process.env.OPENCODE_MODELS_URL?.trim()
185185

186+
// Both branches below check `result.size > 0` before returning
187+
// `{ status: 'cache', ... }`. Symmetric with the empty-API collapse in
188+
// `getAvailableModels`: a cache file that loads successfully but produces a
189+
// zero-size set is operationally identical to no cache at all — we cannot
190+
// point bundled agents at any model the user can call. Funneling the empty
191+
// case through `emptyAvailability()` keeps the downstream gate
192+
// `availability.status !== 'unknown'` a single rule for every consumer
193+
// rather than `status !== 'unknown' && models.size > 0`.
194+
186195
// When OPENCODE_MODELS_URL is set, OpenCode writes the cache to
187196
// `models-<sha1-hex>.json` where the hash is `Hash.fast(url)` — verified
188197
// against `.slim/clonedeps/repos/anomalyco__opencode/packages/core/src/util/hash.ts`.
@@ -195,7 +204,7 @@ function readFallbackCache(): ModelAvailability {
195204
`models-${fastHash(openCodeModelsUrl)}.json`,
196205
)
197206
const urlResult = readModelsFromCache(urlDerivedPath)
198-
if (urlResult !== null) {
207+
if (urlResult !== null && urlResult.size > 0) {
199208
return { status: 'cache', models: urlResult }
200209
}
201210
// When OPENCODE_MODELS_URL is set, the URL-derived cache file is the
@@ -208,7 +217,7 @@ function readFallbackCache(): ModelAvailability {
208217

209218
const defaultPath = path.join(cacheDir, MODELS_JSON_FILENAME)
210219
const defaultResult = readModelsFromCache(defaultPath)
211-
if (defaultResult !== null) {
220+
if (defaultResult !== null && defaultResult.size > 0) {
212221
return { status: 'cache', models: defaultResult }
213222
}
214223

tests/unit/model-availability.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,79 @@ describe('getAvailableModels', () => {
253253
})
254254
})
255255

256+
describe('edge case: empty cache collapses to unknown', () => {
257+
// Parallel of `edge case: empty discovery collapses to unknown` for the cache
258+
// fallback path. When the API call fails AND the on-disk cache file is present
259+
// but produces a zero-size model set (e.g., schema-valid empty object, or
260+
// provider entries with `models: {}`), `readFallbackCache` must collapse to
261+
// `emptyAvailability()` rather than returning `{ status: 'cache', models: <empty Set> }`.
262+
//
263+
// Operationally identical to the empty-API case: an empty set on either path
264+
// means we cannot pin bundled agents to anything the user can call. The downstream
265+
// gate `availability.status !== 'unknown'` admits any non-'unknown' status, so
266+
// the collapse must happen at the envelope construction site to keep the gate a
267+
// single rule rather than `status !== 'unknown' && models.size > 0` at every consumer.
268+
269+
test('returns status: unknown when models.json is a schema-valid empty object', async () => {
270+
const cacheDir = path.join(testDir, 'cache', 'opencode')
271+
fs.mkdirSync(cacheDir, { recursive: true })
272+
fs.writeFileSync(path.join(cacheDir, 'models.json'), '{}')
273+
process.env.XDG_CACHE_HOME = path.join(testDir, 'cache')
274+
275+
const client = makeThrowingClient(new Error('ECONNREFUSED'))
276+
277+
const result = await getAvailableModels(client)
278+
279+
expect(result.status).toBe('unknown')
280+
expect(result.models).toBeInstanceOf(Set)
281+
expect(result.models.size).toBe(0)
282+
})
283+
284+
test('returns status: unknown when models.json has providers with empty models records', async () => {
285+
// Catches the case where `enabled_providers` populates entries without
286+
// auth-driven model lists — schema is valid, `buildSetFromCache` produces
287+
// a zero-size set, but `'cache'` would still slip past the gate without
288+
// the collapse.
289+
const cacheDir = path.join(testDir, 'cache', 'opencode')
290+
fs.mkdirSync(cacheDir, { recursive: true })
291+
fs.writeFileSync(
292+
path.join(cacheDir, 'models.json'),
293+
JSON.stringify({
294+
anthropic: { models: {} },
295+
openai: { models: {} },
296+
}),
297+
)
298+
process.env.XDG_CACHE_HOME = path.join(testDir, 'cache')
299+
300+
const client = makeThrowingClient(new Error('ECONNREFUSED'))
301+
302+
const result = await getAvailableModels(client)
303+
304+
expect(result.status).toBe('unknown')
305+
expect(result.models.size).toBe(0)
306+
})
307+
308+
test('returns status: unknown when OPENCODE_MODELS_URL-derived cache exists but is empty', async () => {
309+
// OPENCODE_MODELS_URL routes the cache through the URL-derived `models-<hash>.json`
310+
// filename and explicitly does NOT fall through to default models.json (trust-domain
311+
// boundary). The collapse must still apply on the URL-derived path.
312+
const cacheDir = path.join(testDir, 'cache', 'opencode')
313+
fs.mkdirSync(cacheDir, { recursive: true })
314+
const url = 'https://models.example.com/registry.json'
315+
const hashedFilename = `models-${sha1Hex(url)}.json`
316+
fs.writeFileSync(path.join(cacheDir, hashedFilename), '{}')
317+
process.env.XDG_CACHE_HOME = path.join(testDir, 'cache')
318+
process.env.OPENCODE_MODELS_URL = url
319+
320+
const client = makeThrowingClient(new Error('ECONNREFUSED'))
321+
322+
const result = await getAvailableModels(client)
323+
324+
expect(result.status).toBe('unknown')
325+
expect(result.models.size).toBe(0)
326+
})
327+
})
328+
256329
describe('edge case: cache path resolution', () => {
257330
test('uses XDG_CACHE_HOME when set', async () => {
258331
const cacheDir = path.join(testDir, 'xdg-cache', 'opencode')

0 commit comments

Comments
 (0)