@vymalo/opencode-models-info 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.js CHANGED
@@ -4,344 +4,371 @@ import { fetchOpenRouterModels } from "./fetcher.js";
4
4
  import { isTextOnlyModality, mapOpenRouterEntry, mergeIntoModel } from "./mapping.js";
5
5
  import { startScheduler } from "./scheduler.js";
6
6
  /**
7
- * Walk every provider in the assembled OpenCode config, fetch its
8
- * `meta.modelsInfoUrl` (if any) — honoring the cache — and merge derived
9
- * metadata onto each matching model entry. Runs providers in parallel; one
10
- * failure never blocks others.
11
- */
7
+ * Walk every provider in the assembled OpenCode config, fetch its
8
+ * `meta.modelsInfoUrl` (if any) — honoring the cache — and merge derived
9
+ * metadata onto each matching model entry. Runs providers in parallel; one
10
+ * failure never blocks others.
11
+ */
12
12
  export async function enrichConfig(input, deps) {
13
- const providers = input.provider;
14
- if (!providers) {
15
- deps.logger.trace("models_info_enrich_no_providers", {});
16
- return;
17
- }
18
- deps.logger.trace("models_info_enrich_start", {
19
- providerCount: Object.keys(providers).length
20
- });
21
- await Promise.allSettled(Object.entries(providers).map(([providerId, providerConfig]) => enrichProvider(providerId, providerConfig, deps)));
13
+ const providers = input.provider;
14
+ if (!providers) {
15
+ deps.logger.trace("models_info_enrich_no_providers", {});
16
+ return;
17
+ }
18
+ deps.logger.trace("models_info_enrich_start", { providerCount: Object.keys(providers).length });
19
+ await Promise.allSettled(Object.entries(providers).map(([providerId, providerConfig]) => enrichProvider(providerId, providerConfig, deps)));
22
20
  }
