@bitkyc08/opencodex 2.11.1 → 2.12.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 (175) hide show
  1. package/README.md +1 -1
  2. package/bin/ocx.mjs +4 -0
  3. package/gui/dist/assets/index-BNVYzdn0.css +1 -0
  4. package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/command-code.ts +94 -5
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-tool-schema.ts +80 -15
  10. package/src/adapters/google.ts +24 -7
  11. package/src/adapters/openai-chat.ts +233 -305
  12. package/src/adapters/openai-responses.ts +62 -0
  13. package/src/adapters/tool-catalog-nudge.ts +2 -11
  14. package/src/claude/outbound.ts +42 -14
  15. package/src/cli/claude.ts +61 -9
  16. package/src/cli/codex-shim-autorestore.ts +2 -0
  17. package/src/cli/codex-shim-readiness.ts +8 -1
  18. package/src/cli/config-command.ts +4 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +15 -0
  21. package/src/cli/index.ts +27 -11
  22. package/src/cli/lab.ts +367 -0
  23. package/src/cli/provider.ts +9 -1
  24. package/src/clients/config-export.ts +100 -3
  25. package/src/codex/app-server-processes.ts +11 -8
  26. package/src/codex/auth-api.ts +92 -28
  27. package/src/codex/auth-collision.ts +5 -3
  28. package/src/codex/catalog/metadata.ts +7 -3
  29. package/src/codex/catalog/provider-fetch.ts +191 -47
  30. package/src/codex/catalog.ts +2 -2
  31. package/src/codex/history-job.ts +83 -18
  32. package/src/codex/history-migration-guardian.ts +9 -16
  33. package/src/codex/history-provider.ts +178 -1
  34. package/src/codex/history-transition.ts +5 -5
  35. package/src/codex/history-worker.ts +22 -2
  36. package/src/codex/native-profile-store.ts +1 -1
  37. package/src/codex/plan.ts +15 -0
  38. package/src/codex/quota.ts +6 -6
  39. package/src/codex/routing.ts +3 -3
  40. package/src/codex/subagent-model-fallback.ts +246 -22
  41. package/src/codex/user-identity.ts +63 -12
  42. package/src/config.ts +265 -32
  43. package/src/images/loop.ts +3 -1
  44. package/src/images/plan.ts +23 -13
  45. package/src/integrations/omp-yaml-source.ts +225 -0
  46. package/src/integrations/ownership.ts +5 -5
  47. package/src/integrations/state.ts +45 -6
  48. package/src/integrations/writer.ts +38 -3
  49. package/src/lab/artifacts/sanitize.ts +586 -0
  50. package/src/lab/artifacts/secure-fs.ts +475 -0
  51. package/src/lab/artifacts/store.ts +310 -0
  52. package/src/lab/conformance/assertion.ts +325 -0
  53. package/src/lab/conformance/digest.ts +22 -0
  54. package/src/lab/conformance/executor.ts +741 -0
  55. package/src/lab/conformance/fixture-provider.ts +27 -0
  56. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  57. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  58. package/src/lab/conformance/harness-budget.ts +47 -0
  59. package/src/lab/conformance/index.ts +5 -0
  60. package/src/lab/conformance/jcs.ts +24 -0
  61. package/src/lab/conformance/json-pointer.ts +39 -0
  62. package/src/lab/conformance/manifest.ts +180 -0
  63. package/src/lab/conformance/mcp-stub.ts +179 -0
  64. package/src/lab/conformance/negative-controls.ts +160 -0
  65. package/src/lab/conformance/observation.ts +355 -0
  66. package/src/lab/conformance/runner.ts +57 -0
  67. package/src/lab/conformance/sse-normalize.ts +59 -0
  68. package/src/lab/conformance/suite-manifest.ts +78 -0
  69. package/src/lab/conformance/types.ts +191 -0
  70. package/src/lab/constants.ts +126 -0
  71. package/src/lab/digest.ts +64 -0
  72. package/src/lab/events/errors.ts +9 -0
  73. package/src/lab/events/limits.ts +117 -0
  74. package/src/lab/events/types.ts +228 -0
  75. package/src/lab/events/validate.ts +780 -0
  76. package/src/lab/index.ts +36 -0
  77. package/src/lab/ledger/artifact-refs.ts +127 -0
  78. package/src/lab/ledger/invalidation.ts +136 -0
  79. package/src/lab/ledger/purge.ts +241 -0
  80. package/src/lab/ledger/store.ts +263 -0
  81. package/src/lab/live/credential-lease.ts +53 -0
  82. package/src/lab/live/destination.ts +155 -0
  83. package/src/lab/live/executor.ts +311 -0
  84. package/src/lab/live/inert-tools.ts +56 -0
  85. package/src/lab/live/manifest.ts +85 -0
  86. package/src/lab/live/mcp-loopback.ts +57 -0
  87. package/src/lab/live/runner.ts +19 -0
  88. package/src/lab/live/sandbox.ts +61 -0
  89. package/src/lab/live/suite-manifest.ts +41 -0
  90. package/src/lab/live/transport.ts +116 -0
  91. package/src/lab/live/types.ts +197 -0
  92. package/src/lab/observe/from-conformance.ts +344 -0
  93. package/src/lab/observe/from-live.ts +103 -0
  94. package/src/lab/paths.ts +65 -0
  95. package/src/lab/projection/rebuild.ts +477 -0
  96. package/src/lab/projection/schema.ts +122 -0
  97. package/src/lab/projection/verdicts.ts +438 -0
  98. package/src/lab/projection/verification.ts +332 -0
  99. package/src/lab/query/catalog.ts +72 -0
  100. package/src/lab/query/connection.ts +107 -0
  101. package/src/lab/query/constants.ts +4 -0
  102. package/src/lab/query/cursor.ts +132 -0
  103. package/src/lab/query/dto-map.ts +277 -0
  104. package/src/lab/query/errors.ts +22 -0
  105. package/src/lab/query/index.ts +34 -0
  106. package/src/lab/query/queries.ts +444 -0
  107. package/src/lab/query/types.ts +266 -0
  108. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  109. package/src/lab/subject/installation-salt.ts +81 -0
  110. package/src/lab/subject/route-subject.ts +73 -0
  111. package/src/lib/bounded-body.ts +104 -0
  112. package/src/lib/lab-live-execution-authority.ts +13 -0
  113. package/src/lib/lab-live-host.ts +30 -0
  114. package/src/lib/lab-live-pinned-sender.ts +45 -0
  115. package/src/lib/pinned-http.ts +36 -7
  116. package/src/oauth/index.ts +20 -1
  117. package/src/oauth/local-token-detect.ts +11 -2
  118. package/src/oauth/login-cli.ts +38 -4
  119. package/src/providers/alibaba-region-migration.ts +1 -1
  120. package/src/providers/codex-capacity.ts +7 -4
  121. package/src/providers/command-code-efforts.ts +5 -2
  122. package/src/providers/derive.ts +105 -0
  123. package/src/providers/label.ts +1 -1
  124. package/src/providers/openai-tiers.ts +59 -3
  125. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  126. package/src/providers/quota.ts +67 -21
  127. package/src/providers/registry.ts +118 -5
  128. package/src/router.ts +15 -1
  129. package/src/routing/analytics.ts +8 -7
  130. package/src/routing/evaluator.ts +51 -5
  131. package/src/routing/profile.ts +10 -1
  132. package/src/routing/trace.ts +42 -6
  133. package/src/server/auth-cors.ts +15 -0
  134. package/src/server/background-lifecycle.ts +169 -0
  135. package/src/server/chat-completions.ts +18 -3
  136. package/src/server/claude-messages.ts +1 -0
  137. package/src/server/images.ts +78 -11
  138. package/src/server/index.ts +72 -18
  139. package/src/server/lifecycle.ts +6 -0
  140. package/src/server/live.ts +5 -1
  141. package/src/server/management/agent-settings-routes.ts +41 -24
  142. package/src/server/management/body.ts +6 -0
  143. package/src/server/management/config-routes.ts +51 -8
  144. package/src/server/management/lab-routes.ts +388 -0
  145. package/src/server/management/logs-usage-routes.ts +19 -1
  146. package/src/server/management/model-rows.ts +25 -11
  147. package/src/server/management/native-integration-routes.ts +3 -3
  148. package/src/server/management/provider-routes.ts +5 -0
  149. package/src/server/management/shared.ts +8 -1
  150. package/src/server/management/usage-summary-cache.ts +2 -0
  151. package/src/server/management/vision-sidecar-options.ts +125 -0
  152. package/src/server/management-api.ts +2 -0
  153. package/src/server/request-decompress.ts +106 -5
  154. package/src/server/responses/collaboration.ts +4 -1
  155. package/src/server/responses/core.ts +117 -31
  156. package/src/server/responses-json-events.ts +50 -12
  157. package/src/server/search.ts +23 -7
  158. package/src/server/ws-bridge.ts +3 -1
  159. package/src/service.ts +40 -20
  160. package/src/types.ts +74 -0
  161. package/src/update/job.ts +10 -2
  162. package/src/usage/cost.ts +0 -0
  163. package/src/usage/expected-prices.ts +9 -0
  164. package/src/usage/log.ts +9 -9
  165. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  166. package/src/usage/user-cost-overlays.ts +314 -0
  167. package/src/vision/describe.ts +2 -2
  168. package/src/vision/eligibility.ts +229 -0
  169. package/src/vision/index.ts +20 -2
  170. package/src/web-search/executor.ts +2 -2
  171. package/src/web-search/index.ts +4 -2
  172. package/src/web-search/loop.ts +3 -1
  173. package/src/web-search/parse.ts +72 -3
  174. package/gui/dist/assets/index-BA1dgo4q.css +0 -1
  175. package/gui/dist/assets/index-DDZpgzKk.js +0 -70
