From 10316589b470bda05d36c72cb6c4445e7705086a Mon Sep 17 00:00:00 2001 From: Francois Le Pape <32224751+Lp-Francois@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:29:21 +0200 Subject: [PATCH 1/7] feat: SWR file cache for model discovery Cache discovered model entries under ~/.cache/opencode-litellm keyed by baseURL hash. The config hook serves cached entries synchronously so startup isn't blocked on the network after the first run; a background refresh triggered on session.created revalidates the cache for the next launch (stale-while-revalidate). --- src/plugin/index.ts | 314 ++++++++++++++++++++++++++------------- src/utils/model-cache.ts | 76 ++++++++++ 2 files changed, 286 insertions(+), 104 deletions(-) create mode 100644 src/utils/model-cache.ts diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 888e61e..a2c3504 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -11,6 +11,7 @@ import { categorizeModel, } from '../utils/format-model-name' import type { LiteLLMModel, LiteLLMModelInfo } from '../types' +import { readModelCache, writeModelCache } from '../utils/model-cache' const CHAT_PROVIDER_ID = 'litellm' // Covers the sequential 3 s health check plus the parallel 15 s @@ -25,6 +26,21 @@ const DISCOVERY_TIMEOUT_MS = 20000 */ const injectedModelIds = new Map>() +/** + * Per-baseURL fetch context captured during the `config` hook, so the + * `event` hook can revalidate the cache in the background (SWR) without + * re-deriving auth/headers. + */ +interface RefreshContext { + apiKey?: string + customHeaders?: Record + providerId: string +} +const refreshContexts = new Map() + +/** baseURLs with an in-flight background refresh, to avoid pile-ups. */ +const refreshInFlight = new Set() + /** * Helper to determine if a provider ID or its configured options indicate * compatibility with LiteLLM. @@ -151,6 +167,157 @@ function toConfigModel( return entry } +/** + * Fetch and build OpenCode model entries from a LiteLLM proxy. + * + * Pure with respect to plugin config: it performs the network calls, + * classifies + formats each model, and returns a `{ id -> entry }` map. + * Returns `null` when the proxy is unreachable/unauthorized or exposes + * no models, so callers can distinguish "no data" from "empty result". + */ +async function discoverModels( + baseURL: string, + apiKey: string | undefined, + customHeaders: Record | undefined, + providerId: string, +): Promise | null> { + if (!(await checkLiteLLMHealth(baseURL, apiKey, customHeaders))) { + console.warn( + `[opencode-litellm] LiteLLM appears offline or unauthorized for provider "${providerId}" at ${baseURL}`, + ) + return null + } + + // `/v1/models` omits `mode` and capability metadata for + // database-defined models, so fetch `/v1/model/info` alongside + // it. The info call is best-effort: without it, classification + // falls back to id heuristics. + const [modelsResult, infoResult] = await Promise.allSettled([ + discoverLiteLLMModels(baseURL, apiKey, customHeaders), + discoverLiteLLMModelInfo(baseURL, apiKey, customHeaders), + ]) + + if (modelsResult.status === 'rejected') { + const error = modelsResult.reason + console.warn( + `[opencode-litellm] Model discovery failed for provider "${providerId}":`, + error instanceof Error ? error.message : String(error), + ) + return null + } + + const discovered = modelsResult.value + let infoByName: Map | null = null + if (infoResult.status === 'fulfilled') { + infoByName = infoResult.value + } else { + const reason = infoResult.reason + console.warn( + `[opencode-litellm] /v1/model/info unavailable for provider "${providerId}"; non-chat model filtering will use id heuristics only:`, + reason instanceof Error ? reason.message : String(reason), + ) + } + + if (discovered.length === 0) { + console.warn( + `[opencode-litellm] LiteLLM responded for provider "${providerId}" but exposed zero models.`, + ) + return null + } + + const built: Record = {} + let skipped = 0 + let wildcards = 0 + const unmatched: string[] = [] + for (const model of discovered) { + // Wildcard entries (`deepseek/*`) are access rules, not + // callable models — invoking one sends a literal `*` upstream. + if (model.id.includes('*')) { + wildcards++ + continue + } + const info = infoByName?.get(model.id) + if (infoByName && !info) unmatched.push(model.id) + const entry = toConfigModel(info ? enrichModel(model, info) : model, info) + if (!entry) { + skipped++ + continue + } + built[model.id] = entry + } + + if (unmatched.length > 0) { + console.warn( + `[opencode-litellm] /v1/model/info has no entry for ${unmatched.length} model(s) on provider "${providerId}"; ` + + `classification uses id heuristics for: ${unmatched.slice(0, 5).join(', ')}` + + (unmatched.length > 5 ? `, +${unmatched.length - 5} more` : ''), + ) + } + + console.log( + `[opencode-litellm] Discovered ${discovered.length} models for provider "${providerId}" from ${baseURL} ` + + `(${Object.keys(built).length} built` + + (skipped > 0 ? `, ${skipped} non-chat hidden` : '') + + (wildcards > 0 ? `, ${wildcards} wildcard ignored` : '') + + ')', + ) + + return built +} + +/** + * Merge freshly built model entries into a provider's `models` map + * without clobbering user-curated (or previously injected) entries. + * Returns the number of newly added ids. + */ +function mergeModels( + models: Record, + built: Record, +): number { + let added = 0 + for (const [id, entry] of Object.entries(built)) { + if (models[id]) continue + models[id] = entry + added++ + } + // Remove the seed placeholder if real models were merged in. + if (models['_'] && Object.keys(models).length > 1) { + delete models['_'] + } + return added +} + +/** + * Revalidate a baseURL's model cache off the critical path (SWR). The + * refreshed entries land in the on-disk cache and surface on the next + * OpenCode start — OpenCode only reads provider config at startup, so + * we can't mutate the live picker here. + */ +async function backgroundRefresh(baseURL: string): Promise { + if (refreshInFlight.has(baseURL)) return + const ctx = refreshContexts.get(baseURL) + if (!ctx) return + refreshInFlight.add(baseURL) + try { + const built = await Promise.race([ + discoverModels(baseURL, ctx.apiKey, ctx.customHeaders, ctx.providerId), + new Promise((resolve) => + setTimeout(() => resolve(null), DISCOVERY_TIMEOUT_MS), + ), + ]) + if (built && Object.keys(built).length > 0) { + writeModelCache(baseURL, built) + console.log( + `[opencode-litellm] Background-refreshed model cache for ${baseURL} (${Object.keys(built).length} models)`, + ) + } + } catch { + // Best-effort — a failed refresh just leaves the stale cache in place. + } finally { + refreshInFlight.delete(baseURL) + } +} + /** * LiteLLM Plugin for OpenCode. * @@ -265,117 +432,56 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { const models = actualProvider.models as Record - // Discover models with timeout - const work = async () => { - const alreadyInjected = injectedModelIds.get(baseURL!) - if ( - alreadyInjected && - [...alreadyInjected].every((id) => models[id]) - ) { - return - } - - if (!(await checkLiteLLMHealth(baseURL!, apiKey, customHeaders))) { - console.warn( - `[opencode-litellm] LiteLLM appears offline or unauthorized for provider "${providerId}" at ${baseURL}`, - ) - return - } - - // `/v1/models` omits `mode` and capability metadata for - // database-defined models, so fetch `/v1/model/info` alongside - // it. The info call is best-effort: without it, classification - // falls back to id heuristics. - const [modelsResult, infoResult] = await Promise.allSettled([ - discoverLiteLLMModels(baseURL!, apiKey, customHeaders), - discoverLiteLLMModelInfo(baseURL!, apiKey, customHeaders), - ]) - - if (modelsResult.status === 'rejected') { - const error = modelsResult.reason - console.warn( - `[opencode-litellm] Model discovery failed for provider "${providerId}":`, - error instanceof Error ? error.message : String(error), - ) - return - } - - const discovered = modelsResult.value - let infoByName: Map | null = null - if (infoResult.status === 'fulfilled') { - infoByName = infoResult.value - } else { - const reason = infoResult.reason - console.warn( - `[opencode-litellm] /v1/model/info unavailable for provider "${providerId}"; non-chat model filtering will use id heuristics only:`, - reason instanceof Error ? reason.message : String(reason), - ) - } - - if (discovered.length === 0) { - console.warn( - `[opencode-litellm] LiteLLM responded for provider "${providerId}" but exposed zero models.`, - ) - return - } - - let added = 0 - let skipped = 0 - let wildcards = 0 - const unmatched: string[] = [] - for (const model of discovered) { - // Wildcard entries (`deepseek/*`) are access rules, not - // callable models — invoking one sends a literal `*` upstream. - if (model.id.includes('*')) { - wildcards++ - continue - } - // Don't overwrite user-curated entries - if (models[model.id]) continue - const info = infoByName?.get(model.id) - if (infoByName && !info) unmatched.push(model.id) - const entry = toConfigModel( - info ? enrichModel(model, info) : model, - info, - ) - if (!entry) { - skipped++ - continue - } - models[model.id] = entry - added++ - } - - if (unmatched.length > 0) { - console.warn( - `[opencode-litellm] /v1/model/info has no entry for ${unmatched.length} model(s) on provider "${providerId}"; ` + - `classification uses id heuristics for: ${unmatched.slice(0, 5).join(', ')}` + - (unmatched.length > 5 ? `, +${unmatched.length - 5} more` : ''), - ) - } - - // Remove the seed placeholder if real models were discovered - if (models['_'] && Object.keys(models).length > 1) { - delete models['_'] - } - - injectedModelIds.set(baseURL!, new Set(Object.keys(models))) + // Remember how to reach this proxy so the `event` hook can + // revalidate its cache in the background on new sessions. + refreshContexts.set(baseURL, { apiKey, customHeaders, providerId }) + + // Repeat config-hook invocations within a run are a no-op once + // we've injected this baseURL's models. + const alreadyInjected = injectedModelIds.get(baseURL) + if ( + alreadyInjected && + [...alreadyInjected].every((id) => models[id]) + ) { + continue + } + // SWR fast path: serve cached entries synchronously so startup + // isn't blocked on the network. A background refresh (see the + // `event` hook) keeps the cache fresh for the next launch. + const cached = readModelCache(baseURL) + if (cached && Object.keys(cached).length > 0) { + mergeModels(models, cached) + injectedModelIds.set(baseURL, new Set(Object.keys(models))) console.log( - `[opencode-litellm] Discovered ${discovered.length} models for provider "${providerId}" from ${baseURL} ` + - `(${added} added` + - (skipped > 0 ? `, ${skipped} non-chat hidden` : '') + - (wildcards > 0 ? `, ${wildcards} wildcard ignored` : '') + - ')', + `[opencode-litellm] Loaded ${Object.keys(cached).length} models from cache for provider "${providerId}" (${baseURL}); refresh happens in the background on new sessions.`, ) + continue } - await Promise.race([ - work(), - new Promise((resolve) => - setTimeout(resolve, DISCOVERY_TIMEOUT_MS), + // Cold cache: do a live fetch (slow first run only), inject, and + // persist for subsequent startups. Capped by a timeout so a slow + // proxy never blocks boot. + const built = await Promise.race([ + discoverModels(baseURL, apiKey, customHeaders, providerId), + new Promise((resolve) => + setTimeout(() => resolve(null), DISCOVERY_TIMEOUT_MS), ), ]) + if (built && Object.keys(built).length > 0) { + mergeModels(models, built) + injectedModelIds.set(baseURL, new Set(Object.keys(models))) + writeModelCache(baseURL, built) + } + } + }, + event: async ({ event }) => { + // Revalidate model caches off the critical path when a new session + // opens. Fresh data lands in the cache and surfaces on the next + // OpenCode start (SWR). + if (event.type !== 'session.created') return + for (const baseURL of refreshContexts.keys()) { + void backgroundRefresh(baseURL) } }, } diff --git a/src/utils/model-cache.ts b/src/utils/model-cache.ts new file mode 100644 index 0000000..cfb215e --- /dev/null +++ b/src/utils/model-cache.ts @@ -0,0 +1,76 @@ +import { createHash } from 'node:crypto' +import { readFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' + +/** + * Bump when the shape of a cached model entry changes so stale caches + * from older plugin versions are ignored rather than deserialized into + * an incompatible structure. + */ +const CACHE_VERSION = 1 + +/** The on-disk shape of a per-baseURL model cache file. */ +interface ModelCacheFile { + version: number + savedAt: number + models: Record +} + +/** + * Directory where SWR model caches live. Honours `XDG_CACHE_HOME`, + * falling back to `~/.cache`, and finally the OS temp dir if the home + * directory is somehow unavailable. + */ +function cacheDir(): string { + const base = + process.env.XDG_CACHE_HOME || + (homedir() ? join(homedir(), '.cache') : tmpdir()) + return join(base, 'opencode-litellm') +} + +/** Stable, filesystem-safe filename derived from the normalized baseURL. */ +function cacheFile(baseURL: string): string { + const hash = createHash('sha256').update(baseURL).digest('hex').slice(0, 16) + return join(cacheDir(), `models-${hash}.json`) +} + +/** + * Read cached model entries for a baseURL. Returns `null` on any + * problem (missing file, parse error, version mismatch) — the caller + * treats that as a cache miss and falls back to a live fetch. + */ +export function readModelCache( + baseURL: string, +): Record | null { + try { + const raw = readFileSync(cacheFile(baseURL), 'utf8') + const parsed = JSON.parse(raw) as ModelCacheFile + if (parsed.version !== CACHE_VERSION) return null + if (!parsed.models || typeof parsed.models !== 'object') return null + return parsed.models + } catch { + return null + } +} + +/** + * Persist model entries for a baseURL. Best-effort: never throws, so a + * read-only or full filesystem can't break discovery. + */ +export function writeModelCache( + baseURL: string, + models: Record, +): void { + try { + mkdirSync(cacheDir(), { recursive: true }) + const payload: ModelCacheFile = { + version: CACHE_VERSION, + savedAt: Date.now(), + models, + } + writeFileSync(cacheFile(baseURL), JSON.stringify(payload), 'utf8') + } catch { + // Ignore — the cache is an optimization, not a requirement. + } +} From 8a029af109ec6d1b59c0d88fc8777d6511bff0e1 Mon Sep 17 00:00:00 2001 From: Francois Le Pape <32224751+Lp-Francois@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:55:55 +0200 Subject: [PATCH 2/7] refactor: call homedir() once in cacheDir() --- src/utils/model-cache.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/model-cache.ts b/src/utils/model-cache.ts index cfb215e..680d9e4 100644 --- a/src/utils/model-cache.ts +++ b/src/utils/model-cache.ts @@ -23,9 +23,9 @@ interface ModelCacheFile { * directory is somehow unavailable. */ function cacheDir(): string { + const home = homedir() const base = - process.env.XDG_CACHE_HOME || - (homedir() ? join(homedir(), '.cache') : tmpdir()) + process.env.XDG_CACHE_HOME || (home ? join(home, '.cache') : tmpdir()) return join(base, 'opencode-litellm') } From ee1dc3892cb314e000e42de8a56419e33a6b2ab1 Mon Sep 17 00:00:00 2001 From: Francois Le Pape <32224751+Lp-Francois@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:56:23 +0200 Subject: [PATCH 3/7] feat: expire model cache entries older than 7 days Bound cache entries by savedAt so a permanently-gone proxy no longer serves the same stale model list forever. Add readModelCacheSavedAt so callers can throttle background refreshes on still-fresh caches. --- src/utils/model-cache.ts | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/utils/model-cache.ts b/src/utils/model-cache.ts index 680d9e4..83a3671 100644 --- a/src/utils/model-cache.ts +++ b/src/utils/model-cache.ts @@ -10,6 +10,14 @@ import { join } from 'node:path' */ const CACHE_VERSION = 1 +/** + * Maximum age of a cache entry before it's treated as a miss. Without a + * bound, a proxy that goes away permanently would keep serving the same + * stale model list on every launch. A background refresh normally keeps + * entries fresh well within this window. + */ +const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + /** The on-disk shape of a per-baseURL model cache file. */ interface ModelCacheFile { version: number @@ -37,8 +45,9 @@ function cacheFile(baseURL: string): string { /** * Read cached model entries for a baseURL. Returns `null` on any - * problem (missing file, parse error, version mismatch) — the caller - * treats that as a cache miss and falls back to a live fetch. + * problem (missing file, parse error, version mismatch, or an entry + * older than `CACHE_MAX_AGE_MS`) — the caller treats that as a cache + * miss and falls back to a live fetch. */ export function readModelCache( baseURL: string, @@ -48,12 +57,35 @@ export function readModelCache( const parsed = JSON.parse(raw) as ModelCacheFile if (parsed.version !== CACHE_VERSION) return null if (!parsed.models || typeof parsed.models !== 'object') return null + if ( + typeof parsed.savedAt !== 'number' || + Date.now() - parsed.savedAt > CACHE_MAX_AGE_MS + ) { + return null + } return parsed.models } catch { return null } } +/** + * Return the `savedAt` timestamp (epoch ms) of a baseURL's cache entry, + * or `null` if there's no readable/valid cache. Used to throttle + * background refreshes so still-fresh caches aren't re-fetched. + */ +export function readModelCacheSavedAt(baseURL: string): number | null { + try { + const raw = readFileSync(cacheFile(baseURL), 'utf8') + const parsed = JSON.parse(raw) as ModelCacheFile + if (parsed.version !== CACHE_VERSION) return null + if (typeof parsed.savedAt !== 'number') return null + return parsed.savedAt + } catch { + return null + } +} + /** * Persist model entries for a baseURL. Best-effort: never throws, so a * read-only or full filesystem can't break discovery. From 7ddedcfd7b10db9ac90b535ec7291adf366728d4 Mon Sep 17 00:00:00 2001 From: Francois Le Pape <32224751+Lp-Francois@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:56:47 +0200 Subject: [PATCH 4/7] fix: write model cache atomically via temp file + rename Prevents readers from observing a partially-written JSON file when two OpenCode processes refresh the same baseURL concurrently. --- src/utils/model-cache.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/utils/model-cache.ts b/src/utils/model-cache.ts index 83a3671..b5a76dc 100644 --- a/src/utils/model-cache.ts +++ b/src/utils/model-cache.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { readFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { readFileSync, mkdirSync, writeFileSync, renameSync, rmSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -101,7 +101,22 @@ export function writeModelCache( savedAt: Date.now(), models, } - writeFileSync(cacheFile(baseURL), JSON.stringify(payload), 'utf8') + // Write to a temp file and rename so concurrent OpenCode processes + // never observe a partially-written JSON file (rename is atomic on + // the same filesystem). + const target = cacheFile(baseURL) + const tmp = `${target}.${process.pid}.tmp` + try { + writeFileSync(tmp, JSON.stringify(payload), 'utf8') + renameSync(tmp, target) + } catch (err) { + try { + rmSync(tmp, { force: true }) + } catch { + // Ignore cleanup failure. + } + throw err + } } catch { // Ignore — the cache is an optimization, not a requirement. } From ac3f0f2b8316085dcf110a00805a5c98a78021af Mon Sep 17 00:00:00 2001 From: Francois Le Pape <32224751+Lp-Francois@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:57:25 +0200 Subject: [PATCH 5/7] fix: clear discovery timeout timer to avoid keeping process alive Extract a withTimeout helper that clears its setTimeout once the race settles, so a completed discovery doesn't leave a pending timer holding a short-lived process open. --- src/plugin/index.ts | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/plugin/index.ts b/src/plugin/index.ts index a2c3504..53e979a 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -41,6 +41,22 @@ const refreshContexts = new Map() /** baseURLs with an in-flight background refresh, to avoid pile-ups. */ const refreshInFlight = new Set() +/** + * Race a promise against a timeout, resolving to `null` if the timeout + * wins. Clears the timer either way so a resolved discovery can't keep + * a short-lived process alive waiting on a pending `setTimeout`. + */ +function withTimeout( + promise: Promise, + timeoutMs: number, +): Promise { + let timer: ReturnType + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs) + }) + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) +} + /** * Helper to determine if a provider ID or its configured options indicate * compatibility with LiteLLM. @@ -299,12 +315,10 @@ async function backgroundRefresh(baseURL: string): Promise { if (!ctx) return refreshInFlight.add(baseURL) try { - const built = await Promise.race([ + const built = await withTimeout( discoverModels(baseURL, ctx.apiKey, ctx.customHeaders, ctx.providerId), - new Promise((resolve) => - setTimeout(() => resolve(null), DISCOVERY_TIMEOUT_MS), - ), - ]) + DISCOVERY_TIMEOUT_MS, + ) if (built && Object.keys(built).length > 0) { writeModelCache(baseURL, built) console.log( @@ -462,12 +476,10 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { // Cold cache: do a live fetch (slow first run only), inject, and // persist for subsequent startups. Capped by a timeout so a slow // proxy never blocks boot. - const built = await Promise.race([ + const built = await withTimeout( discoverModels(baseURL, apiKey, customHeaders, providerId), - new Promise((resolve) => - setTimeout(() => resolve(null), DISCOVERY_TIMEOUT_MS), - ), - ]) + DISCOVERY_TIMEOUT_MS, + ) if (built && Object.keys(built).length > 0) { mergeModels(models, built) injectedModelIds.set(baseURL, new Set(Object.keys(models))) From 866e17770e00f4224190e41785b59e2437e671bf Mon Sep 17 00:00:00 2001 From: Francois Le Pape <32224751+Lp-Francois@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:57:50 +0200 Subject: [PATCH 6/7] fix: throttle background cache refresh to a 5-minute interval Skip revalidation when the cache's savedAt is within REFRESH_MIN_INTERVAL_MS so a burst of session.created events can't generate repeated health checks and discovery traffic. refreshInFlight still guards concurrent runs. --- src/plugin/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 53e979a..53ba52c 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -11,12 +11,15 @@ import { categorizeModel, } from '../utils/format-model-name' import type { LiteLLMModel, LiteLLMModelInfo } from '../types' -import { readModelCache, writeModelCache } from '../utils/model-cache' +import { readModelCache, writeModelCache, readModelCacheSavedAt } from '../utils/model-cache' const CHAT_PROVIDER_ID = 'litellm' // Covers the sequential 3 s health check plus the parallel 15 s // models/model-info fetch phase, with headroom. const DISCOVERY_TIMEOUT_MS = 20000 +// Don't revalidate a baseURL's cache more often than this, so a burst +// of `session.created` events can't generate repeated discovery traffic. +const REFRESH_MIN_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes /** * OpenCode invokes the `config` hook several times per run with a @@ -313,6 +316,12 @@ async function backgroundRefresh(baseURL: string): Promise { if (refreshInFlight.has(baseURL)) return const ctx = refreshContexts.get(baseURL) if (!ctx) return + // Skip if the cache was refreshed recently — a burst of new sessions + // shouldn't hammer the proxy with health checks and discovery calls. + const savedAt = readModelCacheSavedAt(baseURL) + if (savedAt !== null && Date.now() - savedAt < REFRESH_MIN_INTERVAL_MS) { + return + } refreshInFlight.add(baseURL) try { const built = await withTimeout( From 2d85f38550110e09ffc323d14930b50e91d38a82 Mon Sep 17 00:00:00 2001 From: Francois Le Pape <32224751+Lp-Francois@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:58:27 +0200 Subject: [PATCH 7/7] refactor: track only plugin-injected model ids and use key presence mergeModels now returns the ids it actually added, and injectedModelIds stores exactly those instead of every provider key (which included user-curated entries). Also use Object.hasOwn so entries that are null, false, or 0 aren't treated as absent. --- src/plugin/index.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 53ba52c..8cf637b 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -287,20 +287,20 @@ async function discoverModels( /** * Merge freshly built model entries into a provider's `models` map * without clobbering user-curated (or previously injected) entries. - * Returns the number of newly added ids. + * Returns the ids actually added by this call. */ function mergeModels( models: Record, built: Record, -): number { - let added = 0 +): string[] { + const added: string[] = [] for (const [id, entry] of Object.entries(built)) { - if (models[id]) continue + if (Object.hasOwn(models, id)) continue models[id] = entry - added++ + added.push(id) } // Remove the seed placeholder if real models were merged in. - if (models['_'] && Object.keys(models).length > 1) { + if (Object.hasOwn(models, '_') && Object.keys(models).length > 1) { delete models['_'] } return added @@ -464,7 +464,7 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { const alreadyInjected = injectedModelIds.get(baseURL) if ( alreadyInjected && - [...alreadyInjected].every((id) => models[id]) + [...alreadyInjected].every((id) => Object.hasOwn(models, id)) ) { continue } @@ -474,8 +474,8 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { // `event` hook) keeps the cache fresh for the next launch. const cached = readModelCache(baseURL) if (cached && Object.keys(cached).length > 0) { - mergeModels(models, cached) - injectedModelIds.set(baseURL, new Set(Object.keys(models))) + const added = mergeModels(models, cached) + injectedModelIds.set(baseURL, new Set(added)) console.log( `[opencode-litellm] Loaded ${Object.keys(cached).length} models from cache for provider "${providerId}" (${baseURL}); refresh happens in the background on new sessions.`, ) @@ -490,8 +490,8 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { DISCOVERY_TIMEOUT_MS, ) if (built && Object.keys(built).length > 0) { - mergeModels(models, built) - injectedModelIds.set(baseURL, new Set(Object.keys(models))) + const added = mergeModels(models, built) + injectedModelIds.set(baseURL, new Set(added)) writeModelCache(baseURL, built) } }