@tzyc/dsh-model-visibility 1.10.2 → 1.12.1

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/lib/export-csv.js CHANGED
@@ -8,33 +8,61 @@
8
8
  * @module lib/export-csv
9
9
  */
10
10
 
11
- /** Quote one CSV field, RFC 4180 style. */
11
+ /** Quote one CSV field, RFC 4180 style, and defuse spreadsheet formulas. */
12
12
  function csvField(value) {
13
13
  if (value === null || value === undefined) return ''
14
14
  let text = String(value)
15
+ // A leading =, +, -, @ or tab makes Excel/Sheets evaluate the cell. Model
16
+ // ids come from a third-party relay, so they are not trusted input.
17
+ if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`
15
18
  if (/[",\r\n]/.test(text)) text = `"${text.replace(/"/g, '""')}"`
16
19
  return text
17
20
  }
18
21
 
19
- /** Prefix the sheet with the window totals, then one (day, route) row each. */
22
+ /** Render one bucket tuple as four CSV columns. */
23
+ function bucketFields(buckets) {
24
+ return [0, 1, 2, 3].map((index) => csvField(buckets?.[index] ?? 0))
25
+ }
26
+
27
+ /**
28
+ * Prefix the sheet with the window totals, then one (day, route) row each.
29
+ *
30
+ * `summary.days` is `{ dayKey: [in, out, cacheRead, cacheWrite] }` — the ledger
31
+ * folds to a single tuple per day. Per-route detail lives in `summary.byModel`
32
+ * (`{ "provider/model": tuple }`) and has no day dimension, so it is exported
33
+ * as its own section rather than being mistaken for a day/route map.
34
+ */
20
35
  export function toUsageCsv(summary) {
21
- const fmt = (buckets) => `${buckets?.[0] ?? 0},${buckets?.[1] ?? 0},${buckets?.[2] ?? 0},${buckets?.[3] ?? 0}`
22
36
  const rows = []
23
37
  rows.push('section,range,input,output,cacheRead,cacheWrite')
24
38
  const ranges = summary?.ranges ?? {}
25
39
  for (const name of ['today', 'week', 'month', 'total']) {
26
- rows.push(`sum,${name},${fmt(ranges[name])}`)
40
+ // `range` is a single bucket tuple, so it expands to the four numeric
41
+ // columns rather than being joined as if it were a string.
42
+ rows.push(['sum', name, ...bucketFields(ranges[name])].join(','))
27
43
  }
44
+
28
45
  rows.push('')
29
- rows.push('day,provider/model,input,output,cacheRead,cacheWrite')
46
+ rows.push('day,input,output,cacheRead,cacheWrite')
30
47
  const days = summary?.days ?? {}
31
- const dayKeys = Object.keys(days).sort()
32
- for (const dayKey of dayKeys) {
33
- const byRoute = days[dayKey] ?? {}
34
- for (const [route, values] of Object.entries(byRoute)) {
35
- // `*` is the day's own total; the per-route rows already carry it.
36
- if (route === '*' || values.every((n) => n === 0)) continue
37
- rows.push(`${dayKey},${route},${fmt(values)}`)
48
+ for (const dayKey of Object.keys(days).sort()) {
49
+ const values = days[dayKey]
50
+ if (!Array.isArray(values)) continue
51
+ if (values.every((n) => n === 0)) continue
52
+ rows.push([csvField(dayKey), ...bucketFields(values)].join(','))
53
+ }
54
+
55
+ const byModel = summary?.byModel ?? {}
56
+ const modelKeys = Object.keys(byModel).sort()
57
+ if (modelKeys.length > 0) {
58
+ rows.push('')
59
+ rows.push('provider/model,input,output,cacheRead,cacheWrite')
60
+ for (const route of modelKeys) {
61
+ if (route === '*') continue
62
+ const values = byModel[route]
63
+ if (!Array.isArray(values)) continue
64
+ if (values.every((n) => n === 0)) continue
65
+ rows.push([csvField(route), ...bucketFields(values)].join(','))
38
66
  }
39
67
  }
40
68
  return `${rows.join('\r\n')}\r\n`
@@ -47,7 +75,13 @@ export function toModelListCsv(catalog, { hidden = [] } = {}) {
47
75
  for (const group of catalog?.groups ?? []) {
48
76
  for (const model of group?.models ?? []) {
49
77
  const sources = model.sources ?? {}
50
- const reasoning = model.reasoning?.efforts?.map((effort) => effort.id).join('|') ?? []
78
+ // `efforts` is an array of `{ id, name }`; joining it needs the ids, not
79
+ // the array itself (the old code called `.join` on the array and threw).
80
+ const efforts = Array.isArray(model.reasoning?.efforts)
81
+ ? model.reasoning.efforts
82
+ .map((effort) => (typeof effort === 'string' ? effort : effort?.id))
83
+ .filter((id) => typeof id === 'string' && id !== '')
84
+ : []
51
85
  rows.push(
52
86
  [
53
87
  group.id,
@@ -56,7 +90,7 @@ export function toModelListCsv(catalog, { hidden = [] } = {}) {
56
90
  model.kind ?? '',
57
91
  (model.inputModalities ?? []).join('|'),
58
92
  model.contextWindow ?? '',
59
- reasoning.length > 0 ? reasoning.join('|') : '',
93
+ efforts.join('|'),
60
94
  model.reasoningSupported === true ? 'yes' : model.reasoningSupported === false ? 'no' : '',
61
95
  `${sources.modalities ?? 'unknown'};${sources.context ?? 'unknown'};${sources.reasoning ?? 'unknown'}`,
62
96
  ]
@@ -232,8 +232,15 @@ export function createLearnedCatalog(options = {}) {
232
232
  * from the checker), so records merge instead of replacing: newer facts
233
233
  * overwrite older ones field by field and the freshest `verifiedAt` stays.
234
234
  * @param inputs - check results, each `{ id | provider+model, …facts }`.
235
+ * @param extra - provenance for the whole call: `verifiedAt`, `origins`,
236
+ * `checker`. `origins` is merged into each record so a later public-only
237
+ * pass appends `public` next to an earlier `checker` instead of replacing
238
+ * it (which is what made "publicOnly: 0" look like a missing source).
235
239
  */
236
240
  const upsertEntries = (inputs, extra = {}) => {
241
+ // A verify sweep now flushes one array of facts per batch instead of one
242
+ // call per model. Reloading by force for every call forced a full file read
243
+ // and a full rewrite per model (199 reads + 199 writes for 200 models).
237
244
  load(true)
238
245
  const source = Array.isArray(inputs) ? inputs : [inputs]
239
246
  let changed = false
@@ -241,10 +248,11 @@ export function createLearnedCatalog(options = {}) {
241
248
  const sanitized = sanitizeLearned(raw)
242
249
  if (sanitized === undefined) continue
243
250
  // Call-side provenance beats the entry's own stamps: the orchestrator
244
- // knows the run that produced the fact.
251
+ // knows the run that produced the fact. An entry that carries its own
252
+ // `origins` keeps them, so a public-only row stays `['public']`.
245
253
  const provenance = {}
246
254
  if (extra?.verifiedAt !== undefined) provenance.verifiedAt = extra.verifiedAt
247
- if (extra?.origins !== undefined) provenance.origins = extra.origins
255
+ if (extra?.origins !== undefined && raw?.origins === undefined) provenance.origins = extra.origins
248
256
  if (extra?.checker !== undefined) provenance.checker = extra.checker
249
257
  const record = { provider: raw?.provider, ...sanitized.entry, ...provenance, id: sanitized.id }
250
258
  const key = modelIdVariants(record.id)[0] ?? sanitized.id
package/lib/model-info.js CHANGED
@@ -700,8 +700,10 @@ function catalogMeta() {
700
700
  // `reload` means the route understands `?reload=1` and drops every cache;
701
701
  // `overrides` means the Host persists and serves manual capability edits;
702
702
  // `verify` means the Host can sweep every model online; `usage` means the
703
- // Host serves the token-usage ledger.
704
- features: ['applyInput', 'reload', 'overrides', 'verify', 'usage'],
703
+ // Host serves the token-usage ledger; `checkerBudget` means the verify
704
+ // sweep sizes its output budget and pins a reasoning effort (a Host
705
+ // without it truncates every batch on a thinking checker).
706
+ features: ['applyInput', 'reload', 'overrides', 'verify', 'usage', 'checkerBudget'],
705
707
  }
706
708
  }
707
709
 
@@ -30,10 +30,15 @@ import { MODEL_KINDS } from './capability-catalog.js'
30
30
  export const OVERRIDE_MODALITIES = ['text', 'image', 'audio', 'video', 'file']
31
31
 
32
32
  /**
33
- * Effort ids offered by the editor. The list is intentionally wider than the
34
- * `off/low/medium/high` ladder most adapters publish: relays and vendors also
35
- * use `minimal`, `max` and `xhigh`, and the whole point of a manual override is
36
- * to describe the upstream the adapter cannot describe.
33
+ * Effort ids the editor offers and the provider schema can key.
34
+ *
35
+ * The list is intentionally wider than the `off/low/medium/high` ladder most
36
+ * adapters publish: relays and vendors also use `minimal`, `max` and `xhigh`,
37
+ * and the whole point of a manual override is to describe the upstream the
38
+ * adapter cannot describe. It happens to be the same set `llm-pi-ai` accepts
39
+ * as `reasoningEfforts` keys, so `lib/provider-write.js` keeps its own frozen
40
+ * copy for the write path (a schema change there must not silently widen the
41
+ * editor, or vice versa).
37
42
  */
38
43
  export const OVERRIDE_EFFORT_IDS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
39
44
 
@@ -231,6 +231,27 @@ export function parseModelListing(body) {
231
231
  return out
232
232
  }
233
233
 
234
+ /**
235
+ * Build the model-id set from a New API `/api/pricing` response.
236
+ *
237
+ * A relay that refuses or times out on `/models` usually still answers the
238
+ * public pricing endpoint, and a bare id list is enough for the page to show
239
+ * the right total and to classify every model it serves.
240
+ * @param body - parsed `/api/pricing` payload.
241
+ * @returns a Map of model id to `{ id }` (kind filled in by the caller).
242
+ */
243
+ export function parsePricingIds(body) {
244
+ const out = new Map()
245
+ const data = body !== null && typeof body === 'object' && Array.isArray(body.data) ? body.data : []
246
+ for (const raw of data) {
247
+ if (raw === null || typeof raw !== 'object') continue
248
+ const id = str(raw.model_name) ?? str(raw.model) ?? str(raw.modelName) ?? str(raw.id)
249
+ if (id === undefined) continue
250
+ out.set(id, { id })
251
+ }
252
+ return out
253
+ }
254
+
234
255
  /**
235
256
  * Parse New API's `/api/pricing` response (model kind only).
236
257
  * @param body - parsed JSON.
@@ -321,11 +342,46 @@ export function profileSignature(profiles) {
321
342
  export function listingUrls(baseURL) {
322
343
  const base = String(baseURL).replace(/\/+$/, '')
323
344
  if (!/^https?:\/\//i.test(base)) return []
345
+ // A plaintext base URL would put the resolved API key on the wire in clear
346
+ // text. Loopback stays allowed for a local gateway.
347
+ if (!isSecureBase(base) && !isLoopback(base)) return []
324
348
  const urls = [`${base}/models`]
325
349
  if (!/\/v\d+$/i.test(base)) urls.push(`${base}/v1/models`)
326
350
  return urls
327
351
  }
328
352
 
353
+ /** True when the base URL speaks TLS. */
354
+ function isSecureBase(value) {
355
+ return /^https:\/\//i.test(String(value))
356
+ }
357
+
358
+ /** True for a loopback host, where plaintext never leaves the machine. */
359
+ function isLoopback(value) {
360
+ try {
361
+ const host = new URL(value).hostname.toLowerCase()
362
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'
363
+ } catch (_error) {
364
+ return false
365
+ }
366
+ }
367
+
368
+ /** Turn a transport/DNS failure into something the page can show. */
369
+ function transportMessage(error) {
370
+ const code = error?.code ?? error?.cause?.code
371
+ if (typeof code === 'string') {
372
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return '域名解析失败(DNS 查不到该主机)'
373
+ if (code === 'ECONNREFUSED') return '连接被拒绝'
374
+ if (code === 'ECONNRESET') return '连接被重置'
375
+ if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT') return '连接超时'
376
+ if (code === 'CERT_HAS_EXPIRED') return 'TLS 证书已过期'
377
+ if (code.startsWith('ERR_TLS') || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') return 'TLS 校验失败'
378
+ if (code === 'ENOTFOUND') return '域名解析失败'
379
+ }
380
+ const message = error?.message ?? String(error)
381
+ if (/aborted|timeout/i.test(message)) return '请求超时'
382
+ return message
383
+ }
384
+
329
385
  /** One JSON GET with a timeout, redirect refusal and a body cap. */
330
386
  async function getJson(fetchImpl, url, headers, timeoutMs) {
331
387
  const response = await fetchImpl(url, {
@@ -453,13 +509,17 @@ export function createProviderProbe(options = {}) {
453
509
 
454
510
  let body
455
511
  let lastError
512
+ let refused = false
456
513
  for (const url of listingUrls(profile.baseURL)) {
457
514
  try {
458
515
  const response = await getJson(fetchImpl, url, headers, timeoutMs)
459
516
  if (response.status === 401 || response.status === 403) {
517
+ // Keep going: a relay that rejects the listing still often serves the
518
+ // public pricing endpoint, and its id list is better than nothing.
460
519
  diagnostics.status = 'unauthorized'
461
520
  diagnostics.message = `HTTP ${response.status}`
462
- return { entries: new Map(), diagnostics }
521
+ refused = true
522
+ break
463
523
  }
464
524
  if (response.status < 200 || response.status >= 300) {
465
525
  lastError = new Error(`HTTP ${response.status}`)
@@ -471,9 +531,34 @@ export function createProviderProbe(options = {}) {
471
531
  lastError = error
472
532
  }
473
533
  }
534
+
535
+ // New API exposes model ids and kinds through its public pricing endpoint.
536
+ // When `/models` is unreachable (DNS, timeout, 401, 5xx) that listing is
537
+ // still worth having: it is what lets the page show "served N models"
538
+ // instead of a bare failure. It is also frequently 403/404, which is not an
539
+ // error.
540
+ const pricing = await probePricing(profile, headers)
474
541
  if (body === undefined) {
475
- diagnostics.status = 'failed'
476
- diagnostics.message = lastError instanceof Error ? lastError.message : String(lastError)
542
+ if (pricing.entries.size > 0) {
543
+ // The listing failed but the pricing endpoint enumerated the route.
544
+ diagnostics.status = 'partial'
545
+ diagnostics.models = pricing.entries.size
546
+ diagnostics.capabilities = 0
547
+ diagnostics.pricing = pricing.state
548
+ const status = refused ? String(diagnostics.message ?? '') : undefined
549
+ diagnostics.message = refused
550
+ ? `${status === '' ? 'HTTP 401/403' : status}(鉴权被拒),已用价格接口列出模型`
551
+ : `模型列表不可用(${
552
+ lastError === undefined ? '未知原因' : transportMessage(lastError)
553
+ }),已用价格接口列出模型`
554
+ return { entries: withKinds(pricing.entries, pricing.kinds), diagnostics }
555
+ }
556
+ diagnostics.status = refused ? 'unauthorized' : 'failed'
557
+ diagnostics.pricing = pricing.state
558
+ if (!refused) {
559
+ diagnostics.message =
560
+ lastError === undefined ? 'no listing endpoint answered' : transportMessage(lastError)
561
+ }
477
562
  return { entries: new Map(), diagnostics }
478
563
  }
479
564
 
@@ -485,29 +570,60 @@ export function createProviderProbe(options = {}) {
485
570
  capabilityKeys.some((field) => entry[field] !== undefined),
486
571
  ).length
487
572
 
488
- // New API exposes model kind through its public pricing endpoint. It is
489
- // frequently disabled (403); that is a normal outcome, not a failure.
573
+ diagnostics.pricing = pricing.state
574
+ return { entries: withKinds(entries, pricing.kinds), diagnostics }
575
+ }
576
+
577
+ /**
578
+ * Fill in the kind of every entry from the pricing endpoint, keeping the
579
+ * richer `/models` facts when both sources describe the same model.
580
+ */
581
+ /**
582
+ * Return a copy of `entries` with each model's kind filled in from the
583
+ * pricing endpoint, keeping the richer `/models` facts when both sources
584
+ * describe the same model.
585
+ *
586
+ * A copy, not an edit: the partial branch passes the pricing map itself, and
587
+ * mutating it while iterating it would make the result depend on map order.
588
+ */
589
+ const withKinds = (entries, kinds) => {
590
+ const out = new Map()
591
+ for (const [id, entry] of entries) out.set(id, { ...entry })
592
+ for (const [id, info] of kinds) {
593
+ const entry = out.get(id) ?? { id }
594
+ if (entry.kind === undefined) entry.kind = info.kind
595
+ out.set(id, entry)
596
+ }
597
+ return out
598
+ }
599
+
600
+ /**
601
+ * Read New API's public pricing endpoint for model ids and kinds.
602
+ * @returns `{ entries, kinds, state }`; `state` is ok / disabled / failed.
603
+ */
604
+ const probePricing = async (profile, headers) => {
605
+ const empty = { entries: new Map(), kinds: new Map(), state: 'failed' }
606
+ let origin
490
607
  try {
491
- const url = new URL(profile.baseURL)
492
- const response = await getJson(fetchImpl, `${url.origin}/api/pricing`, headers, timeoutMs)
493
- if (response.status === 403 || response.status === 404) {
494
- diagnostics.pricing = 'disabled'
495
- } else if (response.status >= 200 && response.status < 300) {
496
- const pricing = parsePricing(response.body)
497
- for (const [id, info] of pricing) {
498
- const entry = entries.get(id) ?? { id }
499
- if (entry.kind === undefined) entry.kind = info.kind
500
- entries.set(id, entry)
501
- }
502
- diagnostics.pricing = 'ok'
503
- } else {
504
- diagnostics.pricing = 'failed'
608
+ origin = new URL(profile.baseURL).origin
609
+ } catch (_error) {
610
+ return { ...empty, state: 'skipped' }
611
+ }
612
+ // The pricing request carries the same Authorization header, so it obeys the
613
+ // same rule as the listing: TLS only, unless the target is loopback.
614
+ if (!isSecureBase(origin) && !isLoopback(origin)) return { ...empty, state: 'skipped' }
615
+ try {
616
+ const response = await getJson(fetchImpl, `${origin}/api/pricing`, headers, timeoutMs)
617
+ if (response.status === 403 || response.status === 404) return { ...empty, state: 'disabled' }
618
+ if (response.status < 200 || response.status >= 300) return empty
619
+ return {
620
+ entries: parsePricingIds(response.body),
621
+ kinds: parsePricing(response.body),
622
+ state: 'ok',
505
623
  }
506
624
  } catch (_error) {
507
- diagnostics.pricing = 'failed'
625
+ return empty
508
626
  }
509
-
510
- return { entries, diagnostics }
511
627
  }
512
628
 
513
629
  const sweep = async () => {
@@ -23,8 +23,22 @@
23
23
  * @module lib/provider-write
24
24
  */
25
25
 
26
- /** Fields this module knows how to mirror into a provider profile. */
27
- export const PROVIDER_WRITE_FIELDS = ['input', 'contextWindow', 'reasoningEfforts']
26
+ /**
27
+ * Effort keys the provider schema accepts.
28
+ *
29
+ * Must stay in lock-step with `llm-pi-ai`'s `reasoningEfforts` dict key schema.
30
+ * A single unknown key rejects the entire `settings.mutate` op list, which also
31
+ * silently discards the modalities and context window saved alongside it.
32
+ */
33
+ export const PROVIDER_EFFORT_LEVELS = Object.freeze([
34
+ 'off',
35
+ 'minimal',
36
+ 'low',
37
+ 'medium',
38
+ 'high',
39
+ 'xhigh',
40
+ 'max',
41
+ ])
28
42
 
29
43
  /** Plain-object guard. */
30
44
  function isRecord(value) {
@@ -43,9 +57,13 @@ function messageOf(error) {
43
57
  * Convert a manual ladder into the provider `reasoningEfforts` shape.
44
58
  *
45
59
  * `off` maps to `null` ("supported, send nothing"), every other level maps to
46
- * its own id as the wire value. Returns undefined when there is nothing worth
47
- * writing: an unset ladder, or one that only offers `off` (provider config
48
- * rejects that as "offers no level beyond off").
60
+ * its own id as the wire value. Levels the provider schema cannot key are
61
+ * dropped here rather than sent: one unknown key rejects the whole write, and
62
+ * the user would lose the modalities and context window saved with it.
63
+ *
64
+ * Returns undefined when there is nothing worth writing: an unset ladder, a
65
+ * ladder that only offers `off` (the schema rejects that as "offers no level
66
+ * beyond off"), or one whose every level is unusable.
49
67
  * @param override - sanitized override entry.
50
68
  * @returns a provider ladder, `false` for "no reasoning", or undefined.
51
69
  */
@@ -55,8 +73,10 @@ export function effortsForProvider(override) {
55
73
  const ladder = {}
56
74
  let beyondOff = false
57
75
  for (const effort of override.efforts) {
58
- const id = typeof effort === 'string' ? effort : effort?.id
59
- if (typeof id !== 'string' || id === '') continue
76
+ const raw = typeof effort === 'string' ? effort : effort?.id
77
+ if (typeof raw !== 'string' || raw === '') continue
78
+ const id = raw.toLowerCase()
79
+ if (!PROVIDER_EFFORT_LEVELS.includes(id)) continue
60
80
  if (id === 'off') ladder.off = null
61
81
  else {
62
82
  ladder[id] = id
@@ -68,6 +88,10 @@ export function effortsForProvider(override) {
68
88
 
69
89
  /**
70
90
  * The provider fields an override implies.
91
+ *
92
+ * These are the only fields this module mirrors; anything else an override
93
+ * carries (`kind`, `defaultEffort`, an off-only ladder) has no provider
94
+ * equivalent and stays in the local override file.
71
95
  * @param override - sanitized override entry.
72
96
  * @returns `{input?, contextWindow?, reasoningEfforts?}`; empty when nothing is writable.
73
97
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tzyc/dsh-model-visibility",
3
- "version": "1.10.2",
3
+ "version": "1.12.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -24,7 +24,7 @@
24
24
  "docs"
25
25
  ],
26
26
  "scripts": {
27
- "test": "node --test test/*.test.mjs",
27
+ "test": "node --import ./test/setup.mjs --test test/*.test.mjs",
28
28
  "build": "node scripts/build-client.mjs"
29
29
  },
30
30
  "dsh": {