@bitkyc08/opencodex 2.18.2 → 2.20.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 (131) hide show
  1. package/gui/dist/assets/index-DF_UFrGS.css +1 -0
  2. package/gui/dist/assets/index-DSK3S5HY.js +76 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/client-fingerprint.ts +14 -10
  6. package/src/adapters/google-antigravity-wire.ts +4 -3
  7. package/src/adapters/google.ts +1 -1
  8. package/src/adapters/mimo-free.ts +17 -0
  9. package/src/adapters/openai-chat.ts +367 -32
  10. package/src/adapters/registry.ts +144 -0
  11. package/src/chat/inbound.ts +37 -8
  12. package/src/cli/claude.ts +2 -1
  13. package/src/cli/combo.ts +3 -0
  14. package/src/cli/dispatch.ts +8 -0
  15. package/src/cli/export-command.ts +2 -2
  16. package/src/cli/help.ts +2 -0
  17. package/src/cli/index.ts +3 -2
  18. package/src/cli/lab.ts +135 -1
  19. package/src/cli/minimax.ts +491 -0
  20. package/src/cli/models-runtime.ts +22 -1
  21. package/src/cli/models.ts +67 -2
  22. package/src/cli/opencode.ts +2 -1
  23. package/src/cli/registry.ts +22 -2
  24. package/src/clients/config-export.ts +125 -7
  25. package/src/codex/app-server-processes.ts +57 -2
  26. package/src/codex/app-server-restart-service.ts +232 -0
  27. package/src/codex/catalog/aggregation.ts +10 -1
  28. package/src/codex/catalog/effort.ts +15 -3
  29. package/src/codex/catalog/parsing.ts +3 -1
  30. package/src/codex/catalog/provider-fetch.ts +45 -5
  31. package/src/codex/catalog/sync.ts +74 -4
  32. package/src/codex/convergence.ts +2 -0
  33. package/src/codex/shim.ts +100 -5
  34. package/src/combos/index.ts +1 -0
  35. package/src/combos/request.ts +30 -0
  36. package/src/combos/types.ts +6 -0
  37. package/src/config.ts +52 -0
  38. package/src/generated/compatibility-version.json +239 -87
  39. package/src/images/loop.ts +11 -1
  40. package/src/integrations/registry.ts +7 -0
  41. package/src/lab/conformance/jcs.ts +42 -2
  42. package/src/lab/conformance/negative-controls.ts +6 -2
  43. package/src/lab/conformance/runner.ts +16 -5
  44. package/src/lab/fabric/observe.ts +49 -14
  45. package/src/lab/index.ts +16 -0
  46. package/src/lab/ledger/purge.ts +152 -83
  47. package/src/lab/ledger/store.ts +168 -54
  48. package/src/lab/observe/from-conformance.ts +8 -6
  49. package/src/lab/observe/from-live.ts +8 -2
  50. package/src/lab/paths.ts +23 -0
  51. package/src/lab/public/bundle.ts +217 -0
  52. package/src/lab/public/community-authority.ts +175 -0
  53. package/src/lab/public/community-files.ts +29 -0
  54. package/src/lab/public/community.ts +479 -0
  55. package/src/lab/public/file-safety.ts +155 -0
  56. package/src/lab/public/ids.ts +26 -0
  57. package/src/lab/public/index.ts +16 -0
  58. package/src/lab/public/mutation-lock.ts +424 -0
  59. package/src/lab/public/operator.ts +353 -0
  60. package/src/lab/public/origin-purge.ts +79 -0
  61. package/src/lab/public/origin.ts +203 -0
  62. package/src/lab/public/privacy.ts +143 -0
  63. package/src/lab/public/private-file.ts +261 -0
  64. package/src/lab/public/project.ts +124 -0
  65. package/src/lab/public/purge-test-fault.ts +21 -0
  66. package/src/lab/public/purge.ts +223 -0
  67. package/src/lab/public/registry.ts +44 -0
  68. package/src/lab/public/revocation.ts +252 -0
  69. package/src/lab/public/signature.ts +219 -0
  70. package/src/lab/public/storage.ts +105 -0
  71. package/src/lab/public/strict-json.ts +206 -0
  72. package/src/lab/public/time.ts +26 -0
  73. package/src/lab/public/types.ts +172 -0
  74. package/src/lab/public/validate.ts +391 -0
  75. package/src/lib/codex-restart-contract.ts +120 -0
  76. package/src/lib/errors.ts +27 -0
  77. package/src/lib/lab-activation.ts +109 -47
  78. package/src/lib/lab-live-pinned-sender.ts +16 -5
  79. package/src/lib/pinned-http.ts +70 -16
  80. package/src/lib/self-launch-argv.ts +15 -0
  81. package/src/lib/state-store-registrations.ts +2 -0
  82. package/src/lib/upstream-reachability.ts +4 -0
  83. package/src/lib/windows-elevation.ts +10 -1
  84. package/src/providers/derive.ts +24 -4
  85. package/src/providers/registry.ts +9 -3
  86. package/src/providers/request-pacing.ts +310 -0
  87. package/src/providers/service-tier.ts +143 -0
  88. package/src/providers/static-model-discovery.ts +86 -0
  89. package/src/reasoning-effort.ts +27 -1
  90. package/src/responses/spill-store.ts +20 -1
  91. package/src/responses/state.ts +159 -3
  92. package/src/router.ts +23 -6
  93. package/src/routing/capability.ts +4 -2
  94. package/src/routing/compatibility/behavior.ts +5 -1
  95. package/src/server/adapter-resolve.ts +2 -32
  96. package/src/server/auth-cors.ts +8 -0
  97. package/src/server/chat-completions.ts +74 -34
  98. package/src/server/chat-native-sse.ts +331 -0
  99. package/src/server/chat-native.ts +371 -0
  100. package/src/server/effort-policy.ts +18 -0
  101. package/src/server/management/combo-routes.ts +16 -2
  102. package/src/server/management/config-routes.ts +6 -4
  103. package/src/server/management/context.ts +17 -0
  104. package/src/server/management/lab-routes.ts +181 -19
  105. package/src/server/management/model-routes.ts +76 -2
  106. package/src/server/management/model-rows.ts +8 -0
  107. package/src/server/management/provider-capability-config.ts +48 -0
  108. package/src/server/management/provider-routes.ts +76 -4
  109. package/src/server/management/system-restart.ts +4 -2
  110. package/src/server/management/system-routes.ts +38 -0
  111. package/src/server/relay.ts +17 -3
  112. package/src/server/responses/compact.ts +4 -1
  113. package/src/server/responses/core.ts +278 -48
  114. package/src/server/responses/empty-completion-guard.ts +276 -0
  115. package/src/server/responses/fetch-helpers.ts +35 -4
  116. package/src/server/responses/input-admission.ts +169 -0
  117. package/src/server/responses/pacing-overload.ts +13 -0
  118. package/src/server/responses/policy-fallback.ts +16 -2
  119. package/src/server/responses/terminal-guard.ts +1 -1
  120. package/src/server/responses/upstream-error.ts +5 -0
  121. package/src/server/responses.ts +17 -2
  122. package/src/service-manager-probe.ts +2 -3
  123. package/src/types.ts +76 -6
  124. package/src/update/index.ts +6 -5
  125. package/src/update/job.ts +5 -6
  126. package/src/update/notify.ts +5 -3
  127. package/src/usage/log.ts +11 -1
  128. package/src/vision/index.ts +21 -4
  129. package/src/web-search/index.ts +2 -1
  130. package/gui/dist/assets/index-CXI1262_.js +0 -76
  131. package/gui/dist/assets/index-DUCH59lJ.css +0 -1
