audit-tools 0.33.6 → 0.33.7

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.
Files changed (37) hide show
  1. package/dist/audit/cli/nextStepCommand.js +3 -3
  2. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  3. package/dist/remediate/steps/sessionConfigLoad.js +1 -1
  4. package/dist/remediate/steps/sessionConfigLoad.js.map +1 -1
  5. package/dist/shared/dispatch/admissionLoop.d.ts +1 -1
  6. package/dist/shared/dispatch/admissionLoop.js +1 -1
  7. package/dist/shared/index.js +1 -1
  8. package/dist/shared/index.js.map +1 -1
  9. package/dist/shared/providers/auditorSources.d.ts +39 -44
  10. package/dist/shared/providers/auditorSources.d.ts.map +1 -1
  11. package/dist/shared/providers/auditorSources.js +90 -63
  12. package/dist/shared/providers/auditorSources.js.map +1 -1
  13. package/dist/shared/providers/claudeWorkerProvider.d.ts +12 -11
  14. package/dist/shared/providers/claudeWorkerProvider.d.ts.map +1 -1
  15. package/dist/shared/providers/claudeWorkerProvider.js +23 -16
  16. package/dist/shared/providers/claudeWorkerProvider.js.map +1 -1
  17. package/dist/shared/providers/providerConfirmation.d.ts +2 -2
  18. package/dist/shared/providers/providerConfirmation.js +2 -2
  19. package/dist/shared/providers/providerConfirmation.js.map +1 -1
  20. package/dist/shared/providers/proxyCatalog.d.ts +19 -16
  21. package/dist/shared/providers/proxyCatalog.d.ts.map +1 -1
  22. package/dist/shared/providers/proxyCatalog.js +272 -181
  23. package/dist/shared/providers/proxyCatalog.js.map +1 -1
  24. package/dist/shared/providers/sharedProviderConfirmation.d.ts +1 -1
  25. package/dist/shared/providers/sharedProviderConfirmation.js +2 -2
  26. package/dist/shared/providers/sharedProviderConfirmation.js.map +1 -1
  27. package/dist/shared/quota/apiPool.js +4 -4
  28. package/dist/shared/quota/apiPool.js.map +1 -1
  29. package/dist/shared/quota/capacity.d.ts +1 -1
  30. package/dist/shared/types/providerConfirmation.d.ts +2 -2
  31. package/dist/shared/types/sessionConfig.d.ts +9 -8
  32. package/dist/shared/types/sessionConfig.d.ts.map +1 -1
  33. package/dist/shared/types/sessionConfig.js +1 -1
  34. package/dist/shared/types/sessionConfig.js.map +1 -1
  35. package/dist/shared/validation/sessionConfig.js +1 -1
  36. package/dist/shared/validation/sessionConfig.js.map +1 -1
  37. package/package.json +1 -1
@@ -5,15 +5,15 @@ import { writeJsonFile } from "../io/json.js";
5
5
  import { resolveAuditCodeStateDir } from "../io/stateDir.js";
6
6
  import { validateSessionConfig } from "../validation/sessionConfig.js";
