claude-token-saver 3.44.0 → 3.55.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.
Files changed (84) hide show
  1. package/LICENSE +202 -21
  2. package/NOTICE +35 -0
  3. package/README.ko.md +150 -48
  4. package/README.md +205 -192
  5. package/bin/cli.js +108 -39
  6. package/docs/CODEX.ko.md +184 -0
  7. package/docs/CODEX.md +497 -0
  8. package/examples/statusline-command.ps1 +11 -5
  9. package/examples/statusline-command.sh +9 -4
  10. package/package.json +20 -6
  11. package/presets/delegation/bounds.md +29 -0
  12. package/presets/model-rules.json +50 -9
  13. package/presets/ratchet-rules.json +4 -1
  14. package/src/agent.js +33 -0
  15. package/src/caps-cache.js +6 -2
  16. package/src/changelog.js +70 -0
  17. package/src/cli-name.js +25 -0
  18. package/src/codex-brief.js +89 -0
  19. package/src/codex-budget.js +73 -0
  20. package/src/codex-cache-policy.js +203 -0
  21. package/src/codex-cache.js +46 -0
  22. package/src/codex-delegation.js +154 -0
  23. package/src/codex-doc2md-ledger.js +45 -0
  24. package/src/codex-doc2md.js +99 -0
  25. package/src/codex-doctor.js +53 -0
  26. package/src/codex-harness.js +200 -0
  27. package/src/codex-hooks.js +138 -0
  28. package/src/codex-installer.js +79 -0
  29. package/src/codex-ledger.js +143 -0
  30. package/src/codex-panel-launcher.js +48 -0
  31. package/src/codex-panel-runner.js +74 -0
  32. package/src/codex-panel-session.js +35 -0
  33. package/src/codex-panel-setup.js +98 -0
  34. package/src/codex-panel-shell.js +51 -0
  35. package/src/codex-panel-state.js +40 -0
  36. package/src/codex-panel.js +448 -0
  37. package/src/codex-parser.js +397 -0
  38. package/src/codex-route-scan.js +171 -0
  39. package/src/commands/brief.js +24 -1
  40. package/src/commands/codex-delegate.js +59 -0
  41. package/src/commands/codex.js +579 -0
  42. package/src/commands/delegate.js +133 -0
  43. package/src/commands/harness.js +16 -3
  44. package/src/commands/install.js +109 -12
  45. package/src/commands/korean.js +2 -2
  46. package/src/commands/mode.js +22 -2
  47. package/src/commands/profile-map.js +164 -0
  48. package/src/commands/route-scan.js +74 -7
  49. package/src/commands/seed.js +16 -12
  50. package/src/commands/uninstall.js +28 -4
  51. package/src/commands/upgrade.js +13 -0
  52. package/src/compact-window.js +7 -1
  53. package/src/config.js +22 -3
  54. package/src/cost.js +34 -0
  55. package/src/delegation-guard.js +278 -0
  56. package/src/doc2md-ledger.cjs +3 -3
  57. package/src/doc2md.cjs +72 -28
  58. package/src/effort-palette.js +145 -0
  59. package/src/formatters/statusline.js +352 -123
  60. package/src/formatters/table.js +5 -4
  61. package/src/gateway-auth.js +86 -0
  62. package/src/glyphs.js +103 -0
  63. package/src/handoff.js +27 -7
  64. package/src/harness-analyzer.cjs +7 -2
  65. package/src/harness-templates.js +33 -1
  66. package/src/harness.js +51 -17
  67. package/src/hook-manager.js +42 -14
  68. package/src/hook.cjs +34 -2
  69. package/src/installer.js +300 -16
  70. package/src/litellm-budget.js +38 -67
  71. package/src/litellm-models.js +264 -0
  72. package/src/model-alias.js +170 -16
  73. package/src/model-rules.js +53 -17
  74. package/src/parser.js +89 -1
  75. package/src/preset-ratchet.js +133 -0
  76. package/src/route-inject.js +161 -0
  77. package/src/route-scan.js +68 -8
  78. package/src/seed-rules.js +117 -55
  79. package/src/session-paths.js +242 -0
  80. package/src/statusline-mode.js +106 -0
  81. package/src/stdin-payload.js +40 -0
  82. package/src/ultracode.js +202 -0
  83. package/src/update-check.js +217 -3
  84. package/src/window-labels.js +19 -8
@@ -21,7 +21,10 @@
21
21
  "agent": "haiku-runner",
22
22
  "example": "npm test 돌려봐",
23
23
  "exampleEn": "run npm test",
24
- "budget": { "calls": 8, "out": 1500 }
24
+ "budget": {
25
+ "calls": 8,
26
+ "out": 1500
27
+ }
25
28
  },
26
29
  {
27
30
  "id": "run-t1",
@@ -32,7 +35,10 @@
32
35
  "agent": "sonnet",
33
36
  "example": "빌드하고 실패한 테스트만 보여줘",
34
37
  "exampleEn": "build it and show only the failing tests",
35
- "budget": { "calls": null, "out": 6000 }
38
+ "budget": {
39
+ "calls": null,
40
+ "out": 6000
41
+ }
36
42
  },
