@golba98/codexa 1.0.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.
Files changed (226) hide show
  1. package/README.md +320 -0
  2. package/bin/codexa.js +445 -0
  3. package/package.json +45 -0
  4. package/scripts/audit-codexa-capabilities.mjs +466 -0
  5. package/scripts/smoke-terminal-bench.mjs +35 -0
  6. package/src/app.tsx +4561 -0
  7. package/src/appRenderStability.test.ts +131 -0
  8. package/src/commands/handler.test.ts +643 -0
  9. package/src/commands/handler.ts +875 -0
  10. package/src/config/launchArgs.test.ts +158 -0
  11. package/src/config/launchArgs.ts +186 -0
  12. package/src/config/layeredConfig.test.ts +143 -0
  13. package/src/config/layeredConfig.ts +836 -0
  14. package/src/config/persistence.test.ts +110 -0
  15. package/src/config/persistence.ts +311 -0
  16. package/src/config/runtimeConfig.test.ts +218 -0
  17. package/src/config/runtimeConfig.ts +554 -0
  18. package/src/config/settings.test.ts +155 -0
  19. package/src/config/settings.ts +401 -0
  20. package/src/config/toml-serialize.ts +98 -0
  21. package/src/config/trustStore.test.ts +29 -0
  22. package/src/config/trustStore.ts +68 -0
  23. package/src/core/attachments.test.ts +155 -0
  24. package/src/core/attachments.ts +71 -0
  25. package/src/core/auth/codexAuth.test.ts +68 -0
  26. package/src/core/auth/codexAuth.ts +359 -0
  27. package/src/core/cleanupFastFail.test.ts +76 -0
  28. package/src/core/cleanupFastFail.ts +67 -0
  29. package/src/core/clipboard.ts +24 -0
  30. package/src/core/codex.ts +124 -0
  31. package/src/core/codexExecArgs.test.ts +195 -0
  32. package/src/core/codexExecArgs.ts +152 -0
  33. package/src/core/codexLaunch.test.ts +205 -0
  34. package/src/core/codexLaunch.ts +162 -0
  35. package/src/core/codexPrompt.test.ts +252 -0
  36. package/src/core/codexPrompt.ts +428 -0
  37. package/src/core/executables/claudeExecutable.ts +63 -0
  38. package/src/core/executables/codexExecutable.test.ts +212 -0
  39. package/src/core/executables/codexExecutable.ts +159 -0
  40. package/src/core/executables/executableResolver.test.ts +129 -0
  41. package/src/core/executables/executableResolver.ts +138 -0
  42. package/src/core/executables/geminiExecutable.test.ts +116 -0
  43. package/src/core/executables/geminiExecutable.ts +78 -0
  44. package/src/core/executables/pathSanityScan.test.ts +47 -0
  45. package/src/core/githubDiagnostics.test.ts +92 -0
  46. package/src/core/githubDiagnostics.ts +222 -0
  47. package/src/core/hollowResponseFormat.test.ts +58 -0
  48. package/src/core/hollowResponseFormat.ts +39 -0
  49. package/src/core/inputDebug.ts +51 -0
  50. package/src/core/launchContext.test.ts +157 -0
  51. package/src/core/launchContext.ts +266 -0
  52. package/src/core/models/codexCapabilities.test.ts +45 -0
  53. package/src/core/models/codexCapabilities.ts +95 -0
  54. package/src/core/models/codexModelCapabilities.test.ts +246 -0
  55. package/src/core/models/codexModelCapabilities.ts +571 -0
  56. package/src/core/models/modelSpecs.test.ts +283 -0
  57. package/src/core/models/modelSpecs.ts +300 -0
  58. package/src/core/perf/profiler.ts +125 -0
  59. package/src/core/perf/renderDebug.test.ts +230 -0
  60. package/src/core/perf/renderDebug.ts +373 -0
  61. package/src/core/planStorage.test.ts +143 -0
  62. package/src/core/planStorage.ts +141 -0
  63. package/src/core/process/CommandRunner.test.ts +105 -0
  64. package/src/core/process/CommandRunner.ts +269 -0
  65. package/src/core/process/processValidation.ts +101 -0
  66. package/src/core/projectInstructions.test.ts +50 -0
  67. package/src/core/projectInstructions.ts +54 -0
  68. package/src/core/providerLauncher/launcher.test.ts +238 -0
  69. package/src/core/providerLauncher/launcher.ts +203 -0
  70. package/src/core/providerLauncher/registry.test.ts +324 -0
  71. package/src/core/providerLauncher/registry.ts +253 -0
  72. package/src/core/providerLauncher/types.ts +84 -0
  73. package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
  74. package/src/core/providerLauncher/workspaceConfig.ts +407 -0
  75. package/src/core/providerRuntime/anthropic.test.ts +1120 -0
  76. package/src/core/providerRuntime/anthropic.ts +576 -0
  77. package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
  78. package/src/core/providerRuntime/capabilityProfile.ts +288 -0
  79. package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
  80. package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
  81. package/src/core/providerRuntime/contextMetadata.ts +409 -0
  82. package/src/core/providerRuntime/gemini.test.ts +437 -0
  83. package/src/core/providerRuntime/gemini.ts +784 -0
  84. package/src/core/providerRuntime/lmstudio.test.ts +168 -0
  85. package/src/core/providerRuntime/lmstudio.ts +118 -0
  86. package/src/core/providerRuntime/local.test.ts +787 -0
  87. package/src/core/providerRuntime/local.ts +754 -0
  88. package/src/core/providerRuntime/models.ts +150 -0
  89. package/src/core/providerRuntime/reasoning.ts +17 -0
  90. package/src/core/providerRuntime/registry.test.ts +233 -0
  91. package/src/core/providerRuntime/registry.ts +203 -0
  92. package/src/core/providerRuntime/types.ts +103 -0
  93. package/src/core/providers/codexJsonStream.test.ts +148 -0
  94. package/src/core/providers/codexJsonStream.ts +305 -0
  95. package/src/core/providers/codexSubprocess.test.ts +68 -0
  96. package/src/core/providers/codexSubprocess.ts +372 -0
  97. package/src/core/providers/codexTranscript.test.ts +284 -0
  98. package/src/core/providers/codexTranscript.ts +695 -0
  99. package/src/core/providers/openaiNative.ts +13 -0
  100. package/src/core/providers/registry.ts +21 -0
  101. package/src/core/providers/types.ts +59 -0
  102. package/src/core/terminal/terminalCapabilities.test.ts +93 -0
  103. package/src/core/terminal/terminalCapabilities.ts +100 -0
  104. package/src/core/terminal/terminalControl.test.ts +75 -0
  105. package/src/core/terminal/terminalControl.ts +147 -0
  106. package/src/core/terminal/terminalSanitize.test.ts +22 -0
  107. package/src/core/terminal/terminalSanitize.ts +147 -0
  108. package/src/core/terminal/terminalSelection.test.ts +42 -0
  109. package/src/core/terminal/terminalSelection.ts +66 -0
  110. package/src/core/terminal/terminalTitle.test.ts +328 -0
  111. package/src/core/terminal/terminalTitle.ts +483 -0
  112. package/src/core/workspaceActivity.test.ts +163 -0
  113. package/src/core/workspaceActivity.ts +380 -0
  114. package/src/core/workspaceGuard.test.ts +151 -0
  115. package/src/core/workspaceGuard.ts +288 -0
  116. package/src/core/workspaceRoot.test.ts +23 -0
  117. package/src/core/workspaceRoot.ts +47 -0
  118. package/src/exec.test.ts +13 -0
  119. package/src/exec.ts +72 -0
  120. package/src/headless/execArgs.test.ts +147 -0
  121. package/src/headless/execArgs.ts +294 -0
  122. package/src/headless/execRunner.test.ts +434 -0
  123. package/src/headless/execRunner.ts +304 -0
  124. package/src/index.test.tsx +618 -0
  125. package/src/index.tsx +296 -0
  126. package/src/session/appSession.test.ts +897 -0
  127. package/src/session/appSession.ts +761 -0
  128. package/src/session/chatLifecycle.test.ts +64 -0
  129. package/src/session/chatLifecycle.ts +951 -0
  130. package/src/session/liveRenderScheduler.test.ts +201 -0
  131. package/src/session/liveRenderScheduler.ts +214 -0
  132. package/src/session/planFlow.test.ts +103 -0
  133. package/src/session/planFlow.ts +149 -0
  134. package/src/session/planTranscript.test.ts +65 -0
  135. package/src/session/planTranscript.ts +15 -0
  136. package/src/session/promptRunSchedule.test.ts +36 -0
  137. package/src/session/promptRunSchedule.ts +26 -0
  138. package/src/session/types.ts +228 -0
  139. package/src/test/runtimeTestUtils.ts +14 -0
  140. package/src/types/react-dom.d.ts +3 -0
  141. package/src/ui/ActionRequiredBlock.tsx +38 -0
  142. package/src/ui/ActivityBars.tsx +68 -0
  143. package/src/ui/ActivityIndicator.test.tsx +58 -0
  144. package/src/ui/ActivityIndicator.tsx +58 -0
  145. package/src/ui/AgentBlock.test.ts +6 -0
  146. package/src/ui/AgentBlock.tsx +130 -0
  147. package/src/ui/AnimatedStatusText.test.ts +16 -0
  148. package/src/ui/AnimatedStatusText.tsx +69 -0
  149. package/src/ui/AppShell.test.tsx +1739 -0
  150. package/src/ui/AppShell.tsx +698 -0
  151. package/src/ui/AttachmentImportPanel.test.tsx +204 -0
  152. package/src/ui/AttachmentImportPanel.tsx +98 -0
  153. package/src/ui/AuthPanel.tsx +113 -0
  154. package/src/ui/BackendPicker.tsx +28 -0
  155. package/src/ui/BottomComposer.test.ts +674 -0
  156. package/src/ui/BottomComposer.tsx +1028 -0
  157. package/src/ui/CodexLogo.tsx +55 -0
  158. package/src/ui/DashCard.tsx +82 -0
  159. package/src/ui/Markdown.test.ts +157 -0
  160. package/src/ui/Markdown.tsx +310 -0
  161. package/src/ui/ModePicker.tsx +27 -0
  162. package/src/ui/ModelPicker.tsx +31 -0
  163. package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
  164. package/src/ui/ModelPickerScreen.test.tsx +99 -0
  165. package/src/ui/ModelPickerScreen.tsx +416 -0
  166. package/src/ui/ModelPickerState.test.tsx +151 -0
  167. package/src/ui/ModelReasoningPicker.test.tsx +447 -0
  168. package/src/ui/ModelReasoningPicker.tsx +458 -0
  169. package/src/ui/Panel.tsx +51 -0
  170. package/src/ui/PermissionsPanel.tsx +78 -0
  171. package/src/ui/PlanActionPicker.tsx +119 -0
  172. package/src/ui/PlanReviewPanel.test.tsx +267 -0
  173. package/src/ui/PlanReviewPanel.tsx +212 -0
  174. package/src/ui/PromptCardBorder.test.tsx +161 -0
  175. package/src/ui/ProviderPicker.test.tsx +289 -0
  176. package/src/ui/ProviderPicker.tsx +321 -0
  177. package/src/ui/ProviderShortcut.test.tsx +143 -0
  178. package/src/ui/ReasoningPicker.tsx +46 -0
  179. package/src/ui/RunFooter.tsx +65 -0
  180. package/src/ui/SelectionPanel.tsx +67 -0
  181. package/src/ui/SettingsPanel.test.tsx +233 -0
  182. package/src/ui/SettingsPanel.tsx +156 -0
  183. package/src/ui/Spinner.tsx +25 -0
  184. package/src/ui/StaticIntroItem.tsx +54 -0
  185. package/src/ui/StaticTranscriptItem.tsx +56 -0
  186. package/src/ui/TextEntryPanel.tsx +139 -0
  187. package/src/ui/ThemePicker.tsx +31 -0
  188. package/src/ui/ThinkingBlock.tsx +100 -0
  189. package/src/ui/Timeline.test.ts +2067 -0
  190. package/src/ui/Timeline.tsx +1472 -0
  191. package/src/ui/TimelineNavigation.test.tsx +201 -0
  192. package/src/ui/TopHeader.test.tsx +239 -0
  193. package/src/ui/TopHeader.tsx +257 -0
  194. package/src/ui/TurnGroup.test.tsx +365 -0
  195. package/src/ui/TurnGroup.tsx +657 -0
  196. package/src/ui/busyStatusAnimation.test.ts +30 -0
  197. package/src/ui/busyStatusAnimation.ts +11 -0
  198. package/src/ui/commandNormalize.test.ts +142 -0
  199. package/src/ui/commandNormalize.ts +66 -0
  200. package/src/ui/diffRenderer.test.ts +102 -0
  201. package/src/ui/diffRenderer.ts +116 -0
  202. package/src/ui/focus.ts +61 -0
  203. package/src/ui/focusFlow.test.tsx +1098 -0
  204. package/src/ui/inputBuffer.test.ts +151 -0
  205. package/src/ui/inputBuffer.ts +203 -0
  206. package/src/ui/layout.test.ts +145 -0
  207. package/src/ui/layout.ts +287 -0
  208. package/src/ui/modeDisplay.test.ts +42 -0
  209. package/src/ui/modeDisplay.ts +52 -0
  210. package/src/ui/outputPipeline.ts +64 -0
  211. package/src/ui/progressEntries.ts +156 -0
  212. package/src/ui/runActivityView.test.ts +89 -0
  213. package/src/ui/runActivityView.ts +37 -0
  214. package/src/ui/runLifecycleView.test.tsx +237 -0
  215. package/src/ui/slashCommands.ts +41 -0
  216. package/src/ui/statusRenderIsolation.test.tsx +654 -0
  217. package/src/ui/terminalAnswerFormat.test.ts +19 -0
  218. package/src/ui/terminalAnswerFormat.ts +128 -0
  219. package/src/ui/textLayout.test.ts +18 -0
  220. package/src/ui/textLayout.ts +338 -0
  221. package/src/ui/theme.tsx +395 -0
  222. package/src/ui/themeFlow.test.ts +53 -0
  223. package/src/ui/themeFlow.ts +41 -0
  224. package/src/ui/timelineMeasure.ts +3088 -0
  225. package/src/ui/timelineMeasureCache.test.ts +986 -0
  226. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,283 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mkdtempSync, readFileSync, rmSync } from "fs";
