@vymalo/opencode-models-info 0.11.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,340 +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. `modelsInfoHideTextOnly` governs both deletion paths: a
144
- * model absent from the catalog entirely, and a matched model the catalog
145
- * reports as text-in/text-out only. `modelsInfoHideInternal` is a separate,
146
- * independent deletion path for a matched model the catalog flags
147
- * `internal: true` modality and internal/restricted status are unrelated
148
- * signals, and conflating them (routing "hide internal" through
149
- * `modelsInfoHideTextOnly`) hides legitimate text-only external models. It
150
- * does NOT extend the unmatched-model pathan unmatched model's status is
151
- * unknown, not "internal", so that stays governed solely by
152
- * `modelsInfoHideTextOnly`.
153
- */
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
+ */
154
168
  function reconcileModel(modelId, modelConfig, ctx) {
155
- const { providerId, models, byId, opts, overwrite, deps } = ctx;
156
- const declaredId = typeof modelConfig.id === "string" ? modelConfig.id : undefined;
157
- const matchById = byId.has(modelId);
158
- const match = byId.get(modelId) ?? (declaredId ? byId.get(declaredId) : undefined);
159
- if (!match) {
160
- if (!opts.modelsInfoHideTextOnly) {
161
- deps.logger.trace("models_info_model_unmatched", { providerId, modelId, declaredId });
162
- return "skipped";
163
- }
164
- delete models[modelId];
165
- deps.logger.debug("models_info_model_hidden_unmatched", { providerId, modelId, declaredId });
166
- return "hidden";
167
- }
168
- deps.logger.trace("models_info_model_matched", {
169
- providerId,
170
- modelId,
171
- matchedBy: matchById ? "id" : "declaredId"
172
- });
173
- if (opts.modelsInfoHideInternal && match.internal === true) {
174
- delete models[modelId];
175
- deps.logger.debug("models_info_model_hidden_internal", { providerId, modelId });
176
- return "hidden";
177
- }
178
- const derived = mapOpenRouterEntry(match, overwrite);
179
- if (opts.modelsInfoHideTextOnly && isTextOnlyModality(derived.modalities)) {
180
- delete models[modelId];
181
- deps.logger.debug("models_info_model_hidden_text_only", { providerId, modelId });
182
- return "hidden";
183
- }
184
- const derivedFields = Object.keys(derived);
185
- const appliedFields = derivedFields.filter((f) => modelConfig[f] === undefined || overwrite?.has(f));
186
- const skippedFields = derivedFields.filter((f) => !appliedFields.includes(f));
187
- deps.logger.trace("models_info_model_merge", {
188
- providerId,
189
- modelId,
190
- derivedFields,
191
- appliedFields,
192
- skippedFields
193
- });
194
- mergeIntoModel(modelConfig, derived, overwrite);
195
- 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";
196
224
  }
197
225
  async function loadRecord(providerId, opts, providerHeaders, deps) {
198
- // Cache key is keyed on the user-specified `modelsInfoHeaders` (NOT the
199
- // provider's rotating auth header) — so switching tenants busts the cache,
200
- // but an OAuth2 token rotation does not thrash it. See cacheKey() docstring.
201
- const key = cacheKey(providerId, opts.modelsInfoUrl, opts.modelsInfoHeaders);
202
- deps.logger.trace("models_info_cache_key_computed", { providerId, key });
203
- const now = deps.now ? deps.now() : Date.now();
204
- const cached = await deps.cache.get(key);
205
- deps.logger.trace("models_info_cache_lookup", {
206
- providerId,
207
- found: Boolean(cached),
208
- expired: cached ? isExpired(cached, now) : undefined
209
- });
210
- if (cached && !isExpired(cached, now)) {
211
- deps.logger.debug("models_info_cache_hit", {
212
- providerId,
213
- url: opts.modelsInfoUrl,
214
- ageMs: now - cached.fetchedAt
215
- });
216
- return cached;
217
- }
218
- 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);
219
250
  }
220
251
  /**
221
- * Unconditionally revalidates a provider's catalog against the network
222
- * (still honoring the `ETag`, so an unchanged catalog is a cheap `304`) and
223
- * writes the result back to the cache — regardless of whether the current
224
- * entry is still within its TTL. This is the periodic-refresh path (see
225
- * {@link startCacheRefreshSchedulers}): `loadRecord`'s TTL fast-path exists
226
- * to avoid a network call from the `config` hook on every launch, but a
227
- * background scheduler's entire point is to go check anyway.
228
- */
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
+ */
229
260
  async function refreshProviderCache(providerId, opts, providerHeaders, deps) {
230
- const key = cacheKey(providerId, opts.modelsInfoUrl, opts.modelsInfoHeaders);
231
- const now = deps.now ? deps.now() : Date.now();
232
- const cached = await deps.cache.get(key);
233
- 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);
234
265
  }