@@ -0,0 +1,388 @@
1
+ /**
2
+ * CL-04 read-only Compatibility Lab management API.
3
+ *
4
+ * - GET /api/lab/status
5
+ * - GET /api/lab/verdicts
6
+ * - GET /api/lab/subjects
7
+ * - GET /api/lab/subjects/:subjectId
8
+ * - GET /api/lab/observations
9
+ * - GET /api/lab/events
10
+ * - GET /api/lab/events/:eventId
11
+ * - GET /api/lab/artifacts
12
+ * - GET /api/lab/artifacts/:digest
13
+ * - GET /api/lab/catalog
14
+ */
15
+
16
+ import {
17
+ EVIDENCE_LAYERS,
18
+ EXECUTION_MODES,
19
+ EVENT_KINDS,
20
+ OUTCOMES,
21
+ VERDICTS,
22
+ type EvidenceLayer,
23
+ type ExecutionMode,
24
+ type LabEventKind,
25
+ type ObservationOutcome,
26
+ type CompatibilityVerdict,
27
+ } from "../../lab/constants";
28
+ import {
29
+ InvalidCursorError,
30
+ LabProjectionIncompatibleError,
31
+ LabProjectionUnavailableError,
32
+ LAB_QUERY_MAX_PAGE_SIZE,
33
+ queryLabArtifactByDigest,
34
+ queryLabArtifacts,
35
+ queryLabCatalogEntries,
36
+ queryLabEventById,
37
+ queryLabEvents,
38
+ queryLabObservations,
39
+ queryLabStatus,
40
+ queryLabSubjectById,
41
+ queryLabSubjects,
42
+ queryLabVerdicts,
43
+ } from "../../lab/query";
44
+ import { jsonResponse } from "../auth-cors";
45
+ import type { ManagementContext } from "./context";
46
+
47
+ function parseQueryInt(raw: string | null): number | undefined | "invalid" {
48
+ if (raw === null) return undefined;
49
+ const trimmed = raw.trim();
50
+ if (trimmed.length === 0) return "invalid";
51
+ const value = Number(trimmed);
52
+ return Number.isInteger(value) ? value : "invalid";
53
+ }
54
+
55
+ function errorResponse(
56
+ code: string,
57
+ message: string,
58
+ status: number,
59
+ ctx: ManagementContext,
60
+ ): Response {
61
+ return jsonResponse({ error: { code, message } }, status, ctx.req, ctx.config);
62
+ }
63
+
64
+ function projectionErrorResponse(err: unknown, ctx: ManagementContext): Response | null {
65
+ if (err instanceof LabProjectionUnavailableError) {
66
+ return errorResponse("lab_projection_unavailable", "lab projection is not available", 503, ctx);
67
+ }
68
+ if (err instanceof LabProjectionIncompatibleError) {
69
+ return errorResponse("lab_projection_incompatible", "lab projection schema or spec version is incompatible", 503, ctx);
70
+ }
71
+ if (err instanceof InvalidCursorError) {
72
+ return errorResponse("invalid_cursor", "invalid cursor", 400, ctx);
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function parseLimit(raw: string | null, ctx: ManagementContext): number | undefined | Response {
78
+ const parsed = raw === null ? undefined : parseQueryInt(raw);
79
+ if (parsed === "invalid") {
80
+ return errorResponse(
81
+ "invalid_limit",
82
+ `limit must be an integer from 1 to ${LAB_QUERY_MAX_PAGE_SIZE}`,
83
+ 400,
84
+ ctx,
85
+ );
86
+ }
87
+ if (parsed !== undefined && (parsed < 1 || parsed > LAB_QUERY_MAX_PAGE_SIZE)) {
88
+ return errorResponse(
89
+ "invalid_limit",
90
+ `limit must be an integer from 1 to ${LAB_QUERY_MAX_PAGE_SIZE}`,
91
+ 400,
92
+ ctx,
93
+ );
94
+ }
95
+ return parsed;
96
+ }
97
+
98
+ function parseRange(
99
+ fromRaw: string | null,
100
+ toRaw: string | null,
101
+ ctx: ManagementContext,
102
+ ): { from?: number; to?: number } | Response {
103
+ const fromParsed = parseQueryInt(fromRaw);
104
+ if (fromParsed === "invalid") {
105
+ return errorResponse("invalid_range", "from must be an integer timestamp", 400, ctx);
106
+ }
107
+ const toParsed = parseQueryInt(toRaw);
108
+ if (toParsed === "invalid") {
109
+ return errorResponse("invalid_range", "to must be an integer timestamp", 400, ctx);
110
+ }
111
+ if (fromParsed !== undefined && toParsed !== undefined && fromParsed > toParsed) {
112
+ return errorResponse("invalid_range", "from must not be after to", 400, ctx);
113
+ }
114
+ return { from: fromParsed, to: toParsed };
115
+ }
116
+
117
+ function parseLayer(raw: string | null, ctx: ManagementContext): EvidenceLayer | undefined | Response {
118
+ if (!raw) return undefined;
119
+ const trimmed = raw.trim();
120
+ if (!EVIDENCE_LAYERS.includes(trimmed as EvidenceLayer)) {
121
+ return errorResponse("invalid_layer", "layer must be a supported evidence layer", 400, ctx);
122
+ }
123
+ return trimmed as EvidenceLayer;
124
+ }
125
+
126
+ function parseVerdict(raw: string | null, ctx: ManagementContext): CompatibilityVerdict | undefined | Response {
127
+ if (!raw) return undefined;
128
+ const trimmed = raw.trim();
129
+ if (!VERDICTS.includes(trimmed as CompatibilityVerdict)) {
130
+ return errorResponse("invalid_verdict", "verdict must be a supported compatibility verdict", 400, ctx);
131
+ }
132
+ return trimmed as CompatibilityVerdict;
133
+ }
134
+
135
+ function parseEventKind(raw: string | null, ctx: ManagementContext): LabEventKind | undefined | Response {
136
+ if (!raw) return undefined;
137
+ const trimmed = raw.trim();
138
+ if (!EVENT_KINDS.includes(trimmed as LabEventKind)) {
139
+ return errorResponse("invalid_event_kind", "eventKind must be a supported lab event kind", 400, ctx);
140
+ }
141
+ return trimmed as LabEventKind;
142
+ }
143
+
144
+ function parseOutcome(raw: string | null, ctx: ManagementContext): ObservationOutcome | undefined | Response {
145
+ if (!raw) return undefined;
146
+ const trimmed = raw.trim();
147
+ if (!OUTCOMES.includes(trimmed as ObservationOutcome)) {
148
+ return errorResponse("invalid_outcome", "outcome must be a supported observation outcome", 400, ctx);
149
+ }
150
+ return trimmed as ObservationOutcome;
151
+ }
152
+
153
+ function parseExecutionMode(raw: string | null, ctx: ManagementContext): ExecutionMode | undefined | Response {
154
+ if (!raw) return undefined;
155
+ const trimmed = raw.trim();
156
+ if (!EXECUTION_MODES.includes(trimmed as ExecutionMode)) {
157
+ return errorResponse("invalid_execution_mode", "executionMode must be a supported execution mode", 400, ctx);
158
+ }
159
+ return trimmed as ExecutionMode;
160
+ }
161
+
162
+ function rejectUnsafeId(id: string, ctx: ManagementContext): Response | null {
163
+ if (id.includes("/") || id.includes("\\") || id.includes("%2f") || id.includes("%5c")) {
164
+ return errorResponse("not_found", "unknown resource", 404, ctx);
165
+ }
166
+ if (id.length === 0 || id.length > 256) {
167
+ return errorResponse("not_found", "unknown resource", 404, ctx);
168
+ }
169
+ return null;
170
+ }
171
+
172
+ function decodePathSegment(raw: string): string | null {
173
+ try {
174
+ return decodeURIComponent(raw);
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ function paginatedEnvelope<T>(page: { items: T[]; nextCursor?: string; hasMore: boolean }, key: string) {
181
+ return {
182
+ [key]: page.items,
183
+ hasMore: page.hasMore,
184
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
185
+ };
186
+ }
187
+
188
+ export async function handleLabRoutes(ctx: ManagementContext): Promise<Response | null> {
189
+ const { url, req, config } = ctx;
190
+ if (!url.pathname.startsWith("/api/lab")) return null;
191
+ if (req.method !== "GET") return null;
192
+
193
+ if (url.pathname === "/api/lab/status") {
194
+ return jsonResponse(queryLabStatus(), 200, req, config);
195
+ }
196
+
197
+ if (url.pathname === "/api/lab/catalog") {
198
+ const layerRaw = url.searchParams.get("layer");
199
+ const layerParsed = layerRaw ? parseLayer(layerRaw, ctx) : undefined;
200
+ if (layerParsed instanceof Response) return layerParsed;
201
+ const suiteId = url.searchParams.get("suiteId")?.trim() || url.searchParams.get("suite")?.trim() || undefined;
202
+ try {
203
+ const scenarios = queryLabCatalogEntries({
204
+ layer: layerParsed,
205
+ suiteId,
206
+ });
207
+ return jsonResponse({ scenarios }, 200, req, config);
208
+ } catch (err) {
209
+ const mapped = projectionErrorResponse(err, ctx);
210
+ if (mapped) return mapped;
211
+ return errorResponse("server_error", "internal lab read failure", 500, ctx);
212
+ }
213
+ }
214
+
215
+ if (url.pathname === "/api/lab/verdicts") {
216
+ const limit = parseLimit(url.searchParams.get("limit"), ctx);
217
+ if (limit instanceof Response) return limit;
218
+ const range = parseRange(url.searchParams.get("from"), url.searchParams.get("to"), ctx);
219
+ if (range instanceof Response) return range;
220
+ const layer = parseLayer(url.searchParams.get("layer"), ctx);
221
+ if (layer instanceof Response) return layer;
222
+ const verdict = parseVerdict(url.searchParams.get("verdict"), ctx);
223
+ if (verdict instanceof Response) return verdict;
224
+ try {
225
+ const page = queryLabVerdicts({
226
+ subjectId: url.searchParams.get("subjectId")?.trim() || undefined,
227
+ layer,
228
+ suiteId: url.searchParams.get("suiteId")?.trim() || undefined,
229
+ verdict,
230
+ from: range.from,
231
+ to: range.to,
232
+ }, url.searchParams.get("cursor"), limit);
233
+ return jsonResponse(paginatedEnvelope(page, "verdicts"), 200, req, config);
234
+ } catch (err) {
235
+ const mapped = projectionErrorResponse(err, ctx);
236
+ if (mapped) return mapped;
237
+ return errorResponse("server_error", "internal lab read failure", 500, ctx);
238
+ }
239
+ }
240
+
241
+ if (url.pathname === "/api/lab/subjects") {
242
+ const limit = parseLimit(url.searchParams.get("limit"), ctx);
243
+ if (limit instanceof Response) return limit;
244
+ try {
245
+ const page = queryLabSubjects(
246
+ url.searchParams.get("kind")?.trim() || undefined,
247
+ url.searchParams.get("cursor"),
248
+ limit,
249
+ );
250
+ return jsonResponse(paginatedEnvelope(page, "subjects"), 200, req, config);
251
+ } catch (err) {
252
+ const mapped = projectionErrorResponse(err, ctx);
253
+ if (mapped) return mapped;
254
+ return errorResponse("server_error", "internal lab read failure", 500, ctx);
255
+ }
256
+ }
257
+
258
+ const subjectMatch = url.pathname.match(/^\/api\/lab\/subjects\/([^/]+)$/);
259
+ if (subjectMatch) {
260
+ const subjectId = decodePathSegment(subjectMatch[1]!);
261
+ if (subjectId === null) return errorResponse("not_found", "unknown resource", 404, ctx);
262
+ const unsafe = rejectUnsafeId(subjectId, ctx);
263
+ if (unsafe) return unsafe;
264
+ try {
265
+ const subject = queryLabSubjectById(subjectId);
266
+ if (!subject) return errorResponse("not_found", "unknown subject", 404, ctx);
267
+ return jsonResponse({ subjectId, subject }, 200, req, config);
268
+ } catch (err) {
269
+ const mapped = projectionErrorResponse(err, ctx);
270
+ if (mapped) return mapped;
271
+ return errorResponse("server_error", "internal lab read failure", 500, ctx);
272
+ }
273
+ }
274
+
275
+ if (url.pathname === "/api/lab/observations") {
276
+ const limit = parseLimit(url.searchParams.get("limit"), ctx);
277
+ if (limit instanceof Response) return limit;
278
+ const range = parseRange(url.searchParams.get("from"), url.searchParams.get("to"), ctx);
279
+ if (range instanceof Response) return range;
280
+ const layer = parseLayer(url.searchParams.get("layer"), ctx);
281
+ if (layer instanceof Response) return layer;
282
+ const outcome = parseOutcome(url.searchParams.get("outcome"), ctx);
283
+ if (outcome instanceof Response) return outcome;
284
+ const executionMode = parseExecutionMode(url.searchParams.get("executionMode"), ctx);
285
+ if (executionMode instanceof Response) return executionMode;
286
+ try {
287
+ const page = queryLabObservations({
288
+ subjectId: url.searchParams.get("subjectId")?.trim() || undefined,
289
+ layer,
290
+ suiteId: url.searchParams.get("suiteId")?.trim() || undefined,
291
+ scenarioId: url.searchParams.get("scenarioId")?.trim() || undefined,
292
+ outcome,
293
+ executionMode,
294
+ from: range.from,
295
+ to: range.to,
296
+ }, url.searchParams.get("cursor"), limit);
297
+ return jsonResponse(paginatedEnvelope(page, "observations"), 200, req, config);
298
+ } catch (err) {
299
+ const mapped = projectionErrorResponse(err, ctx);
300
+ if (mapped) return mapped;
301
+ return errorResponse("server_error", "internal lab read failure", 500, ctx);
302
+ }
303
+ }
304
+
305
+ if (url.pathname === "/api/lab/events") {
306
+ const limit = parseLimit(url.searchParams.get("limit"), ctx);
307
+ if (limit instanceof Response) return limit;
308
+ const range = parseRange(url.searchParams.get("from"), url.searchParams.get("to"), ctx);
309
+ if (range instanceof Response) return range;
310
+ const eventKind = parseEventKind(url.searchParams.get("eventKind"), ctx);
311
+ if (eventKind instanceof Response) return eventKind;
312
+ const excludedRaw = url.searchParams.get("excluded");
313
+ let excluded: boolean | undefined;
314
+ if (excludedRaw === "true") excluded = true;
315
+ else if (excludedRaw === "false") excluded = false;
316
+ try {
317
+ const page = queryLabEvents({
318
+ eventKind,
319
+ subjectId: url.searchParams.get("subjectId")?.trim() || undefined,
320
+ from: range.from,
321
+ to: range.to,
322
+ excluded,
323
+ }, url.searchParams.get("cursor"), limit);
324
+ return jsonResponse(paginatedEnvelope(page, "events"), 200, req, config);
325
+ } catch (err) {
326
+ const mapped = projectionErrorResponse(err, ctx);
327
+ if (mapped) return mapped;
328
+ return errorResponse("server_error", "internal lab read failure", 500, ctx);
329
+ }
330
+ }
331
+
332
+ const eventMatch = url.pathname.match(/^\/api\/lab\/events\/([^/]+)$/);
333
+ if (eventMatch) {
334
+ const eventId = decodePathSegment(eventMatch[1]!);
335
+ if (eventId === null) return errorResponse("not_found", "unknown resource", 404, ctx);
336
+ const unsafe = rejectUnsafeId(eventId, ctx);
337
+ if (unsafe) return unsafe;
338
+ try {
339
+ const event = queryLabEventById(eventId);
340
+ if (!event) return errorResponse("not_found", "unknown event", 404, ctx);
341
+ return jsonResponse({ event }, 200, req, config);
342
+ } catch (err) {
343
+ const mapped = projectionErrorResponse(err, ctx);
344
+ if (mapped) return mapped;
345
+ return errorResponse("server_error", "internal lab read failure", 500, ctx);
346
+ }
347
+ }
348
+
349
+ if (url.pathname === "/api/lab/artifacts") {
350
+ const limit = parseLimit(url.searchParams.get("limit"), ctx);
351
+ if (limit instanceof Response) return limit;
352
+ const statusRaw = url.searchParams.get("status")?.trim();
353
+ const artifactClass = url.searchParams.get("artifactClass")?.trim() || undefined;
354
+ if (statusRaw && !["present", "corrupt", "purged_unavailable"].includes(statusRaw)) {
355
+ return errorResponse("invalid_status", "status must be present, corrupt, or purged_unavailable", 400, ctx);
356
+ }
357
+ try {
358
+ const page = queryLabArtifacts({
359
+ status: statusRaw as "present" | "corrupt" | "purged_unavailable" | undefined,
360
+ artifactClass: artifactClass as import("../../lab/constants").ArtifactClass | undefined,
361
+ }, url.searchParams.get("cursor"), limit);
362
+ return jsonResponse(paginatedEnvelope(page, "artifacts"), 200, req, config);
363
+ } catch (err) {
364
+ const mapped = projectionErrorResponse(err, ctx);
365
+ if (mapped) return mapped;
366
+ return errorResponse("server_error", "internal lab read failure", 500, ctx);
367
+ }
368
+ }
369
+
370
+ const artifactMatch = url.pathname.match(/^\/api\/lab\/artifacts\/([^/]+)$/);
371
+ if (artifactMatch) {
372
+ const digest = decodePathSegment(artifactMatch[1]!);
373
+ if (digest === null) return errorResponse("not_found", "unknown resource", 404, ctx);
374
+ const unsafe = rejectUnsafeId(digest, ctx);
375
+ if (unsafe) return unsafe;
376
+ try {
377
+ const artifact = queryLabArtifactByDigest(digest);
378
+ if (!artifact) return errorResponse("not_found", "unknown artifact", 404, ctx);
379
+ return jsonResponse({ artifact }, 200, req, config);
380
+ } catch (err) {
381
+ const mapped = projectionErrorResponse(err, ctx);
382
+ if (mapped) return mapped;
383
+ return errorResponse("server_error", "internal lab read failure", 500, ctx);
384
+ }
385
+ }
386
+
387
+ return null;
388
+ }
@@ -70,6 +70,7 @@ import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig
70
70
  import { drainAndShutdown } from "../lifecycle";
71
71
  import { filterRequestLogs, filteredRequestLogCount, getRequestLogEntries, type RequestLogEntry } from "../request-log";
72
72
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
73
+ import { userCostOverlayVersion } from "../../usage/user-cost-overlays";
73
74
  import type { PersistedUsageAttempt } from "../../usage/log";
74
75
  import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
75
76
  import { applySystemEnvToggle } from "../system-env";
@@ -193,10 +194,19 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
193
194
  const effectiveReadLimit = config.managementUsageMaxReadBytes ?? 64 * 1024 * 1024;
194
195
  const observedRevisionKey = `${usageLogRevisionKey(currentUsageLogRevision())}\0${effectiveReadLimit}`;
195
196
  const cached = getUsageSummaryCacheEntry(cacheKey);
196
- if (cached && cached.revisionKey === observedRevisionKey && now < cached.expiresAt) {
197
+ if (cached
198
+ && cached.revisionKey === observedRevisionKey
199
+ && cached.overlayVersion === userCostOverlayVersion()
200
+ && now < cached.expiresAt) {
197
201
  return jsonResponse(refreshedUsageSummary(cached.summary, range, now));
198
202
  }
199
203
  if (cached) discardUsageSummaryCacheEntry(cacheKey);
204
+ // Capture the overlay version BEFORE reading/computing: the cache entry
205
+ // must be stamped with the version the summary was priced under. Reading
206
+ // it again at stamp time could cache an old-price summary as current,
207
+ // and the next request would then accept stale pricing for the whole
208
+ // cache lifetime.
209
+ const overlayVersion = userCostOverlayVersion();
200
210
  const snapshot = await readUsageSnapshotForManagement(effectiveReadLimit);
201
211
  const revisionReadAt = Date.now();
202
212
  const summary = {
@@ -206,8 +216,16 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
206
216
  entriesTruncated: snapshot.entriesTruncated,
207
217
  entriesDropped: snapshot.entriesDropped,
208
218
  };
219
+ if (userCostOverlayVersion() !== overlayVersion) {
220
+ // The overlay changed while the summary was being computed, so this
221
+ // summary may mix old and new prices. Serve it uncached: the next
222
+ // request recomputes against the settled overlay instead of caching a
223
+ // mixed-price entry under either version.
224
+ return jsonResponse(summary);
225
+ }
209
226
  setUsageSummaryCacheEntry(cacheKey, {
210
227
  revisionKey: `${usageLogRevisionKey(snapshot.revision)}\0${effectiveReadLimit}`,
228
+ overlayVersion,
211
229
  expiresAt: usageSummaryExpiresAt(snapshot.entries, range, surface, now),
212
230
  revisionReadAt,
213
231
  summary,
@@ -9,7 +9,14 @@
9
9
  * Bodies are unchanged from their previous home; only `export` was added.
10
10
  */
11
11
  import type { CatalogModel } from "../../codex/catalog";
12
- import { catalogModelSlug, nativeModelRows, nativeReasoningEfforts, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
12
+ import {
13
+ catalogModelSlug,
14
+ nativeDefaultReasoningEffort,
15
+ nativeInputModalities,
16
+ nativeModelRows,
17
+ nativeReasoningEfforts,
18
+ uniqueCatalogModelsForPublicList,
19
+ } from "../../codex/catalog";
13
20
  import type { ExportModel } from "../../clients/config-export";
14
21
  import { providerContextCap } from "../../providers/context-cap";
15
22
  import { isVisionReasoningEffort } from "../../reasoning-effort";
@@ -42,16 +49,21 @@ export async function listManagementModelRows(config: OcxConfig): Promise<Manage
42
49
  const disabled = new Set(config.disabledModels ?? []);
43
50
  // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
44
51
  // from the static supported set so a disabled model stays listed and re-enableable.
45
- const native: ManagementModelRow[] = nativeModelRows(config).map(row => ({
46
- provider: "openai",
47
- id: row.slug,
48
- namespaced: row.slug,
49
- disabled: row.disabled,
50
- native: true,
51
- // The Codex catalog may advertise `ultra`, but the image describer accepts only low..max.
52
- reasoningEfforts: nativeReasoningEfforts(row.slug).filter(isVisionReasoningEffort),
53
- ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
54
- }));
52
+ const native: ManagementModelRow[] = nativeModelRows(config).map(row => {
53
+ const reasoningEfforts = nativeReasoningEfforts(row.slug).filter(isVisionReasoningEffort);
54
+ const defaultReasoningEffort = nativeDefaultReasoningEffort(row.slug);
55
+ return {
56
+ provider: "openai",
57
+ id: row.slug,
58
+ namespaced: row.slug,
59
+ disabled: row.disabled,
60
+ native: true,
61
+ reasoningEfforts,
62
+ ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
63
+ inputModalities: nativeInputModalities(row.slug),
64
+ ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
65
+ };
66
+ });
55
67
  const customModels: ManagementModelRow[] = (config.customModels ?? []).map(cm => {
56
68
  const namespaced = routedSlug(cm.provider, cm.modelId);
57
69
  return {
@@ -102,6 +114,8 @@ export function toExportModel(row: ManagementModelRow): ExportModel {
102
114
  ...(row.displayName ? { displayName: row.displayName } : {}),
103
115
  ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
104
116
  ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}),
117
+ ...(row.reasoningEfforts ? { reasoningEfforts: row.reasoningEfforts } : {}),
118
+ ...(row.defaultReasoningEffort ? { defaultReasoningEffort: row.defaultReasoningEffort } : {}),
105
119
  };
106
120
  }
107
121
 
@@ -18,7 +18,7 @@
18
18
  * 011 (Claude Code), 012 (Grok).
19
19
  */
20
20
  import { loadConfig, readRuntimePort, saveConfigPreservingClaudeCode } from "../../config";
21
- import { filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } from "../../codex/catalog";
21
+ import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } from "../../codex/catalog";
22
22
  import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
23
23
  import { injectGrokConfig, stripGrokConfig, type GrokInjectModel } from "../../grok/inject";
24
24
  import { inspectGrokConfig } from "../../grok/inspect";
@@ -656,7 +656,7 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise<Respon
656
656
  const runtime = (ctx.deps.readRuntimePort ?? readRuntimePort)(process.pid);
657
657
  const result = (ctx.deps.writeDesktop3pConfig ?? writeDesktop3pConfig)(
658
658
  runtime?.port ?? latest.port,
659
- [...visibleNativeSlugs(latest)],
659
+ [...desktopVisibleNativeSlugs(latest)],
660
660
  routed,
661
661
  latest.apiKeys?.[0]?.key,
662
662
  "static",
@@ -696,7 +696,7 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro
696
696
  rethrowManagementBodyTooLarge(error);
697
697
  return jsonResponse({ error: "invalid JSON body" }, 400);
698
698
  }
699
- if (typeof body.enabled !== "boolean") {
699
+ if (!body || typeof body !== "object" || Array.isArray(body) || typeof body.enabled !== "boolean") {
700
700
  return jsonResponse({ error: "enabled must be a boolean" }, 400);
701
701
  }
702
702
 
@@ -340,6 +340,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
340
340
  // let the (possibly new) apiKey join the pool as the active entry.
341
341
  const existingPool = config.providers[name]?.apiKeyPool;
342
342
  if (existingPool && !prov.apiKeyPool) prov.apiKeyPool = existingPool;
343
+ // The same rule applies to user-configured price overlays: the dashboard's
344
+ // add/edit form does not send modelCosts, so an overwrite must not silently
345
+ // erase hand-edited per-model prices from Logs/Usage estimates.
346
+ const existingCosts = config.providers[name]?.modelCosts;
347
+ if (existingCosts && !prov.modelCosts) prov.modelCosts = existingCosts;
343
348
  config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
344
349
  if (body.setDefault === true) config.defaultProvider = name;
345
350
  save(config);
@@ -82,7 +82,11 @@ export type TokPerSecondResult =
82
82
  | { kind: "value"; value: number; estimated: boolean }
83
83
  | { kind: "unavailable"; reason: MetricUnavailableReason };
84
84
 
85
- export type CostEstimateReason = "usage_estimated" | "cache_detail_missing" | "expected_price_overlay";
85
+ export type CostEstimateReason =
86
+ | "usage_estimated"
87
+ | "cache_detail_missing"
88
+ | "expected_price_overlay"
89
+ | "provider_cost_overlay";
86
90
 
87
91
  export type CostResult =
88
92
  | { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
@@ -123,6 +127,7 @@ export function unavailableCostReason(entry: MetricSource): MetricUnavailableRea
123
127
  return "price_unmatched";
124
128
  }
125
129
 
130
+ /** Display-time cost estimate for one log entry (or its attempt list), including the reasons that qualify the estimate. */
126
131
  export function costResult(entry: MetricSource): CostResult {
127
132
  const tier = serviceTierContext(entry);
128
133
  const estimate = entry.attempts?.length
@@ -136,6 +141,8 @@ export function costResult(entry: MetricSource): CostResult {
136
141
  && entry.usage.cacheCreationInputTokens === undefined ? "cache_detail_missing" as const : undefined,
137
142
  estimate.price?.source === "expected" || estimate.attempts?.some(a => a.price.source === "expected")
138
143
  ? "expected_price_overlay" as const : undefined,
144
+ estimate.price?.source === "user" || estimate.attempts?.some(a => a.price.source === "user")
145
+ ? "provider_cost_overlay" as const : undefined,
139
146
  ].filter((reason): reason is CostEstimateReason => reason !== undefined);
140
147
  return { kind: "value", estimate, estimateReasons };
141
148
  }
@@ -10,6 +10,8 @@ export type CachedUsageSummary = UsageSummary & {
10
10
 
11
11
  interface UsageSummaryCacheEntry {
12
12
  revisionKey: string;
13
+ /** userCostOverlayVersion() when the summary was computed; overlay edits invalidate the entry. */
14
+ overlayVersion: number;
13
15
  expiresAt: number;
14
16
  summary: CachedUsageSummary;
15
17
  revisionReadAt: number;