@bitkyc08/opencodex 2.19.0 → 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 (119) 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/mimo-free.ts +17 -0
  6. package/src/adapters/openai-chat.ts +367 -32
  7. package/src/adapters/registry.ts +144 -0
  8. package/src/chat/inbound.ts +13 -7
  9. package/src/cli/claude.ts +2 -1
  10. package/src/cli/combo.ts +3 -0
  11. package/src/cli/dispatch.ts +8 -0
  12. package/src/cli/export-command.ts +2 -2
  13. package/src/cli/help.ts +2 -0
  14. package/src/cli/index.ts +3 -2
  15. package/src/cli/lab.ts +135 -1
  16. package/src/cli/minimax.ts +491 -0
  17. package/src/cli/models-runtime.ts +22 -1
  18. package/src/cli/models.ts +67 -2
  19. package/src/cli/opencode.ts +2 -1
  20. package/src/cli/registry.ts +22 -2
  21. package/src/clients/config-export.ts +125 -7
  22. package/src/codex/app-server-processes.ts +57 -2
  23. package/src/codex/app-server-restart-service.ts +232 -0
  24. package/src/codex/catalog/aggregation.ts +10 -1
  25. package/src/codex/catalog/effort.ts +15 -3
  26. package/src/codex/catalog/parsing.ts +3 -1
  27. package/src/codex/catalog/provider-fetch.ts +45 -5
  28. package/src/codex/catalog/sync.ts +74 -4
  29. package/src/codex/convergence.ts +2 -0
  30. package/src/combos/index.ts +1 -0
  31. package/src/combos/request.ts +30 -0
  32. package/src/combos/types.ts +6 -0
  33. package/src/config.ts +52 -0
  34. package/src/generated/compatibility-version.json +224 -76
  35. package/src/images/loop.ts +11 -1
  36. package/src/integrations/registry.ts +7 -0
  37. package/src/lab/conformance/jcs.ts +42 -2
  38. package/src/lab/conformance/negative-controls.ts +6 -2
  39. package/src/lab/conformance/runner.ts +16 -5
  40. package/src/lab/fabric/observe.ts +49 -14
  41. package/src/lab/index.ts +16 -0
  42. package/src/lab/ledger/purge.ts +152 -83
  43. package/src/lab/ledger/store.ts +168 -54
  44. package/src/lab/observe/from-conformance.ts +8 -6
  45. package/src/lab/observe/from-live.ts +8 -2
  46. package/src/lab/paths.ts +23 -0
  47. package/src/lab/public/bundle.ts +217 -0
  48. package/src/lab/public/community-authority.ts +175 -0
  49. package/src/lab/public/community-files.ts +29 -0
  50. package/src/lab/public/community.ts +479 -0
  51. package/src/lab/public/file-safety.ts +155 -0
  52. package/src/lab/public/ids.ts +26 -0
  53. package/src/lab/public/index.ts +16 -0
  54. package/src/lab/public/mutation-lock.ts +424 -0
  55. package/src/lab/public/operator.ts +353 -0
  56. package/src/lab/public/origin-purge.ts +79 -0
  57. package/src/lab/public/origin.ts +203 -0
  58. package/src/lab/public/privacy.ts +143 -0
  59. package/src/lab/public/private-file.ts +261 -0
  60. package/src/lab/public/project.ts +124 -0
  61. package/src/lab/public/purge-test-fault.ts +21 -0
  62. package/src/lab/public/purge.ts +223 -0
  63. package/src/lab/public/registry.ts +44 -0
  64. package/src/lab/public/revocation.ts +252 -0
  65. package/src/lab/public/signature.ts +219 -0
  66. package/src/lab/public/storage.ts +105 -0
  67. package/src/lab/public/strict-json.ts +206 -0
  68. package/src/lab/public/time.ts +26 -0
  69. package/src/lab/public/types.ts +172 -0
  70. package/src/lab/public/validate.ts +391 -0
  71. package/src/lib/codex-restart-contract.ts +120 -0
  72. package/src/lib/lab-activation.ts +109 -47
  73. package/src/lib/lab-live-pinned-sender.ts +16 -5
  74. package/src/lib/pinned-http.ts +70 -16
  75. package/src/lib/self-launch-argv.ts +15 -0
  76. package/src/lib/state-store-registrations.ts +2 -0
  77. package/src/lib/upstream-reachability.ts +4 -0
  78. package/src/lib/windows-elevation.ts +10 -1
  79. package/src/providers/derive.ts +24 -4
  80. package/src/providers/registry.ts +7 -3
  81. package/src/providers/request-pacing.ts +310 -0
  82. package/src/providers/service-tier.ts +143 -0
  83. package/src/providers/static-model-discovery.ts +86 -0
  84. package/src/reasoning-effort.ts +27 -1
  85. package/src/router.ts +23 -6
  86. package/src/routing/capability.ts +4 -2
  87. package/src/routing/compatibility/behavior.ts +5 -1
  88. package/src/server/adapter-resolve.ts +2 -32
  89. package/src/server/auth-cors.ts +8 -0
  90. package/src/server/chat-completions.ts +74 -36
  91. package/src/server/chat-native-sse.ts +331 -0
  92. package/src/server/chat-native.ts +371 -0
  93. package/src/server/management/combo-routes.ts +16 -2
  94. package/src/server/management/config-routes.ts +6 -4
  95. package/src/server/management/context.ts +17 -0
  96. package/src/server/management/lab-routes.ts +181 -19
  97. package/src/server/management/model-routes.ts +76 -2
  98. package/src/server/management/model-rows.ts +8 -0
  99. package/src/server/management/provider-capability-config.ts +48 -0
  100. package/src/server/management/provider-routes.ts +76 -4
  101. package/src/server/management/system-restart.ts +4 -2
  102. package/src/server/management/system-routes.ts +38 -0
  103. package/src/server/relay.ts +17 -3
  104. package/src/server/responses/compact.ts +4 -1
  105. package/src/server/responses/core.ts +257 -46
  106. package/src/server/responses/empty-completion-guard.ts +276 -0
  107. package/src/server/responses/fetch-helpers.ts +35 -4
  108. package/src/server/responses/pacing-overload.ts +13 -0
  109. package/src/server/responses/policy-fallback.ts +16 -2
  110. package/src/server/responses/terminal-guard.ts +1 -1
  111. package/src/server/responses/upstream-error.ts +5 -0
  112. package/src/server/responses.ts +17 -2
  113. package/src/types.ts +66 -4
  114. package/src/update/index.ts +6 -5
  115. package/src/update/job.ts +5 -6
  116. package/src/update/notify.ts +5 -3
  117. package/src/usage/log.ts +11 -1
  118. package/gui/dist/assets/index-CQ7bIKee.css +0 -1
  119. package/gui/dist/assets/index-D_JUZLEC.js +0 -76