7
7
  /**
8
- * The POPULATE cache for the repair-proxy lane: `GET <proxy>/registry` expanded into
9
- * ready-to-fold `claude-worker` {@link DispatchableSource}s, written once per populate
10
- * (Gate-0 build / explicit refresh) and READ by resolve — never fetched mid-resolve
11
- * (`docs/reviews/commit3-proxy-kind1-transport-plan-2026-07-16.md` §populate/resolve).
8
+ * The POPULATE cache for the proxy lane: discovered via `GET <proxy>/v1/models` +
9
+ * `GET <proxy>/model/info` (OpenAI-compatible surfaces) expanded into ready-to-fold
10
+ * `claude-worker` {@link DispatchableSource}s, written once per populate (Gate-0 build
11
+ * / explicit refresh) and READ by resolve — never fetched mid-resolve.
12
12
  *
13
13
  * Named `catalog-cache.json`, NOT the `catalog-<auditor-id>.json` the reserved-name
14
14
  * comment at `auditorSources.ts` anticipates: populate/resolve run on the AMBIENT
15
15
  * path, where no auditor id exists to key on. The cache is machine-level like the
16
- * declaration beside it — and it is a CACHE of live registry state, not resolved
16
+ * declaration beside it — and it is a CACHE of live discovery state, not resolved
17
17
  * per-auditor capability, so an auditor-id key would assert an isolation the data
18
18
  * doesn't have. Per-auditor never-inherit still holds: every resolve re-proves proxy
19
19
  * REACH itself; the cache only supplies the expansion.
@@ -41,34 +41,55 @@ function finiteNonNegative(value) {
41
41
  : undefined;
42
42
  }
43
43
  /**
44
- * Best-effort "higher = better" score for ranking. A flat `score` wins; otherwise
45
- * the live repair-proxy's `capability` block supplies someone-else-maintained
46
- * relative-capability RANKS (`composite_rank`, then `arena_rank`; lower = better,
47
- * so negated). A model with none stays unscored and ranks last — capability-less
48
- * registry rows are frequently non-chat models (TTS, embeddings) that cannot serve
49
- * as agentic workers.
44
+ * Edge adapter: proxy `/model/info` response shape → neutral ModelAdvert contract.
45
+ * Maps from proxy-specific field names to a generic contract.
46
+ *
47
+ * Returns:
48
+ * - { advert, filtered: false | undefined } for valid models
49
+ * - { filtered: true } for models filtered by eligibility rules
50
+ * - undefined for invalid data (missing modelName/model_info)
51
+ */
52
+ function adaptProxyModelInfo(modelInfo) {
53
+ const modelName = modelInfo.model_name;
54
+ if (typeof modelName !== "string" || modelName.trim().length === 0)
55
+ return undefined;
56
+ const info = modelInfo.model_info;
57
+ if (info === null || typeof info !== "object")
58
+ return undefined;
59
+ const infoObj = info;
60
+ const proxyProvider = infoObj.litellm_provider;
61
+ const provider = typeof proxyProvider === "string" ? proxyProvider : undefined;
62
+ const mode = infoObj.mode;
63
+ const supportsToolCalls = infoObj.supports_tool_calls;
64
+ // Check eligibility: mode must be "chat" or absent (unknown ≠ incapable).
65
+ // supports_tool_calls false → skip; absent or true → keep.
66
+ if (typeof mode === "string" && mode !== "chat")
67
+ return { filtered: true };
68
+ if (supportsToolCalls === false)
69
+ return { filtered: true };
70
+ return {
71
+ advert: {
72
+ alias: modelName.trim(),
73
+ ...(provider !== undefined ? { provider } : {}),
74
+ context_tokens: finiteNonNegative(infoObj.max_input_tokens),
75
+ input_cost_per_token: finiteNonNegative(infoObj.input_cost_per_token),
76
+ output_cost_per_token: finiteNonNegative(infoObj.output_cost_per_token),
77
+ mode: typeof mode === "string" ? mode : undefined,
78
+ supports_tool_calls: typeof supportsToolCalls === "boolean" ? supportsToolCalls : undefined,
79
+ // Operator-declared rank via advert custom key (consumed as-is when present).
80
+ declared_rank: finiteNonNegative(infoObj.capability_rank),
81
+ },
82
+ };
83
+ }
84
+ /**
85
+ * Best-effort "higher = better" score for ranking. Uses a flat `score` field
86
+ * when present; absent/non-finite → null (unscored models rank last).
50
87
  */
51
88
  function deriveScore(entry) {
52
89
  if (typeof entry.score === "number" && Number.isFinite(entry.score)) {
53
90
  return entry.score;
54
91
  }
55
- const rank = deriveRawCapabilityRank(entry);
56
- return rank === undefined ? null : -rank;
57
- }
58
- /**
59
- * The RAW relative-capability rank (LOWER = better) from a registry entry's
60
- * capability block, when the active proxy exposes one — best-effort and
61
- * proxy-agnostic (a registry with no capability data yields undefined; the floor
62
- * then fails open per the owner decision, [[litellm-replaces-repair-proxy]]).
63
- * Stamped onto the expanded source as `capability_rank` (unified-routing step C)
64
- * so the admission floor reads per-model capability with no operator declaration.
65
- */
66
- function deriveRawCapabilityRank(entry) {
67
- const capability = entry.capability;
68
- if (capability === null || typeof capability !== "object")
69
- return undefined;
70
- return (finiteNonNegative(capability.composite_rank) ??
71
- finiteNonNegative(capability.arena_rank));
92
+ return null;
72
93
  }