23
21
  /**
24
- * Start one background refresh scheduler per opted-in provider, keeping the
25
- * on-disk cache warm at `meta.modelsInfoTtlSeconds` cadence (the same knob
26
- * that already governs cache freshness — no separate interval to configure).
27
- *
28
- * `config` only runs at plugin load and on rare config-signature changes
29
- * (see docs/models-info.md#periodic-refresh), so a long-lived OpenCode
30
- * process would otherwise keep serving whatever the catalog looked like at
31
- * boot until it happens to restart. This closes that gap for the *next*
32
- * `config` run — it cannot push a live update into an already-open session,
33
- * since a config hook has no way to hot-patch a running one. Callers own the
34
- * returned handles' lifecycle: stop them when the config that produced them
35
- * is superseded (a rebuilt `config` hook) or the plugin is disposed.
36
- */
22
+ * Start one background refresh scheduler per opted-in provider, keeping the
23
+ * on-disk cache warm at `meta.modelsInfoTtlSeconds` cadence (the same knob
24
+ * that already governs cache freshness — no separate interval to configure).
25
+ *
26
+ * `config` only runs at plugin load and on rare config-signature changes
27
+ * (see docs/models-info.md#periodic-refresh), so a long-lived OpenCode
28
+ * process would otherwise keep serving whatever the catalog looked like at
29
+ * boot until it happens to restart. This closes that gap for the *next*
30
+ * `config` run — it cannot push a live update into an already-open session,
31
+ * since a config hook has no way to hot-patch a running one. Callers own the
32
+ * returned handles' lifecycle: stop them when the config that produced them
33
+ * is superseded (a rebuilt `config` hook) or the plugin is disposed.
34
+ */
37
35
  export function startCacheRefreshSchedulers(input, deps) {
38
- const providers = input.provider;
39
- if (!providers) {
40
- return [];
41
- }
42
- const handles = [];
43
- for (const [providerId, providerConfig] of Object.entries(providers)) {
44
- const opts = parseMetaOptions(providerConfig?.options);
45
- if (!opts) {
46
- continue;
47
- }
48
- const providerHeaders = asHeaderMap(providerConfig?.options?.headers);
49
- const intervalMs = opts.modelsInfoTtlSeconds * 1000;
50
- deps.logger.trace("models_info_refresh_scheduler_started", { providerId, intervalMs });
51
- handles.push(startScheduler({
52
- intervalMs,
53
- logger: deps.logger,
54
- taskName: `models-info-refresh:${providerId}`,
55
- run: () => refreshProviderCache(providerId, opts, providerHeaders, deps)
56
- }));
57
- }
58
- return handles;
36
+ const providers = input.provider;
37
+ if (!providers) {
38
+ return [];
39
+ }
40
+ const handles = [];
41
+ for (const [providerId, providerConfig] of Object.entries(providers)) {
42
+ const opts = parseMetaOptions(providerConfig?.options);
43
+ if (!opts) {
44
+ continue;
45
+ }
46
+ const providerHeaders = asHeaderMap(providerConfig?.options?.headers);
47
+ const intervalMs = opts.modelsInfoTtlSeconds * 1e3;
48
+ deps.logger.trace("models_info_refresh_scheduler_started", {
49
+ providerId,
50
+ intervalMs
51
+ });
52
+ handles.push(startScheduler({
53
+ intervalMs,
54
+ logger: deps.logger,
55
+ taskName: `models-info-refresh:${providerId}`,
56
+ run: () => refreshProviderCache(providerId, opts, providerHeaders, deps)
57
+ }));
58
+ }
59
+ return handles;
59
60
  }
60
61
  async function enrichProvider(providerId, providerConfig, deps) {
61
- try {
62
- if (!providerConfig) {
63
- deps.logger.trace("models_info_provider_no_config", { providerId });
64
- return;
65
- }
66
- const opts = parseMetaOptions(providerConfig.options);
67
- deps.logger.trace("models_info_provider_meta_parsed", {
68
- providerId,
69
- hasModelsInfoUrl: Boolean(opts)
70
- });
71
- if (!opts) {
72
- return;
73
- }
74
- const models = providerConfig.models;
75
- const modelCount = models ? Object.keys(models).length : 0;
76
- deps.logger.trace("models_info_provider_models_present", {
77
- providerId,
78
- hasModels: modelCount > 0,
79
- modelCount
80
- });
81
- if (!models || Object.keys(models).length === 0) {
82
- deps.logger.debug("models_info_provider_skipped_no_models", { providerId });
83
- return;
84
- }
85
- // Pull whatever headers the upstream config (oauth2 plugin, static API
86
- // key, etc.) has already attached to the provider; the meta-specific
87
- // `modelsInfoHeaders` win on conflict. This is what makes the plugin
88
- // truly auth-agnostic — we never need to know how the token was acquired.
89
- const providerHeaders = asHeaderMap(providerConfig.options?.headers);
90
- deps.logger.trace("models_info_provider_headers_resolved", {
91
- providerId,
92
- hasProviderHeaders: Boolean(providerHeaders),
93
- hasMetaHeaders: Boolean(opts.modelsInfoHeaders)
94
- });
95
- const record = await loadRecord(providerId, opts, providerHeaders, deps);
96
- if (!record) {
97
- deps.logger.trace("models_info_no_record", { providerId });
98
- return;
99
- }
100
- const byId = new Map(record.models.map((m) => [m.id, m]));
101
- const overwrite = opts.modelsInfoOverwrite ? new Set(opts.modelsInfoOverwrite) : undefined;
102
- deps.logger.trace("models_info_match_table_built", {
103
- providerId,
104
- sourceModels: record.models.length,
105
- overwriteFields: overwrite ? [...overwrite] : []
106
- });
107
- const totalModels = Object.keys(models).length;
108
- const tally = { enriched: 0, hidden: 0 };
109
- const reconcileCtx = { providerId, models, byId, opts, overwrite, deps };
110
- for (const [modelId, modelConfig] of Object.entries(models)) {
111
- const outcome = reconcileModel(modelId, modelConfig, reconcileCtx);
112
- if (outcome !== "skipped") {
113
- tally[outcome] += 1;
114
- }
115
- }
116
- const { enriched: enrichedCount, hidden: hiddenCount } = tally;
117
- deps.logger.trace("models_info_provider_done", {
118
- providerId,
119
- enrichedCount,
120
- hiddenCount,
121
- totalModels
122
- });
123
- deps.logger.debug("models_info_enriched", {
124
- providerId,
125
- enrichedCount,
126
- hiddenCount,
127
- totalModels,
128
- sourceModels: record.models.length
129
- });
130
- }
131
- catch (error) {
132
- // Promise.allSettled would otherwise swallow this — surface it loudly so
133
- // a broken cache disk or mapping bug isn't silently no-op'd per provider.
134
- deps.logger.error("models_info_enrichment_failed", {
135
- providerId,
136
- error: error instanceof Error ? error.message : String(error)
137
- });
138
- }
62
+ try {
63
+ if (!providerConfig) {
64
+ deps.logger.trace("models_info_provider_no_config", { providerId });
65
+ return;
66
+ }
67
+ const opts = parseMetaOptions(providerConfig.options);
68
+ deps.logger.trace("models_info_provider_meta_parsed", {
69
+ providerId,
70
+ hasModelsInfoUrl: Boolean(opts)
71
+ });
72
+ if (!opts) {
73
+ return;
74
+ }
75
+ const models = providerConfig.models;
76
+ const modelCount = models ? Object.keys(models).length : 0;
77
+ deps.logger.trace("models_info_provider_models_present", {
78
+ providerId,
79
+ hasModels: modelCount > 0,
80
+ modelCount
81
+ });
82
+ if (!models || Object.keys(models).length === 0) {
83
+ deps.logger.debug("models_info_provider_skipped_no_models", { providerId });
84
+ return;
85
+ }
86
+ // Pull whatever headers the upstream config (oauth2 plugin, static API
87
+ // key, etc.) has already attached to the provider; the meta-specific
88
+ // `modelsInfoHeaders` win on conflict. This is what makes the plugin
89
+ // truly auth-agnostic — we never need to know how the token was acquired.
90
+ const providerHeaders = asHeaderMap(providerConfig.options?.headers);
91
+ deps.logger.trace("models_info_provider_headers_resolved", {
92
+ providerId,
93
+ hasProviderHeaders: Boolean(providerHeaders),
94
+ hasMetaHeaders: Boolean(opts.modelsInfoHeaders)
95
+ });
96
+ const record = await loadRecord(providerId, opts, providerHeaders, deps);
97
+ if (!record) {
98
+ deps.logger.trace("models_info_no_record", { providerId });
99
+ return;
100
+ }
101
+ const byId = new Map(record.models.map((m) => [m.id, m]));
102
+ const overwrite = opts.modelsInfoOverwrite ? new Set(opts.modelsInfoOverwrite) : undefined;
103
+ deps.logger.trace("models_info_match_table_built", {
104
+ providerId,
105
+ sourceModels: record.models.length,
106
+ overwriteFields: overwrite ? [...overwrite] : []
107
+ });
108
+ const totalModels = Object.keys(models).length;
109
+ const tally = {
110
+ enriched: 0,
111
+ hidden: 0
112
+ };
113
+ const reconcileCtx = {
114
+ providerId,
115
+ models,
116
+ byId,
117
+ opts,
118
+ overwrite,
119
+ deps
120
+ };
121
+ for (const [modelId, modelConfig] of Object.entries(models)) {
122
+ const outcome = reconcileModel(modelId, modelConfig, reconcileCtx);
123
+ if (outcome !== "skipped") {
124
+ tally[outcome] += 1;
125
+ }
126
+ }
127
+ const { enriched: enrichedCount, hidden: hiddenCount } = tally;
128
+ deps.logger.trace("models_info_provider_done", {
129
+ providerId,
130
+ enrichedCount,
131
+ hiddenCount,
132
+ totalModels
133
+ });
134
+ deps.logger.debug("models_info_enriched", {
135
+ providerId,
136
+ enrichedCount,
137
+ hiddenCount,
138
+ totalModels,
139
+ sourceModels: record.models.length
140
+ });
141
+ } catch (error) {
142
+ // Promise.allSettled would otherwise swallow this — surface it loudly so
143
+ // a broken cache disk or mapping bug isn't silently no-op'd per provider.
144
+ deps.logger.error("models_info_enrichment_failed", {
145
+ providerId,
146
+ error: error instanceof Error ? error.message : String(error)
147
+ });
148
+ }
139
149
  }
140
150
  /**
141
- * Decide one model's fate against the catalog and apply it in place —
142
- * merge, delete, or leave untouched — returning what happened for the
143
- * caller's tally. Three independent gates:
144
- * - `modelsInfoHideTextOnly` OR `modelsInfoHideUnmatched` — a model absent
145
- * from the catalog entirely gets deleted. Either flag alone is enough;
146
- * `modelsInfoHideTextOnly` has triggered this since 0.10.0 (unchanged,
147
- * for backward compat with existing adopters) — `modelsInfoHideUnmatched`
148
- * exists for a consumer that wants catalog-authoritative *membership*
149
- * WITHOUT also pulling in modality-based hiding.
150
- * - `modelsInfoHideInternal` — a matched model the catalog flags
151
- * `internal: true` gets deleted. Independent of the above: modality and
152
- * internal/restricted status are unrelated signals, and conflating them
153
- * (routing "hide internal" through `modelsInfoHideTextOnly`) hides
154
- * legitimate text-only external models.
155
- * - `modelsInfoHideTextOnly` — a matched model the catalog reports as
156
- * text-in/text-out only gets deleted. Unchanged from 0.10.0.
157
- */
151
+ * Decide one model's fate against the catalog and apply it in place —
152
+ * merge, delete, or leave untouched — returning what happened for the
153
+ * caller's tally. Three independent gates:
154
+ * - `modelsInfoHideTextOnly` OR `modelsInfoHideUnmatched` — a model absent
155
+ * from the catalog entirely gets deleted. Either flag alone is enough;
156
+ * `modelsInfoHideTextOnly` has triggered this since 0.10.0 (unchanged,
157
+ * for backward compat with existing adopters) — `modelsInfoHideUnmatched`
158
+ * exists for a consumer that wants catalog-authoritative *membership*
159
+ * WITHOUT also pulling in modality-based hiding.
160
+ * - `modelsInfoHideInternal` — a matched model the catalog flags
161
+ * `internal: true` gets deleted. Independent of the above: modality and
162
+ * internal/restricted status are unrelated signals, and conflating them
163
+ * (routing "hide internal" through `modelsInfoHideTextOnly`) hides
164
+ * legitimate text-only external models.
165
+ * - `modelsInfoHideTextOnly` — a matched model the catalog reports as
166
+ * text-in/text-out only gets deleted. Unchanged from 0.10.0.
167
+ */
158
168
  function reconcileModel(modelId, modelConfig, ctx) {
159
- const { providerId, models, byId, opts, overwrite, deps } = ctx;
160
- const declaredId = typeof modelConfig.id === "string" ? modelConfig.id : undefined;
161
- const matchById = byId.has(modelId);
162
- const match = byId.get(modelId) ?? (declaredId ? byId.get(declaredId) : undefined);
163
- if (!match) {
164
- if (!opts.modelsInfoHideTextOnly && !opts.modelsInfoHideUnmatched) {
165
- deps.logger.trace("models_info_model_unmatched", { providerId, modelId, declaredId });
166
- return "skipped";
167
- }
168
- delete models[modelId];
169
- deps.logger.debug("models_info_model_hidden_unmatched", { providerId, modelId, declaredId });
170
- return "hidden";
171
- }
172
- deps.logger.trace("models_info_model_matched", {
173
- providerId,
174
- modelId,
175
- matchedBy: matchById ? "id" : "declaredId"
176
- });
177
- if (opts.modelsInfoHideInternal && match.internal === true) {
178
- delete models[modelId];
179
- deps.logger.debug("models_info_model_hidden_internal", { providerId, modelId });
180
- return "hidden";
181
- }
182
- const derived = mapOpenRouterEntry(match, overwrite);
183
- if (opts.modelsInfoHideTextOnly && isTextOnlyModality(derived.modalities)) {
184
- delete models[modelId];
185
- deps.logger.debug("models_info_model_hidden_text_only", { providerId, modelId });
186
- return "hidden";
187
- }
188
- const derivedFields = Object.keys(derived);
189
- const appliedFields = derivedFields.filter((f) => modelConfig[f] === undefined || overwrite?.has(f));
190
- const skippedFields = derivedFields.filter((f) => !appliedFields.includes(f));
191
- deps.logger.trace("models_info_model_merge", {
192
- providerId,
193
- modelId,
194
- derivedFields,
195
- appliedFields,
196
- skippedFields
197
- });
198
- mergeIntoModel(modelConfig, derived, overwrite);
199
- return "enriched";
169
+ const { providerId, models, byId, opts, overwrite, deps } = ctx;
170
+ const declaredId = typeof modelConfig.id === "string" ? modelConfig.id : undefined;
171
+ const matchById = byId.has(modelId);
172
+ const match = byId.get(modelId) ?? (declaredId ? byId.get(declaredId) : undefined);
173
+ if (!match) {
174
+ if (!opts.modelsInfoHideTextOnly && !opts.modelsInfoHideUnmatched) {
175
+ deps.logger.trace("models_info_model_unmatched", {
176
+ providerId,
177
+ modelId,
178
+ declaredId
179
+ });
180
+ return "skipped";
181
+ }
182
+ delete models[modelId];
183
+ deps.logger.debug("models_info_model_hidden_unmatched", {
184
+ providerId,
185
+ modelId,
186
+ declaredId
187
+ });
188
+ return "hidden";
189
+ }
190
+ deps.logger.trace("models_info_model_matched", {
191
+ providerId,
192
+ modelId,
193
+ matchedBy: matchById ? "id" : "declaredId"
194
+ });
195
+ if (opts.modelsInfoHideInternal && match.internal === true) {
196
+ delete models[modelId];
197
+ deps.logger.debug("models_info_model_hidden_internal", {
198
+ providerId,
199
+ modelId
200
+ });
201
+ return "hidden";
202
+ }
203
+ const derived = mapOpenRouterEntry(match, overwrite);
204
+ if (opts.modelsInfoHideTextOnly && isTextOnlyModality(derived.modalities)) {
205
+ delete models[modelId];
206
+ deps.logger.debug("models_info_model_hidden_text_only", {
207
+ providerId,
208
+ modelId
209
+ });
210
+ return "hidden";
211
+ }
212
+ const derivedFields = Object.keys(derived);
213
+ const appliedFields = derivedFields.filter((f) => modelConfig[f] === undefined || overwrite?.has(f));
214
+ const skippedFields = derivedFields.filter((f) => !appliedFields.includes(f));
215
+ deps.logger.trace("models_info_model_merge", {
216
+ providerId,
217
+ modelId,
218
+ derivedFields,
219
+ appliedFields,
220
+ skippedFields
221
+ });
222
+ mergeIntoModel(modelConfig, derived, overwrite);
223
+ return "enriched";
200
224
  }
201
225
  async function loadRecord(providerId, opts, providerHeaders, deps) {
202
- // Cache key is keyed on the user-specified `modelsInfoHeaders` (NOT the
203
- // provider's rotating auth header) — so switching tenants busts the cache,
204
- // but an OAuth2 token rotation does not thrash it. See cacheKey() docstring.
205
- const key = cacheKey(providerId, opts.modelsInfoUrl, opts.modelsInfoHeaders);
206
- deps.logger.trace("models_info_cache_key_computed", { providerId, key });
207
- const now = deps.now ? deps.now() : Date.now();
208
- const cached = await deps.cache.get(key);
209
- deps.logger.trace("models_info_cache_lookup", {
210
- providerId,
211
- found: Boolean(cached),
212
- expired: cached ? isExpired(cached, now) : undefined
213
- });
214
- if (cached && !isExpired(cached, now)) {
215
- deps.logger.debug("models_info_cache_hit", {
216
- providerId,
217
- url: opts.modelsInfoUrl,
218
- ageMs: now - cached.fetchedAt
219
- });
220
- return cached;
221
- }
222
- return fetchAndCache(providerId, opts, providerHeaders, deps, key, cached, now);
226
+ // Cache key is keyed on the user-specified `modelsInfoHeaders` (NOT the
227
+ // provider's rotating auth header) — so switching tenants busts the cache,
228
+ // but an OAuth2 token rotation does not thrash it. See cacheKey() docstring.
229
+ const key = cacheKey(providerId, opts.modelsInfoUrl, opts.modelsInfoHeaders);
230
+ deps.logger.trace("models_info_cache_key_computed", {
231
+ providerId,
232
+ key
233
+ });
234
+ const now = deps.now ? deps.now() : Date.now();
235
+ const cached = await deps.cache.get(key);
236
+ deps.logger.trace("models_info_cache_lookup", {
237
+ providerId,
238
+ found: Boolean(cached),
239
+ expired: cached ? isExpired(cached, now) : undefined
240
+ });
241
+ if (cached && !isExpired(cached, now)) {
242
+ deps.logger.debug("models_info_cache_hit", {
243
+ providerId,
244
+ url: opts.modelsInfoUrl,
245
+ ageMs: now - cached.fetchedAt
246
+ });
247
+ return cached;
248
+ }
249
+ return fetchAndCache(providerId, opts, providerHeaders, deps, key, cached, now);
223
250
  }
224
251
  /**
225
- * Unconditionally revalidates a provider's catalog against the network
226
- * (still honoring the `ETag`, so an unchanged catalog is a cheap `304`) and
227
- * writes the result back to the cache — regardless of whether the current
228
- * entry is still within its TTL. This is the periodic-refresh path (see
229
- * {@link startCacheRefreshSchedulers}): `loadRecord`'s TTL fast-path exists
230
- * to avoid a network call from the `config` hook on every launch, but a
231
- * background scheduler's entire point is to go check anyway.
232
- */
252
+ * Unconditionally revalidates a provider's catalog against the network
253
+ * (still honoring the `ETag`, so an unchanged catalog is a cheap `304`) and
254
+ * writes the result back to the cache — regardless of whether the current
255
+ * entry is still within its TTL. This is the periodic-refresh path (see
256
+ * {@link startCacheRefreshSchedulers}): `loadRecord`'s TTL fast-path exists
257
+ * to avoid a network call from the `config` hook on every launch, but a
258
+ * background scheduler's entire point is to go check anyway.
259
+ */
233
260
  async function refreshProviderCache(providerId, opts, providerHeaders, deps) {
234
- const key = cacheKey(providerId, opts.modelsInfoUrl, opts.modelsInfoHeaders);
235
- const now = deps.now ? deps.now() : Date.now();
236
- const cached = await deps.cache.get(key);
237
- await fetchAndCache(providerId, opts, providerHeaders, deps, key, cached, now);
261
+ const key = cacheKey(providerId, opts.modelsInfoUrl, opts.modelsInfoHeaders);
262
+ const now = deps.now ? deps.now() : Date.now();
263
+ const cached = await deps.cache.get(key);
264
+ await fetchAndCache(providerId, opts, providerHeaders, deps, key, cached, now);
238
265
  }
239
266
  async function fetchAndCache(providerId, opts, providerHeaders, deps, key, cached, now) {
240
- const headers = buildFetchHeaders(opts, providerHeaders);
241
- deps.logger.trace("models_info_fetch_start", {
242
- providerId,
243
- url: opts.modelsInfoUrl,
244
- hasHeaders: Boolean(headers),
245
- hasConditional: Boolean(cached?.etag)
246
- });
247
- const result = await fetchOpenRouterModels({
248
- url: opts.modelsInfoUrl,
249
- headers,
250
- timeoutMs: opts.modelsInfoTimeoutMs,
251
- etag: cached?.etag,
252
- fetchImpl: deps.fetchImpl
253
- });
254
- deps.logger.trace("models_info_fetch_result", {
255
- providerId,
256
- status: result.status,
257
- count: result.models?.length
258
- });
259
- if (result.status === "ok" && result.models) {
260
- const next = {
261
- fetchedAt: now,
262
- ttlSeconds: opts.modelsInfoTtlSeconds,
263
- etag: result.etag,
264
- models: result.models
265
- };
266
- // Disk write is best-effort — a read-only $HOME / cache dir shouldn't
267
- // make us throw away a perfectly good fresh response.
268
- await safePut(deps, key, next, providerId, opts.modelsInfoUrl);
269
- deps.logger.info("models_info_fetched", {
270
- providerId,
271
- url: opts.modelsInfoUrl,
272
- count: result.models.length
273
- });
274
- return next;
275
- }
276
- if (result.status === "not-modified" && cached) {
277
- // Apply the CURRENT TTL from config — a tightened TTL in opencode.json
278
- // should take effect on the next revalidation, not on the next full
279
- // 200 fetch (which might be 24h away).
280
- const refreshed = {
281
- ...cached,
282
- fetchedAt: now,
283
- ttlSeconds: opts.modelsInfoTtlSeconds
284
- };
285
- await safePut(deps, key, refreshed, providerId, opts.modelsInfoUrl);
286
- deps.logger.debug("models_info_not_modified", {
287
- providerId,
288
- url: opts.modelsInfoUrl
289
- });
290
- return refreshed;
291
- }
292
- if (cached) {
293
- deps.logger.warn("models_info_fetch_failed_using_stale", {
294
- providerId,
295
- url: opts.modelsInfoUrl,
296
- error: result.error,
297
- ageMs: now - cached.fetchedAt
298
- });
299
- return cached;
300
- }
301
- deps.logger.warn("models_info_fetch_failed_no_cache", {
302
- providerId,
303
- url: opts.modelsInfoUrl,
304
- error: result.error
305
- });
306
- return undefined;
267
+ const headers = buildFetchHeaders(opts, providerHeaders);
268
+ deps.logger.trace("models_info_fetch_start", {
269
+ providerId,
270
+ url: opts.modelsInfoUrl,
271
+ hasHeaders: Boolean(headers),
272
+ hasConditional: Boolean(cached?.etag)
273
+ });
274
+ const result = await fetchOpenRouterModels({
275
+ url: opts.modelsInfoUrl,
276
+ headers,
277
+ timeoutMs: opts.modelsInfoTimeoutMs,
278
+ etag: cached?.etag,
279
+ fetchImpl: deps.fetchImpl
280
+ });
281
+ deps.logger.trace("models_info_fetch_result", {
282
+ providerId,
283
+ status: result.status,
284
+ count: result.models?.length
285
+ });
286
+ if (result.status === "ok" && result.models) {
287
+ const next = {
288
+ fetchedAt: now,
289
+ ttlSeconds: opts.modelsInfoTtlSeconds,
290
+ etag: result.etag,
291
+ models: result.models
292
+ };
293
+ // Disk write is best-effort — a read-only $HOME / cache dir shouldn't
294
+ // make us throw away a perfectly good fresh response.
295
+ await safePut(deps, key, next, providerId, opts.modelsInfoUrl);
296
+ deps.logger.info("models_info_fetched", {
297
+ providerId,
298
+ url: opts.modelsInfoUrl,
299
+ count: result.models.length
300
+ });
301
+ return next;
302
+ }
303
+ if (result.status === "not-modified" && cached) {
304
+ // Apply the CURRENT TTL from config — a tightened TTL in opencode.json
305
+ // should take effect on the next revalidation, not on the next full
306
+ // 200 fetch (which might be 24h away).
307
+ const refreshed = {
308
+ ...cached,
309
+ fetchedAt: now,
310
+ ttlSeconds: opts.modelsInfoTtlSeconds
311
+ };
312
+ await safePut(deps, key, refreshed, providerId, opts.modelsInfoUrl);
313
+ deps.logger.debug("models_info_not_modified", {
314
+ providerId,
315
+ url: opts.modelsInfoUrl
316
+ });
317
+ return refreshed;
318
+ }
319
+ if (cached) {
320
+ deps.logger.warn("models_info_fetch_failed_using_stale", {
321
+ providerId,
322
+ url: opts.modelsInfoUrl,
323
+ error: result.error,
324
+ ageMs: now - cached.fetchedAt
325
+ });
326
+ return cached;
327
+ }
328
+ deps.logger.warn("models_info_fetch_failed_no_cache", {
329
+ providerId,
330
+ url: opts.modelsInfoUrl,
331
+ error: result.error
332
+ });
333
+ return undefined;
307
334
  }
308
335
  /**
309
- * Merge the provider's resolved request headers with the meta-specific
310
- * `modelsInfoHeaders`. Meta wins on conflict so a user can override e.g. a
311
- * dynamic `Authorization` header for the metadata endpoint specifically.
312
- */
336
+ * Merge the provider's resolved request headers with the meta-specific
337
+ * `modelsInfoHeaders`. Meta wins on conflict so a user can override e.g. a
338
+ * dynamic `Authorization` header for the metadata endpoint specifically.
339
+ */
313
340
  function buildFetchHeaders(opts, providerHeaders) {
314
- if (!providerHeaders && !opts.modelsInfoHeaders) {
315
- return undefined;
316
- }
317
- return {
318
- ...(providerHeaders ?? {}),
319
- ...(opts.modelsInfoHeaders ?? {})
320
- };
341
+ if (!providerHeaders && !opts.modelsInfoHeaders) {
342
+ return undefined;
343
+ }
344
+ return {
345
+ ...providerHeaders ?? {},
346
+ ...opts.modelsInfoHeaders ?? {}
347
+ };
321
348
  }
322
349
  async function safePut(deps, key, record, providerId, url) {
323
- try {
324
- await deps.cache.put(key, record);
325
- }
326
- catch (error) {
327
- deps.logger.warn("models_info_cache_write_failed", {
328
- providerId,
329
- url,
330
- error: error instanceof Error ? error.message : String(error)
331
- });
332
- }
350
+ try {
351
+ await deps.cache.put(key, record);
352
+ } catch (error) {
353
+ deps.logger.warn("models_info_cache_write_failed", {
354
+ providerId,
355
+ url,
356
+ error: error instanceof Error ? error.message : String(error)
357
+ });
358
+ }
333
359
  }
334
360
  function asHeaderMap(value) {
335
- if (!value || typeof value !== "object" || Array.isArray(value)) {
336
- return undefined;
337
- }
338
- const out = {};
339
- for (const [k, v] of Object.entries(value)) {
340
- if (typeof v === "string" && v.length > 0) {
341
- out[k] = v;
342
- }
343
- }
344
- return Object.keys(out).length > 0 ? out : undefined;
361
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
362
+ return undefined;
363
+ }
364
+ const out = {};
365
+ for (const [k, v] of Object.entries(value)) {
366
+ if (typeof v === "string" && v.length > 0) {
367
+ out[k] = v;
368
+ }
369
+ }
370
+ return Object.keys(out).length > 0 ? out : undefined;
345
371
  }
346
372
  export { FileCacheStore };
373
+
347
374
  //# sourceMappingURL=plugin.js.map