Skip to content

Commit ce3eed6

Browse files
authored
fix: stop fallback to models.json when OPENCODE_MODELS_URL is set (#362)
When OPENCODE_MODELS_URL is configured, the URL-derived cache file (models-<sha1(url)>.json) is the only authoritative source for that registry. Falling through to the default models.json crosses a trust domain boundary and can cause Systematic to pin source defaults from unrelated availability data. Per Option B in issue #361: treat a URL-derived cache miss as status: 'unknown' rather than reading models.json. This aligns with Systematic's existing safety posture of skipping source-default emission when availability cannot be trusted. Updates the OPENCODE_MODELS_URL test that previously asserted the fallthrough, and adds a spy-based test confirming models.json is never opened when OPENCODE_MODELS_URL is set. Closes #361
1 parent 107510c commit ce3eed6

2 files changed

Lines changed: 37 additions & 4 deletions

File tree

src/lib/model-availability.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,12 @@ function readFallbackCache(): ModelAvailability {
192192
if (urlResult !== null) {
193193
return { status: 'cache', models: urlResult }
194194
}
195-
// Fall through to models.json when the URL-derived cache is absent
195+
// When OPENCODE_MODELS_URL is set, the URL-derived cache file is the
196+
// only authoritative source for this registry. Do not fall through to
197+
// the default models.json — that file belongs to a different registry
198+
// trust domain and could cause Systematic to pin source defaults from
199+
// unrelated availability data.
200+
return emptyAvailability()
196201
}
197202

198203
const defaultPath = path.join(cacheDir, MODELS_JSON_FILENAME)

tests/unit/model-availability.test.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ describe('getAvailableModels', () => {
273273
expect(result.models.has('openai/gpt-5.5')).toBe(false)
274274
})
275275

276-
test('falls back to models.json when OPENCODE_MODELS_URL is set but no URL-derived file exists', async () => {
276+
test('returns unknown (not models.json) when OPENCODE_MODELS_URL is set but no URL-derived file exists', async () => {
277277
const cacheDir = path.join(testDir, 'cache', 'opencode')
278278
fs.mkdirSync(cacheDir, { recursive: true })
279279

@@ -289,8 +289,36 @@ describe('getAvailableModels', () => {
289289
const client = makeThrowingClient(new Error('network error'))
290290
const result = await getAvailableModels(client)
291291

292-
expect(result.status).toBe('cache')
293-
expect(result.models.has('openai/gpt-5.5')).toBe(true)
292+
expect(result.status).toBe('unknown')
293+
expect(result.models.size).toBe(0)
294+
})
295+
296+
test('does NOT read models.json when OPENCODE_MODELS_URL is set and URL-derived file is absent (spy verification)', async () => {
297+
const cacheDir = path.join(testDir, 'cache', 'opencode')
298+
fs.mkdirSync(cacheDir, { recursive: true })
299+
300+
fs.writeFileSync(
301+
path.join(cacheDir, 'models.json'),
302+
JSON.stringify({ openai: { models: { 'gpt-5.5': {} } } }),
303+
)
304+
305+
process.env.XDG_CACHE_HOME = path.join(testDir, 'cache')
306+
process.env.OPENCODE_MODELS_URL =
307+
'https://custom.models.example.com/api.json'
308+
309+
const openSyncSpy = spyOn(fs, 'openSync')
310+
311+
const client = makeThrowingClient(new Error('network error'))
312+
await getAvailableModels(client)
313+
314+
const modelsJsonCalls = openSyncSpy.mock.calls.filter(
315+
(args) =>
316+
typeof args[0] === 'string' &&
317+
(args[0] as string).endsWith('models.json'),
318+
)
319+
expect(modelsJsonCalls.length).toBe(0)
320+
321+
openSyncSpy.mockRestore()
294322
})
295323
})
296324

0 commit comments

Comments
 (0)