73
94
  /**
74
95
  * Tolerantly extract context-window field from a registry entry or its nested
@@ -92,98 +113,118 @@ function deriveContextTokens(entry) {
92
113
  return undefined;
93
114
  }
94
115
  /**
95
- * Tolerantly extract `{provider, model, score, price}` rows from a registry payload,
96
- * keeping only entries this process could actually dispatch through: `reachable` AND
97
- * `has_key` must be literally `true` (the proxy's own liveness/credential verdicts).
98
- * Anything malformed — wrong types, missing provider/model — is FILTERED, never thrown:
99
- * a half-broken registry degrades to a smaller expansion, not a failed populate.
116
+ * Discover models via the neutral proxy contract: `GET /v1/models` (required baseline)
117
+ * returns the alias roster; `GET /model/info` (optional enrichment) provides per-model
118
+ * metadata. Tolerant at every step: missing/unparseable/malformed → graceful degradation.
119
+ * Eligibility filters remove models from the pool entirely (mode != 'chat' or
120
+ * supports_tool_calls === false).
121
+ *
122
+ * Returns { adverts, filtered } where adverts is a map of alias → ModelAdvert,
123
+ * and filtered is a set of aliases that failed the eligibility filter.
100
124
  */
101
- function extractRegistryModels(payload) {
102
- // The registry is providers × live models; tolerate a flat entry array, a
103
- // `{providers|models|entries: [...]}` wrapper, or the live repair-proxy's
104
- // provider-MAP form (`providers: {<name>: {has_key, reachable, models: [...]}}`
105
- // — the name is the key, so it is folded into each entry as `name`), plus
106
- // per-provider nested `models` in every form.
107
- let container;
108
- if (Array.isArray(payload)) {
109
- container = payload;
110
- }
111
- else if (payload !== null && typeof payload === "object") {
112
- for (const key of ["providers", "models", "entries"]) {
113
- const wrapped = payload[key];
114
- if (Array.isArray(wrapped)) {
115
- container = wrapped;
116
- break;
117
- }
118
- if (wrapped !== null && typeof wrapped === "object") {
119
- container = Object.entries(wrapped).map(([name, entry]) => entry !== null && typeof entry === "object"
120
- ? { name, ...entry }
121
- : entry);
122
- break;
125
+ async function discoverModelAdverts(endpoint, fetchImpl, authHeader) {
126
+ const adverts = new Map();
127
+ const filtered = new Set();
128
+ try {
129
+ const headers = { "content-type": "application/json" };
130
+ if (authHeader)
131
+ headers.authorization = authHeader;
132
+ // Try /model/info first (richer advert); tolerate absent/unparseable
133
+ try {
134
+ const response = await fetchImpl(`${endpoint}/model/info`, { headers });
135
+ if (response.ok) {
136
+ const payload = await response.json();
137
+ // Target the current array form: {data: [...]}
138
+ if (payload !== null && typeof payload === "object") {
139
+ const data = payload.data;
140
+ if (Array.isArray(data)) {
141
+ for (const raw of data) {
142
+ if (raw !== null && typeof raw === "object") {
143
+ const result = adaptProxyModelInfo(raw);
144
+ if (result) {
145
+ if (result.filtered) {
146
+ // Track which aliases failed the eligibility filter
147
+ const modelName = raw.model_name;
148
+ if (typeof modelName === "string" && modelName.trim().length > 0) {
149
+ filtered.add(modelName.trim());
150
+ }
151
+ }
152
+ else if (result.advert) {
153
+ // Successful adaptation
154
+ adverts.set(result.advert.alias, result.advert);
155
+ }
156
+ }
157
+ // If result is undefined, it's invalid data (not eligibility filter)
158
+ }
159
+ }
160
+ }
161
+ }
123
162
  }
124
163
  }
164
+ catch {
165
+ // /model/info absent/error → degrade to roster-only (no enrichment)
166
+ }
125
167
  }
126
- if (!Array.isArray(container))
127
- return [];
128
- const models = [];
129
- const push = (entry, provider, model) => {
130
- if (typeof provider !== "string" || provider.trim().length === 0)
131
- return;
132
- if (typeof model !== "string" || model.trim().length === 0)
133
- return;
134
- if (entry.reachable !== true || entry.has_key !== true)
135
- return;
136
- models.push({
137
- provider: provider.trim(),
138
- model: model.trim(),
139
- score: deriveScore(entry),
140
- capabilityRank: deriveRawCapabilityRank(entry),
141
- costPerMtok: finiteNonNegative(entry.cost_per_mtok) ??
142
- finiteNonNegative(entry.price_per_mtok) ??
143
- finiteNonNegative(entry.price),
144
- contextTokens: deriveContextTokens(entry),
145
- });
146
- };
147
- for (const raw of container) {
148
- if (raw === null || typeof raw !== "object")
149
- continue;
150
- const entry = raw;
151
- const provider = entry.provider ?? entry.name;
152
- if (Array.isArray(entry.models)) {
153
- // Provider-grouped form: reachable/has_key live on the provider row; each
154
- // model row contributes id + score/price (and may override the flags).
155
- for (const rawModel of entry.models) {
156
- if (rawModel === null || typeof rawModel === "string") {
157
- if (typeof rawModel === "string")
158
- push(entry, provider, rawModel);
159
- continue;
168
+ catch {
169
+ // No adverts → degrade to roster-only (carrier of aliases only)
170
+ }
171
+ return { adverts, filtered };
172
+ }
173
+ /**
174
+ * Discover the model roster via the neutral proxy contract: `GET /v1/models`
175
+ * (OpenAI-compatible list). Returns { aliases, error? }; absent/unparseable → empty aliases + reason.
176
+ */
177
+ async function discoverModelRoster(endpoint, fetchImpl, authHeader) {
178
+ try {
179
+ const headers = { "content-type": "application/json" };
180
+ if (authHeader)
181
+ headers.authorization = authHeader;
182
+ const response = await fetchImpl(`${endpoint}/v1/models`, { headers });
183
+ if (!response.ok) {
184
+ return {
185
+ aliases: [],
186
+ error: `HTTP ${response.status}`,
187
+ };
188
+ }
189
+ const payload = await response.json();
190
+ if (payload === null || typeof payload !== "object")
191
+ return { aliases: [] };
192
+ const data = payload.data;
193
+ if (!Array.isArray(data))
194
+ return { aliases: [] };
195
+ const aliases = [];
196
+ for (const entry of data) {
197
+ if (entry !== null && typeof entry === "object") {
198
+ const id = entry.id;
199
+ if (typeof id === "string" && id.trim().length > 0) {
200
+ aliases.push(id.trim());
160
201
  }
161
- if (typeof rawModel !== "object")
162
- continue;
163
- const model = rawModel;
164
- push({ ...entry, ...model, models: undefined }, provider, model.id ?? model.model);
165
202
  }
166
- continue;
167
203
  }
168
- push(entry, provider, entry.model ?? entry.id);
204
+ return { aliases };
205
+ }
206
+ catch (err) {
207
+ const reason = err instanceof Error ? err.message : String(err);
208
+ return { aliases: [], error: reason };
169
209
  }
170
- return models;
171
210
  }
172
211
  /**
173
- * Expand registry models into `claude-worker` sources: per reachable+keyed backend
174
- * provider, the top-K models by best-effort score (higher = better; unscored last).
212
+ * Expand discovered models into `claude-worker` sources: per backend provider,
213
+ * the top-K models by best-effort score (higher = better; unscored last).
175
214
  * Shape per the plan's identity section: the transport never enters the identity —
176
- * `backend_provider` + `model` key quota; `endpoint` is the proxy url the launch
177
- * transport (3b) fronts the spawn with; `worker_kind` is `agentic` by definition
178
- * (the whole point of the proxied lane is tool-call repair for a harness worker).
215
+ * `backend_provider` + `model` (alias) key quota; `endpoint` is the proxy url the
216
+ * launch transport fronts the spawn with; `worker_kind` is `agentic` by definition.
217
+ *
218
+ * Provider derivation: advert `provider` (from /model/info) > slash-prefix of alias
219
+ * (e.g., `anthropic/claude-*` → `anthropic`) > default shared `"proxy"` bucket
220
+ * (coarse pool identity at degradation rung, revisitable per owner decision).
179
221
  */
180
- function expandSources(models, options) {
222
+ function expandSources(discovered, options) {
181
223
  const byProvider = new Map();
182
- // Dedup by (provider, model): live registries list some models twice, and one
183
- // pool identity must expand to exactly one source (first row wins).
224
+ // Dedup by (provider, alias): first row wins.
184
225
  const seen = new Set();
185
- for (const model of models) {
186
- const identity = `${model.provider}/${model.model}`;
226
+ for (const model of discovered) {
227
+ const identity = `${model.provider}/${model.alias}`;
187
228
  if (seen.has(identity))
188
229
  continue;
189
230
  seen.add(identity);
@@ -192,25 +233,26 @@ function expandSources(models, options) {
192
233
  byProvider.set(model.provider, bucket);
193
234
  }
194
235
  const sources = [];
195
- // Stable, content-derived order (provider, then score desc, then model id) so a
196
- // re-populate over identical registry state emits byte-identical sources.
236
+ // Stable, content-derived order (provider, then score desc, then alias) so
237
+ // re-populate over identical state emits byte-identical sources.
197
238
  for (const provider of [...byProvider.keys()].sort()) {
198
239
  const ranked = byProvider
199
240
  .get(provider)
200
241
  .sort((a, b) => (b.score ?? Number.NEGATIVE_INFINITY) - (a.score ?? Number.NEGATIVE_INFINITY) ||
201
- a.model.localeCompare(b.model))
242
+ a.alias.localeCompare(b.alias))
202
243
  .slice(0, options.topK);
203
244
  for (const model of ranked) {
204
- // Cost precedence: operator-declared (free-to-operator axis) > registry list
205
- // price > absent (falls through to the models.dev catalog / tier downstream).
245
+ // Cost precedence: operator-declared (free-to-operator axis) > advert price
246
+ // > absent (falls through to models.dev catalog / tier downstream).
206
247
  const cost = options.costPerMtok ?? model.costPerMtok;
207
248
  sources.push({
208
- id: `claude-worker:${provider}/${model.model}`,
249
+ id: `claude-worker:${provider}/${model.alias}`,
209
250
  provider: "claude-worker",
210
251
  endpoint: options.endpoint,
211
252
  backend_provider: provider,
212
- model: model.model,
253
+ model: model.alias, // Alias VERBATIM — the proxy's routing key
213
254
  worker_kind: "agentic",
255
+ ...(options.apiKeyEnv !== undefined ? { api_key_env: options.apiKeyEnv } : {}),
214
256
  ...(cost !== undefined ? { cost_per_mtok: cost } : {}),
215
257
  // Step C: per-model capability (raw rank, LOWER = better) rides the source →
216
258
  // CapacityPool.declaredCapabilityRank → the admission capability floor.
@@ -226,23 +268,25 @@ function expandSources(models, options) {
226
268
  return sources;
227
269
  }
228
270
  /**
229
- * Probe a single source to verify the model is actually reachable.
230
- * Returns { dropped: true, reason } if the model should be excluded (404/unavailable),
231
- * or { dropped: false } to keep the source.
271
+ * Probe a single model via Anthropic-compatible `/v1/messages` to verify it is
272
+ * actually reachable. Returns { dropped: true, reason } if the model should be
273
+ * excluded (404/unavailable), or { dropped: false } to keep it.
232
274
  */
233
- async function probeSource(source, endpoint, fetchImpl, timeoutMs) {
275
+ async function probeModelViaMessages(alias, backendProvider, endpoint, fetchImpl, timeoutMs, authHeader) {
234
276
  const controller = new AbortController();
235
277
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
236
278
  try {
279
+ const headers = {
280
+ "anthropic-version": "2023-06-01",
281
+ "content-type": "application/json",
282
+ };
283
+ if (authHeader)
284
+ headers.authorization = authHeader;
237
285
  const response = await fetchImpl(`${endpoint}/v1/messages`, {
238
286
  method: "POST",
239
- headers: {
240
- "x-api-key": "audit-tools-populate-probe",
241
- "anthropic-version": "2023-06-01",
242
- "content-type": "application/json",
243
- },
287
+ headers,
244
288
  body: JSON.stringify({
245
- model: `${source.backend_provider}/${source.model}`,
289
+ model: alias, // Route on proxy alias, not backend namespace
246
290
  max_tokens: 1,
247
291
  messages: [{ role: "user", content: "hi" }],
248
292
  }),
@@ -265,11 +309,11 @@ async function probeSource(source, endpoint, fetchImpl, timeoutMs) {
265
309
  return { dropped: true, reason: `HTTP ${response.status} (model unavailable)` };
266
310
  }
267
311
  }
268
- // 200, 401, 429, 5xx, etc. → keep the source
312
+ // 200, 401, 429, 5xx, etc. → keep the model
269
313
  return { dropped: false };
270
314
  }
271
315
  catch (error) {
272
- // Transport failure (timeout, network error) → fail-open, keep the source
316
+ // Transport failure (timeout, network error) → fail-open, keep the model
273
317
  return { dropped: false };
274
318
  }
275
319
  finally {
@@ -277,52 +321,56 @@ async function probeSource(source, endpoint, fetchImpl, timeoutMs) {
277
321
  }
278
322
  }
279
323
  /**
280
- * Run probes with bounded concurrency using a simple worker pool.
324
+ * Probe models with bounded concurrency using a simple worker pool.
281
325
  */
282
- async function probeSourcesWithConcurrency(sources, endpoint, fetchImpl, timeoutMs, concurrency) {
326
+ async function probeModelsWithConcurrency(aliases, backendProvider, endpoint, fetchImpl, timeoutMs, concurrency, authHeader) {
283
327
  const results = [];
284
328
  let index = 0;
285
329
  const worker = async () => {
286
- while (index < sources.length) {
330
+ while (index < aliases.length) {
287
331
  const currentIndex = index++;
288
- const source = sources[currentIndex];
289
- const probeResult = await probeSource(source, endpoint, fetchImpl, timeoutMs);
332
+ const alias = aliases[currentIndex];
333
+ const probeResult = await probeModelViaMessages(alias, backendProvider, endpoint, fetchImpl, timeoutMs, authHeader);
290
334
  results[currentIndex] = {
291
- source,
335
+ alias,
292
336
  dropped: probeResult.dropped,
293
337
  reason: probeResult.reason,
294
338
  };
295
339
  }
296
340
  };
297
- const workers = Array(Math.min(concurrency, sources.length))
341
+ const workers = Array(Math.min(concurrency, aliases.length))
298
342
  .fill(null)
299
343
  .map(() => worker());
300
344
  await Promise.all(workers);
301
- // results is index-assigned (results[currentIndex] = ...), so it is already in
302
- // the sources' order regardless of probe completion order.
345
+ // results is index-assigned, so already in order.
303
346
  return results;
304
347
  }
305
348
  /**
306
- * POPULATE: fetch `GET <endpoint>/registry` and write the expanded `claude-worker`
307
- * sources to the machine-level cache. Network-bound and cacheable — runs at Gate-0
308
- * build / explicit refresh, NEVER inside `resolveAmbientSources` (which only READS
309
- * via {@link readProxyCatalog}). Never throws: a failed fetch returns
310
- * `{written:false, reason}` and leaves any prior cache untouched; an empty/zero-match
311
- * registry WRITES an empty expansion (fresh knowledge — resolve reports the lane
312
- * unexpanded with a reason).
349
+ * POPULATE: discover and expand models from an OpenAI-compatible proxy
350
+ * (`GET <endpoint>/v1/models` + `GET <endpoint>/model/info`) into
351
+ * ready-to-fold `claude-worker` sources, then write to the machine-level cache.
352
+ * Network-bound and cacheable — runs at Gate-0 build / explicit refresh, NEVER
353
+ * inside `resolveAmbientSources` (which only READS via {@link readProxyCatalog}).
354
+ * Never throws: a failed fetch returns `{written:false, reason}` and leaves any
355
+ * prior cache untouched; an empty discovery WRITES an empty expansion (fresh
356
+ * knowledge — resolve reports the lane unexpanded with a reason).
313
357
  */
314
358
  export async function populateProxyCatalog(options) {
315
359
  const endpoint = options.endpoint.replace(/\/+$/u, "");
316
360
  const doFetch = options.fetchImpl ?? fetch;
317
- // Freshness short-circuit: the populate trigger fires on EVERY
318
- // confirmation-absent next-step (nextStepCommand.ts), and populate now carries
319
- // live per-model probes — real `/v1/messages` POSTs through the proxy that cost
320
- // seconds of wall AND burn free-tier rate quota. A same-endpoint cache younger
321
- // than the TTL answers instead (measured 2026-07-17: per-invocation populate
322
- // was ~5.6s live, which alone pushed the e2e wrapper tests past their
323
- // timeouts). This is a REFRESH throttle, not staleness acceptance — the
324
- // no-TTL-on-READ residual (backlog: catalog accepted arbitrarily stale by
325
- // resolve) is unchanged.
361
+ // Build auth header when api_key_env is declared and the env var is set.
362
+ let authHeader;
363
+ if (options.apiKeyEnv) {
364
+ const keyValue = process.env[options.apiKeyEnv];
365
+ if (keyValue?.trim()) {
366
+ authHeader = `Bearer ${keyValue}`;
367
+ }
368
+ }
369
+ // Freshness short-circuit: populate carries live per-model probes — real
370
+ // `/v1/messages` POSTs through the proxy that cost seconds and burn quota.
371
+ // A same-endpoint cache younger than the TTL answers instead. This is a
372
+ // REFRESH throttle, not staleness acceptance — the no-TTL-on-READ residual
373
+ // (backlog) is unchanged.
326
374
  const cached = readProxyCatalog({ homeDir: options.homeDir });
327
375
  if (cached && cached.endpoint === endpoint) {
328
376
  const nowMs = (options.now?.() ?? new Date()).getTime();
@@ -338,47 +386,90 @@ export async function populateProxyCatalog(options) {
338
386
  };
339
387
  }
340
388
  }
341
- let payload;
342
- try {
343
- const response = await doFetch(`${endpoint}/registry`);
344
- if (!response.ok) {
345
- return {
346
- sources: [],
347
- written: false,
348
- reason: `GET ${endpoint}/registry returned HTTP ${response.status}.`,
349
- dropped: [],
350
- };
351
- }
352
- payload = await response.json();
353
- }
354
- catch (error) {
389
+ // Discover model roster (required baseline: /v1/models).
390
+ const { aliases, error: rosterError } = await discoverModelRoster(endpoint, doFetch, authHeader);
391
+ if (aliases.length === 0) {
355
392
  return {
356
393
  sources: [],
357
394
  written: false,
358
- reason: `GET ${endpoint}/registry failed: ${error instanceof Error ? error.message : String(error)}.`,
395
+ reason: rosterError
396
+ ? `GET ${endpoint}/v1/models failed: ${rosterError}`
397
+ : `GET ${endpoint}/v1/models returned no models or was unreachable.`,
359
398
  dropped: [],
360
399
  };
361
400
  }
362
- let sources = expandSources(extractRegistryModels(payload), {
401
+ // Discover model adverts (optional enrichment: /model/info).
402
+ const { adverts, filtered } = await discoverModelAdverts(endpoint, doFetch, authHeader);
403
+ // Build discovered models: join roster + adverts, deriving provider.
404
+ const discovered = [];
405
+ for (const alias of aliases) {
406
+ // Skip models that failed the eligibility filter
407
+ if (filtered.has(alias))
408
+ continue;
409
+ const advert = adverts.get(alias);
410
+ // Provider derivation: advert provider > slash-prefix > default "proxy" bucket.
411
+ let provider;
412
+ if (advert?.provider) {
413
+ provider = advert.provider;
414
+ }
415
+ else if (alias.includes("/")) {
416
+ provider = alias.split("/")[0];
417
+ }
418
+ else {
419
+ provider = "proxy";
420
+ }
421
+ // Cost blend: mean of input/output $/Mtok when both present; otherwise the one present.
422
+ const availableCosts = [];
423
+ if (advert?.input_cost_per_token !== undefined)
424
+ availableCosts.push(advert.input_cost_per_token);
425
+ if (advert?.output_cost_per_token !== undefined)
426
+ availableCosts.push(advert.output_cost_per_token);
427
+ let costPerMtok;
428
+ if (availableCosts.length === 2) {
429
+ costPerMtok = (availableCosts[0] + availableCosts[1]) / 2;
430
+ }
431
+ else if (availableCosts.length === 1) {
432
+ costPerMtok = availableCosts[0];
433
+ }
434
+ discovered.push({
435
+ alias,
436
+ provider,
437
+ score: advert?.declared_rank !== undefined ? -advert.declared_rank : null,
438
+ capabilityRank: advert?.declared_rank,
439
+ costPerMtok,
440
+ contextTokens: advert?.context_tokens,
441
+ });
442
+ }
443
+ // Probe to verify models are reachable and drop 404s.
444
+ // Group by provider for parallel probing per provider.
445
+ const byProvider = new Map();
446
+ for (const model of discovered) {
447
+ const bucket = byProvider.get(model.provider) ?? [];
448
+ bucket.push(model.alias);
449
+ byProvider.set(model.provider, bucket);
450
+ }
451
+ const droppedAliases = new Set();
452
+ const dropped = [];
453
+ for (const [provider, providerAliases] of byProvider) {
454
+ const probeResults = await probeModelsWithConcurrency(providerAliases, provider, endpoint, doFetch, POPULATE_PROBE_TIMEOUT_MS, POPULATE_PROBE_CONCURRENCY, authHeader);
455
+ for (const result of probeResults) {
456
+ if (result.dropped) {
457
+ droppedAliases.add(result.alias);
458
+ dropped.push({
459
+ id: `claude-worker:${provider}/${result.alias}`,
460
+ reason: result.reason ?? "model unavailable",
461
+ });
462
+ }
463
+ }
464
+ }
465
+ // Filter out dropped models and expand sources.
466
+ const toExpand = discovered.filter((m) => !droppedAliases.has(m.alias));
467
+ let sources = expandSources(toExpand, {
363
468
  endpoint,
364
469
  topK: options.topK ?? DEFAULT_PROXY_TOP_K,
365
470
  costPerMtok: options.costPerMtok,
471
+ apiKeyEnv: options.apiKeyEnv,
366
472
  });
367
- // Probe each source to verify reachability
368
- const probeResults = await probeSourcesWithConcurrency(sources, endpoint, doFetch, POPULATE_PROBE_TIMEOUT_MS, POPULATE_PROBE_CONCURRENCY);
369
- const dropped = [];
370
- sources = probeResults
371
- .filter((result) => {
372
- if (result.dropped) {
373
- dropped.push({
374
- id: result.source.id ?? `claude-worker:${result.source.backend_provider}/${result.source.model}`,
375
- reason: result.reason ?? "model unavailable",
376
- });
377
- return false;
378
- }
379
- return true;
380
- })
381
- .map((result) => result.source);
382
473
  const catalog = {
383
474
  fetched_at: (options.now?.() ?? new Date()).toISOString(),
384
475
  endpoint,