@@ -0,0 +1,371 @@
1
+ import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter } from "../adapters/openai-chat";
2
+ import type { AdapterRequest, ProviderAdapter } from "../adapters/base";
3
+ import {
4
+ chatCompletionsErrorBody,
5
+ chatCompletionsErrorResponse,
6
+ collectChatCompletion,
7
+ isChatCompletionsStreamError,
8
+ } from "../chat/outbound";
9
+ import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors";
10
+ import { readBoundedResponseBody } from "../lib/bounded-body";
11
+ import { redactSecretString } from "../lib/redact";
12
+ import { resolveClientRetryAfter } from "../lib/retry-after";
13
+ import {
14
+ applyUpstreamRecoveryInit,
15
+ fetchWithResetRetry,
16
+ prepareSameTarget429Wait,
17
+ type UpstreamSendRecovery,
18
+ } from "../lib/upstream-retry";
19
+ import {
20
+ isTranslatorBudgetExceededError,
21
+ type TranslatorBudget,
22
+ } from "../lib/translator-budget";
23
+ import {
24
+ hasKeyPoolFailover,
25
+ rateLimitRetryDelayMs,
26
+ rateLimitRetryPolicyFor,
27
+ rotateProviderTransportOn429,
28
+ } from "../providers/key-failover";
29
+ import type { RouteResult } from "../router";
30
+ import type { OcxConfig, OcxProviderConfig } from "../types";
31
+ import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./responses/fetch-helpers";
32
+ import { linkAbortSignal } from "./responses";
33
+ import {
34
+ addFinalRequestLog,
35
+ beginRequestAttempt,
36
+ noteAttemptSend,
37
+ recordFirstOutput,
38
+ sealRequestAttemptIdentity,
39
+ type RequestLogContext,
40
+ } from "./request-log";
41
+ import { jsonCompletionSse, nativeChatSse, structuredError, usageFromChat } from "./chat-native-sse";
42
+
43
+ type Rec = Record<string, unknown>;
44
+
45
+ const MAX_NATIVE_CHAT_JSON_BYTES = 32 * 1024 * 1024;
46
+ const MAX_NATIVE_CHAT_ERROR_BYTES = 64 * 1024;
47
+
48
+ function isRec(value: unknown): value is Rec {
49
+ return value !== null && typeof value === "object" && !Array.isArray(value);
50
+ }
51
+
52
+ export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boolean {
53
+ const provider = route.provider;
54
+ if (provider.adapter !== "openai-chat") return false;
55
+ if (provider.authMode !== undefined && provider.authMode !== "key" && provider.authMode !== "local") return false;
56
+ // Combo and policy execution own multi-candidate retries in the Responses pipeline.
57
+ if (route.combo || route.routeKind === "combo" || route.routeKind === "policy") return false;
58
+ if (rawBody.store === true || rawBody.background === true) return false;
59
+ if (typeof rawBody.previous_response_id === "string" && rawBody.previous_response_id.length > 0) return false;
60
+ if (rawBody.compaction_trigger !== undefined) return false;
61
+ if (Array.isArray(rawBody.tools)) {
62
+ for (const tool of rawBody.tools) {
63
+ if (!isRec(tool)) continue;
64
+ if (tool.type === "web_search" || tool.type === "web_search_preview" || tool.type === "image_generation") {
65
+ return false;
66
+ }
67
+ }
68
+ }
69
+ return true;
70
+ }
71
+
72
+ function chatCompletionJson(value: unknown): Rec | null {
73
+ if (!isRec(value) || !Array.isArray(value.choices) || value.choices.length === 0) return null;
74
+ return value;
75
+ }
76
+
77
+ interface HandleNativeChatOptions {
78
+ req: Request;
79
+ config: OcxConfig;
80
+ logCtx: RequestLogContext;
81
+ logIds?: { requestId: string; start: number };
82
+ route: RouteResult;
83
+ chatBody: Rec;
84
+ requestedModel: string;
85
+ requestedStream: boolean;
86
+ translatorBudget: TranslatorBudget;
87
+ }
88
+
89
+ export async function handleNativeChatCompletions(options: HandleNativeChatOptions): Promise<Response> {
90
+ const { req, config, logCtx, logIds, route, requestedModel, requestedStream, translatorBudget } = options;
91
+ logCtx.inboundProtocol = "chat";
92
+ const attempt = beginRequestAttempt(
93
+ (logCtx.attempts?.length ?? 0) + 1,
94
+ route.providerName,
95
+ route.modelId,
96
+ "openai-chat",
97
+ );
98
+ logCtx.activeAttempt = attempt;
99
+ logCtx.activeAttemptStartedAt = Date.now();
100
+ (logCtx.attempts ??= []).push(attempt);
101
+ sealRequestAttemptIdentity(attempt, route.providerName, "openai-chat", logCtx.accountLogLabel);
102
+
103
+ let logged = false;
104
+ const finishLog = (status: number, message?: string, closeReason: "non_stream" | "terminal" | "client_cancel" = "non_stream") => {
105
+ if (logged) return;
106
+ logged = true;
107
+ if (message) logCtx.upstreamError = redactSecretString(message).slice(0, 500);
108
+ if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason });
109
+ };
110
+ const fail = (status: number, message: string, type?: string, code?: string | null): Response => {
111
+ const safeMessage = redactSecretString(message);
112
+ finishLog(status, safeMessage);
113
+ return chatCompletionsErrorResponse(status, safeMessage, type, code);
114
+ };
115
+
116
+ logCtx.requestedEffort = typeof options.chatBody.reasoning_effort === "string"
117
+ ? options.chatBody.reasoning_effort
118
+ : undefined;
119
+ logCtx.requestedServiceTier = typeof options.chatBody.service_tier === "string"
120
+ ? options.chatBody.service_tier
121
+ : undefined;
122
+
123
+ const upstream = new AbortController();
124
+ const cleanupAbort = linkAbortSignal(upstream, req.signal);
125
+ const connectMs = config.connectTimeoutMs ?? 200_000;
126
+ let activeProvider: OcxProviderConfig = route.provider;
127
+ let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider);
128
+ let activeRequest: AdapterRequest;
129
+ let retainedRequestBytes = 0;
130
+ const releaseRetainedRequest = () => {
131
+ if (retainedRequestBytes === 0) return;
132
+ translatorBudget.releaseRetained(retainedRequestBytes, { kind: "request_copies" });
133
+ retainedRequestBytes = 0;
134
+ };
135
+ const retainRequest = (request: AdapterRequest) => {
136
+ const bytes = new TextEncoder().encode(request.body).byteLength;
137
+ translatorBudget.chargeRetained(bytes, { kind: "request_copies" });
138
+ retainedRequestBytes = bytes;
139
+ };
140
+ try {
141
+ activeRequest = buildOpenAIChatPassthroughRequest(activeProvider, options.chatBody, route.modelId, requestedStream);
142
+ retainRequest(activeRequest);
143
+ } catch (error) {
144
+ releaseRetainedRequest();
145
+ cleanupAbort();
146
+ upstream.abort();
147
+ if (isTranslatorBudgetExceededError(error)) {
148
+ return fail(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit");
149
+ }
150
+ return fail(400, error instanceof Error ? error.message : String(error), "invalid_request_error");
151
+ }
152
+
153
+ const send = async (request: AdapterRequest, recovery?: "rate-limit-429" | "key-429"): Promise<Response> => {
154
+ try {
155
+ return await fetchWithResetRetry(
156
+ (transportRecovery?: UpstreamSendRecovery) => {
157
+ noteAttemptSend(attempt, logCtx.usageLogInputTokens, transportRecovery ?? recovery);
158
+ return fetchWithHeaderTimeout(
159
+ request.url,
160
+ applyUpstreamRecoveryInit({
161
+ method: request.method,
162
+ headers: request.headers,
163
+ body: request.body,
164
+ }, transportRecovery),
165
+ upstream.signal,
166
+ connectMs,
167
+ requestedStream,
168
+ providerFetch(activeProvider, undefined, {
169
+ providerName: route.providerName,
170
+ modelId: route.modelId,
171
+ }),
172
+ );
173
+ },
174
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
175
+ );
176
+ } finally {
177
+ request.releaseBodyObservation?.();
178
+ }
179
+ };
180
+
181
+ let response: Response;
182
+ try {
183
+ response = await send(activeRequest);
184
+ const retryPolicy = rateLimitRetryPolicyFor(activeProvider);
185
+ let retries = 0;
186
+ while (response.status === 429 && retryPolicy && retries < retryPolicy.attempts) {
187
+ retries += 1;
188
+ for await (const _ of prepareSameTarget429Wait({
189
+ body: response.body,
190
+ signal: upstream.signal,
191
+ delayMs: rateLimitRetryDelayMs(retryPolicy, response.headers.get("retry-after"), Date.now()),
192
+ })) { /* pre-stream wait */ }
193
+ if (upstream.signal.aborted) throw upstream.signal.reason;
194
+ response = await send(activeRequest, "rate-limit-429");
195
+ }
196
+ while (response.status === 429 && hasKeyPoolFailover(activeProvider)) {
197
+ const rotated = rotateProviderTransportOn429(config, route.providerName, activeProvider, {
198
+ retryAfter: response.headers.get("retry-after"),
199
+ now: Date.now(),
200
+ attemptedKey: activeProvider.apiKey,
201
+ promptCacheKey: typeof options.chatBody.prompt_cache_key === "string" ? options.chatBody.prompt_cache_key : undefined,
202
+ });
203
+ if (!rotated) break;
204
+ try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
205
+ activeProvider = rotated;
206
+ activeAdapter = createOpenAIChatAdapter(activeProvider);
207
+ releaseRetainedRequest();
208
+ activeRequest = buildOpenAIChatPassthroughRequest(activeProvider, options.chatBody, route.modelId, requestedStream);
209
+ retainRequest(activeRequest);
210
+ response = await send(activeRequest, "key-429");
211
+ }
212
+ } catch (error) {
213
+ releaseRetainedRequest();
214
+ cleanupAbort();
215
+ upstream.abort();
216
+ if (req.signal.aborted) return fail(499, "Client cancelled request", "client_cancelled");
217
+ return fail(502, error instanceof Error ? error.message : String(error), "server_error");
218
+ }
219
+ releaseRetainedRequest();
220
+
221
+ if (!response.ok) {
222
+ let bodyText = "";
223
+ try {
224
+ const body = await readBoundedResponseBody(response, {
225
+ signal: upstream.signal,
226
+ maxBytes: MAX_NATIVE_CHAT_ERROR_BYTES,
227
+ });
228
+ if (body.displaySafe) bodyText = body.text;
229
+ } catch { /* status-only fallback */ }
230
+ cleanupAbort();
231
+ if (req.signal.aborted) {
232
+ upstream.abort();
233
+ return fail(499, "Client cancelled request", "client_cancelled");
234
+ }
235
+ const detail = activeAdapter.formatErrorBody?.(response.status, response.headers, bodyText) ?? "";
236
+ let upstreamType: string | undefined;
237
+ let upstreamCode: string | null | undefined;
238
+ try {
239
+ const parsedError = JSON.parse(bodyText) as Rec;
240
+ const nested = isRec(parsedError.error) ? parsedError.error : undefined;
241
+ if (typeof nested?.type === "string") upstreamType = nested.type;
242
+ if (nested?.code === null || typeof nested?.code === "string") upstreamCode = nested.code;
243
+ } catch { /* keep generic classification */ }
244
+ const message = detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`;
245
+ const classified = classifyError(
246
+ response.status,
247
+ upstreamType ?? (response.status === 401 ? "authentication_error"
248
+ : response.status === 429 ? "rate_limit_error"
249
+ : response.status >= 500 ? "server_error" : "invalid_request_error"),
250
+ message,
251
+ );
252
+ if (isCyberPolicyCode(upstreamCode)) {
253
+ classified.code = CYBER_POLICY_ERROR_CODE;
254
+ classified.type = "invalid_request_error";
255
+ } else if (upstreamCode === "model_not_found") {
256
+ classified.code = "model_not_found";
257
+ classified.type = "invalid_request_error";
258
+ } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) {
259
+ classified.code = upstreamCode;
260
+ }
261
+ const status = isCyberPolicyCode(classified.code) ? 400 : response.status;
262
+ const retryAfter = resolveClientRetryAfter({
263
+ status: response.status,
264
+ message: classified.message,
265
+ upstreamRetryAfter: response.headers.get("retry-after"),
266
+ });
267
+ finishLog(status, classified.message);
268
+ return new Response(JSON.stringify(chatCompletionsErrorBody(status, classified.message, classified.type, classified.code)), {
269
+ status,
270
+ headers: {
271
+ "Content-Type": "application/json",
272
+ ...(retryAfter ? { "Retry-After": retryAfter } : {}),
273
+ },
274
+ });
275
+ }
276
+
277
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
278
+ if (contentType.includes("text/event-stream") && response.body) {
279
+ const stream = nativeChatSse(response.body, {
280
+ requestedModel,
281
+ translatorBudget,
282
+ signal: upstream.signal,
283
+ onFirstOutput: logIds ? () => recordFirstOutput(logCtx, logIds.start) : undefined,
284
+ onUsage: usage => {
285
+ logCtx.usage = usage;
286
+ attempt.usage = usage;
287
+ },
288
+ ...(requestedStream ? {
289
+ onTerminal: (status: number, message?: string) => {
290
+ cleanupAbort();
291
+ finishLog(status, message, "terminal");
292
+ },
293
+ onCancel: () => {
294
+ cleanupAbort();
295
+ upstream.abort();
296
+ finishLog(499, undefined, "client_cancel");
297
+ },
298
+ } : {}),
299
+ });
300
+ if (requestedStream) {
301
+ return new Response(stream, {
302
+ status: 200,
303
+ headers: {
304
+ "Content-Type": "text/event-stream; charset=utf-8",
305
+ "Cache-Control": "no-cache",
306
+ Connection: "keep-alive",
307
+ },
308
+ });
309
+ }
310
+ try {
311
+ const completion = await collectChatCompletion(stream, requestedModel, translatorBudget);
312
+ cleanupAbort();
313
+ finishLog(200);
314
+ return Response.json(completion);
315
+ } catch (error) {
316
+ cleanupAbort();
317
+ upstream.abort();
318
+ if (isChatCompletionsStreamError(error)) {
319
+ return fail(error.status, error.message, error.type, error.code);
320
+ }
321
+ return fail(502, error instanceof Error ? error.message : String(error), "upstream_error");
322
+ }
323
+ }
324
+
325
+ let body;
326
+ try {
327
+ body = await readBoundedResponseBody(response, {
328
+ signal: upstream.signal,
329
+ maxBytes: MAX_NATIVE_CHAT_JSON_BYTES,
330
+ totalTimeoutMs: Math.max(connectMs, 5_000),
331
+ inactivityTimeoutMs: Math.max(connectMs, 5_000),
332
+ });
333
+ } catch (error) {
334
+ cleanupAbort();
335
+ upstream.abort();
336
+ if (req.signal.aborted) return fail(499, "Client cancelled request", "client_cancelled");
337
+ return fail(502, error instanceof Error ? error.message : String(error), "upstream_error");
338
+ }
339
+ cleanupAbort();
340
+ if (body.oversized) {
341
+ upstream.abort();
342
+ return fail(502, "upstream response exceeded the safe limit", "upstream_error", "translation_buffer_limit");
343
+ }
344
+ let parsedJson: unknown;
345
+ try {
346
+ parsedJson = JSON.parse(body.text);
347
+ } catch {
348
+ return fail(502, "upstream returned malformed Chat Completions JSON", "upstream_error");
349
+ }
350
+ const error = structuredError(parsedJson);
351
+ if (error) return fail(error.status ?? 502, error.message, error.type, error.code);
352
+ const completion = chatCompletionJson(parsedJson);
353
+ if (!completion) return fail(502, "upstream response contained no choices", "upstream_error");
354
+ const usage = usageFromChat(completion.usage);
355
+ if (usage) {
356
+ logCtx.usage = usage;
357
+ attempt.usage = usage;
358
+ }
359
+ if (logIds) recordFirstOutput(logCtx, logIds.start);
360
+ finishLog(200);
361
+ if (requestedStream) {
362
+ return new Response(jsonCompletionSse(completion, requestedModel), {
363
+ status: 200,
364
+ headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" },
365
+ });
366
+ }
367
+ return new Response(JSON.stringify(completion), {
368
+ status: 200,
369
+ headers: { "Content-Type": "application/json" },
370
+ });
371
+ }
@@ -65,6 +65,16 @@ import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, C
65
65
  import type { ManagementContext } from "./context";
66
66
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
67
67
 
68
+
69
+ /** Management wire shape: omit default imageInput "auto" (persist/response sparse). */
70
+ function sparseComboConfig<T extends { imageInput?: "auto" | "disabled" }>(combo: T): Omit<T, "imageInput"> & { imageInput?: "disabled" } {
71
+ const { imageInput, ...rest } = combo;
72
+ return {
73
+ ...rest,
74
+ ...(imageInput === "disabled" ? { imageInput: "disabled" as const } : {}),
75
+ };
76
+ }
77
+
68
78
  export async function handleComboRoutes(ctx: ManagementContext): Promise<Response | null> {
69
79
  const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
70
80
 
@@ -75,7 +85,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
75
85
  return {
76
86
  id,
77
87
  model: comboPublicModelId(id, combo),
78
- ...combo,
88
+ ...sparseComboConfig(combo),
79
89
  };
80
90
  }) });
81
91
  }
@@ -124,10 +134,12 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
124
134
  });
125
135
  if (error) return jsonResponse({ error }, 400);
126
136
  const normalized = normalizeComboConfig(body.combo as import("../../types").OcxComboConfig);
137
+ // Persist only non-default identity/capability fields so config stays sparse.
127
138
  const {
128
139
  alias: normalizedAlias,
129
140
  nativeAlias: normalizedNativeAlias,
130
141
  displayName: normalizedDisplayName,
142
+ imageInput: normalizedImageInput,
131
143
  ...normalizedBase
132
144
  } = normalized;
133
145
  const stored: import("../../types").OcxComboConfig = {
@@ -135,6 +147,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
135
147
  ...(normalizedAlias ? { alias: normalizedAlias } : {}),
136
148
  ...(normalizedNativeAlias ? { nativeAlias: true } : {}),
137
149
  ...(normalizedDisplayName ? { displayName: normalizedDisplayName } : {}),
150
+ ...(normalizedImageInput === "disabled" ? { imageInput: "disabled" as const } : {}),
138
151
  };
139
152
  const sourceId = renameFrom ?? id;
140
153
  const previous = config.combos?.[sourceId];
@@ -221,7 +234,8 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
221
234
  }
222
235
  const catalogRefresh = await convergeCodexCatalog();
223
236
  if (shouldSyncClaudeAgentDefs) await syncClaudeAgentDefsBestEffort();
224
- return jsonResponse({ success: true, id, model: newPublicModel, combo: stored, catalogRefresh });
237
+ // Wire shape matches persistence: omit default imageInput "auto".
238
+ return jsonResponse({ success: true, id, model: newPublicModel, combo: sparseComboConfig(stored), catalogRefresh });
225
239
  }
226
240
 
227
241
  if (url.pathname === "/api/combos" && req.method === "DELETE") {
@@ -85,6 +85,7 @@ import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from ".
85
85
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
86
86
  import type { PersistedUsageAttempt } from "../../usage/log";
87
87
  import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
88
+ import { withProviderServiceTierDTO } from "./provider-capability-config";
88
89
  import { applySystemEnvToggle } from "../system-env";
89
90
  import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache";
90
91
  import { runWindowsTrayAction } from "../windows-tray-control";
@@ -134,8 +135,9 @@ function publicVisionSidecarSettings(
134
135
 
135
136
  export async function handleConfigRoutes(ctx: ManagementContext): Promise<Response | null> {
136
137
  const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
138
+ const readStartupHealth = deps.getCachedStartupHealth ?? getCachedStartupHealth;
137
139
  if (url.pathname === "/api/config" && req.method === "GET") {
138
- return jsonResponse(safeConfigDTO(config));
140
+ return jsonResponse(withProviderServiceTierDTO(safeConfigDTO(config), config));
139
141
  }
140
142
 
141
143
  if (url.pathname === "/api/config" && req.method === "PUT") {
@@ -189,7 +191,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
189
191
  streamMode: config.streamMode ?? "auto",
190
192
  appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256,
191
193
  codexAccountPickerEnabled: codexAccountPickerEnabled(config),
192
- startupHealth: await getCachedStartupHealth(config),
194
+ startupHealth: await readStartupHealth(config),
193
195
  codexRuntime: {
194
196
  path: displayCodexRuntimePath(resolved.runtime.command),
195
197
  version: resolved.runtime.version,
@@ -211,7 +213,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
211
213
  }
212
214
 
213
215
  if (url.pathname === "/api/startup-health" && req.method === "GET") {
214
- return jsonResponse(await getCachedStartupHealth(config));
216
+ return jsonResponse(await readStartupHealth(config));
215
217
  }
216
218
 
217
219
  if (url.pathname === "/api/startup-action" && req.method === "POST") {
@@ -368,7 +370,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
368
370
  appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256,
369
371
  codexAccountPickerEnabled: pickerIsEnabled,
370
372
  catalogRefreshPending,
371
- startupHealth: await getCachedStartupHealth(config),
373
+ startupHealth: await readStartupHealth(config),
372
374
  });
373
375
  }
374
376
 
@@ -1,5 +1,6 @@
1
1
  import type { OcxConfig } from "../../types";
2
2
  import type { NativeProfileApiDeps } from "../../codex/native-profile-api";
3
+ import type { StartupHealth } from "../../codex/autostart-health";
3
4
  import type { StartupInstallAction } from "../startup-action-control";
4
5
  import type { ManagementPrincipal } from "../management-auth";
5
6
  import type { CatalogModel } from "../../codex/catalog";
@@ -7,11 +8,17 @@ import type { injectGrokConfig } from "../../grok/inject";
7
8
  import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
8
9
  import type { RuntimePortState } from "../../config";
9
10
  import type { CatalogDisposition, ConvergeCodex } from "../../codex/convergence-types";
11
+ import type {
12
+ performCodexRestart,
13
+ readCodexAppServerState,
14
+ } from "../../codex/app-server-restart-service";
10
15
 
11
16
  export interface ManagementApiDeps {
12
17
  toggleCodexMultiAgentV2?: (enabled: boolean) => void;
13
18
  toggleDefaultModeRequestUserInput?: (enabled: boolean) => void;
14
19
  createManagementConvergeCodex?: (config: Readonly<OcxConfig>) => ConvergeCodex;
20
+ /** Startup-health seam keeps route tests from launching platform probes. */
21
+ getCachedStartupHealth?: (config: Pick<OcxConfig, "codexAutoStart">) => Promise<StartupHealth>;
15
22
  /**
16
23
  * Persistence seam for route-level tests. Production leaves this unset and uses
17
24
  * `saveConfigPreservingClaudeCode`; tests that pass an in-memory fixture config
@@ -52,6 +59,16 @@ export interface ManagementApiDeps {
52
59
  * Native-main profile persistence seam for server-boundary tests. Production
53
60
  * leaves this unset, so the route creates its normal NativeProfileManager.
54
61
  */
62
+ /**
63
+ * Codex app-server restart seam (devlog/_plan/260815_gui_codex_restart).
64
+ * Grouped rather than three separate fields: the route is an adapter over one
65
+ * service, and a route test that could not stub it would really terminate the
66
+ * developer's own Codex app-servers.
67
+ */
68
+ codexRestartService?: {
69
+ readState: typeof readCodexAppServerState;
70
+ performRestart: typeof performCodexRestart;
71
+ };
55
72
  nativeProfileApi?: NativeProfileApiDeps;
56
73
  }
57
74