235
266
  async function fetchAndCache(providerId, opts, providerHeaders, deps, key, cached, now) {
236
- const headers = buildFetchHeaders(opts, providerHeaders);
237
- deps.logger.trace("models_info_fetch_start", {
238
- providerId,
239
- url: opts.modelsInfoUrl,
240
- hasHeaders: Boolean(headers),
241
- hasConditional: Boolean(cached?.etag)
242
- });
243
- const result = await fetchOpenRouterModels({
244
- url: opts.modelsInfoUrl,
245
- headers,
246
- timeoutMs: opts.modelsInfoTimeoutMs,
247
- etag: cached?.etag,
248
- fetchImpl: deps.fetchImpl
249
- });
250
- deps.logger.trace("models_info_fetch_result", {
251
- providerId,
252
- status: result.status,
253
- count: result.models?.length
254
- });
255
- if (result.status === "ok" && result.models) {
256
- const next = {
257
- fetchedAt: now,
258
- ttlSeconds: opts.modelsInfoTtlSeconds,
259
- etag: result.etag,
260
- models: result.models
261
- };
262
- // Disk write is best-effort — a read-only $HOME / cache dir shouldn't
263
- // make us throw away a perfectly good fresh response.
264
- await safePut(deps, key, next, providerId, opts.modelsInfoUrl);
265
- deps.logger.info("models_info_fetched", {
266
- providerId,
267
- url: opts.modelsInfoUrl,
268
- count: result.models.length
269
- });
270
- return next;
271
- }
272
- if (result.status === "not-modified" && cached) {
273
- // Apply the CURRENT TTL from config — a tightened TTL in opencode.json
274
- // should take effect on the next revalidation, not on the next full
275
- // 200 fetch (which might be 24h away).
276
- const refreshed = {
277
- ...cached,
278
- fetchedAt: now,
279
- ttlSeconds: opts.modelsInfoTtlSeconds
280
- };
281
- await safePut(deps, key, refreshed, providerId, opts.modelsInfoUrl);
282
- deps.logger.debug("models_info_not_modified", {
283
- providerId,
284
- url: opts.modelsInfoUrl
285
- });
286
- return refreshed;
287
- }
288
- if (cached) {
289
- deps.logger.warn("models_info_fetch_failed_using_stale", {
290
- providerId,
291
- url: opts.modelsInfoUrl,
292
- error: result.error,
293
- ageMs: now - cached.fetchedAt
294
- });
295
- return cached;
296
- }
297
- deps.logger.warn("models_info_fetch_failed_no_cache", {
298
- providerId,
299
- url: opts.modelsInfoUrl,
300
- error: result.error
301
- });
302
- 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;
303
334
  }
304
335
  /**
305
- * Merge the provider's resolved request headers with the meta-specific
306
- * `modelsInfoHeaders`. Meta wins on conflict so a user can override e.g. a
307
- * dynamic `Authorization` header for the metadata endpoint specifically.
308
- */
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
+ */
309
340
  function buildFetchHeaders(opts, providerHeaders) {
310
- if (!providerHeaders && !opts.modelsInfoHeaders) {
311
- return undefined;
312
- }
313
- return {
314
- ...(providerHeaders ?? {}),
315
- ...(opts.modelsInfoHeaders ?? {})
316
- };
341
+ if (!providerHeaders && !opts.modelsInfoHeaders) {
342
+ return undefined;
343
+ }
344
+ return {
345
+ ...providerHeaders ?? {},
346
+ ...opts.modelsInfoHeaders ?? {}
347
+ };
317
348
  }
318
349
  async function safePut(deps, key, record, providerId, url) {
319
- try {
320
- await deps.cache.put(key, record);
321
- }
322
- catch (error) {
323
- deps.logger.warn("models_info_cache_write_failed", {
324
- providerId,
325
- url,
326
- error: error instanceof Error ? error.message : String(error)
327
- });
328
- }
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
+ }
329
359
  }
330
360
  function asHeaderMap(value) {
331
- if (!value || typeof value !== "object" || Array.isArray(value)) {
332
- return undefined;
333
- }
334
- const out = {};
335
- for (const [k, v] of Object.entries(value)) {
336
- if (typeof v === "string" && v.length > 0) {
337
- out[k] = v;
338
- }
339
- }
340
- 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;
341
371
  }
342
372
  export { FileCacheStore };
373
+
343
374
  //# sourceMappingURL=plugin.js.map