37
43
  {
38
44
  "id": "explore-t2",
@@ -43,7 +49,10 @@
43
49
  "agent": "haiku-explore",
44
50
  "example": "이 설정값 어느 파일에 있어?",
45
51
  "exampleEn": "which file holds this config value?",
46
- "budget": { "calls": 8, "out": 1500 }
52
+ "budget": {
53
+ "calls": 8,
54
+ "out": 1500
55
+ }
47
56
  },
48
57
  {
49
58
  "id": "explore-t1",
@@ -54,7 +63,10 @@
54
63
  "agent": "sonnet",
55
64
  "example": "이 함수를 호출하는 곳 전부 찾아줘",
56
65
  "exampleEn": "find every caller of this function",
57
- "budget": { "calls": null, "out": 6000 }
66
+ "budget": {
67
+ "calls": null,
68
+ "out": 6000
69
+ }
58
70
  },
59
71
  {
60
72
  "id": "check-t2",
@@ -65,7 +77,10 @@
65
77
  "agent": "haiku-explore",
66
78
  "example": "지금 배포된 버전이 뭔지 확인해줘",
67
79
  "exampleEn": "check which version is deployed right now",
68
- "budget": { "calls": 8, "out": 1500 }
80
+ "budget": {
81
+ "calls": 8,
82
+ "out": 1500
83
+ }
69
84
  },
70
85
  {
71
86
  "id": "check-t1",
@@ -76,7 +91,10 @@
76
91
  "agent": "sonnet",
77
92
  "example": "마이그레이션이 실제로 적용됐는지 확인해줘",
78
93
  "exampleEn": "verify the migration actually applied",
79
- "budget": { "calls": null, "out": 6000 }
94
+ "budget": {
95
+ "calls": null,
96
+ "out": 6000
97
+ }
80
98
  },
81
99
  {
82
100
  "id": "paste-t2",
@@ -87,7 +105,10 @@
87
105
  "agent": "haiku-explore",
88
106
  "example": "이 에러 로그는 무슨 뜻이야?",
89
107
  "exampleEn": "what does this error log mean?",
90
- "budget": { "calls": 8, "out": 1500 }
108
+ "budget": {
109
+ "calls": 8,
110
+ "out": 1500
111
+ }
91
112
  },
92
113
  {
93
114
  "id": "paste-t1",
@@ -98,7 +119,10 @@
98
119
  "agent": "sonnet",
99
120
  "example": "이 스택트레이스에서 어느 모듈이 죽은 건지 짚어줘",
100
121
  "exampleEn": "point out which module failed in this stack trace",
101
- "budget": { "calls": null, "out": 8000 }
122
+ "budget": {
123
+ "calls": null,
124
+ "out": 8000
125
+ }
102
126
  },
103
127
  {
104
128
  "id": "read-t2",
@@ -109,7 +133,24 @@
109
133
  "agent": "haiku-explore",
110
134
  "example": "이 README 요약해줘",
111
135
  "exampleEn": "summarize this README",
112
- "budget": { "calls": 8, "out": 1900 }
136
+ "budget": {
137
+ "calls": 8,
138
+ "out": 1900
139
+ }
140
+ },
141
+ {
142
+ "id": "read-t1",
143
+ "tier": "T1",
144
+ "category": "read",
145
+ "label": "읽기·요약·설명",
146
+ "labelEn": "reading / summarizing / explaining",
147
+ "agent": "sonnet",
148
+ "example": "이 함수가 하는 일을 단계별로 정리해줘",
149
+ "exampleEn": "walk through what this function does, step by step",
150
+ "budget": {
151
+ "calls": null,
152
+ "out": 8000
153
+ }
113
154
  }
114
155
  ]
115
156
  }
@@ -32,7 +32,10 @@
32
32
  },
33
33
  {
34
34
  "ko": "탐색·조회·상태 확인·명령 실행 같은 단순 요청은 `model: haiku` 서브에이전트로 위임해 상위 모델 토큰을 아낀다 (읽기 전용 preset 에이전트가 이미 있으면 그걸 쓰고, 없으면 티어만 내려서 위임)",
35
- "en": "Delegate simple requests — lookup, status checks, running commands — to a `model: haiku` subagent to keep the expensive model's tokens for real work (use an existing read-only preset agent when there is one; otherwise delegate with the tier lowered)"
35
+ "en": "Delegate simple requests — lookup, status checks, running commands — to a `model: haiku` subagent to keep the expensive model's tokens for real work (use an existing read-only preset agent when there is one; otherwise delegate with the tier lowered)",
36
+ "agents": [
37
+ "claude"
38
+ ]
36
39
  },
