@bitkyc08/opencodex 2.68.0-preview.20260927 → 2.69.0-preview.20260928

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/bin/ocx.mjs +25 -17
  2. package/gui/dist/assets/App-cDPyCb_V.js +51 -0
  3. package/gui/dist/assets/{Tray-DFnMiyD0.js → Tray-H1IAB8mN.js} +1 -1
  4. package/gui/dist/assets/index-BF9QnKO_.css +1 -0
  5. package/gui/dist/assets/index-CI6YTp8z.js +86 -0
  6. package/gui/dist/assets/{quota-summary-i84eOU3o.js → quota-summary-CutOlPu2.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/factory-droid.svg +8 -0
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-output-schema.ts +109 -11
  11. package/src/adapters/coding-agent/protocol.ts +106 -11
  12. package/src/adapters/coding-agent/turn.ts +23 -18
  13. package/src/adapters/google-errors.ts +16 -1
  14. package/src/adapters/google-http.ts +14 -0
  15. package/src/adapters/openai-chat/passthrough.ts +2 -0
  16. package/src/adapters/openai-chat/summary-budget.ts +72 -0
  17. package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
  18. package/src/adapters/openai-chat.ts +6 -9
  19. package/src/bridge/response-json.ts +3 -1
  20. package/src/bridge/sse.ts +3 -1
  21. package/src/claude/desktop-picker.ts +6 -2
  22. package/src/claude/desktop-system-proxy.ts +155 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound.ts +1 -1
  25. package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
  26. package/src/claude/intercept/picker-ca.ts +283 -83
  27. package/src/claude/intercept/picker-models.ts +4 -1
  28. package/src/claude/intercept/picker-trust.ts +15 -2
  29. package/src/claude/intercept/runtime.ts +99 -32
  30. package/src/cli/account-api.ts +5 -2
  31. package/src/cli/account-extended.ts +47 -11
  32. package/src/cli/account-target.ts +12 -7
  33. package/src/cli/account.ts +47 -3
  34. package/src/cli/capabilities.ts +33 -7
  35. package/src/cli/claude-agent-startup-sync.ts +2 -0
  36. package/src/cli/claude-desktop.ts +37 -13
  37. package/src/cli/cross-home-owner.ts +138 -0
  38. package/src/cli/dispatch.ts +1 -1
  39. package/src/cli/doctor.ts +18 -8
  40. package/src/cli/ensure-desired-integrations.ts +5 -0
  41. package/src/cli/export-command.ts +6 -6
  42. package/src/cli/help.ts +1 -1
  43. package/src/cli/index.ts +15 -18
  44. package/src/cli/integrations.ts +8 -1
  45. package/src/cli/models-runtime-subcommands.ts +1 -0
  46. package/src/cli/models-runtime.ts +106 -4
  47. package/src/cli/models.ts +6 -1
  48. package/src/cli/opencode.ts +2 -83
  49. package/src/cli/registry.ts +3 -2
  50. package/src/cli/status.ts +86 -7
  51. package/src/client/connect.ts +24 -10
  52. package/src/client/link-ingress.ts +3 -1
  53. package/src/client/link-join.ts +76 -9
  54. package/src/client/link-relay-transport.ts +184 -0
  55. package/src/client/link-relay.ts +16 -4
  56. package/src/client/machine-listener.ts +2 -0
  57. package/src/clients/config-export/constants.ts +8 -0
  58. package/src/clients/config-export/contracts.ts +13 -1
  59. package/src/clients/config-export/droid.ts +71 -0
  60. package/src/clients/config-export/kilo.ts +116 -0
  61. package/src/clients/config-export.ts +33 -2
  62. package/src/codex/auth-api/routes.ts +1 -1
  63. package/src/codex/catalog/access-programs.ts +14 -2
  64. package/src/codex/catalog/provider-models.ts +1 -1
  65. package/src/codex/catalog-auto-refresh.ts +122 -0
  66. package/src/codex/config-drift-heal.ts +80 -0
  67. package/src/codex/desktop-switches.ts +7 -5
  68. package/src/codex/inject/bounded-config-reader.ts +69 -0
  69. package/src/codex/inject/config-toml.ts +31 -3
  70. package/src/codex/inject/routing-target.ts +6 -1
  71. package/src/codex/inject.ts +4 -0
  72. package/src/codex/model-entitlements.ts +32 -5
  73. package/src/codex/native-profile-startup.ts +17 -5
  74. package/src/codex/project-config-warnings.ts +72 -21
  75. package/src/codex/quota-auto-refresh-state.ts +8 -2
  76. package/src/codex/quota-auto-refresh.ts +80 -9
  77. package/src/codex/sync.ts +10 -0
  78. package/src/combos/failover.ts +4 -1
  79. package/src/combos/jev.ts +15 -4
  80. package/src/combos/types.ts +16 -0
  81. package/src/config/diagnostics.ts +19 -1
  82. package/src/config/load-degrade.ts +29 -0
  83. package/src/config/macos-system-proxy.ts +114 -0
  84. package/src/config/proxy-env.ts +67 -20
  85. package/src/config/schema/config-schema.ts +15 -0
  86. package/src/config/schema/leaf-validators.ts +23 -0
  87. package/src/config.ts +2 -0
  88. package/src/generated/compatibility-version.json +220 -144
  89. package/src/integrations/catalog-refresh.ts +1 -1
  90. package/src/integrations/config-io.ts +12 -3
  91. package/src/integrations/droid-settings.ts +65 -0
  92. package/src/integrations/kilo-candidates.ts +30 -0
  93. package/src/integrations/mutation-plan.ts +62 -7
  94. package/src/integrations/native/ownership-preflight.ts +46 -12
  95. package/src/integrations/registry.ts +136 -7
  96. package/src/integrations/state.ts +52 -4
  97. package/src/integrations/target.ts +3 -0
  98. package/src/integrations/writer.ts +55 -14
  99. package/src/lib/account-selection-events.ts +14 -0
  100. package/src/lib/jsonc.ts +110 -0
  101. package/src/lib/standalone.ts +9 -1
  102. package/src/lib/windows-system-proxy.ts +101 -1
  103. package/src/link/relay-auth.ts +29 -0
  104. package/src/link/ssh-argv.ts +6 -3
  105. package/src/link/ssh-runner.ts +5 -5
  106. package/src/oauth/generic-account-failover.ts +52 -8
  107. package/src/oauth/index.ts +37 -14
  108. package/src/oauth/store.ts +96 -11
  109. package/src/oauth/token-guardian.ts +7 -3
  110. package/src/oauth/types.ts +2 -0
  111. package/src/plugins/loader.ts +5 -3
  112. package/src/providers/fastwire.ts +3 -0
  113. package/src/providers/key-failover.ts +13 -5
  114. package/src/providers/kiro-model-catalog.ts +4 -3
  115. package/src/providers/model-rename-fields.ts +1 -0
  116. package/src/providers/openai-tiers-destination.ts +9 -0
  117. package/src/providers/quota/account-cache.ts +17 -3
  118. package/src/providers/quota/vendor-probes-oauth.ts +2 -0
  119. package/src/providers/quota.ts +2 -2
  120. package/src/responses/code-mode-helper-compat.ts +14 -1
  121. package/src/responses/custom-tool-compat.ts +1 -1
  122. package/src/server/auth-cors.ts +1 -0
  123. package/src/server/images.ts +70 -20
  124. package/src/server/index/link-listener.ts +20 -3
  125. package/src/server/index/link-relay-sessions.ts +138 -0
  126. package/src/server/index/optional-listeners.ts +11 -1
  127. package/src/server/index/serve-options.ts +12 -0
  128. package/src/server/inference/client-encoder-delivery.ts +1 -0
  129. package/src/server/local-management-read-client.ts +21 -2
  130. package/src/server/management/agent-settings-routes.ts +19 -5
  131. package/src/server/management/combo-routes.ts +5 -3
  132. package/src/server/management/config-routes.ts +32 -6
  133. package/src/server/management/model-routes.ts +232 -7
  134. package/src/server/management/model-rows.ts +199 -1
  135. package/src/server/management/oauth-account-routes.ts +63 -3
  136. package/src/server/management/route-registry.ts +2 -0
  137. package/src/server/port-reclaim.ts +170 -32
  138. package/src/server/responses/adapter-delivery.ts +6 -4
  139. package/src/server/responses/adapter-dispatch.ts +72 -2
  140. package/src/server/responses/core-combo.ts +6 -1
  141. package/src/server/responses/core-normalize.ts +19 -4
  142. package/src/server/responses/core-options.ts +2 -0
  143. package/src/server/responses/memory-models.ts +169 -0
  144. package/src/server/responses/passthrough-delivery.ts +4 -0
  145. package/src/server/responses/passthrough-dispatch.ts +10 -0
  146. package/src/server/responses/request-prepare.ts +86 -4
  147. package/src/server/responses/request-transport.ts +4 -0
  148. package/src/server/responses/run-turn-execution.ts +5 -2
  149. package/src/server/responses/shadow-target-availability.ts +17 -4
  150. package/src/server/responses/sidecar-execution.ts +18 -2
  151. package/src/server/responses/skills-snapshot.ts +239 -0
  152. package/src/server/responses-undeclared-tool-guard.ts +43 -6
  153. package/src/server/search.ts +1 -1
  154. package/src/service/guards.ts +4 -5
  155. package/src/service/state.ts +39 -2
  156. package/src/service.ts +2 -2
  157. package/src/types/config.ts +46 -4
  158. package/src/types/provider.ts +9 -0
  159. package/src/types/request.ts +6 -0
  160. package/src/types/tools.ts +38 -8
  161. package/src/types.ts +3 -0
  162. package/src/update/async-check.ts +5 -1
  163. package/src/update/index.ts +30 -12
  164. package/src/update/job.ts +3 -1
  165. package/src/update/pnpm-read-policy.d.mts +4 -0
  166. package/src/update/pnpm-read-policy.mjs +44 -0
  167. package/src/update/worker-launch.ts +152 -11
  168. package/src/usage/cost.ts +6 -3
  169. package/src/usage/log.ts +4 -0
  170. package/src/web-search/backends.ts +2 -2
  171. package/src/web-search/devin-executor.ts +1 -1
  172. package/src/web-search/sidecar-providers.ts +5 -5
  173. package/gui/dist/assets/App-B5-cAEd2.js +0 -51
  174. package/gui/dist/assets/index-BDUBS8PW.css +0 -1
  175. package/gui/dist/assets/index-BcXblvem.js +0 -86
@@ -26,12 +26,15 @@ import {
26
26
  nativeReasoningEfforts,
27
27
  uniqueCatalogModelsForPublicList,
28
28
  shouldIncludeAccountBoundNativeOpenAi,
29
+ catalogModelEfforts,
29
30
  } from "../../codex/catalog";
31
+ import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../../codex/catalog/native-models";
30
32
  import type { ExportModel } from "../../clients/config-export";
31
33
  import { providerContextCap } from "../../providers/context-cap";
32
- import { isVisionReasoningEffort } from "../../reasoning-effort";
34
+ import { isVisionReasoningEffort, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
33
35
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
34
36
  import type { OcxConfig } from "../../types";
37
+ import { enrichProviderFromRegistry } from "../../providers/derive";
35
38
  import { ensureCodexEntitlementFreshness } from "../../codex/model-entitlements";
36
39
  import { fetchAllModels } from "./shared";
37
40
  import { initialModelSelectionPending, pendingModelSelectionProviders } from "../../providers/initial-model-selection";
@@ -56,6 +59,21 @@ export type ManagementModelRow = Partial<CatalogModel> & {
56
59
  fastRowAvailable?: boolean;
57
60
  displayNameOverride?: string;
58
61
  displayNameSource?: "operator" | "provider" | "fallback";
62
+ /**
63
+ * The modality declaration a per-model editor writes, read from the same sources the runtime
64
+ * consults. It is separate from the row's own `inputModalities` because that one is the
65
+ * CATALOG value: an editor that pre-fills from the catalog while writing the declaration shows
66
+ * one thing and saves another, so its own writes never appear to take.
67
+ */
68
+ inputModalitiesDeclared?: string[];
69
+ /** Exact stored context override, distinct from the effective catalog window. */
70
+ contextWindowDeclared?: number;
71
+ /**
72
+ * True when the provider config actually CHANGES reasoning for this model. A persisted key is
73
+ * not proof of an operator edit — OpenCodex itself writes the registry ladder into the provider
74
+ * config — so this compares values against what the model would inherit anyway.
75
+ */
76
+ reasoningOverridden?: boolean;
59
77
  };
60
78
 
61
79
  /** Resolve the exact text and source shown for one routed discovered model. */
@@ -76,6 +94,163 @@ export function effectiveManagementDisplayName(
76
94
  return { displayName: catalogModelSlug(model), displayNameSource: "fallback" };
77
95
  }
78
96
 
97
+ /**
98
+ * The modality declaration an editor writes, resolved the way the runtime resolves it: an exact
99
+ * `modelCapabilities` entry first, then the legacy `modelInputModalities` record, both after the
100
+ * registry has filled in whatever the saved config predates.
101
+ */
102
+ export function declaredModelInputModalities(
103
+ config: OcxConfig,
104
+ providerName: string,
105
+ modelId: string,
106
+ ): string[] | undefined {
107
+ const provider = config.providers[providerName];
108
+ if (!provider) return undefined;
109
+ const enriched = {
110
+ ...provider,
111
+ ...(provider.modelInputModalities ? { modelInputModalities: { ...provider.modelInputModalities } } : {}),
112
+ ...(provider.modelCapabilities ? { modelCapabilities: { ...provider.modelCapabilities } } : {}),
113
+ };
114
+ enrichProviderFromRegistry(providerName, enriched);
115
+ const declared = enriched.modelCapabilities?.[modelId]?.inputModalities
116
+ ?? modelRecordValue(enriched.modelInputModalities, modelId);
117
+ return Array.isArray(declared) && declared.length > 0 ? [...declared] : undefined;
118
+ }
119
+
120
+ /**
121
+ * The ladder that actually applies to this model: an explicit declaration wins, then the
122
+ * registry-enriched provider map, then the pinned native OpenAI table for a resold native id,
123
+ * then the catalog's own effort hints.
124
+ *
125
+ * The registry enrichment is not optional. The catalog reads the enriched provider, so a registry
126
+ * declaration (google-antigravity's `gemini-3.8-flash`, for one) IS the model's ladder; reading
127
+ * the saved config alone would report no ladder for a model that has one.
128
+ *
129
+ * The catalog's own hints are the one source that costs a file read per lookup. `catalogLadders`
130
+ * is the batched form of it: a roster projection resolves every routed id once and passes the map
131
+ * down, instead of asking `catalogModelEfforts` per row and re-parsing the same file each time.
132
+ * Omitting it keeps the single-model behaviour for every other caller.
133
+ */
134
+ export type CatalogLadderLookup = ReadonlyMap<string, string[]>;
135
+
136
+ /** Catalog entries for routed rows carry the Codex-facing `provider/model` slug, never the bare id. */
137
+ function catalogLadder(ladders: CatalogLadderLookup | undefined, providerName: string, modelId: string): string[] | undefined {
138
+ const slug = routedSlug(providerName, modelId);
139
+ return ladders ? ladders.get(slug) : catalogModelEfforts([slug]).get(slug);
140
+ }
141
+
142
+ export function effectiveModelReasoningEfforts(config: OcxConfig, providerName: string, modelId: string, declaredEfforts?: string[], catalogLadders?: CatalogLadderLookup): string[] | undefined {
143
+ // An empty array is a declaration, not an absence: the operator stored "this model has no
144
+ // reasoning rungs", and falling through to the inherited ladder here would report a ladder the
145
+ // row does not have and hand it back to the editor on the next refresh.
146
+ if (Array.isArray(declaredEfforts)) return declaredEfforts;
147
+ const provider = config.providers[providerName];
148
+ if (provider) {
149
+ const enriched = {
150
+ ...provider,
151
+ ...(provider.modelReasoningEfforts ? { modelReasoningEfforts: { ...provider.modelReasoningEfforts } } : {}),
152
+ };
153
+ enrichProviderFromRegistry(providerName, enriched);
154
+ const raw = modelRecordValue(enriched.modelReasoningEfforts, modelId) ?? enriched.reasoningEfforts;
155
+ // Mirrors `configuredReasoningEfforts`, the reader the wire actually uses: a stored array is a
156
+ // declaration even when it sanitizes down to nothing, and only a missing entry inherits.
157
+ if (Array.isArray(raw)) return sanitizeCodexReasoningEfforts(raw) ?? [];
158
+ }
159
+ // A provider reselling a native OpenAI id inherits that model's pinned ladder.
160
+ if (SUPPORTED_NATIVE_OPENAI_SLUGS.has(modelId)) {
161
+ const native = nativeReasoningEfforts(modelId);
162
+ if (native.length > 0) return native;
163
+ }
164
+ const hits = catalogLadder(catalogLadders, providerName, modelId);
165
+ if (hits && hits.length > 0) return hits;
166
+ return undefined;
167
+ }
168
+
169
+ export function effectiveModelDefaultReasoningEffort(config: OcxConfig, providerName: string, modelId: string, declaredDefault?: string, efforts?: string[]): string | undefined {
170
+ if (declaredDefault) return declaredDefault;
171
+ const provider = config.providers[providerName];
172
+ if (provider) {
173
+ const stored = modelRecordValue(provider.modelDefaultReasoningEfforts, modelId);
174
+ if (stored) return stored;
175
+ }
176
+ if (efforts && efforts.length > 0) {
177
+ // The default is the ladder's own preference order, not its first entry: a model offering
178
+ // low..high defaults to medium, and only a ladder that omits it falls back to low.
179
+ if (efforts.includes("medium")) return "medium";
180
+ if (efforts.includes("low")) return "low";
181
+ return efforts[0];
182
+ }
183
+ return undefined;
184
+ }
185
+
186
+ /**
187
+ * Whether the saved config really overrides reasoning for this model, decided by VALUE rather
188
+ * than by key presence.
189
+ *
190
+ * The distinction is the whole point: OpenCodex writes the registry ladder (and its default) into
191
+ * the provider config itself, so a bare `hasOwnProperty` test reports every registry-declared
192
+ * model as hand-edited. An entry that resolves to the same ladder and default the model would
193
+ * inherit anyway is not an override, and an editor that pre-fills an "override" checkbox from one
194
+ * claims an edit nobody made.
195
+ */
196
+ export function reasoningOverrideFor(config: OcxConfig, providerName: string, modelId: string, catalogLadders?: CatalogLadderLookup): boolean {
197
+ const provider = config.providers[providerName];
198
+ if (!provider) return false;
199
+ const owns = (record: Record<string, unknown> | undefined) =>
200
+ !!record && Object.prototype.hasOwnProperty.call(record, modelId);
201
+ const ownsEfforts = owns(provider.modelReasoningEfforts as Record<string, unknown> | undefined);
202
+ const ownsDefault = owns(provider.modelDefaultReasoningEfforts as Record<string, unknown> | undefined);
203
+ if (!ownsEfforts && !ownsDefault) return false;
204
+ if (ownsEfforts) {
205
+ // `undefined` (nothing known) and `[]` (pinned to no rungs) are different facts, and joining
206
+ // both to "" would report a pinned-empty ladder as no override at all.
207
+ const ladderKey = (values: string[] | undefined) => (values === undefined ? null : values.join(","));
208
+ const effective = effectiveModelReasoningEfforts(config, providerName, modelId, undefined, catalogLadders);
209
+ const inherited = inheritedModelReasoningEfforts(config, providerName, modelId, catalogLadders);
210
+ if (ladderKey(effective) !== ladderKey(inherited)) return true;
211
+ }
212
+ if (ownsDefault) {
213
+ const effective = effectiveModelDefaultReasoningEffort(config, providerName, modelId);
214
+ const inherited = inheritedModelDefaultReasoningEffort(config, providerName, modelId, catalogLadders);
215
+ if (effective !== inherited) return true;
216
+ }
217
+ return false;
218
+ }
219
+
220
+ /** The ladder this model inherits when no per-model override exists. */
221
+ export function inheritedModelReasoningEfforts(config: OcxConfig, providerName: string, modelId: string, catalogLadders?: CatalogLadderLookup): string[] | undefined {
222
+ const provider = config.providers[providerName];
223
+ if (!provider) return undefined;
224
+ const record = provider.modelReasoningEfforts;
225
+ if (!record || !Object.prototype.hasOwnProperty.call(record, modelId)) {
226
+ return effectiveModelReasoningEfforts(config, providerName, modelId, undefined, catalogLadders);
227
+ }
228
+ const next = { ...record };
229
+ delete next[modelId];
230
+ const stripped = { ...provider, modelReasoningEfforts: next };
231
+ const view = { ...config, providers: { ...config.providers, [providerName]: stripped } };
232
+ return effectiveModelReasoningEfforts(view, providerName, modelId, undefined, catalogLadders);
233
+ }
234
+
235
+ /** The default this model inherits when no per-model default override exists. */
236
+ export function inheritedModelDefaultReasoningEffort(config: OcxConfig, providerName: string, modelId: string, catalogLadders?: CatalogLadderLookup): string | undefined {
237
+ const provider = config.providers[providerName];
238
+ if (!provider) return undefined;
239
+ const record = provider.modelDefaultReasoningEfforts;
240
+ if (!record || !Object.prototype.hasOwnProperty.call(record, modelId)) {
241
+ return effectiveModelDefaultReasoningEffort(
242
+ config, providerName, modelId, undefined, effectiveModelReasoningEfforts(config, providerName, modelId, undefined, catalogLadders),
243
+ );
244
+ }
245
+ const next = { ...record };
246
+ delete next[modelId];
247
+ const stripped = { ...provider, modelDefaultReasoningEfforts: next };
248
+ const view = { ...config, providers: { ...config.providers, [providerName]: stripped } };
249
+ return effectiveModelDefaultReasoningEffort(
250
+ view, providerName, modelId, undefined, effectiveModelReasoningEfforts(view, providerName, modelId, undefined, catalogLadders),
251
+ );
252
+ }
253
+
79
254
  /**
80
255
  * The exact row list `/api/models` returns. Extracted so `/api/client-config` exports the
81
256
  * models the GUI's Models tab shows — including this function's `disabled` computation,
@@ -190,6 +365,11 @@ export async function listManagementModelRows(
190
365
  // Custom metadata wins when a physical live/static row resolves to the same Codex-facing
191
366
  // slug, while a combo keeps the same precedence it has in routing and /v1/models.
192
367
  const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced));
368
+ // One catalog read for the whole roster. Every `catalogModelEfforts` call parses the catalog
369
+ // file and scans it once per requested slug, so resolving these per row (and twice per row, as
370
+ // the guard and the value) re-read the same file thousands of times on a large roster.
371
+ const routedSlugs = publicModels.filter(m => m.provider !== "combo").map(m => routedSlug(m.provider, m.id));
372
+ const catalogLadders = catalogModelEfforts([...new Set(routedSlugs)]);
193
373
  const dedupedRouted = publicModels.map((m): ManagementModelRow | null => {
194
374
  // Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms.
195
375
  const namespaced = catalogModelSlug(m);
@@ -197,6 +377,16 @@ export async function listManagementModelRows(
197
377
  const contextCap = providerContextCap(config, m.provider);
198
378
  const nativeAlias = m.provider === "combo" && m.nativeAlias === true;
199
379
  const displayName = effectiveManagementDisplayName(config, m);
380
+ const routed = m.provider !== "combo";
381
+ // Resolved once each: the spread below used to evaluate the same helpers again for the guard.
382
+ const reasoningEfforts = routed
383
+ ? effectiveModelReasoningEfforts(config, m.provider, m.id, m.reasoningEfforts, catalogLadders)
384
+ : undefined;
385
+ const defaultReasoningEffort = routed
386
+ ? effectiveModelDefaultReasoningEffort(config, m.provider, m.id, m.defaultReasoningEffort, reasoningEfforts)
387
+ : undefined;
388
+ const inputModalitiesDeclared = routed ? declaredModelInputModalities(config, m.provider, m.id) : undefined;
389
+ const contextWindowDeclared = routed ? config.providers[m.provider]?.modelContextWindows?.[m.id] : undefined;
200
390
  return {
201
391
  ...m,
202
392
  ...displayName,
@@ -205,6 +395,14 @@ export async function listManagementModelRows(
205
395
  (!nativeAlias && stored === namespaced) || slugEquals(stored, m.provider, m.id)
206
396
  )),
207
397
  ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
398
+ // A routed row inherits nothing about reasoning or modalities from the row spread above:
399
+ // the catalog only carries what discovery reported, so the effective answers are resolved
400
+ // here, where the config and the registry are both in hand.
401
+ ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
402
+ ...(defaultReasoningEffort !== undefined ? { defaultReasoningEffort } : {}),
403
+ ...(routed ? { reasoningOverridden: reasoningOverrideFor(config, m.provider, m.id, catalogLadders) } : {}),
404
+ ...(inputModalitiesDeclared !== undefined ? { inputModalitiesDeclared } : {}),
405
+ ...(contextWindowDeclared !== undefined ? { contextWindowDeclared } : {}),
208
406
  };
209
407
  }).filter((row): row is ManagementModelRow => row !== null);
210
408
  // Manual OpenAI rows retain their routed selector but replace the bare dashboard row.
@@ -19,6 +19,7 @@ import {
19
19
  getLoginStatus,
20
20
  isPublicOAuthProvider,
21
21
  listOAuthProviders,
22
+ OAUTH_PROVIDERS,
22
23
  publicOAuthAuthenticationErrorMessage,
23
24
  startLoginFlow,
24
25
  submitManualLoginCode,
@@ -199,6 +200,13 @@ function metaMuseConsentRequired(provider: string, principal: ManagementContext[
199
200
  }, 403);
200
201
  }
201
202
 
203
+ function genericOAuthProviderConfig(provider: string, config: ManagementContext["config"]) {
204
+ const configured = config.providers[provider];
205
+ if (configured) return configured;
206
+ const definition = OAUTH_PROVIDERS[provider];
207
+ return definition?.resolveProviderConfig?.(config) ?? definition?.providerConfig;
208
+ }
209
+
202
210
  export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<Response | null> {
203
211
  const { req, url, config, deps, principal, syncClaudeAgentDefsBestEffort } = ctx;
204
212
 
@@ -384,7 +392,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
384
392
  const quotaMode = providerOAuthAccountQuotaMode(provider);
385
393
  const quotaProvider = config.providers[provider];
386
394
  const { getAccountSet } = await import("../../oauth/store");
387
- const { kiroAutoSelection } = await import("../../oauth/generic-account-failover");
395
+ const { isGenericFailoverProvider, kiroAutoSelection } = await import("../../oauth/generic-account-failover");
396
+ const effectiveProvider = genericOAuthProviderConfig(provider, config);
397
+ const supportsPause = effectiveProvider !== undefined
398
+ && isGenericFailoverProvider(provider, effectiveProvider);
388
399
  const {
389
400
  oauthAccountHealthFields,
390
401
  projectOAuthAccountHealth,
@@ -404,6 +415,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
404
415
  reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
405
416
  });
406
417
  return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode,
418
+ ...(supportsPause ? { paused: full?.paused === true } : {}),
407
419
  ...(provider === "kiro" && full ? kiroAutoSelection(full) : {}) };
408
420
  }),
409
421
  };
@@ -449,8 +461,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
449
461
  const provider = (body.provider ?? "").trim().toLowerCase();
450
462
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
451
463
  if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400);
452
- const { setActiveAccount } = await import("../../oauth/store");
453
- if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404);
464
+ const { getAccountCredentialWithStatus, setActiveAccount } = await import("../../oauth/store");
465
+ const current = getAccountCredentialWithStatus(provider, body.accountId);
466
+ if (!current) return jsonResponse({ error: "account not found" }, 404);
467
+ if (current.paused) return jsonResponse({ error: "account is paused" }, 409);
468
+ if (!(await setActiveAccount(provider, body.accountId))) {
469
+ const latest = getAccountCredentialWithStatus(provider, body.accountId);
470
+ if (!latest) return jsonResponse({ error: "account not found" }, 404);
471
+ return jsonResponse({ error: latest.paused ? "account is paused" : "account selection changed" }, 409);
472
+ }
454
473
  const { forgetGenericFailoverRoster } = await import("../../oauth/generic-account-failover");
455
474
  forgetGenericFailoverRoster(provider);
456
475
  // Seed the rotation cursor on the operator's pick, or a sticky round-robin ring hands the
@@ -472,6 +491,47 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
472
491
  return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
473
492
  }
474
493
 
494
+ if (url.pathname === "/api/oauth/accounts/pause" && req.method === "PUT") {
495
+ const body = await readManagementJsonBodyOr(req, {});
496
+ if (!isPlainRecord(body)) return jsonResponse({ error: "body must be an object" }, 400);
497
+ const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
498
+ if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
499
+ if (typeof body.accountId !== "string" || body.accountId.length === 0) {
500
+ return jsonResponse({ error: "missing accountId" }, 400);
501
+ }
502
+ if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400);
503
+
504
+ const { isGenericFailoverProvider } = await import("../../oauth/generic-account-failover");
505
+ const effectiveProvider = genericOAuthProviderConfig(provider, config);
506
+ if (!effectiveProvider || !isGenericFailoverProvider(provider, effectiveProvider)) {
507
+ return jsonResponse({ error: "account pause is not supported for this OAuth provider" }, 400);
508
+ }
509
+
510
+ const { setAccountPaused } = await import("../../oauth/store");
511
+ const result = await setAccountPaused(provider, body.accountId, body.paused);
512
+ if (result.status === "not-found") return jsonResponse({ error: "account not found" }, 404);
513
+
514
+ if (result.activeAccountChanged) {
515
+ const { genericPoolKey, seedPoolRotationAccount } = await import("../../oauth/pool-kernel");
516
+ seedPoolRotationAccount(genericPoolKey(provider), result.activeAccountId);
517
+ const { clearModelCache } = await import("../../codex/model-cache");
518
+ const { clearGatherRoutedModelsInflight } = await import("../../codex/catalog");
519
+ clearModelCache(provider);
520
+ clearGatherRoutedModelsInflight();
521
+ const { clearProviderQuotaCache } = await import("../../providers/quota");
522
+ clearProviderQuotaCache();
523
+ }
524
+
525
+ return jsonResponse({
526
+ ok: true,
527
+ provider,
528
+ accountId: body.accountId,
529
+ paused: body.paused,
530
+ activeAccountId: result.activeAccountId,
531
+ activeAccountChanged: result.activeAccountChanged,
532
+ });
533
+ }
534
+
475
535
  // The unified pool-settings contract (#695 wp5c). The three legacy paths keep working and
476
536
  // keep their own shapes -- goldens pin them -- but this is the one an operator or a dashboard
477
537
  // should read, because it answers with the same keys for every kind and DECLARES which of
@@ -277,6 +277,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
277
277
  { method: "PUT", path: "/api/disabled-models", module: "server/management/model-routes", mutates: true },
278
278
  { method: "PUT", path: "/api/model-discovery", module: "server/management/model-routes", mutates: true },
279
279
  { method: "PUT", path: "/api/model-presets", module: "server/management/model-routes", mutates: true },
280
+ { method: "PUT", path: "/api/model-settings", module: "server/management/model-routes", mutates: true },
280
281
  { method: "PUT", path: "/api/model-visibility", module: "server/management/model-routes", mutates: true },
281
282
  { method: "PUT", path: "/api/selected-models", module: "server/management/model-routes", mutates: true },
282
283
  // server/management/native-integration-routes
@@ -319,6 +320,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
319
320
  { method: "POST", path: "/api/providers/keys", module: "server/management/oauth-account-routes", mutates: true },
320
321
  { method: "PUT", path: "/api/oauth/accounts/active", module: "server/management/oauth-account-routes", mutates: true },
321
322
  { method: "PUT", path: "/api/oauth/accounts/alias", module: "server/management/oauth-account-routes", mutates: true },
323
+ { method: "PUT", path: "/api/oauth/accounts/pause", module: "server/management/oauth-account-routes", mutates: true },
322
324
  { method: "PUT", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: true },
323
325
  { method: "PUT", path: "/api/providers/keys/active", module: "server/management/oauth-account-routes", mutates: true },
324
326
  { method: "PUT", path: "/api/providers/keys/alias", module: "server/management/oauth-account-routes", mutates: true },
@@ -18,6 +18,16 @@ export type ListenPidScan =
18
18
  | { ok: true; pids: number[] }
19
19
  | { ok: false; error?: string };
20
20
 
21
+ /** One listening socket with its bound local address (host part only). */
22
+ export interface ListenEntry {
23
+ pid: number;
24
+ address: string;
25
+ }
26
+
27
+ export type ListenEntryScan =
28
+ | { ok: true; listeners: ListenEntry[] }
29
+ | { ok: false; error?: string };
30
+
21
31
  export type ReclaimListenPortOptions = WaitForPortOptions & {
22
32
  /**
23
33
  * When true AND `onlyKillPids` is a non-empty allowlist, those PIDs may be
@@ -60,12 +70,49 @@ export type ReclaimListenPortOptions = WaitForPortOptions & {
60
70
  sleepMs?: (ms: number) => Promise<void>;
61
71
  };
62
72
 
73
+ /** Split `host:port`/`[v6]:port` on a numeric port boundary; returns the host part. */
74
+ function listenHost(token: string): string {
75
+ const bracketed = /^(\[[0-9a-fA-F:.]+\]):/.exec(token);
76
+ if (bracketed) return bracketed[1].slice(1, -1).toLowerCase();
77
+ // Only a trailing :<digits> is a port; a bare "::" or hostname wildcard has none.
78
+ const withPort = /^(.*):(\d+)$/.exec(token);
79
+ return (withPort ? withPort[1] : token).toLowerCase();
80
+ }
81
+
82
+ /** Normalize a listen-address host: strips brackets and the IPv4-mapped prefix. */
83
+ export function normalizeListenAddress(token: string): string {
84
+ let host = listenHost(token);
85
+ if (host.startsWith("::ffff:")) host = host.slice(7);
86
+ return host;
87
+ }
88
+
89
+ /** Normalize a bare bind address (no port): drops brackets, keeps bare IPv6 whole. */
90
+ function bareListenAddress(address: string): string {
91
+ let host = address.replace(/^\[|\]$/g, "").toLowerCase();
92
+ if (host.startsWith("::ffff:")) host = host.slice(7);
93
+ return host;
94
+ }
95
+
96
+ const WILDCARD_LISTEN_HOSTS = new Set(["", "*", "0.0.0.0", "::"]);
97
+
63
98
  /**
64
- * Parse `netstat -ano` (Windows) / `netstat -anlp` listen lines for a port.
65
- * Exported for unit tests.
99
+ * Whether a socket bound to `listenerAddress` also serves connections to `bound` —
100
+ * exact match, or a wildcard listener, or a wildcard `bound` (the caller listens on
101
+ * every address). IPv4-mapped IPv6 forms of the same address are equalized first.
66
102
  */
67
- export function parseListenPidsFromNetstat(output: string, port: number): number[] {
68
- const pids = new Set<number>();
103
+ export function listenAddressServes(listenerAddress: string, bound: string): boolean {
104
+ const listener = normalizeListenAddress(listenerAddress);
105
+ const want = bareListenAddress(bound);
106
+ return WILDCARD_LISTEN_HOSTS.has(listener) || WILDCARD_LISTEN_HOSTS.has(want)
107
+ || listener === want;
108
+ }
109
+
110
+ /**
111
+ * Parse `netstat -ano` (Windows) / `netstat -anlp` listen lines for a port, keeping
112
+ * each distinct PID/address pair. Exported for unit tests.
113
+ */
114
+ export function parseListenEntriesFromNetstat(output: string, port: number): ListenEntry[] {
115
+ const entries = new Map<string, ListenEntry>();
69
116
  const portSuffix = `:${port}`;
70
117
  for (const rawLine of output.split(/\r?\n/)) {
71
118
  const line = rawLine.trim();
@@ -88,9 +135,66 @@ export function parseListenPidsFromNetstat(output: string, port: number): number
88
135
  : unixPid
89
136
  ? Number(unixPid[1])
90
137
  : NaN;
91
- if (Number.isSafeInteger(pid) && pid > 0) pids.add(pid);
138
+ if (Number.isSafeInteger(pid) && pid > 0) {
139
+ const address = normalizeListenAddress(parts[localIdx]);
140
+ entries.set(`${pid}|${address}`, { pid, address });
141
+ }
92
142
  }
93
- return [...pids];
143
+ return [...entries.values()];
144
+ }
145
+
146
+ /** Parse netstat LISTEN owners, deduplicating PIDs after preserving their addresses. */
147
+ export function parseListenPidsFromNetstat(output: string, port: number): number[] {
148
+ return [...new Set(parseListenEntriesFromNetstat(output, port).map(entry => entry.pid))];
149
+ }
150
+
151
+ /**
152
+ * Parse `ss -Hltnp` rows for a port, keeping each distinct PID/address pair. A row
153
+ * without a `pid=` attribution (another user's socket) is dropped rather than
154
+ * reported unverifiable. Exported for unit tests.
155
+ */
156
+ export function parseListenEntriesFromSs(output: string, port: number): ListenEntry[] {
157
+ const entries = new Map<string, ListenEntry>();
158
+ const portSuffix = `:${port}`;
159
+ for (const rawLine of output.split(/\r?\n/)) {
160
+ const line = rawLine.trim();
161
+ if (!/^LISTEN\b/i.test(line)) continue;
162
+ const parts = line.split(/\s+/);
163
+ // LISTEN <recv-q> <send-q> <local-addr:port> <peer-addr:port> users:(...)
164
+ const localIdx = parts.findIndex(part => part.endsWith(portSuffix) || part.endsWith(`]:${port}`));
165
+ if (localIdx < 0) continue;
166
+ const pidMatch = /pid=(\d+)/.exec(line);
167
+ const pid = pidMatch ? Number(pidMatch[1]) : NaN;
168
+ if (Number.isSafeInteger(pid) && pid > 0) {
169
+ const address = normalizeListenAddress(parts[localIdx]);
170
+ entries.set(`${pid}|${address}`, { pid, address });
171
+ }
172
+ }
173
+ return [...entries.values()];
174
+ }
175
+
176
+ /**
177
+ * Parse `lsof -nP -iTCP:<port> -sTCP:LISTEN` output (without -t), keeping each
178
+ * distinct PID/address pair. The NAME column is the last address token, optionally
179
+ * followed by `(LISTEN)`; skip the header and nonnumeric PIDs. Exported for tests.
180
+ */
181
+ export function parseListenEntriesFromLsof(output: string, port: number): ListenEntry[] {
182
+ const entries = new Map<string, ListenEntry>();
183
+ const portSuffix = `:${port}`;
184
+ for (const rawLine of output.split(/\r?\n/)) {
185
+ const line = rawLine.trim();
186
+ if (!line || /^COMMAND\b/.test(line)) continue;
187
+ const parts = line.split(/\s+/);
188
+ const pid = /^\d+$/.test(parts[1] ?? "") ? Number(parts[1]) : NaN;
189
+ if (!Number.isSafeInteger(pid) || pid <= 0) continue;
190
+ let addressIdx = parts.length - 1;
191
+ if (/^\(.*\)$/.test(parts[addressIdx] ?? "")) addressIdx -= 1;
192
+ const address = parts[addressIdx] ?? "";
193
+ if (!address.endsWith(portSuffix) && !address.endsWith(`]:${port}`)) continue;
194
+ const normalized = normalizeListenAddress(address);
195
+ entries.set(`${pid}|${normalized}`, { pid, address: normalized });
196
+ }
197
+ return [...entries.values()];
94
198
  }
95
199
 
96
200
  function normalizeListenPidScan(result: ListenPidScan | number[]): ListenPidScan {
@@ -121,50 +225,84 @@ function readWindowsNetstatAno(): string {
121
225
  }
122
226
 
123
227
  /**
124
- * Scan for PIDs currently LISTENing on `port`.
125
- * Distinguishes probe failure (`ok: false`) from a successful empty result.
228
+ * Scan for the sockets currently LISTENing on `port`, with each listener's bound
229
+ * local address. Distinguishes probe failure (`ok: false`) from a successful empty
230
+ * result. POSIX backends are tried in order — `lsof`, `ss` (iproute2, the only
231
+ * scanner on minimal Linux installs), then `netstat` — and a missing scanner falls
232
+ * through to the next instead of failing the scan.
126
233
  */
127
- export function scanListenPids(port: number): ListenPidScan {
234
+ export function scanListenEntries(port: number): ListenEntryScan {
128
235
  if (!Number.isFinite(port) || port <= 0 || port > 65535) {
129
236
  return { ok: false, error: "invalid port" };
130
237
  }
238
+ const scanned = Math.trunc(port);
131
239
  try {
132
240
  if (process.platform === "win32") {
133
- return { ok: true, pids: parseListenPidsFromNetstat(readWindowsNetstatAno(), port) };
241
+ return { ok: true, listeners: parseListenEntriesFromNetstat(readWindowsNetstatAno(), scanned) };
134
242
  }
243
+ const errors: string[] = [];
135
244
  try {
136
- const output = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
245
+ const output = execFileSync("lsof", ["-nP", `-iTCP:${scanned}`, "-sTCP:LISTEN"], {
137
246
  encoding: "utf-8",
138
247
  stdio: ["ignore", "pipe", "ignore"],
139
248
  timeout: 3000,
140
249
  });
141
- return {
142
- ok: true,
143
- pids: output
144
- .split(/\r?\n/)
145
- .map(line => Number(line.trim()))
146
- .filter(pid => Number.isSafeInteger(pid) && pid > 0),
147
- };
148
- } catch (lsofErr) {
149
- try {
150
- const output = execFileSync("netstat", ["-anlp"], {
151
- encoding: "utf-8",
152
- stdio: ["ignore", "pipe", "ignore"],
153
- timeout: 3000,
154
- });
155
- return { ok: true, pids: parseListenPidsFromNetstat(output, Math.trunc(port)) };
156
- } catch (netstatErr) {
157
- return {
158
- ok: false,
159
- error: `lsof/netstat unavailable: ${String(lsofErr)} / ${String(netstatErr)}`,
160
- };
161
- }
250
+ return { ok: true, listeners: parseListenEntriesFromLsof(output, scanned) };
251
+ } catch (error) {
252
+ errors.push(`lsof: ${String(error)}`);
253
+ }
254
+ try {
255
+ const output = execFileSync("ss", ["-Hltnp"], {
256
+ encoding: "utf-8",
257
+ stdio: ["ignore", "pipe", "ignore"],
258
+ timeout: 3000,
259
+ });
260
+ return { ok: true, listeners: parseListenEntriesFromSs(output, scanned) };
261
+ } catch (error) {
262
+ errors.push(`ss: ${String(error)}`);
263
+ }
264
+ try {
265
+ const output = execFileSync("netstat", ["-anlp"], {
266
+ encoding: "utf-8",
267
+ stdio: ["ignore", "pipe", "ignore"],
268
+ timeout: 3000,
269
+ });
270
+ return { ok: true, listeners: parseListenEntriesFromNetstat(output, scanned) };
271
+ } catch (error) {
272
+ errors.push(`netstat: ${String(error)}`);
162
273
  }
274
+ return { ok: false, error: `no listener scanner available (${errors.join(" / ")})` };
163
275
  } catch (error) {
164
276
  return { ok: false, error: String(error) };
165
277
  }
166
278
  }
167
279
 
280
+ /**
281
+ * Scan for PIDs currently LISTENing on `port`.
282
+ * Distinguishes probe failure (`ok: false`) from a successful empty result.
283
+ */
284
+ export function scanListenPids(port: number): ListenPidScan {
285
+ const scan = scanListenEntries(port);
286
+ if (!scan.ok) return { ok: false, error: scan.error };
287
+ return { ok: true, pids: [...new Set(scan.listeners.map(entry => entry.pid))] };
288
+ }
289
+
290
+ /**
291
+ * PIDs LISTENing on `port` that actually serve `address`: listeners bound to that
292
+ * exact address plus wildcards (0.0.0.0/::). A listener on a different loopback or
293
+ * interface address (e.g. 127.0.0.2 while the tunnel binds 127.0.0.1) never receives
294
+ * the connection and must not block or qualify a readiness check.
295
+ */
296
+ export function scanListenPidsForAddress(port: number, address = "127.0.0.1"): ListenPidScan {
297
+ const scan = scanListenEntries(port);
298
+ if (!scan.ok) return { ok: false, error: scan.error };
299
+ const pids = new Set<number>();
300
+ for (const entry of scan.listeners) {
301
+ if (listenAddressServes(entry.address, address)) pids.add(entry.pid);
302
+ }
303
+ return { ok: true, pids: [...pids] };
304
+ }
305
+
168
306
  /** Best-effort PIDs currently LISTENing on `port`. Empty on probe failure. */
169
307
  export function listListenPids(port: number): number[] {
170
308
  const scan = scanListenPids(port);