package/src/types.ts CHANGED
@@ -32,11 +32,19 @@ export interface OcxParsedRequest {
32
32
  stream: boolean;
33
33
  options: OcxRequestOptions;
34
34
  _rawBody?: unknown;
35
- /** Number of leading raw input items restored from local previous_response_id state. */
35
+ /**
36
+ * Boundary between replayed history and this turn's newly appended input. Usually the
37
+ * items the proxy restored from local previous_response_id state; also set when the
38
+ * CLIENT already carried that history verbatim and the proxy skipped the prepend.
39
+ */
36
40
  _replayPrefixLen?: number;
37
41
  /** Parsed-message index before the first conversational item in a continuation's current delta. */
38
42
  _continuationConversationMessageIndex?: number;
39
- /** True when the proxy expanded a previous_response_id request into a full input replay. */
43
+ /**
44
+ * True when the full history for a previous_response_id request is present in the input —
45
+ * whether the proxy expanded it or the client already sent it. Consumers read this as
46
+ * "this request is self-contained", never as "the proxy mutated it".
47
+ */
40
48
  _previousResponseInputExpanded?: boolean;
41
49
  /** Provider-private stable Cursor conversation id resolved from the Responses previous_response_id chain. */
42
50
  _cursorConversationId?: string;
@@ -579,6 +587,13 @@ export interface OcxCustomModel {
579
587
  contextWindow?: number;
580
588
  /** 입력 모달리티 (선택, 기본 ["text"]) */
581
589
  inputModalities?: string[];
590
+ /**
591
+ * Reasoning ladder (Codex labels) this custom row explicitly advertises. An empty array
592
+ * hides the effort control; an omitted key leaves the provider-derived ladder in charge.
593
+ */
594
+ reasoningEfforts?: string[];
595
+ /** Default effort label when `reasoningEfforts` is non-empty. */
596
+ defaultReasoningEffort?: string;
582
597
  /** 추가 시각 (ISO 8601) */
583
598
  addedAt?: string;
584
599
  }
@@ -615,6 +630,8 @@ export interface OcxClientIntegrationsConfig {
615
630
 
616
631
  export interface OcxConfig {
617
632
  port: number;
633
+ /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */
634
+ emptyCompletionRetry?: boolean;
618
635
  /** Maximum usage-log bytes read for one management snapshot. */
619
636
  managementUsageMaxReadBytes?: number;
620
637
  providers: Record<string, OcxProviderConfig>;
@@ -637,6 +654,20 @@ export interface OcxConfig {
637
654
  * into a selector-qualified group; Codex still advertises only the first 5 visible rows.
638
655
  */
639
656
  subagentModels?: string[];
657
+ /**
658
+ * Optional full picker ordering for the Codex model catalog, independent of the
659
+ * 5-slot `subagentModels` spawn_agent cap. DISPLAY-ONLY: it controls the visual order of
660
+ * the Codex model picker for large routed catalogs (10-20+ models) that would otherwise sort
661
+ * arbitrarily and reshuffle on every rebuild. Values are routed `<provider>/<model>` catalog
662
+ * slugs (matched by exact slug or `provider/id`); native OpenAI passthrough rows and
663
+ * account-qualified native rows are not reordered (order native rows via `subagentModels`).
664
+ * Listed routed rows appear in array order; rows not listed keep their normal display order.
665
+ * `subagentModels`-featured rows keep their top position. When unset or empty, catalog
666
+ * priority is unchanged. This changes ONLY what the user sees in the picker: the spawn_agent
667
+ * candidate set is derived from each row's natural priority and is provably unaffected, even
668
+ * when every routed row is listed (see opencodex_spawn_priority / effectiveSubagentRoster).
669
+ */
670
+ modelPickerOrder?: string[];
640
671
  /**
641
672
  * Priority-ordered fallback models for spawned sub-agents. When the requested
642
673
  * model is quota-exhausted or recently failed, opencodex rewrites the child
@@ -958,6 +989,12 @@ export interface OcxComboConfig {
958
989
  stickyLimit?: number;
959
990
  /** Used when the client omits reasoning.effort. null/omitted leaves the target default unchanged. */
960
991
  defaultEffort?: OcxComboDefaultEffort | null;
992
+ /**
993
+ * Disable image input even when every target supports it.
994
+ * Omitted / `"auto"` keeps automatic capability derivation (default: enabled when
995
+ * the target intersection includes image).
996
+ */
997
+ imageInput?: "auto" | "disabled";
961
998
  /**
962
999
  * Optional public model name replacing the default `combo/<id>` slug. Bare names
963
1000
  * without "/" are allowed (e.g. "deepseek-v4-flash") so the combo can answer to a
@@ -1226,12 +1263,28 @@ export interface ProviderCostOverlay {
1226
1263
  cacheWrite: number;
1227
1264
  }
1228
1265
 
1266
+ export interface RequestPacingRule {
1267
+ /** Evenly spread request starts to this many requests per minute. */
1268
+ requestsPerMinute?: number;
1269
+ /** Minimum delay between request starts. The slower configured value wins. */
1270
+ minIntervalMs?: number;
1271
+ }
1272
+
1273
+ export interface ProviderRequestPacingConfig extends RequestPacingRule {
1274
+ /** False preserves legacy behavior with no client-side waiting. */
1275
+ enabled: boolean;
1276
+ /** Exact upstream model-id overrides; other models inherit the provider rule. */
1277
+ models?: Record<string, RequestPacingRule>;
1278
+ }
1279
+
1229
1280
  /**
1230
1281
  * One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429
1231
1282
  * retries are allowed; OAuth/forward credentials and local runtimes are never replayed.
1232
1283
  */
1233
1284
  export interface OcxProviderConfig {
1234
1285
  adapter: string;
1286
+ /** Optional outbound request-start pacing shared by this provider and its model overrides. */
1287
+ requestPacing?: ProviderRequestPacingConfig;
1235
1288
  /** Cursor MCP compatibility bounds; positive integers when configured. */
1236
1289
  mcpMaxTools?: number;
1237
1290
  mcpMaxSchemaBytes?: number;
@@ -1274,8 +1327,10 @@ export interface OcxProviderConfig {
1274
1327
  */
1275
1328
  requiresAdjacentResponsesToolResults?: boolean;
1276
1329
  /**
1277
- * Whether this provider's Responses route honours the OpenAI `service_tier`
1278
- * parameter. Tri-state: `true` lets fast mode inject/remove the field (an unset
1330
+ * Provider fallback for the OpenAI `service_tier` parameter. On Responses routes this
1331
+ * is the complete wire opt-in; Chat routes additionally require `chatServiceTier` or an
1332
+ * exact-model true declaration.
1333
+ * Tri-state: `true` lets fast mode inject/remove the field (an unset
1279
1334
  * fast mode preserves a caller-supplied value); `false` strips the field and
1280
1335
  * never injects, because an upstream documented as not supporting the parameter
1281
1336
  * must not receive it; absent (`undefined`) leaves the provider unclassified —
@@ -1283,6 +1338,8 @@ export interface OcxProviderConfig {
1283
1338
  * An explicit config value always wins over the registry default.
1284
1339
  */
1285
1340
  supportsServiceTier?: boolean;
1341
+ /** Exact upstream model ids that override the provider-level service-tier capability. */
1342
+ modelSupportsServiceTier?: Record<string, boolean>;
1286
1343
  /**
1287
1344
  * Responses upstream whose native contract accepts plaintext reasoning replay
1288
1345
  * (DeepSeek documents reasoning items with plaintext content). When set, the
@@ -1460,6 +1517,18 @@ export interface OcxProviderConfig {
1460
1517
  * No effect unless `parallelToolCalls === false`; ignored by non-`openai-chat` adapters.
1461
1518
  */
1462
1519
  pinParallelToolCallsFalse?: boolean;
1520
+ /**
1521
+ * Opt-in: extend the no-tool-call terminal continuation guard to this provider's
1522
+ * `openai-chat` routed turns. The guard (originally Anthropic-only, see
1523
+ * devlog/_fin/260706_previous-response-id-400) issues one bounded internal re-ask when a
1524
+ * model announces work but ends the turn without emitting a tool call. Self-hosted
1525
+ * OpenAI-compatible gateways (GLM/Kimi-family, etc.) hit the same premature-completion
1526
+ * pattern, but the heuristic that decides a "suspicious no-tool stop" was tuned on
1527
+ * Anthropic turns, so it stays OFF by default for the many registry providers that share
1528
+ * the `openai-chat` adapter. Enable only for a provider whose models are known to stop
1529
+ * mid-work; non-`openai-chat` adapters ignore this flag.
1530
+ */
1531
+ terminalContinuationGuard?: boolean;
1463
1532
  /**
1464
1533
  * Opt-in: forward `prompt_cache_key` to the upstream `/chat/completions` body.
1465
1534
  * OpenAI-specific extension; strict backends (Groq, Cerebras, etc.) reject unknown
@@ -1471,8 +1540,9 @@ export interface OcxProviderConfig {
1471
1540
  * OpenAI-specific extension with the same hazard as `promptCacheKey` — strict backends
1472
1541
  * reject unknown fields, and 66 registry providers share the `openai-chat` adapter, so a
1473
1542
  * caller-supplied `service_tier` would otherwise turn working requests into upstream 400s.
1474
- * `supportsServiceTier` is the Responses-wire flag and does not apply here.
1475
- * Default off; only enable for providers that document this parameter on the chat wire.
1543
+ * Exact models may opt in through `modelSupportsServiceTier` instead; provider-level
1544
+ * `supportsServiceTier: false` remains a global denial. Default off; only enable for
1545
+ * providers that document this parameter on the chat wire.
1476
1546
  */
1477
1547
  chatServiceTier?: boolean;
1478
1548
  /**
@@ -10,6 +10,7 @@ import {
10
10
  } from "./npm-cache-preflight.mjs";
11
11
  import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs";
12
12
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
13
+ import { selfLaunchArgv } from "../lib/self-launch-argv";
13
14
 
14
15
  /**
15
16
  * A `codex-history-backup-*.json` surviving a stop means the native-history restore was
@@ -248,7 +249,7 @@ export async function runUpdate(): Promise<void> {
248
249
  if (serviceWasInstalled || readPid() || readRuntimePort()) {
249
250
  console.log("⏹ Stopping the running proxy before updating...");
250
251
  const stopStdio = updateChildStdio();
251
- const stop = spawnSync(process.execPath, [process.argv[1], "stop"], {
252
+ const stop = spawnSync(process.execPath, selfLaunchArgv(["stop"]), {
252
253
  stdio: stopStdio,
253
254
  encoding: stopStdio === "pipe" ? "utf8" : undefined,
254
255
  windowsHide: true,
@@ -298,13 +299,13 @@ export async function runUpdate(): Promise<void> {
298
299
  console.warn(`⚠️ Shim repair skipped: ${e instanceof Error ? e.message : e}`);
299
300
  }
300
301
  if (trayWasInstalled) {
301
- const trayArgs = [process.argv[1], ...planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs];
302
+ const trayArgs = selfLaunchArgv(planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs);
302
303
  const tray = spawnSync(process.execPath, trayArgs, { stdio: "inherit", windowsHide: true });
303
304
  if (tray.status === 0) {
304
305
  console.log("🔧 Refreshed Windows tray startup paths.");
305
306
  } else {
306
307
  console.warn("⚠️ Windows tray refresh failed. Run 'ocx tray install'.");
307
- if (trayWasRunning) spawnSync(process.execPath, [process.argv[1], "tray", "start"], { stdio: "ignore", windowsHide: true });
308
+ if (trayWasRunning) spawnSync(process.execPath, selfLaunchArgv(["tray", "start"]), { stdio: "ignore", windowsHide: true });
308
309
  }
309
310
  }
310
311
  // The stop above unloaded any managed service; repair it with the NEW files
@@ -328,7 +329,7 @@ export async function runUpdate(): Promise<void> {
328
329
  process.env.OCX_BAKE_PORT = String(capturedListen.port);
329
330
  try {
330
331
  const svcStdio = updateChildStdio();
331
- const svc = spawnSync(process.execPath, [process.argv[1], ...serviceReinstallArgs()], {
332
+ const svc = spawnSync(process.execPath, selfLaunchArgv(serviceReinstallArgs()), {
332
333
  stdio: svcStdio,
333
334
  encoding: svcStdio === "pipe" ? "utf8" : undefined,
334
335
  windowsHide: true,
@@ -372,7 +373,7 @@ export async function runUpdate(): Promise<void> {
372
373
  : " Run 'ocx service repair' to refresh the background service and see why it failed.");
373
374
  const env = { ...process.env };
374
375
  delete env.OCX_SERVICE;
375
- const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(capturedListen.port)], {
376
+ const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(capturedListen.port)]), {
376
377
  detached: true,
377
378
  stdio: "ignore",
378
379
  windowsHide: true,
package/src/update/job.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  verifyPidIdentity,
14
14
  } from "../config";
15
15
  import { isProcessAlive, killProxy } from "../lib/process-control";
16
+ import { selfLaunchArgv } from "../lib/self-launch-argv";
16
17
  import {
17
18
  buildWindowsElevatedArgumentList,
18
19
  resolveTrustedWindowsPowerShellExe,
@@ -549,14 +550,12 @@ export function spawnGuiUpdateWorker(
549
550
  channel: Channel,
550
551
  restart: boolean,
551
552
  ): UpdateWorkerProcess {
552
- const script = process.argv[1];
553
- const args = [
554
- script,
553
+ const args = selfLaunchArgv([
555
554
  "__gui-update-worker",
556
555
  jobId,
557
556
  channel,
558
557
  restart ? "restart" : "no-restart",
559
- ];
558
+ ]);
560
559
  if (process.platform !== "win32") {
561
560
  return spawn(process.execPath, args, {
562
561
  detached: true,
@@ -1866,11 +1865,11 @@ export async function runGuiUpdateWorker(
1866
1865
  }
1867
1866
 
1868
1867
  if (trayWasInstalled) {
1869
- const trayArgs = [process.argv[1], ...planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs];
1868
+ const trayArgs = selfLaunchArgv(planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs);
1870
1869
  const tray = runLoggedCommand(job, process.execPath, trayArgs, 20_000);
1871
1870
  if (tray.status !== 0) {
1872
1871
  updateJob(job, {}, "Windows tray refresh failed; run 'ocx tray install'.");
1873
- if (trayWasRunning) runLoggedCommand(job, process.execPath, [process.argv[1], "tray", "start"], 15_000);
1872
+ if (trayWasRunning) runLoggedCommand(job, process.execPath, selfLaunchArgv(["tray", "start"]), 15_000);
1874
1873
  }
1875
1874
  }
1876
1875
 
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { atomicWriteFile, getConfigDir } from "../config";
6
6
  import { hasStarPromptRun } from "../cli/star-prompt";
7
+ import { selfLaunchArgv } from "../lib/self-launch-argv";
7
8
  import {
8
9
  type Channel,
9
10
  currentVersion,
@@ -166,9 +167,10 @@ function cacheIsStale(cache: VersionCache | null): boolean {
166
167
  export function triggerBackgroundRefreshIfStale(channel: Channel, cache: VersionCache | null): void {
167
168
  if (!cacheIsStale(cache)) return;
168
169
  try {
169
- const entry = process.argv[1];
170
- if (!entry || !existsSync(entry)) return;
171
- const child = spawn(process.execPath, [entry, "__refresh-version", channel], {
170
+ const commandArgs = ["__refresh-version", channel];
171
+ const args = selfLaunchArgv(commandArgs);
172
+ if (args.length > commandArgs.length && (!args[0] || !existsSync(args[0]))) return;
173
+ const child = spawn(process.execPath, args, {
172
174
  detached: true,
173
175
  stdio: "ignore",
174
176
  windowsHide: true,
package/src/usage/log.ts CHANGED
@@ -27,7 +27,8 @@ export type AttemptRecoveryKind =
27
27
  | "key-429"
28
28
  | "rate-limit-429"
29
29
  | "anthropic-oauth-429"
30
- | "image-413";
30
+ | "image-413"
31
+ | "empty-completion";
31
32
 
32
33
  export interface PersistedUsageAttempt {
33
34
  ordinal: number;
@@ -36,6 +37,12 @@ export interface PersistedUsageAttempt {
36
37
  adapter: string;
37
38
  status: number;
38
39
  durationMs: number;
40
+ /**
41
+ * True only when the upstream stream died after its 200 head was committed,
42
+ * so the row must not meter as a success the client never received.
43
+ * Absent on ordinary attempts so old rows keep their exact shape.
44
+ */
45
+ streamAborted?: boolean;
39
46
  /** TTFT relative to THIS attempt's start (WP4); unset for non-streaming/tool-only. */
40
47
  firstOutputMs?: number;
41
48
  sendCount: number;
@@ -202,6 +209,7 @@ const ATTEMPT_RECOVERY_KINDS = new Set<AttemptRecoveryKind>([
202
209
  "rate-limit-429",
203
210
  "anthropic-oauth-429",
204
211
  "image-413",
212
+ "empty-completion",
205
213
  ]);
206
214
  const USAGE_STATUSES = new Set<UsageStatus>([
207
215
  "reported",
@@ -277,6 +285,8 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null {
277
285
  adapter: attempt.adapter,
278
286
  status: attempt.status,
279
287
  durationMs: attempt.durationMs,
288
+ // Absent by default; only the literal `true` marker survives the round trip.
289
+ ...(attempt.streamAborted === true ? { streamAborted: true } : {}),
280
290
  ...(isNonNegativeFiniteNumber(attempt.firstOutputMs)
281
291
  ? { firstOutputMs: attempt.firstOutputMs }
282
292
  : {}),
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { OcxConfig, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent } from "../types";
3
3
  import { modelInList } from "../types";
4
+ import { modelRecordValue } from "../reasoning-effort";
4
5
  import type { VisionReasoningEffort } from "../reasoning-effort";
5
6
  import { describeImage, type DescribeOutcome, type VisionSettings } from "./describe";
6
7
  import { describeImageAnthropic } from "./anthropic-describe";
@@ -18,6 +19,22 @@ import {
18
19
  } from "./timeout-bounds";
19
20
 
20
21
  export { describeImage } from "./describe";
22
+
23
+ /**
24
+ * True when the model is explicitly known to be text-only — either listed in
25
+ * `noVisionModels` or declared with `modelInputModalities` that exclude "image".
26
+ * Returns false for unknown models (no evidence either way) so they fall through
27
+ * to native image passthrough, which is the safe default for an unclassified model.
28
+ */
29
+ export function isModelTextOnly(
30
+ provider: OcxProviderConfig,
31
+ modelId: string,
32
+ ): boolean {
33
+ if (modelInList(provider.noVisionModels, modelId)) return true;
34
+ const modalities = modelRecordValue(provider.modelInputModalities, modelId);
35
+ if (Array.isArray(modalities) && modalities.length > 0 && !modalities.includes("image")) return true;
36
+ return false;
37
+ }
21
38
  export { describeImageAnthropic, parseAnthropicVisionSSE } from "./anthropic-describe";
22
39
  export {
23
40
  BASELINE_VISION_MODELS,
@@ -245,10 +262,10 @@ function messagesHaveImage(parsed: OcxParsedRequest): boolean {
245
262
  export function shouldResolveOpenAiVisionSidecar(
246
263
  config: OcxConfig,
247
264
  provider: OcxProviderConfig,
248
- modelId: string,
249
- parsed: OcxParsedRequest,
265
+ modelId: string,
266
+ parsed: OcxParsedRequest,
250
267
  ): boolean {
251
- if (!modelInList(provider.noVisionModels, modelId) || !messagesHaveImage(parsed)) return false;
268
+ if (!isModelTextOnly(provider, modelId) || !messagesHaveImage(parsed)) return false;
252
269
  const cfg = config.visionSidecar ?? {};
253
270
  if (cfg.enabled === false) return false;
254
271
  return resolveVisionBackend(cfg.backend, findAnthropicVisionProvider(config)) === "openai";
@@ -275,7 +292,7 @@ export function planVisionSidecar(
275
292
  parsed: OcxParsedRequest,
276
293
  openAiSidecar?: ResolvedOpenAiForwardSidecar,
277
294
  ): VisionPlan | undefined {
278
- if (!modelInList(provider.noVisionModels, modelId)) return undefined;
295
+ if (!isModelTextOnly(provider, modelId)) return undefined;
279
296
  if (!messagesHaveImage(parsed)) return undefined;
280
297
  const cfg = config.visionSidecar ?? {};
281
298
  if (cfg.enabled === false) return undefined;
@@ -1,5 +1,6 @@
1
1
  import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
2
2
  import { modelInList, toolChoiceToolPredicate } from "../types";
3
+ import { isModelTextOnly } from "../vision";
3
4
  import type { SidecarSettings } from "./executor";
4
5
  import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
5
6
  import { getAccountSet } from "../oauth/store";
@@ -166,7 +167,7 @@ export function planWebSearch(
166
167
  timeoutMs,
167
168
  );
168
169
  // The routed model being text-only means the search model must verbalize image results (either backend).
169
- const describeImages = modelInList(provider.noVisionModels, modelId);
170
+ const describeImages = isModelTextOnly(provider, modelId);
170
171
  const reasoning = cfg.reasoning ?? DEFAULT_SIDECAR_REASONING;
171
172
  const streamRoutedModelOutput = cfg.streamRoutedModelOutput === true;
172
173