37
40
  {
38
41
  "ko": "지침이나 룰이 지켜지지 않아 같은 품질 문제가 재발하면 문구를 다시 쓰지 말 것 → 그 조항이 기계로 판정 가능한지부터 따지고(문자열 비교나 정규식으로 결정되면 판정 가능), 가능하면 쓰기 시점 훅이나 테스트 같은 차단 지점을 추가한다. 판단이 필요한 잔여 부분만 지침에 남기고 그 경계를 지침에 적는다. 실제 코퍼스에 돌려 오탐 건수를 센 뒤 좁히고, 회귀 테스트를 같은 커밋에 넣는다",
package/src/agent.js ADDED
@@ -0,0 +1,33 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+ import { readFileSync } from 'node:fs';
4
+ import { parse } from 'smol-toml';
5
+
6
+ export function codexUserDir() {
7
+ return process.env.CODEX_HOME ? resolve(process.env.CODEX_HOME) : join(homedir(), '.codex');
8
+ }
9
+
10
+ export function codexProviderName({ home = codexUserDir() } = {}) {
11
+ try {
12
+ const cfg = parse(readFileSync(join(home, 'config.toml'), 'utf8'));
13
+ return cfg.profiles?.[cfg.profile]?.model_provider || cfg.model_provider || 'openai';
14
+ } catch (e) { return e.code === 'ENOENT' ? 'openai' : null; }
15
+ }
16
+
17
+ /** Remove the global selector before positional subcommand parsers see it. */
18
+ export function selectAgent(argv) {
19
+ const args = [];
20
+ let agent = 'claude';
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const arg = argv[i];
23
+ if (arg === '--') { args.push(...argv.slice(i)); break; }
24
+ if (arg === '--agent' || arg.startsWith('--agent=')) {
25
+ const value = arg === '--agent' ? argv[++i] : arg.slice('--agent='.length);
26
+ if (!['claude', 'codex'].includes(value)) {
27
+ throw new Error('--agent expects claude or codex');
28
+ }
29
+ agent = value;
30
+ } else args.push(arg);
31
+ }
32
+ return { agent, args };
33
+ }
package/src/caps-cache.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * than `maxAgeMs` the loader returns null and the table view stays quiet.
10
10
  */
11
11
 
12
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { userDataDir } from './paths.js';
15
15
 
@@ -38,7 +38,11 @@ export function persistSnapshot(snapshot) {
38
38
  caps: snapshot.caps || null,
39
39
  model: snapshot.model || null,
40
40
  };
