model-eol 0.9.0 → 0.9.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/bot/bot.mjs CHANGED
@@ -67,6 +67,12 @@ const PLAN_SCHEMA = 'model-eol.plan/0.1'
67
67
  const BOT_SCHEMA = 'model-eol.bot/0.1'
68
68
  const EVAL_FAILURE_CHANNEL = 'configured-eval'
69
69
  const EVAL_ARTIFACT_MAX_BYTES = 64 * 1024 * 1024
70
+ const EVAL_RESULT_OVERHEAD_BYTES = 4096
71
+
72
+ export const evalArtifactBudget = (maxReportBytes, groupCount) => Math.min(
73
+ EVAL_ARTIFACT_MAX_BYTES,
74
+ Math.max(1024 * 1024, EVAL_RESULT_OVERHEAD_BYTES + groupCount * (6 * maxReportBytes + EVAL_RESULT_OVERHEAD_BYTES)),
75
+ )
70
76
  const ISSUE_REASONS = new Set([
71
77
  'not-direct-api',
72
78
  'no-replacement',
@@ -223,7 +229,7 @@ const readJsonArtifact = (file, maxBytes, label) => {
223
229
  const absolute = asAbsolute(file)
224
230
  const artifact = readReportCapped(absolute, maxBytes + 1)
225
231
  if (artifact.missing) throw new Error(`${label} is missing`)
226
- if (Buffer.byteLength(artifact.report) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`)
232
+ if (artifact.truncated || Buffer.byteLength(artifact.report) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`)
227
233
  try {
228
234
  return JSON.parse(artifact.report)
229
235
  } catch (error) {
@@ -233,7 +239,7 @@ const readJsonArtifact = (file, maxBytes, label) => {
233
239
 
234
240
  const readEvalResults = ({ file, config, commandOverride, plan, groups, baseHead }) => {
235
241
  const settings = effectiveEval(config, commandOverride)
236
- const maxBytes = Math.min(EVAL_ARTIFACT_MAX_BYTES, Math.max(1024 * 1024, groups.length * (settings.max_report_bytes + 2048)))
242
+ const maxBytes = evalArtifactBudget(settings.max_report_bytes, groups.length)
237
243
  const artifact = readJsonArtifact(file, maxBytes, 'eval results artifact')
238
244
  if (artifact?.schema !== EVAL_SCHEMA) throw new Error(`refusing eval results schema ${artifact?.schema ?? 'missing'}; expected ${EVAL_SCHEMA}`)
239
245
  try {
@@ -712,12 +718,12 @@ const evalWorkspaceDrift = (root, planFiles, expectedHashes, expectedHead) => {
712
718
  return null
713
719
  }
714
720
 
715
- const writeJsonAtomic = (file, value) => {
721
+ const writeTextAtomic = (file, text) => {
716
722
  const absolute = asAbsolute(file)
717
723
  fs.mkdirSync(path.dirname(absolute), { recursive: true })
718
724
  const temporary = `${absolute}.${process.pid}.${sha256(`${Date.now()}-${absolute}`).slice(0, 8)}.tmp`
719
725
  try {
720
- fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
726
+ fs.writeFileSync(temporary, text, { mode: 0o600 })
721
727
  fs.renameSync(temporary, absolute)
722
728
  } finally {
723
729
  fs.rmSync(temporary, { force: true })
@@ -843,7 +849,19 @@ export const evaluatePlan = async ({
843
849
  configured: Boolean(settings.command),
844
850
  results,
845
851
  }
846
- writeJsonAtomic(outputFile, artifact)
852
+ const budget = evalArtifactBudget(settings.max_report_bytes, groups.length)
853
+ let encoded
854
+ try {
855
+ encoded = `${JSON.stringify(artifact, null, 2)}\n`
856
+ } catch (error) {
857
+ if (!(error instanceof RangeError)) throw error
858
+ throw new Error(`refusing eval: eval results artifact is too large to encode, over its ${budget}-byte budget; lower eval.max_report_bytes`)
859
+ }
860
+ const encodedBytes = Buffer.byteLength(encoded)
861
+ if (encodedBytes > budget) {
862
+ throw new Error(`refusing eval: eval results artifact would be ${encodedBytes} bytes, over its ${budget}-byte budget; lower eval.max_report_bytes`)
863
+ }
864
+ writeTextAtomic(outputFile, encoded)
847
865
  return { artifact, plan, config: { ...config, eval: settings }, degraded: Boolean(feedSet.degraded) }
848
866
  } finally {
849
867
  feedSet.cleanup()
package/bot/lib/eval.mjs CHANGED
@@ -2,21 +2,36 @@ import fs from 'node:fs'
2
2
  import { spawnSync } from 'node:child_process'
3
3
  import { markdownFenceText } from './common.mjs'
4
4
 
5
- const truncateReport = (bytes, limit, truncated) => {
6
- if (!truncated) return bytes.toString('utf8')
7
- const marker = Buffer.from('\n[model-eol: report truncated]', 'utf8')
8
- if (limit <= marker.length) return marker.subarray(0, limit).toString('utf8')
9
- return Buffer.concat([bytes.subarray(0, limit - marker.length), marker]).subarray(0, limit).toString('utf8')
5
+ const TRUNCATION_MARKER = '\n[model-eol: report truncated]'
6
+
7
+ const fitUtf8 = (text, limit) => {
8
+ const bytes = Buffer.from(text, 'utf8')
9
+ if (bytes.length <= limit) return text
10
+ let end = Math.max(0, limit)
11
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--
12
+ return bytes.subarray(0, end).toString('utf8')
13
+ }
14
+
15
+ const truncateReport = (text, limit) => {
16
+ const markerBytes = Buffer.byteLength(TRUNCATION_MARKER)
17
+ if (limit <= markerBytes) return fitUtf8(TRUNCATION_MARKER, limit)
18
+ return fitUtf8(text, limit - markerBytes) + TRUNCATION_MARKER
19
+ }
20
+
21
+ const byteCap = limit => {
22
+ if (!Number.isFinite(limit)) throw new Error(`report byte limit must be finite, got ${limit}`)
23
+ return Math.max(0, Math.floor(limit))
10
24
  }
11
25
 
12
26
  export const capReport = (report, limit) => {
13
27
  if (report === null || report === undefined) return null
14
- const bytes = Buffer.from(String(report), 'utf8')
15
- return truncateReport(bytes, Math.max(0, limit), bytes.length > limit)
28
+ const cap = byteCap(limit)
29
+ const text = Buffer.from(String(report), 'utf8').toString('utf8')
30
+ return Buffer.byteLength(text) > cap ? truncateReport(text, cap) : text
16
31
  }
17
32
 
18
33
  export const readReportCapped = (file, limit) => {
19
- const cap = Math.max(0, limit)
34
+ const cap = byteCap(limit)
20
35
  let lstat
21
36
  try {
22
37
  lstat = fs.lstatSync(file)
@@ -41,10 +56,9 @@ export const readReportCapped = (file, limit) => {
41
56
  try {
42
57
  const buffer = Buffer.alloc(cap + 1)
43
58
  const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0)
44
- return {
45
- missing: false,
46
- report: truncateReport(buffer.subarray(0, Math.min(bytesRead, cap)), cap, bytesRead > cap),
47
- }
59
+ const text = buffer.subarray(0, Math.min(bytesRead, cap)).toString('utf8')
60
+ const truncated = bytesRead > cap || Buffer.byteLength(text) > cap
61
+ return { missing: false, truncated, report: truncated ? truncateReport(text, cap) : text }
48
62
  } finally {
49
63
  fs.closeSync(fd)
50
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "model-eol",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "AI model deprecation feeds with a zero-dependency checker, inventory CLI, and migration bot.",
5
5
  "type": "module",
6
6
  "bin": {