4
+ import { join } from "path";
5
+ import { tmpdir } from "os";
6
+ import {
7
+ areModelSpecsEqual,
8
+ createLoadingModelSpec,
9
+ createModelSpecService,
10
+ createUnknownModelSpec,
11
+ extractModelSpecFromDocText,
12
+ KNOWN_MODEL_SPECS,
13
+ loadModelSpecCache,
14
+ MODEL_SPEC_DOC_URLS,
15
+ parseTokenCount,
16
+ resolveModelSpec,
17
+ saveModelSpecCache,
18
+ stripHtmlToText,
19
+ type ModelSpec,
20
+ type VerifiedModelSpec,
21
+ } from "./modelSpecs.js";
22
+
23
+ test("parses token counts with commas and suffixes", () => {
24
+ assert.equal(parseTokenCount("1,050,000"), 1_050_000);
25
+ assert.equal(parseTokenCount("128,000"), 128_000);
26
+ assert.equal(parseTokenCount("1.05M"), 1_050_000);
27
+ assert.equal(parseTokenCount("400k"), 400_000);
28
+ assert.equal(parseTokenCount("nope"), null);
29
+ });
30
+
31
+ test("extracts verified model specs from official-doc-like text", () => {
32
+ const spec = extractModelSpecFromDocText(
33
+ "gpt-5.4",
34
+ "GPT-5.4 1,050,000 context window 128,000 max output tokens",
35
+ 123,
36
+ );
37
+
38
+ assert.deepEqual(spec, {
39
+ status: "verified",
40
+ contextWindow: 1_050_000,
41
+ maxOutputTokens: 128_000,
42
+ sourceUrl: MODEL_SPEC_DOC_URLS["gpt-5.4"],
43
+ verifiedAt: 123,
44
+ });
45
+ });
46
+
47
+ test("returns null for ambiguous or incomplete model spec text", () => {
48
+ assert.equal(
49
+ extractModelSpecFromDocText("gpt-5.4-mini", "GPT-5.4 mini supports coding and agents.", 123),
50
+ null,
51
+ );
52
+ assert.equal(
53
+ extractModelSpecFromDocText("gpt-5.4-mini", "400,000 context window but no output limit", 123),
54
+ null,
55
+ );
56
+ });
57
+
58
+ test("strips HTML before parsing model specs", () => {
59
+ const html = `
60
+ <html>
61
+ <body>
62
+ <h1>GPT-5.3-Codex</h1>
63
+ <div>400,000 <strong>context window</strong></div>
64
+ <div>128,000 <em>max output tokens</em></div>
65
+ </body>
66
+ </html>
67
+ `;
68
+ const text = stripHtmlToText(html);
69
+ assert.match(text, /400,000 context window/i);
70
+ assert.match(text, /128,000 max output tokens/i);
71
+ });
72
+
73
+ test("cache round-trip preserves verified values", () => {
74
+ const dir = mkdtempSync(join(tmpdir(), "codexa-model-specs-"));
75
+ const cacheFile = join(dir, "model-specs.json");
76
+
77
+ try {
78
+ const cache = {
79
+ "gpt-5.2": {
80
+ status: "verified" as const,
81
+ contextWindow: 400_000,
82
+ maxOutputTokens: 128_000,
83
+ sourceUrl: MODEL_SPEC_DOC_URLS["gpt-5.2"],
84
+ verifiedAt: 456,
85
+ },
86
+ };
87
+ saveModelSpecCache(cache, cacheFile);
88
+
89
+ const loaded = loadModelSpecCache(cacheFile);
90
+ assert.deepEqual(loaded, cache);
91
+ } finally {
92
+ rmSync(dir, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ test("background refresh updates specs and dedupes concurrent requests", async () => {
97
+ const dir = mkdtempSync(join(tmpdir(), "codexa-model-specs-"));
98
+ const cacheFile = join(dir, "model-specs.json");
99
+ let fetchCalls = 0;
100
+ const service = createModelSpecService({
101
+ cacheFile,
102
+ now: () => 789,
103
+ fetchImpl: async () => {
104
+ fetchCalls += 1;
105
+ return new Response("400,000 context window 128,000 max output tokens", { status: 200 });
106
+ },
107
+ });
108
+
109
+ try {
110
+ const [left, right] = await Promise.all([
111
+ service.refreshSpec("gpt-5.2"),
112
+ service.refreshSpec("gpt-5.2"),
113
+ ]);
114
+
115
+ assert.equal(fetchCalls, 1);
116
+ assert.equal(left.status, "verified");
117
+ assert.equal(right.status, "verified");
118
+ assert.equal(left.contextWindow, 400_000);
119
+ assert.equal(left.maxOutputTokens, 128_000);
120
+
121
+ const persisted = JSON.parse(readFileSync(cacheFile, "utf-8")) as Record<string, ModelSpec>;
122
+ assert.equal(persisted["gpt-5.2"]?.verifiedAt, 789);
123
+ } finally {
124
+ rmSync(dir, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+ test("refresh returns unknown when a fetch fails even if cache exists", async () => {
129
+ const dir = mkdtempSync(join(tmpdir(), "codexa-model-specs-"));
130
+ const cacheFile = join(dir, "model-specs.json");
131
+ saveModelSpecCache({
132
+ "gpt-5.3-codex": {
133
+ status: "verified",
134
+ contextWindow: 400_000,
135
+ maxOutputTokens: 128_000,
136
+ sourceUrl: MODEL_SPEC_DOC_URLS["gpt-5.3-codex"],
137
+ verifiedAt: 123,
138
+ },
139
+ }, cacheFile);
140
+
141
+ const service = createModelSpecService({
142
+ cacheFile,
143
+ fetchImpl: async () => new Response("broken page", { status: 200 }),
144
+ });
145
+
146
+ try {
147
+ const spec = await service.refreshSpec("gpt-5.3-codex");
148
+ assert.equal(spec.status, "unknown");
149
+ assert.equal(spec.contextWindow, null);
150
+ assert.equal(spec.maxOutputTokens, null);
151
+ assert.equal(spec.error, "Unable to parse model spec for gpt-5.3-codex");
152
+ } finally {
153
+ rmSync(dir, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ test("refresh returns unknown when there is no cache and verification fails", async () => {
158
+ const dir = mkdtempSync(join(tmpdir(), "codexa-model-specs-"));
159
+ const cacheFile = join(dir, "model-specs.json");
160
+ const service = createModelSpecService({
161
+ cacheFile,
162
+ fetchImpl: async () => new Response("no token limits here", { status: 200 }),
163
+ });
164
+
165
+ try {
166
+ const spec = await service.refreshSpec("gpt-5.2");
167
+ assert.deepEqual(spec, createUnknownModelSpec("gpt-5.2", "Unable to parse model spec for gpt-5.2"));
168
+ } finally {
169
+ rmSync(dir, { recursive: true, force: true });
170
+ }
171
+ });
172
+
173
+ test("resolveModelSpec prefers runtime discovery metadata", () => {
174
+ const spec = resolveModelSpec("gpt-5.4", {
175
+ runtimeContextWindow: 2_000_000,
176
+ runtimeMaxOutputTokens: 256_000,
177
+ cache: {
178
+ "gpt-5.4": {
179
+ status: "verified",
180
+ contextWindow: 1_050_000,
181
+ maxOutputTokens: 128_000,
182
+ sourceUrl: MODEL_SPEC_DOC_URLS["gpt-5.4"]!,
183
+ verifiedAt: 1,
184
+ },
185
+ },
186
+ now: () => 999,
187
+ });
188
+
189
+ assert.equal(spec.status, "verified");
190
+ assert.equal(spec.contextWindow, 2_000_000);
191
+ assert.equal(spec.maxOutputTokens, 256_000);
192
+ });
193
+
194
+ test("resolveModelSpec falls back to persisted cache when runtime lacks context metadata", () => {
195
+ const spec = resolveModelSpec("gpt-5.4", {
196
+ cache: {
197
+ "gpt-5.4": {
198
+ status: "verified",
199
+ contextWindow: 1_050_000,
200
+ maxOutputTokens: 128_000,
201
+ sourceUrl: MODEL_SPEC_DOC_URLS["gpt-5.4"]!,
202
+ verifiedAt: 42,
203
+ },
204
+ },
205
+ });
206
+
207
+ assert.equal(spec.status, "verified");
208
+ assert.equal(spec.contextWindow, 1_050_000);
209
+ assert.equal(spec.verifiedAt, 42);
210
+ });
211
+
212
+ test("resolveModelSpec does not use static registry values when cache is empty", () => {
213
+ const spec = resolveModelSpec("gpt-5.4-mini", { now: () => 11 });
214
+ assert.equal(spec.status, "unknown");
215
+ assert.equal(spec.contextWindow, null);
216
+ assert.deepEqual(KNOWN_MODEL_SPECS, {});
217
+ });
218
+
219
+ test("resolveModelSpec returns loading while refresh is in-flight for unmapped models", () => {
220
+ const spec = resolveModelSpec("gpt-future-9", { refreshInFlight: true });
221
+ assert.equal(spec.status, "loading");
222
+ assert.equal(spec.contextWindow, null);
223
+ });
224
+
225
+ test("resolveModelSpec returns unknown only when no source can supply context metadata", () => {
226
+ const spec = resolveModelSpec("gpt-future-9");
227
+ assert.equal(spec.status, "unknown");
228
+ assert.equal(spec.contextWindow, null);
229
+ });
230
+
231
+ test("resolveModelSpec does not infer registry entries when switching between model names", () => {
232
+ const specFour = resolveModelSpec("gpt-5.4");
233
+ const specTwo = resolveModelSpec("gpt-5.2");
234
+ assert.equal(specFour.contextWindow, null);
235
+ assert.equal(specTwo.contextWindow, null);
236
+ });
237
+
238
+ test("reports equality across verified and unknown specs", () => {
239
+ assert.equal(
240
+ areModelSpecsEqual(
241
+ createLoadingModelSpec("gpt-5.4"),
242
+ createLoadingModelSpec("gpt-5.4"),
243
+ ),
244
+ true,
245
+ );
246
+ assert.equal(
247
+ areModelSpecsEqual(
248
+ createUnknownModelSpec("gpt-5.4", "a"),
249
+ createUnknownModelSpec("gpt-5.4", "b"),
250
+ ),
251
+ false,
252
+ );
253
+ });
254
+
255
+ test("resolveModelSpec returns unknown for claude-sonnet-4-6 without runtime/config context", () => {
256
+ const spec = resolveModelSpec("claude-sonnet-4-6");
257
+ assert.equal(spec.status, "unknown");
258
+ assert.equal(spec.contextWindow, null);
259
+ });
260
+
261
+ test("resolveModelSpec returns unknown for anthropic short alias 'haiku'", () => {
262
+ const spec = resolveModelSpec("haiku");
263
+ assert.equal(spec.status, "unknown");
264
+ assert.equal(spec.contextWindow, null);
265
+ });
266
+
267
+ test("resolveModelSpec returns unknown for gemini-2.5-flash without provider context metadata", () => {
268
+ const spec = resolveModelSpec("gemini-2.5-flash");
269
+ assert.equal(spec.status, "unknown");
270
+ assert.equal(spec.contextWindow, null);
271
+ });
272
+
273
+ test("resolveModelSpec returns null contextWindow (not 0) for an unrecognized model", () => {
274
+ const spec = resolveModelSpec("totally-unknown-model");
275
+ assert.equal(spec.status, "unknown");
276
+ assert.equal(spec.contextWindow, null);
277
+ });
278
+
279
+ test("resolveModelSpec does not estimate gemini-3-flash-preview", () => {
280
+ const spec = resolveModelSpec("gemini-3-flash-preview");
281
+ assert.equal(spec.status, "unknown");
282
+ assert.equal(spec.contextWindow, null);
283
+ });
@@ -0,0 +1,300 @@
1
+ import { readFileSync, renameSync, writeFileSync } from "fs";
2
+ import { MODEL_SPECS_FILE } from "../../config/settings.js";
3
+
4
+ export type ModelSpecStatus = "verified" | "loading" | "unknown";
5
+
6
+ export interface VerifiedModelSpec {
7
+ status: "verified";
8
+ contextWindow: number;
9
+ maxOutputTokens: number;
10
+ sourceUrl: string;
11
+ verifiedAt: number;
12
+ isEstimated?: boolean;
13
+ }
14
+
15
+ export interface PendingModelSpec {
16
+ status: "loading" | "unknown";
17
+ contextWindow: null;
18
+ maxOutputTokens: null;
19
+ sourceUrl: string;
20
+ verifiedAt: null;
21
+ error: string | null;
22
+ }
23
+
24
+ export type ModelSpec = VerifiedModelSpec | PendingModelSpec;
25
+
26
+ export const MODEL_SPEC_DOC_URLS: Record<string, string> = {
27
+ "gpt-5.4": "https://developers.openai.com/api/docs/models/gpt-5.4",
28
+ "gpt-5.4-mini": "https://developers.openai.com/api/docs/models/gpt-5.4-mini",
29
+ "gpt-5.3-codex": "https://developers.openai.com/api/docs/models/gpt-5.3-codex",
30
+ "gpt-5.2": "https://developers.openai.com/api/docs/models/gpt-5.2",
31
+ };
32
+
33
+ // Legacy model spec registry. Context-window display now goes through
34
+ // providerRuntime/contextMetadata.ts so values are source-aware and provider
35
+ // scoped. Keep this empty to avoid showing broad or guessed model limits.
36
+ export interface KnownModelSpec {
37
+ contextWindow: number;
38
+ maxOutputTokens: number;
39
+ }
40
+
41
+ export const KNOWN_MODEL_SPECS: Record<string, KnownModelSpec> = {};
42
+
43
+ type ModelSpecCache = Partial<Record<string, VerifiedModelSpec>>;
44
+
45
+ interface ModelSpecServiceOptions {
46
+ cacheFile?: string;
47
+ fetchImpl?: typeof fetch;
48
+ now?: () => number;
49
+ }
50
+
51
+ function createPendingModelSpec(
52
+ model: string,
53
+ status: PendingModelSpec["status"],
54
+ error: string | null = null,
55
+ ): PendingModelSpec {
56
+ return {
57
+ status,
58
+ contextWindow: null,
59
+ maxOutputTokens: null,
60
+ sourceUrl: getModelSpecDocUrl(model),
61
+ verifiedAt: null,
62
+ error,
63
+ };
64
+ }
65
+
66
+ function getModelSpecDocUrl(model: string): string {
67
+ return MODEL_SPEC_DOC_URLS[model] ?? `https://developers.openai.com/api/docs/models/${encodeURIComponent(model)}`;
68
+ }
69
+
70
+ export function createLoadingModelSpec(model: string): ModelSpec {
71
+ return createPendingModelSpec(model, "loading");
72
+ }
73
+
74
+ export function createUnknownModelSpec(model: string, error: string | null = null): ModelSpec {
75
+ return createPendingModelSpec(model, "unknown", error);
76
+ }
77
+
78
+ export function parseTokenCount(rawValue: string): number | null {
79
+ const normalized = rawValue.trim().replace(/\s+/g, "").toLowerCase();
80
+ if (!normalized) return null;
81
+
82
+ const suffix = normalized.endsWith("m")
83
+ ? 1_000_000
84
+ : normalized.endsWith("k")
85
+ ? 1_000
86
+ : 1;
87
+ const numericText = suffix === 1 ? normalized : normalized.slice(0, -1);
88
+ const numeric = Number.parseFloat(numericText.replace(/,/g, ""));
89
+ if (!Number.isFinite(numeric)) return null;
90
+
91
+ return Math.round(numeric * suffix);
92
+ }
93
+
94
+ export function stripHtmlToText(html: string): string {
95
+ return html
96
+ .replace(/<script\b[\s\S]*?<\/script[^>]*>/gi, " ")
97
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
98
+ .replace(/<[^>]+>/g, " ")
99
+ .replace(/&nbsp;|&#160;/gi, " ")
100
+ .replace(/&quot;/gi, "\"")
101
+ .replace(/&#39;|&apos;/gi, "'")
102
+ .replace(/&amp;/gi, "&")
103
+ .replace(/\s+/g, " ")
104
+ .trim();
105
+ }
106
+
107
+ export function extractModelSpecFromDocText(
108
+ model: string,
109
+ text: string,
110
+ verifiedAt = Date.now(),
111
+ ): VerifiedModelSpec | null {
112
+ const normalized = text.replace(/\u00a0/g, " ").replace(/\s+/g, " ");
113
+ const contextMatch = normalized.match(/(\d[\d,.]*(?:\s*[mk])?)\s+context\s+window/i);
114
+ const maxOutputMatch = normalized.match(/(\d[\d,.]*(?:\s*[mk])?)\s+max\s+output\s+tokens/i);
115
+
116
+ const contextWindow = contextMatch ? parseTokenCount(contextMatch[1] ?? "") : null;
117
+ const maxOutputTokens = maxOutputMatch ? parseTokenCount(maxOutputMatch[1] ?? "") : null;
118
+ if (!contextWindow || !maxOutputTokens) {
119
+ return null;
120
+ }
121
+
122
+ return {
123
+ status: "verified",
124
+ contextWindow,
125
+ maxOutputTokens,
126
+ sourceUrl: getModelSpecDocUrl(model),
127
+ verifiedAt,
128
+ };
129
+ }
130
+
131
+ function isVerifiedModelSpec(value: unknown): value is VerifiedModelSpec {
132
+ if (!value || typeof value !== "object") return false;
133
+
134
+ const candidate = value as Partial<VerifiedModelSpec>;
135
+ return candidate.status === "verified"
136
+ && typeof candidate.contextWindow === "number"
137
+ && Number.isFinite(candidate.contextWindow)
138
+ && typeof candidate.maxOutputTokens === "number"
139
+ && Number.isFinite(candidate.maxOutputTokens)
140
+ && typeof candidate.sourceUrl === "string"
141
+ && typeof candidate.verifiedAt === "number"
142
+ && Number.isFinite(candidate.verifiedAt);
143
+ }
144
+
145
+ export function loadModelSpecCache(cacheFile = MODEL_SPECS_FILE): ModelSpecCache {
146
+ try {
147
+ const raw = JSON.parse(readFileSync(cacheFile, "utf-8")) as Record<string, unknown>;
148
+ const cache: ModelSpecCache = {};
149
+
150
+ for (const [model, value] of Object.entries(raw)) {
151
+ if (isVerifiedModelSpec(value)) {
152
+ cache[model] = value;
153
+ }
154
+ }
155
+
156
+ return cache;
157
+ } catch {
158
+ return {};
159
+ }
160
+ }
161
+
162
+ export function saveModelSpecCache(cache: ModelSpecCache, cacheFile = MODEL_SPECS_FILE): void {
163
+ try {
164
+ const tmpFile = `${cacheFile}.tmp`;
165
+ writeFileSync(tmpFile, JSON.stringify(cache, null, 2), "utf-8");
166
+ renameSync(tmpFile, cacheFile);
167
+ } catch {
168
+ // Best-effort cache only.
169
+ }
170
+ }
171
+
172
+ export async function fetchModelSpecFromDocs(
173
+ model: string,
174
+ fetchImpl: typeof fetch = fetch,
175
+ verifiedAt = Date.now(),
176
+ ): Promise<VerifiedModelSpec> {
177
+ const response = await fetchImpl(getModelSpecDocUrl(model), {
178
+ headers: {
179
+ Accept: "text/html,application/xhtml+xml",
180
+ },
181
+ });
182
+
183
+ if (!response.ok) {
184
+ throw new Error(`Spec request failed with ${response.status}`);
185
+ }
186
+
187
+ const html = await response.text();
188
+ const parsed = extractModelSpecFromDocText(model, stripHtmlToText(html), verifiedAt);
189
+ if (!parsed) {
190
+ throw new Error(`Unable to parse model spec for ${model}`);
191
+ }
192
+
193
+ return parsed;
194
+ }
195
+
196
+ export interface ResolveModelSpecOptions {
197
+ // Persisted verified cache (loaded synchronously at startup).
198
+ cache?: Partial<Record<string, VerifiedModelSpec>>;
199
+ // Runtime discovery contextWindow if/when Codex starts providing it.
200
+ runtimeContextWindow?: number | null;
201
+ runtimeMaxOutputTokens?: number | null;
202
+ // Set true when a background refresh is in progress and no other source
203
+ // has produced verified numbers — lets callers render a "loading" state
204
+ // instead of "unknown".
205
+ refreshInFlight?: boolean;
206
+ now?: () => number;
207
+ }
208
+
209
+ // Single authoritative resolver. Precedence:
210
+ // 1. Runtime discovery metadata (if contextWindow is provided)
211
+ // 2. Persistent spec cache (~/.codexa-model-specs.json)
212
+ // 3. Trusted static registry (KNOWN_MODEL_SPECS)
213
+ // 4. Loading (if a background refresh is in-flight)
214
+ // 5. Unknown
215
+ export function resolveModelSpec(model: string, options: ResolveModelSpecOptions = {}): ModelSpec {
216
+ const now = options.now?.() ?? Date.now();
217
+
218
+ const runtimeCtx = options.runtimeContextWindow;
219
+ const runtimeMax = options.runtimeMaxOutputTokens;
220
+ if (
221
+ typeof runtimeCtx === "number" && Number.isFinite(runtimeCtx) && runtimeCtx > 0
222
+ && typeof runtimeMax === "number" && Number.isFinite(runtimeMax) && runtimeMax > 0
223
+ ) {
224
+ return {
225
+ status: "verified",
226
+ contextWindow: runtimeCtx,
227
+ maxOutputTokens: runtimeMax,
228
+ sourceUrl: getModelSpecDocUrl(model),
229
+ verifiedAt: now,
230
+ };
231
+ }
232
+
233
+ const cached = options.cache?.[model];
234
+ if (cached && cached.status === "verified") {
235
+ return cached;
236
+ }
237
+
238
+ const known = KNOWN_MODEL_SPECS[model];
239
+ if (known) {
240
+ return {
241
+ status: "verified",
242
+ contextWindow: known.contextWindow,
243
+ maxOutputTokens: known.maxOutputTokens,
244
+ sourceUrl: getModelSpecDocUrl(model),
245
+ verifiedAt: now,
246
+ };
247
+ }
248
+
249
+ if (options.refreshInFlight) {
250
+ return createLoadingModelSpec(model);
251
+ }
252
+
253
+ return createUnknownModelSpec(model);
254
+ }
255
+
256
+ export function areModelSpecsEqual(left: ModelSpec | undefined, right: ModelSpec): boolean {
257
+ if (!left) return false;
258
+ if (left.status !== right.status) return false;
259
+
260
+ return left.contextWindow === right.contextWindow
261
+ && left.maxOutputTokens === right.maxOutputTokens
262
+ && left.sourceUrl === right.sourceUrl
263
+ && left.verifiedAt === right.verifiedAt
264
+ && ("error" in left ? left.error : null) === ("error" in right ? right.error : null);
265
+ }
266
+
267
+ export function createModelSpecService(options: ModelSpecServiceOptions = {}) {
268
+ const cacheFile = options.cacheFile ?? MODEL_SPECS_FILE;
269
+ const fetchImpl = options.fetchImpl ?? fetch;
270
+ const now = options.now ?? Date.now;
271
+ // Persisted specs are kept for saving, not for live UI trust.
272
+ const cache = loadModelSpecCache(cacheFile);
273
+ const inflight = new Map<string, Promise<ModelSpec>>();
274
+
275
+ return {
276
+ async refreshSpec(model: string): Promise<ModelSpec> {
277
+ const current = inflight.get(model);
278
+ if (current) return current;
279
+
280
+ const refreshPromise = (async () => {
281
+ try {
282
+ const verified = await fetchModelSpecFromDocs(model, fetchImpl, now());
283
+ cache[model] = verified;
284
+ saveModelSpecCache(cache, cacheFile);
285
+ return verified;
286
+ } catch (error) {
287
+ return createUnknownModelSpec(
288
+ model,
289
+ error instanceof Error ? error.message : "Unknown model spec failure",
290
+ );
291
+ } finally {
292
+ inflight.delete(model);
293
+ }
294
+ })();
295
+
296
+ inflight.set(model, refreshPromise);
297
+ return refreshPromise;
298
+ },
299
+ };
300
+ }
@@ -0,0 +1,125 @@
1
+ import { appendFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ export interface PerfSession {
6
+ runId: string;
7
+ marks: Record<string, number>;
8
+ counters: Record<string, number>;
9
+ accumulations: Record<string, number>;
10
+ metadata: Record<string, unknown>;
11
+ }
12
+
13
+ let _enabled: boolean | null = null;
14
+ let _session: PerfSession | null = null;
15
+
16
+ export function isEnabled(): boolean {
17
+ if (_enabled === null) {
18
+ _enabled = process.env["CODEXA_PERF"] === "1";
19
+ }
20
+ return _enabled;
21
+ }
22
+
23
+ export function startSession(runId: string): void {
24
+ if (!isEnabled()) return;
25
+ _session = { runId, marks: {}, counters: {}, accumulations: {}, metadata: {} };
26
+ }
27
+
28
+ export function mark(label: string): void {
29
+ if (_session) _session.marks[label] = performance.now();
30
+ }
31
+
32
+ export function inc(counter: string, by = 1): void {
33
+ if (_session) _session.counters[counter] = (_session.counters[counter] ?? 0) + by;
34
+ }
35
+
36
+ export function accumulate(key: string, ms: number): void {
37
+ if (_session) _session.accumulations[key] = (_session.accumulations[key] ?? 0) + ms;
38
+ }
39
+
40
+ export function setMeta(key: string, value: unknown): void {
41
+ if (_session) _session.metadata[key] = value;
42
+ }
43
+
44
+ export function getSession(): PerfSession | null {
45
+ return _session;
46
+ }
47
+
48
+ // ─── Formatting ───────────────────────────────────────────────────────────────
49
+
50
+ function dur(session: PerfSession, from: string, to: string): string {
51
+ const a = session.marks[from];
52
+ const b = session.marks[to];
53
+ if (a === undefined || b === undefined) return " ?";
54
+ return String(Math.round(b - a)).padStart(4);
55
+ }
56
+
57
+ const STAGE_ROWS: Array<[from: string, to: string, label: string, note?: string]> = [
58
+ ["submit", "dispatch_start", "submit → dispatch_start", "pre-dispatch overhead"],
59
+ ["dispatch_start", "provider_run_start", "dispatch_start → provider_run_start", "orchestration setup"],
60
+ ["exec_resolve_start", "exec_resolve_end", "exec_resolve", "cached after first run"],
61
+ ["caps_probe_start", "caps_probe_end", "caps_probe", "cached after first run"],
62
+ ["provider_run_start", "spawn_done", "provider_run_start → spawn_done", "spawn overhead"],
63
+ ["spawn_done", "first_chunk", "spawn_done → first_chunk (TTFT)", "← backend latency"],
64
+ ["first_chunk", "first_render", "first_chunk → first_render", "render dispatch"],
65
+ ["first_chunk", "last_chunk", "streaming duration", "total stream time"],
66
+ ["last_chunk", "response_cb_start", "last_chunk → response_cb", ""],
67
+ ["snapshot_start", "snapshot_end", "workspace_snapshot (blocking)", "← post-run overhead"],
68
+ ["finalize_start", "finalize_done", "finalize_start → finalize_done", ""],
69
+ ];
70
+
71
+ export function buildSummary(session: PerfSession): string {
72
+ const lines: string[] = [
73
+ "┌── CODEXA PERF REPORT ─────────────────────────────────────────",
74
+ "│ Stage ms",
75
+ ];
76
+
77
+ const durations: Array<{ label: string; ms: number }> = [];
78
+
79
+ for (const [from, to, label, note] of STAGE_ROWS) {
80
+ const a = session.marks[from];
81
+ const b = session.marks[to];
82
+ const ms = a !== undefined && b !== undefined ? Math.round(b - a) : null;
83
+ const msStr = ms !== null ? String(ms).padStart(4) : " ?";
84
+ const noteStr = note ? ` ${note}` : "";
85
+ lines.push(`│ ${label.padEnd(44)} ${msStr}ms${noteStr}`);
86
+ if (ms !== null) durations.push({ label, ms });
87
+ }
88
+
89
+ const c = session.counters;
90
+ const a = session.accumulations;
91
+ lines.push("│");
92
+ lines.push(
93
+ `│ Counters chunks=${c["chunks"] ?? 0} flushes=${c["flushes"] ?? 0} progress_updates=${c["progress_updates"] ?? 0}`,
94
+ );
95
+ lines.push(`│ Sanitise accumulated ${Math.round(a["sanitize_ms"] ?? 0)}ms across ${c["chunks"] ?? 0} chunks`);
96
+
97
+ const metaEntries = Object.entries(session.metadata);
98
+ if (metaEntries.length > 0) {
99
+ lines.push("│");
100
+ for (const [k, v] of metaEntries) {
101
+ lines.push(`│ ${k}: ${String(v)}`);
102
+ }
103
+ }
104
+
105
+ // Bottleneck: largest duration stage
106
+ if (durations.length > 0) {
107
+ const top = durations.reduce((a, b) => (b.ms > a.ms ? b : a));
108
+ lines.push("│");
109
+ lines.push(`│ ► BOTTLENECK: ${top.label} = ${top.ms}ms`);
110
+ }
111
+
112
+ lines.push("└───────────────────────────────────────────────────────────────");
113
+ return lines.join("\n");
114
+ }
115
+
116
+ // Sessions are appended as JSONL to ~/.codexa-perf.jsonl for offline analysis.
117
+ export function persistSession(session: PerfSession): void {
118
+ try {
119
+ const logPath = join(homedir(), ".codexa-perf.jsonl");
120
+ const line = JSON.stringify({ ...session, ts: Date.now() }) + "\n";
121
+ appendFileSync(logPath, line, "utf8");
122
+ } catch {
123
+ // Profiling must never crash the app.
124
+ }
125
+ }