41
- writeFileSync(CACHE_PATH, JSON.stringify(payload) + '\n');
41
+ // tmp + rename: the statusline reads this every tick and must never see a
42
+ // half-written file.
43
+ const tmp = `${CACHE_PATH}.${process.pid}.tmp`;
44
+ writeFileSync(tmp, JSON.stringify(payload) + '\n');
45
+ renameSync(tmp, CACHE_PATH);
42
46
  } catch {
43
47
  // best-effort cache, never blocks the statusline
44
48
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * changelog — read one version's section out of CHANGELOG.md, and say whether a
3
+ * notes draft is still waiting on a person.
4
+ *
5
+ * Separate from scripts/release-notes.mjs, which is a CLI: importing that file
6
+ * runs its argument parsing and exits, so the extraction it depends on cannot be
7
+ * tested through it. The boundary logic is the part worth pinning — a section
8
+ * pulled one line short drops the release's last item, and one line long carries
9
+ * the previous release's first, and the upgrade offer shows either without
10
+ * complaint.
11
+ *
12
+ * The draft marker lives here for a reason. Two scripts gate on it — the one that
13
+ * writes drafts and the one that deploys — and they used to carry the string
14
+ * separately. Renaming it in one left the other looking for text that no longer
15
+ * existed, and a gate that greps for an absent string does not fail: it passes,
16
+ * and an unreviewed draft goes out as the release. One definition, imported.
17
+ */
18
+
19
+ /**
20
+ * The body of one version's section, heading excluded.
21
+ *
22
+ * @param {string} changelog - CHANGELOG.md contents
23
+ * @param {string} version - e.g. "3.45.0"
24
+ * @returns {string|null} the section text, or null when that version has none
25
+ */
26
+ export function sectionFor(changelog, version) {
27
+ const lines = String(changelog).split(/\r?\n/);
28
+ // The version has to match to its end: without the boundary, "3.42.0" would
29
+ // also match the "### v3.42.7" heading and publish the wrong notes.
30
+ const head = new RegExp(`^### v${String(version).replace(/\./g, '\\.')}(\\s|$|\\()`);
31
+ const start = lines.findIndex((l) => head.test(l));
32
+ if (start < 0) return null;
33
+ const out = [];
34
+ for (let i = start + 1; i < lines.length; i += 1) {
35
+ // The next version heading ends this section, and so does a shallower one:
36
+ // "## All releases" is a container, not part of any release.
37
+ if (/^### v/.test(lines[i]) || /^## /.test(lines[i])) break;
38
+ out.push(lines[i]);
39
+ }
40
+ return out.join('\n').trim();
41
+ }
42
+
43
+ /**
44
+ * The line `release-notes.mjs` writes at the top of a fresh draft, and the line
45
+ * every gate refuses on. A draft still carrying it has not been through a person.
46
+ */
47
+ export const DRAFT_MARKER =
48
+ '<!-- REVIEW: edit this draft, add the English half, then delete this line -->';
49
+
50
+ /** Heading that opens the Korean half of a bilingual release body. */
51
+ export const KO_HEADING = '## 한국어';
52
+
53
+ /**
54
+ * The opening of the marker, which is what the gate actually matches. Spelled out
55
+ * rather than sliced off `DRAFT_MARKER`, so that editing the marker's front does
56
+ * not silently change what is being tested for.
57
+ */
58
+ const REVIEW_OPENING = '<!-- REVIEW:';
59
+
60
+ /**
61
+ * Whether a draft still needs review. Matches on the `<!-- REVIEW:` opening
62
+ * rather than the whole line, so editing the wording of the instruction does not
63
+ * quietly disarm the gate.
64
+ *
65
+ * @param {string} draft - the draft file's contents
66
+ * @returns {boolean} true while the draft is unreviewed
67
+ */
68
+ export function needsReview(draft) {
69
+ return String(draft || '').includes(REVIEW_OPENING);
70
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * cli-name — the name this tool tells the user to type.
3
+ *
4
+ * One definition, because the alternative was measured: the marker string in the
5
+ * release scripts lived in two files, one was renamed, and the gate that grepped
6
+ * for the other silently stopped gating. A command name printed for the user to
7
+ * copy fails more quietly still — nothing errors, the user simply types a name
8
+ * that is not the package they installed.
9
+ *
10
+ * `sprag-cli` is the package; `claude-token-saver` is the deprecated name it
11
+ * shipped under, and both binaries are still in `package.json`'s `bin`, so the
12
+ * old name keeps working for anyone whose settings or notes carry it.
13
+ *
14
+ * NOT the same thing as the on-disk identifiers. The state directory, the harness
15
+ * block markers, and the skill directory are all still spelled with the old name
16
+ * on purpose: renaming those would orphan existing users' ledgers and stop the
17
+ * harness markers matching text already in their CLAUDE.md. Those are addresses,
18
+ * not instructions, and an address only has to be stable.
19
+ */
20
+
21
+ /** The command name to print in anything a user is expected to type or read. */
22
+ export const CLI_NAME = 'sprag';
23
+
24
+ /** The deprecated name. Still a working binary; recognised, never printed. */
25
+ export const LEGACY_CLI_NAME = 'claude-token-saver';
@@ -0,0 +1,89 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { codexUserDir } from './agent.js';
5
+ import { userDataDir } from './paths.js';
6
+ import { readCodexSnapshot } from './codex-parser.js';
7
+
8
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
9
+ const stateDir = (dir) => join(dir, 'codex', hash(codexUserDir()), 'sessions');
10
+ const fresh = (value, now) => {
11
+ const timestamp = Date.parse(value);
12
+ return Number.isFinite(timestamp) && timestamp <= now + 60000 && now - timestamp < 300000;
13
+ };
14
+
15
+ export function codexIssues(snapshot, { now = Date.now() } = {}) {
16
+ if (!snapshot) return [];
17
+ const issues = [];
18
+ const fraction = snapshot.contextWindow > 0 && Number.isFinite(snapshot.lastContextTokens)
19
+ ? snapshot.lastContextTokens / snapshot.contextWindow : null;
20
+ if (fraction >= 0.8 && fresh(snapshot.contextAt, now)) {
21
+ const tier = fraction >= 0.95 ? 2 : 1;
22
+ issues.push({ key: 'context', tier, signature: `context:${tier}`, level: tier === 2 ? 'critical' : 'warning',
23
+ message: `Recorded input context is ${Math.round(fraction * 100)}% (${snapshot.lastContextTokens}/${snapshot.contextWindow} tokens).`,
24
+ advice: 'Preserve decisions, verification results, and next steps before compaction. Use sprag handoff --agent codex when a handoff is needed.' });
25
+ }
26
+ if (fresh(snapshot.rateLimitsAt, now)) {
27
+ for (const key of ['primary', 'secondary']) {
28
+ const win = snapshot.rateLimits?.[key];
29
+ if (!Number.isFinite(win?.used_percent) || win.used_percent < 90) continue;
30
+ if (Number.isFinite(win.resets_at) && win.resets_at * 1000 <= now) continue;
31
+ const label = Number.isFinite(win.window_minutes) && win.window_minutes > 0 ? `${win.window_minutes}m` : key;
32
+ issues.push({ key: `limit:${key}`, tier: 1, signature: `limit:${key}:${win.resets_at ?? 'unknown'}`,
33
+ level: 'warning', message: `Recorded ${label} rate limit is ${win.used_percent}% used.`,
34
+ advice: 'Finish the current step and preserve the remaining work with sprag handoff --agent codex. This is a usage snapshot, not a billing estimate.' });
35
+ }
36
+ }
37
+ return issues;
38
+ }
39
+
40
+ function readState(file) {
41
+ try {
42
+ const state = JSON.parse(readFileSync(file, 'utf8'));
43
+ return state && Array.isArray(state.events) ? state : { events: [] };
44
+ } catch { return { events: [] }; }
45
+ }
46
+
47
+ /** One file per home/session keeps unrelated sessions out of the same dedup state. */
48
+ export function runCodexBrief({ sessionId, transcriptPath, cwd, now = Date.now(), dir = userDataDir() } = {}) {
49
+ if (typeof sessionId !== 'string' || !sessionId) return null;
50
+ const snapshot = readCodexSnapshot(transcriptPath);
51
+ if (!snapshot || (snapshot.sessionId && snapshot.sessionId !== sessionId)) return null;
52
+ const issues = codexIssues(snapshot, { now });
53
+ const directory = stateDir(dir);
54
+ const file = join(directory, `${hash(sessionId)}.json`);
55
+ const state = readState(file);
56
+ const active = Array.isArray(state.active) ? state.active.filter((sig) => typeof sig === 'string') : [];
57
+ const next = issues.filter((issue) => !active.includes(issue.signature) &&
58
+ !(issue.key === 'context' && issue.tier === 1 && active.includes('context:2')));
59
+ // Missing or stale measurements do not resolve an earlier warning. A fresh
60
+ // lower measurement does, so a refill after compaction can warn again.
61
+ const observed = new Set();
62
+ if (fresh(snapshot.contextAt, now) && snapshot.contextWindow > 0 && Number.isFinite(snapshot.lastContextTokens)) observed.add('context');
63
+ if (fresh(snapshot.rateLimitsAt, now)) {
64
+ for (const key of ['primary', 'secondary']) {
65
+ if (Number.isFinite(snapshot.rateLimits?.[key]?.used_percent)) observed.add(`limit:${key}`);
66
+ }
67
+ }
68
+ const remaining = active.filter((sig) => ![...observed].some((key) => sig.startsWith(`${key}:`)));
69
+ const nextActive = [...new Set([...remaining, ...issues.map((issue) => issue.signature)])];
70
+ if (!next.length && JSON.stringify(nextActive) === JSON.stringify(active)) return null;
71
+ const at = new Date(now).toISOString();
72
+ const events = [...state.events, ...next.map((issue) => ({ ...issue, at, sessionId, project: cwd || snapshot.projectDir || '' }))].slice(-200);
73
+ mkdirSync(directory, { recursive: true });
74
+ const tmp = `${file}.${process.pid}.tmp`;
75
+ writeFileSync(tmp, JSON.stringify({ active: nextActive, events }) + '\n', { mode: 0o600 });
76
+ renameSync(tmp, file);
77
+ return next.length ? ['[Sprag Codex briefing]', ...next.map((issue) => `${issue.message} ${issue.advice}`)].join('\n') : null;
78
+ }
79
+
80
+ export function readCodexHistory({ days = 7, sessionId, project, dir = userDataDir(), now = Date.now() } = {}) {
81
+ const directory = stateDir(dir);
82
+ let files;
83
+ try { files = readdirSync(directory); } catch { return []; }
84
+ const cutoff = now - days * 86400000;
85
+ return files.filter((file) => /^[a-f0-9]{64}\.json$/.test(file)).flatMap((file) => readState(join(directory, file)).events)
86
+ .filter((event) => event && typeof event.message === 'string' && Date.parse(event.at) >= cutoff && Date.parse(event.at) <= now &&
87
+ (!sessionId || event.sessionId === sessionId) && (!project || (typeof event.project === 'string' && event.project.includes(project))))
88
+ .sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
89
+ }
@@ -0,0 +1,73 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { parse } from 'smol-toml';
6
+ import { codexUserDir } from './agent.js';
7
+ import { pickBudgetSource } from './litellm-budget.js';
8
+
9
+ const run = promisify(execFile);
10
+
11
+ export function codexBudgetProvider({ home = codexUserDir(), env = process.env, providerName } = {}) {
12
+ try {
13
+ const cfg = parse(readFileSync(join(home, 'config.toml'), 'utf8'));
14
+ const profile = cfg.profiles?.[cfg.profile] || {};
15
+ const name = providerName || profile.model_provider || cfg.model_provider;
16
+ const provider = cfg.model_providers?.[name];
17
+ if (!provider || !/litellm/i.test(`${name} ${provider.name || ''}`)) return null;
18
+ const url = new URL(provider.base_url);
19
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) return null;
20
+ url.pathname = url.pathname.replace(/\/v1\/?$/, '').replace(/\/$/, '');
21
+ url.search = ''; url.hash = '';
22
+ return { name, base: url.toString().replace(/\/$/, ''),
23
+ key: provider.env_key ? env[provider.env_key] : null,
24
+ command: provider.auth?.command,
25
+ args: provider.auth?.args || [] };
26
+ } catch { return null; }
27
+ }
28
+
29
+ export async function codexProviderKey(provider, { execute = run } = {}) {
30
+ let key = provider.key;
31
+ if (!key && typeof provider.command === 'string') {
32
+ try {
33
+ const result = await execute(provider.command, provider.args, { encoding: 'utf8', timeout: 10000, maxBuffer: 64 * 1024 });
34
+ key = result.stdout.trim().split(/\r?\n/).filter(Boolean).pop();
35
+ } catch { throw new Error('Codex provider authentication helper failed'); }
36
+ }
37
+ if (!key) throw new Error('Codex provider credentials unavailable');
38
+ return key;
39
+ }
40
+
41
+ export async function fetchCodexBudget(provider, { fetchImpl = fetch, execute = run, now = Date.now() } = {}) {
42
+ const key = await codexProviderKey(provider, { execute });
43
+ // Tokens stay in memory and headers, never in argv, state files or errors.
44
+ const headers = { authorization: `Bearer ${key}`, accept: 'application/json' };
45
+ const get = async (path) => {
46
+ try {
47
+ const res = await fetchImpl(`${provider.base}${path}`, { headers, signal: AbortSignal.timeout(5000), redirect: 'error' });
48
+ return res.ok ? await res.json() : null;
49
+ } catch { return null; }
50
+ };
51
+ const [keyResponse, userResponse] = await Promise.all([get('/key/info'), get('/user/info')]);
52
+ if (!keyResponse && !userResponse) throw new Error('LiteLLM budget endpoints unavailable');
53
+ const budget = pickBudgetSource(keyResponse?.info, userResponse);
54
+ return budget ? { ...budget, checkedAt: now } : null;
55
+ }
56
+
57
+ // Refresh between frames without delaying terminal paint. Cache no credentials.
58
+ export function createCodexBudgetReader({ provider = codexBudgetProvider, fetchBudget = fetchCodexBudget, now = Date.now } = {}) {
59
+ let base, snapshot = null, pending = false, checked = -Infinity;
60
+ return function read() {
61
+ const cfg = provider();
62
+ if (!cfg) { base = null; snapshot = null; return null; }
63
+ if (cfg.base !== base) { base = cfg.base; snapshot = null; checked = -Infinity; }
64
+ if (!pending && now() - checked >= 300000) {
65
+ checked = now(); pending = true;
66
+ const requestedBase = base;
67
+ Promise.resolve().then(() => fetchBudget(cfg)).then((value) => {
68
+ if (base === requestedBase) snapshot = value;
69
+ }).catch(() => {}).finally(() => { pending = false; });
70
+ }
71
+ return snapshot;
72
+ };
73
+ }
@@ -0,0 +1,203 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { parse } from 'smol-toml';
5
+ import { codexUserDir } from './agent.js';
6
+ import { userDataDir } from './paths.js';
7
+ import { codexBudgetProvider, codexProviderKey } from './codex-budget.js';
8
+ import { codexCachePolicy } from './codex-cache.js';
9
+
10
+ export const CACHE_POLICY_MAX_AGE_MS = 300000;
11
+ const OPENAI_POLICY = 'https://developers.openai.com/api/docs/guides/prompt-caching#cache-lifetime';
12
+ const BEDROCK_POLICY = 'https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html#prompt-caching-openai';
13
+
14
+ function deploymentPolicy(row) {
15
+ const params = row?.litellm_params || {}, info = row?.model_info || {};
16
+ const route = typeof params.model === 'string' ? params.model : '';
17
+ const base = typeof info.base_model === 'string' ? info.base_model : route;
18
+ const backend = /^bedrock(?:\/|_mantle\/)/.test(route) ? 'Bedrock'
19
+ : /^(?:openai\/)?gpt-/.test(route) ? 'OpenAI' : null;
20
+ if (!backend || info.supports_prompt_caching === false) return null;
21
+ // An OpenAI-compatible server is not necessarily OpenAI, even with a GPT alias.
22
+ if (backend === 'OpenAI' && params.api_base) {
23
+ try { if (new URL(params.api_base).hostname !== 'api.openai.com') return null; }
24
+ catch { return null; }
25
+ }
26
+ const model = backend === 'Bedrock'
27
+ ? base.replace(/^bedrock(?:\/converse)?\//, '').replace(/^bedrock_mantle\//, '').replace(/^(?:(?:global|us|eu|apac)\.)?openai\./, '')
28
+ : route.replace(/^openai\//, '');
29
+ const seconds = codexCachePolicy(model);
30
+ if (!seconds) return null;
31
+ const options = params.prompt_cache_options;
32
+ if (options != null && (typeof options !== 'object' || Array.isArray(options))) return null;
33
+ if (options?.ttl != null && options.ttl !== '30m') return null;
34
+ if (options?.mode != null && !['implicit', 'explicit'].includes(options.mode)) return null;
35
+ if (params.prompt_cache_retention != null) return null;
36
+ return { backend, model, seconds, mode: options?.mode || 'implicit',
37
+ reference: backend === 'Bedrock' ? BEDROCK_POLICY : OPENAI_POLICY };
38
+ }
39
+
40
+ export function gatewayCachePolicies(payload) {
41
+ if (!Array.isArray(payload?.data)) throw new Error('LiteLLM model/info returned no model list');
42
+ const groups = new Map();
43
+ for (const row of payload.data) {
44
+ if (typeof row?.model_name !== 'string' || row.model_name.length > 256) continue;
45
+ const group = groups.get(row.model_name) || [];
46
+ group.push(deploymentPolicy(row));
47
+ groups.set(row.model_name, group);
48
+ }
49
+ // All deployments behind an alias must agree; load balancing must not change TTL.
50
+ return Object.fromEntries([...groups].map(([name, policies]) => [name,
51
+ policies[0] && policies.every((p) => JSON.stringify(p) === JSON.stringify(policies[0])) ? policies[0] : null]));
52
+ }
53
+
54
+ export const PRICE_MAX_AGE_MS = 7 * 86400000;
55
+ const rate = (value) => Number.isFinite(value) && value >= 0 && value < 1 ? value : null;
56
+
57
+ function deploymentPrice(row) {
58
+ const info = row?.model_info || {};
59
+ const mode = typeof info.mode === 'string' ? info.mode : null;
60
+ if (mode && !['chat', 'responses', 'completion'].includes(mode)) return null;
61
+ const input = rate(info.input_cost_per_token), output = rate(info.output_cost_per_token);
62
+ if (input === null || output === null) return null;
63
+ return { input, output, cacheRead: rate(info.cache_read_input_token_cost), cacheWrite: rate(info.cache_creation_input_token_cost) };
64
+ }
65
+
66
+ /** Per-token prices by alias. Load-balanced deployments must agree, like cache policy. */
67
+ export function gatewayPrices(payload) {
68
+ if (!Array.isArray(payload?.data)) throw new Error('LiteLLM model/info returned no model list');
69
+ const groups = new Map();
70
+ for (const row of payload.data) {
71
+ if (typeof row?.model_name !== 'string' || row.model_name.length > 256) continue;
72
+ groups.set(row.model_name, [...(groups.get(row.model_name) || []), deploymentPrice(row)]);
73
+ }
74
+ return Object.fromEntries([...groups].map(([name, prices]) => [name,
75
+ prices[0] && prices.every((p) => JSON.stringify(p) === JSON.stringify(prices[0])) ? prices[0] : null]));
76
+ }
77
+
78
+ /**
79
+ * USD for a token mix at one model's prices, or null when a needed rate is
80
+ * missing. Cached input without a cache-read rate is unknown, not free.
81
+ */
82
+ export function codexRunCost({ input = 0, cached = 0, cacheWrite = 0, output = 0 }, price) {
83
+ if (!price || price.input === null || price.output === null) return null;
84
+ if (cached > 0 && price.cacheRead === null) return null;
85
+ // cache_write_input_tokens is billed at the creation rate when one is published, else as input.
86
+ const uncached = Math.max(0, input - cached - cacheWrite);
87
+ return uncached * price.input + cached * (price.cacheRead ?? 0) + cacheWrite * (price.cacheWrite ?? price.input) + output * price.output;
88
+ }
89
+
90
+ function cacheFile(provider, { home = codexUserDir(), dir = userDataDir() } = {}) {
91
+ const key = createHash('sha256').update(JSON.stringify([resolve(home), provider.name, provider.base])).digest('hex').slice(0, 24);
92
+ return join(dir, 'codex-cache-policy', `${key}.json`);
93
+ }
94
+
95
+ function gatewayProvider(session, opts) {
96
+ if (!session?.provider || session.provider === 'openai') return null;
97
+ return codexBudgetProvider({ ...opts, providerName: session.provider });
98
+ }
99
+
100
+ function directPolicy(session, { home = codexUserDir(), now = Date.now() } = {}) {
101
+ if (session?.provider !== 'openai' || !codexCachePolicy(session.model)) return null;
102
+ try {
103
+ let cfg = {};
104
+ try { cfg = parse(readFileSync(join(home, 'config.toml'), 'utf8')); }
105
+ catch (e) { if (e.code !== 'ENOENT') return null; }
106
+ if (cfg.openai_base_url && new URL(cfg.openai_base_url).hostname !== 'api.openai.com') return null;
107
+ } catch { return null; }
108
+ return { backend: 'OpenAI', model: session.model, seconds: codexCachePolicy(session.model),
109
+ requestedModel: session.model, provider: session.provider, checkedAt: now, reference: OPENAI_POLICY };
110
+ }
111
+
112
+ function selectPolicy(snapshot, session, now) {
113
+ if (!Number.isFinite(snapshot?.checkedAt) || snapshot.checkedAt > now || now - snapshot.checkedAt >= CACHE_POLICY_MAX_AGE_MS) return null;
114
+ const policy = Object.hasOwn(snapshot.models || {}, session.model) ? snapshot.models[session.model] : null;
115
+ if (!policy || !['OpenAI', 'Bedrock'].includes(policy.backend) || policy.seconds !== codexCachePolicy(policy.model) || !policy.seconds) return null;
116
+ return { ...policy, requestedModel: session.model, provider: session.provider, checkedAt: snapshot.checkedAt };
117
+ }
118
+
119
+ /** Offline read for statuslines and diagnostics; no auth helper or network calls. */
120
+ export function readCodexCachePolicy(session, opts = {}) {
121
+ const direct = directPolicy(session, opts);
122
+ if (direct) return direct;
123
+ const provider = gatewayProvider(session, opts);
124
+ if (!provider) return null;
125
+ try { return selectPolicy(JSON.parse(readFileSync(cacheFile(provider, opts), 'utf8')), session, opts.now ?? Date.now()); }
126
+ catch { return null; }
127
+ }
128
+
129
+ /**
130
+ * Offline price table for the configured gateway, from the snapshot the cache
131
+ * policy refresh writes. Prices change rarely, so a week-old table still counts;
132
+ * anything older reads as unknown rather than silently current.
133
+ */
134
+ export function readCodexPrices({ provider, now = Date.now(), ...opts } = {}) {
135
+ provider ??= codexBudgetProvider(opts);
136
+ if (!provider) return null;
137
+ try {
138
+ const snapshot = JSON.parse(readFileSync(cacheFile(provider, opts), 'utf8'));
139
+ if (!Number.isFinite(snapshot?.checkedAt) || snapshot.checkedAt > now || now - snapshot.checkedAt > PRICE_MAX_AGE_MS) return null;
140
+ if (!snapshot.prices || typeof snapshot.prices !== 'object') return null;
141
+ return { checkedAt: snapshot.checkedAt, provider: provider.name, base: provider.base, prices: snapshot.prices };
142
+ } catch { return null; }
143
+ }
144
+
145
+ /** Fetch prices for the configured gateway without needing a session. */
146
+ export async function refreshCodexPrices({ provider = codexBudgetProvider(), fetchImpl = fetch, execute, ...opts } = {}) {
147
+ if (!provider) throw new Error('No configured LiteLLM endpoint for Codex');
148
+ await writeGatewaySnapshot(provider, { fetchImpl, execute, ...opts });
149
+ return readCodexPrices({ provider, ...opts });
150
+ }
151
+
152
+ async function writeGatewaySnapshot(provider, { fetchImpl = fetch, execute, ...opts } = {}) {
153
+ const key = await codexProviderKey(provider, { execute });
154
+ let payload;
155
+ try {
156
+ const res = await fetchImpl(`${provider.base}/model/info`, {
157
+ headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
158
+ signal: AbortSignal.timeout(5000), redirect: 'error',
159
+ });
160
+ if (!res.ok) throw new Error();
161
+ payload = await res.json();
162
+ } catch { throw new Error('LiteLLM model/info unavailable; cache policy was not verified'); }
163
+ const now = opts.now ?? Date.now();
164
+ let prices = null;
165
+ try { prices = gatewayPrices(payload); } catch { /* Prices are optional; the policy stands alone. */ }
166
+ const snapshot = { checkedAt: now, models: gatewayCachePolicies(payload), prices };
167
+ const file = cacheFile(provider, opts);
168
+ // Persist only policy and price fields, never raw model/info (which can contain secrets).
169
+ mkdirSync(join(opts.dir || userDataDir(), 'codex-cache-policy'), { recursive: true, mode: 0o700 });
170
+ const tmp = `${file}.${randomUUID()}.tmp`;
171
+ writeFileSync(tmp, JSON.stringify(snapshot) + '\n', { mode: 0o600 });
172
+ renameSync(tmp, file);
173
+ return snapshot;
174
+ }
175
+
176
+ export async function refreshCodexCachePolicy(session, { fetchImpl = fetch, execute, ...opts } = {}) {
177
+ const direct = directPolicy(session, opts);
178
+ if (direct) return direct;
179
+ const provider = gatewayProvider(session, opts);
180
+ if (!provider) throw new Error('No configured LiteLLM endpoint for this session provider');
181
+ const snapshot = await writeGatewaySnapshot(provider, { fetchImpl, execute, ...opts });
182
+ return selectPolicy(snapshot, session, snapshot.checkedAt);
183
+ }
184
+
185
+ /** Refresh between frames. Each provider/model has its own in-flight lookup. */
186
+ export function createCodexCachePolicyReader({ refresh = refreshCodexCachePolicy, now = Date.now, ...opts } = {}) {
187
+ const attempts = new Map();
188
+ return function read(session) {
189
+ const at = now();
190
+ const policy = readCodexCachePolicy(session, { ...opts, now: at });
191
+ if (policy || !session) return policy;
192
+ const provider = gatewayProvider(session, opts);
193
+ if (!provider) return null;
194
+ const key = JSON.stringify([provider.name, provider.base, session.model]);
195
+ const last = attempts.get(key);
196
+ if (!last || (!last.pending && at - last.at >= CACHE_POLICY_MAX_AGE_MS)) {
197
+ const attempt = { at, pending: true };
198
+ attempts.set(key, attempt);
199
+ Promise.resolve().then(() => refresh(session, { ...opts, now: at })).catch(() => {}).finally(() => { attempt.pending = false; });
200
+ }
201
+ return null;
202
+ };
203
+ }