@bitkyc08/opencodex 2.48.0 → 2.50.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.
- package/AGENTS_INSTALL.md +9 -1
- package/README.md +11 -5
- package/SPONSORS.md +1 -1
- package/assets/sponsors/orcarouter.png +0 -0
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/index-BoBRSehJ.css +1 -0
- package/gui/dist/assets/index-C39tnjXO.js +115 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/packycode.svg +19 -0
- package/gui/dist/provider-icons/qoder.svg +5 -0
- package/package.json +5 -3
- package/src/adapters/anthropic.ts +31 -16
- package/src/adapters/codebuddy/adapter.ts +85 -0
- package/src/adapters/codebuddy/profiles.ts +52 -0
- package/src/adapters/coding-agent/profile.ts +100 -0
- package/src/adapters/coding-agent/protocol.ts +463 -0
- package/src/adapters/coding-agent/turn.ts +353 -0
- package/src/adapters/google.ts +15 -11
- package/src/adapters/mimo-free.ts +3 -0
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses.ts +18 -11
- package/src/adapters/qoder/adapter.ts +70 -0
- package/src/adapters/qoder/live-models.ts +89 -0
- package/src/adapters/qoder/profiles.ts +36 -0
- package/src/adapters/registry.ts +12 -0
- package/src/adapters/responses-tool-schema.ts +113 -8
- package/src/claude/inbound.ts +17 -5
- package/src/cli/account-api.ts +18 -3
- package/src/cli/account-auth.ts +8 -1
- package/src/cli/account-extended.ts +2 -1
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +15 -1
- package/src/cli/dispatch.ts +2 -0
- package/src/cli/doctor.ts +40 -0
- package/src/cli/effort.ts +24 -8
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +29 -2
- package/src/cli/models-runtime.ts +8 -3
- package/src/cli/observe.ts +13 -3
- package/src/cli/provider-runtime.ts +2 -1
- package/src/cli/registry.ts +2 -2
- package/src/cli/system-command.ts +10 -3
- package/src/cli/usage-report.ts +9 -5
- package/src/clients/config-export/zcode.ts +24 -0
- package/src/codex/account-lifecycle.ts +35 -2
- package/src/codex/account-runtime-state.ts +6 -1
- package/src/codex/account-store.ts +72 -9
- package/src/codex/account-usability.ts +3 -2
- package/src/codex/auth-api.ts +113 -26
- package/src/codex/auth-collision.ts +12 -2
- package/src/codex/auth-context.ts +96 -7
- package/src/codex/catalog/parsing.ts +23 -0
- package/src/codex/catalog/provider-fetch.ts +144 -11
- package/src/codex/catalog/sync.ts +14 -0
- package/src/codex/inject.ts +128 -30
- package/src/codex/internal/catalog-writer.ts +3 -0
- package/src/codex/journal.ts +61 -12
- package/src/codex/model-cache.ts +11 -4
- package/src/codex/native-profile-startup.ts +72 -5
- package/src/codex/native-profile-store.ts +2 -2
- package/src/codex/ocx-compaction-history.ts +226 -0
- package/src/codex/project-config-warnings.ts +3 -1
- package/src/codex/quota-auto-refresh.ts +6 -1
- package/src/codex/quota.ts +71 -15
- package/src/codex/reserve-availability.ts +21 -5
- package/src/codex/runtime.ts +45 -1
- package/src/codex/sync.ts +5 -0
- package/src/combos/index.ts +2 -0
- package/src/combos/resolve.ts +52 -0
- package/src/config.ts +59 -0
- package/src/generated/compatibility-version.json +178 -114
- package/src/images/loop.ts +1 -0
- package/src/images/xai-video-client.ts +2 -0
- package/src/integrations/registry.ts +1 -0
- package/src/lib/errors.ts +8 -0
- package/src/lib/privacy.ts +25 -0
- package/src/lib/process-control.ts +52 -8
- package/src/lib/upstream-retry.ts +1 -0
- package/src/oauth/chatgpt.ts +83 -0
- package/src/oauth/health.ts +47 -12
- package/src/oauth/index.ts +46 -8
- package/src/oauth/token-guardian.ts +32 -6
- package/src/oauth/xai.ts +151 -8
- package/src/providers/api-key-selection-capture.ts +10 -0
- package/src/providers/api-key-selection.ts +2 -7
- package/src/providers/caller-authorization.ts +36 -0
- package/src/providers/codebuddy-models.ts +184 -0
- package/src/providers/derive.ts +5 -0
- package/src/providers/free-directory.ts +26 -2
- package/src/providers/google-ai-studio-model-discovery.ts +74 -0
- package/src/providers/openai-sidecar.ts +35 -11
- package/src/providers/opencode-zen-rate-limit.ts +75 -0
- package/src/providers/qoder-models.ts +25 -0
- package/src/providers/quota.ts +15 -0
- package/src/providers/registry.ts +140 -1
- package/src/responses/compaction.ts +4 -0
- package/src/responses/task-input.ts +21 -1
- package/src/router.ts +1 -1
- package/src/server/auth-cors.ts +6 -0
- package/src/server/chat-completions.ts +30 -13
- package/src/server/chat-native.ts +10 -1
- package/src/server/claude-messages.ts +17 -7
- package/src/server/images.ts +3 -2
- package/src/server/index.ts +25 -2
- package/src/server/management/account-selection-stream.ts +13 -4
- package/src/server/management/config-routes.ts +24 -5
- package/src/server/management/logs-usage-routes.ts +5 -1
- package/src/server/management/model-rows.ts +16 -1
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/oauth-account-routes.ts +6 -2
- package/src/server/management/provider-routes.ts +33 -2
- package/src/server/management/request-history-routes.ts +4 -2
- package/src/server/management/route-registry.ts +5 -4
- package/src/server/management/shared.ts +66 -3
- package/src/server/management-api.ts +15 -1
- package/src/server/port-reclaim.ts +11 -26
- package/src/server/request-decompress.ts +91 -3
- package/src/server/request-log.ts +16 -0
- package/src/server/responses/codex-ws-wire.ts +1 -1
- package/src/server/responses/collaboration.ts +4 -9
- package/src/server/responses/compact.ts +8 -2
- package/src/server/responses/context-overflow.ts +11 -0
- package/src/server/responses/core.ts +285 -57
- package/src/server/responses/fetch-helpers.ts +18 -7
- package/src/server/responses/policy-fallback.ts +18 -2
- package/src/server/search.ts +2 -2
- package/src/service.ts +128 -9
- package/src/storage/cleanup.ts +77 -45
- package/src/types/accounts.ts +18 -0
- package/src/types/config.ts +43 -1
- package/src/types/provider.ts +56 -0
- package/src/types.ts +4 -0
- package/src/usage/log.ts +24 -0
- package/src/vision/anthropic-describe.ts +1 -0
- package/src/web-search/anthropic-executor.ts +1 -0
- package/src/web-search/loop.ts +1 -0
- package/src/web-search/ollama-executor.ts +127 -0
- package/src/web-search/passthrough-bridge.ts +761 -0
- package/src/web-search/progress-stream.ts +4 -0
- package/gui/dist/assets/index-B5r7LNHN.js +0 -115
- package/gui/dist/assets/index-D5SiRo8X.css +0 -1
package/src/server/images.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { sidecarEnter } from "../lib/sidecar-tracker";
|
|
|
29
29
|
import type { OcxConfig } from "../types";
|
|
30
30
|
import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar";
|
|
31
31
|
import { getProviderRegistryEntry } from "../providers/registry";
|
|
32
|
-
import { readJsonRequestBody } from "./request-decompress";
|
|
32
|
+
import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
|
|
33
33
|
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
|
|
34
34
|
import type { RequestLogContext } from "./request-log";
|
|
35
35
|
import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
|
|
@@ -288,6 +288,7 @@ async function tryCcaImageGeneration(
|
|
|
288
288
|
try {
|
|
289
289
|
upstream = await fetch(`${baseUrl}/v1internal:generateContent`, {
|
|
290
290
|
method: "POST",
|
|
291
|
+
redirect: "manual",
|
|
291
292
|
headers: {
|
|
292
293
|
"Content-Type": "application/json",
|
|
293
294
|
"Authorization": `Bearer ${token}`,
|
|
@@ -601,7 +602,7 @@ export async function handleImages(
|
|
|
601
602
|
): Promise<Response> {
|
|
602
603
|
let body: unknown;
|
|
603
604
|
try {
|
|
604
|
-
body = await readJsonRequestBody(req);
|
|
605
|
+
body = await readJsonRequestBody(req, undefined, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
|
|
605
606
|
} catch (err) {
|
|
606
607
|
return decodeRequestErrorResponse(err, "images");
|
|
607
608
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -63,7 +63,11 @@ import { runModelRenameStartupMigration } from "../providers/model-rename-startu
|
|
|
63
63
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
64
64
|
import { providerCodexAccountMode } from "../providers/registry";
|
|
65
65
|
import type { StorageCleanupPolicy } from "../types";
|
|
66
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
MAX_CONFIGURABLE_INBOUND_BODY_BYTES,
|
|
68
|
+
MIN_CONFIGURABLE_INBOUND_BODY_BYTES,
|
|
69
|
+
resolveInboundBodyLimitBytes,
|
|
70
|
+
} from "./request-decompress";
|
|
67
71
|
import {
|
|
68
72
|
CodexAccountCooldownError,
|
|
69
73
|
cooldownErrorMessage,
|
|
@@ -1023,6 +1027,22 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1023
1027
|
let loopbackServer: Server<WsData> | null = null;
|
|
1024
1028
|
let managementIngressServer: Server<WsData> | null = null;
|
|
1025
1029
|
|
|
1030
|
+
// Resolved once, before any listener binds. The clamp is silent inside the resolver so it
|
|
1031
|
+
// stays pure and per-request cheap; the operator is told here instead, once, because a
|
|
1032
|
+
// config value that was quietly reduced is exactly the thing they would otherwise debug
|
|
1033
|
+
// against the wrong limit.
|
|
1034
|
+
const inboundBodyLimitBytes = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes);
|
|
1035
|
+
const requestedInboundBodyLimit = config.maxInboundBodyBytes;
|
|
1036
|
+
if (requestedInboundBodyLimit !== undefined
|
|
1037
|
+
&& requestedInboundBodyLimit > 0
|
|
1038
|
+
&& requestedInboundBodyLimit !== inboundBodyLimitBytes) {
|
|
1039
|
+
console.warn(
|
|
1040
|
+
`[server] maxInboundBodyBytes=${requestedInboundBodyLimit} is outside the supported range `
|
|
1041
|
+
+ `[${MIN_CONFIGURABLE_INBOUND_BODY_BYTES}, ${MAX_CONFIGURABLE_INBOUND_BODY_BYTES}]; `
|
|
1042
|
+
+ `using ${inboundBodyLimitBytes} bytes.`,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1026
1046
|
type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management";
|
|
1027
1047
|
function ingressForServer(requestServer: Server<WsData>): ServerIngress {
|
|
1028
1048
|
if (requestServer === loopbackServer) return "unauthenticated-loopback";
|
|
@@ -1042,7 +1062,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1042
1062
|
userCostOverlayReconciler = startUserCostOverlayReconciler({ liveConfig: config });
|
|
1043
1063
|
const serveOptions = {
|
|
1044
1064
|
idleTimeout: 255,
|
|
1045
|
-
|
|
1065
|
+
// Bun rejects an oversized body before `fetch` runs, so the listener has to be raised
|
|
1066
|
+
// with the admission limit or the opt-in would do nothing. Fixed at bind time: a live
|
|
1067
|
+
// `maxInboundBodyBytes` edit needs a restart, which the config doc states.
|
|
1068
|
+
maxRequestBodySize: inboundBodyLimitBytes,
|
|
1046
1069
|
async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
|
|
1047
1070
|
const ingress = ingressForServer(requestServer);
|
|
1048
1071
|
// The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
|
|
@@ -3,6 +3,7 @@ import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks"
|
|
|
3
3
|
|
|
4
4
|
const MAX_SELECTION_STREAMS = 64;
|
|
5
5
|
const HEARTBEAT_MS = 15_000;
|
|
6
|
+
const STREAM_QUEUE_HIGH_WATER_MARK = 16;
|
|
6
7
|
const encoder = new TextEncoder();
|
|
7
8
|
const connections = new Set<() => void>();
|
|
8
9
|
|
|
@@ -36,9 +37,17 @@ export function accountSelectionStream(request: Request, validate: () => boolean
|
|
|
36
37
|
const send = (frame: string) => {
|
|
37
38
|
if (closed) return;
|
|
38
39
|
if (!authorized()) {
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// A revoked consumer must not drain frames queued before revocation: error() is
|
|
41
|
+
// what discards a non-empty queue. When nothing is queued — the common expired-session
|
|
42
|
+
// path, including the heartbeat — close() alone terminates quietly, so an expired
|
|
43
|
+
// dashboard session does not dump an expected DOMException into the server console.
|
|
44
|
+
// desiredSize equals the high water mark exactly when the queue is empty.
|
|
45
|
+
if (controller.desiredSize !== null && controller.desiredSize < STREAM_QUEUE_HIGH_WATER_MARK) {
|
|
46
|
+
try { controller.error(new DOMException("Management session is no longer authorized", "NotAllowedError")); }
|
|
47
|
+
finally { close(); }
|
|
48
|
+
} else {
|
|
49
|
+
close();
|
|
50
|
+
}
|
|
42
51
|
return;
|
|
43
52
|
}
|
|
44
53
|
// Reconnection sends a ready event, so a slow reader can reconcile without an
|
|
@@ -61,7 +70,7 @@ export function accountSelectionStream(request: Request, validate: () => boolean
|
|
|
61
70
|
heartbeat.unref?.();
|
|
62
71
|
},
|
|
63
72
|
cancel() { cleanup(); },
|
|
64
|
-
}, { highWaterMark:
|
|
73
|
+
}, { highWaterMark: STREAM_QUEUE_HIGH_WATER_MARK });
|
|
65
74
|
return new Response(body, { headers: {
|
|
66
75
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
67
76
|
"Cache-Control": "no-cache, no-transform",
|
|
@@ -329,6 +329,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
329
329
|
oauthOpenBrowser: config.oauthOpenBrowser !== false,
|
|
330
330
|
// Absent means off (today's Design B injection), so the GUI/CLI render a plain switch.
|
|
331
331
|
codexDesktopAuthless: config.codexDesktopAuthless === true,
|
|
332
|
+
// Absent keeps Design B remote compaction; true selects the dedicated provider identity.
|
|
333
|
+
codexClientCompaction: config.codexClientCompaction === true,
|
|
332
334
|
startupHealth: await readStartupHealth(config),
|
|
333
335
|
codexRuntime: {
|
|
334
336
|
path: displayCodexRuntimePath(resolved.runtime.command),
|
|
@@ -419,6 +421,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
419
421
|
ultraFastTier?: unknown;
|
|
420
422
|
codexMainAccountHardLock?: unknown;
|
|
421
423
|
codexDesktopAuthless?: unknown;
|
|
424
|
+
codexClientCompaction?: unknown;
|
|
422
425
|
};
|
|
423
426
|
if (body.codexAutoStart === undefined
|
|
424
427
|
&& body.streamMode === undefined
|
|
@@ -429,8 +432,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
429
432
|
&& body.showCodexSparkQuota === undefined
|
|
430
433
|
&& body.ultraFastTier === undefined
|
|
431
434
|
&& body.codexMainAccountHardLock === undefined
|
|
432
|
-
&& body.codexDesktopAuthless === undefined
|
|
433
|
-
|
|
435
|
+
&& body.codexDesktopAuthless === undefined
|
|
436
|
+
&& body.codexClientCompaction === undefined) {
|
|
437
|
+
return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, codexQuotaAutoRefresh, oauthOpenBrowser, showCodexSparkQuota, ultraFastTier, codexMainAccountHardLock, codexDesktopAuthless, or codexClientCompaction" }, 400);
|
|
434
438
|
}
|
|
435
439
|
if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
|
|
436
440
|
return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
|
|
@@ -457,6 +461,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
457
461
|
if (body.codexDesktopAuthless !== undefined && typeof body.codexDesktopAuthless !== "boolean") {
|
|
458
462
|
return jsonResponse({ error: "codexDesktopAuthless boolean is required" }, 400);
|
|
459
463
|
}
|
|
464
|
+
if (body.codexClientCompaction !== undefined && typeof body.codexClientCompaction !== "boolean") {
|
|
465
|
+
return jsonResponse({ error: "codexClientCompaction boolean is required" }, 400);
|
|
466
|
+
}
|
|
460
467
|
let quotaAutoRefreshChange: { id: string; window: "fiveHour" | "weekly"; enabled: boolean } | undefined;
|
|
461
468
|
if (body.codexQuotaAutoRefresh !== undefined) {
|
|
462
469
|
if (!isPlainRecord(body.codexQuotaAutoRefresh)) {
|
|
@@ -509,10 +516,13 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
509
516
|
hasCodexMainAccountHardLock: Object.hasOwn(config, "codexMainAccountHardLock"),
|
|
510
517
|
codexDesktopAuthless: config.codexDesktopAuthless,
|
|
511
518
|
hasCodexDesktopAuthless: Object.hasOwn(config, "codexDesktopAuthless"),
|
|
519
|
+
codexClientCompaction: config.codexClientCompaction,
|
|
520
|
+
hasCodexClientCompaction: Object.hasOwn(config, "codexClientCompaction"),
|
|
512
521
|
};
|
|
513
522
|
const pickerWasEnabled = codexAccountPickerEnabled(config);
|
|
514
523
|
let pickerIsEnabled = pickerWasEnabled;
|
|
515
524
|
const authlessWasEnabled = config.codexDesktopAuthless === true;
|
|
525
|
+
const clientCompactionWasEnabled = config.codexClientCompaction === true;
|
|
516
526
|
try {
|
|
517
527
|
if (typeof body.codexAutoStart === "boolean") {
|
|
518
528
|
config.codexAutoStart = body.codexAutoStart;
|
|
@@ -547,6 +557,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
547
557
|
else if (body.codexMainAccountHardLock === false) deleteConfigTopLevelKey(config, "codexMainAccountHardLock");
|
|
548
558
|
if (body.codexDesktopAuthless === true) config.codexDesktopAuthless = true;
|
|
549
559
|
else if (body.codexDesktopAuthless === false) deleteConfigTopLevelKey(config, "codexDesktopAuthless");
|
|
560
|
+
if (body.codexClientCompaction === true) config.codexClientCompaction = true;
|
|
561
|
+
else if (body.codexClientCompaction === false) deleteConfigTopLevelKey(config, "codexClientCompaction");
|
|
550
562
|
if (quotaAutoRefreshChange) {
|
|
551
563
|
const { id, window, enabled } = quotaAutoRefreshChange;
|
|
552
564
|
const setting = { ...(config.codexQuotaAutoRefresh?.[id] ?? {}) };
|
|
@@ -592,16 +604,22 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
592
604
|
if (previousSettings.hasCodexDesktopAuthless) {
|
|
593
605
|
config.codexDesktopAuthless = previousSettings.codexDesktopAuthless;
|
|
594
606
|
} else deleteConfigTopLevelKey(config, "codexDesktopAuthless");
|
|
607
|
+
if (previousSettings.hasCodexClientCompaction) {
|
|
608
|
+
config.codexClientCompaction = previousSettings.codexClientCompaction;
|
|
609
|
+
} else deleteConfigTopLevelKey(config, "codexClientCompaction");
|
|
595
610
|
throw error;
|
|
596
611
|
}
|
|
597
612
|
if (typeof body.appOwnedMemoryBudgetMb === "number") {
|
|
598
613
|
configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(body.appOwnedMemoryBudgetMb));
|
|
599
614
|
enforceAppOwnedMemoryBudget();
|
|
600
615
|
}
|
|
601
|
-
//
|
|
602
|
-
// waiting for the next start; the injector re-reads config and rewrites the form.
|
|
616
|
+
// Both Desktop compatibility switches change the injected config.toml shape, so converge now
|
|
617
|
+
// rather than waiting for the next start; the injector re-reads config and rewrites the form.
|
|
603
618
|
const authlessIsEnabled = config.codexDesktopAuthless === true;
|
|
604
|
-
const
|
|
619
|
+
const clientCompactionIsEnabled = config.codexClientCompaction === true;
|
|
620
|
+
const catalogRefresh = pickerWasEnabled !== pickerIsEnabled
|
|
621
|
+
|| authlessWasEnabled !== authlessIsEnabled
|
|
622
|
+
|| clientCompactionWasEnabled !== clientCompactionIsEnabled
|
|
605
623
|
? await convergeCodexCatalog()
|
|
606
624
|
: undefined;
|
|
607
625
|
const catalogRefreshPending = catalogRefresh
|
|
@@ -620,6 +638,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
620
638
|
catalogRefreshPending,
|
|
621
639
|
showCodexSparkQuota: config.showCodexSparkQuota === true,
|
|
622
640
|
codexDesktopAuthless: authlessIsEnabled,
|
|
641
|
+
codexClientCompaction: clientCompactionIsEnabled,
|
|
623
642
|
codexMainAccountHardLock: config.codexMainAccountHardLock === true,
|
|
624
643
|
mainAccountHardLock: getMainAccountHardLockStatus(config),
|
|
625
644
|
startupHealth: await readStartupHealth(config),
|
|
@@ -115,7 +115,10 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
115
115
|
}
|
|
116
116
|
const all = getRequestLogEntries();
|
|
117
117
|
const total = filteredRequestLogCount(all, url.searchParams);
|
|
118
|
-
|
|
118
|
+
// Not point-free: requestLogDto takes an options object second, and Array.map would pass the
|
|
119
|
+
// element INDEX into it. An explicit arrow keeps the default (decode rate included) and is
|
|
120
|
+
// what /api/logs wants; /api/request-history opts out at its own call sites.
|
|
121
|
+
const logs = filterRequestLogs(all, url.searchParams).map(entry => requestLogDto(entry));
|
|
119
122
|
const poll = selectRequestLogPoll(logs, url.searchParams, cursor);
|
|
120
123
|
return jsonResponse({
|
|
121
124
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
@@ -425,6 +428,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
425
428
|
bytes: result.bytes,
|
|
426
429
|
...(result.trashDir ? { trashDir: result.trashDir } : {}),
|
|
427
430
|
removedPaths: result.removedPaths,
|
|
431
|
+
...(result.skippedReferencedPaths?.length ? { skippedReferencedPaths: result.skippedReferencedPaths } : {}),
|
|
428
432
|
});
|
|
429
433
|
} catch {
|
|
430
434
|
return jsonResponse({
|
|
@@ -147,10 +147,25 @@ export async function listManagementModelRows(
|
|
|
147
147
|
};
|
|
148
148
|
});
|
|
149
149
|
const publicModels = uniqueCatalogModelsForPublicList(models);
|
|
150
|
+
// Custom rows below are REBUILT from config.customModels rather than spread from a
|
|
151
|
+
// CatalogModel, so every field gather computed for the same slug has to be carried across by
|
|
152
|
+
// hand. Without this a custom model whose provider is out of credit would be the one row on
|
|
153
|
+
// the page that never shows as inactive (#1711), because the gather-derived row it replaces
|
|
154
|
+
// is dropped by the slug dedup below.
|
|
155
|
+
const quotaInactiveByNamespaced = new Map(
|
|
156
|
+
publicModels
|
|
157
|
+
.filter(model => model.quotaInactiveReason !== undefined)
|
|
158
|
+
.map(model => [catalogModelSlug(model), model.quotaInactiveReason!] as const),
|
|
159
|
+
);
|
|
150
160
|
const comboNamespaced = new Set(
|
|
151
161
|
publicModels.filter(model => model.provider === "combo").map(catalogModelSlug),
|
|
152
162
|
);
|
|
153
|
-
const visibleCustomModels = customModels
|
|
163
|
+
const visibleCustomModels = customModels
|
|
164
|
+
.filter(model => !comboNamespaced.has(model.namespaced))
|
|
165
|
+
.map(model => {
|
|
166
|
+
const quotaInactiveReason = quotaInactiveByNamespaced.get(model.namespaced);
|
|
167
|
+
return quotaInactiveReason ? { ...model, quotaInactiveReason } : model;
|
|
168
|
+
});
|
|
154
169
|
// Custom metadata wins when a physical live/static row resolves to the same Codex-facing
|
|
155
170
|
// slug, while a combo keeps the same precedence it has in routing and /v1/models.
|
|
156
171
|
const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced));
|
|
@@ -366,13 +366,14 @@ async function handleCodexToggle(ctx: ManagementContext): Promise<Response> {
|
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
368
|
const { restoreNativeCodexAsync } = await import("../../codex/inject");
|
|
369
|
+
const { OCX_NATIVE_REPLAY_RECOVERY_NOTE } = await import("../../responses/compaction");
|
|
369
370
|
const restored = await restoreNativeCodexAsync({ revalidateDesiredState: true });
|
|
370
371
|
return jsonResponse({
|
|
371
372
|
ok: true, clientId: "codex", changed: durable && persisted.status === "committed",
|
|
372
373
|
state: restored.success ? "absent" : "unsafe",
|
|
373
374
|
desiredEnabled: enabled,
|
|
374
375
|
message: restored.success
|
|
375
|
-
?
|
|
376
|
+
? `Codex restored to its native path; the proxy is still serving other clients. ${OCX_NATIVE_REPLAY_RECOVERY_NOTE}`
|
|
376
377
|
: `Codex intent saved, but restoring the native path did not complete: ${restored.message}`,
|
|
377
378
|
...(restored.success
|
|
378
379
|
? (durable ? {} : { reason: "not_durable" })
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "../../oauth";
|
|
26
26
|
import { OAuthMutationBusyError, removeCredential } from "../../oauth/store";
|
|
27
27
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
28
|
+
import { emailMaskingEnabled } from "../../lib/privacy";
|
|
28
29
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
29
30
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
30
31
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
@@ -233,7 +234,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
233
234
|
if (url.pathname === "/api/oauth/status" && req.method === "GET") {
|
|
234
235
|
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
235
236
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
236
|
-
|
|
237
|
+
// Resolved here, at the request boundary that already holds the config, and passed down.
|
|
238
|
+
// getLoginStatus stays free of config I/O. This route does not re-mask afterwards: it
|
|
239
|
+
// consumes the already-projected status rather than redacting a second time.
|
|
240
|
+
const status = getLoginStatus(provider, emailMaskingEnabled(config));
|
|
237
241
|
return jsonResponse(status);
|
|
238
242
|
}
|
|
239
243
|
|
|
@@ -269,7 +273,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
269
273
|
} = await import("../../oauth/health");
|
|
270
274
|
const projectAccounts = () => {
|
|
271
275
|
const set = getAccountSet(provider);
|
|
272
|
-
const current = getLoginStatus(provider);
|
|
276
|
+
const current = getLoginStatus(provider, emailMaskingEnabled(config));
|
|
273
277
|
return {
|
|
274
278
|
activeAccountId: current.activeAccountId ?? null,
|
|
275
279
|
accounts: (current.accounts ?? []).map(summary => {
|
|
@@ -40,6 +40,8 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
|
40
40
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
41
41
|
import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, providerRedirectError } from "../../lib/provider-outbound";
|
|
42
42
|
import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
|
|
43
|
+
import { fetchQoderModels } from "../../adapters/qoder/live-models";
|
|
44
|
+
import { resolveQoderProfile } from "../../adapters/qoder/profiles";
|
|
43
45
|
import { parseAntigravityAvailableModels } from "../../providers/antigravity-models";
|
|
44
46
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
45
47
|
import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive";
|
|
@@ -52,6 +54,7 @@ import {
|
|
|
52
54
|
readBoundedDiscoveryJson,
|
|
53
55
|
resolveProviderModelDiscovery,
|
|
54
56
|
} from "../../providers/model-discovery";
|
|
57
|
+
import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery";
|
|
55
58
|
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
56
59
|
import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
|
|
57
60
|
import { clearKeyCooldowns } from "../../providers/key-failover";
|
|
@@ -1350,6 +1353,28 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1350
1353
|
message: `Connected. ${live.models.length} models.`,
|
|
1351
1354
|
});
|
|
1352
1355
|
}
|
|
1356
|
+
if (prov.adapter === "qoder") {
|
|
1357
|
+
const started = Date.now();
|
|
1358
|
+
const profile = resolveQoderProfile(prov.baseUrl);
|
|
1359
|
+
if (!profile) {
|
|
1360
|
+
return jsonResponse({ ok: false, latencyMs: 0, error: "qoder discovery refused a non-canonical destination" });
|
|
1361
|
+
}
|
|
1362
|
+
const live = await fetchQoderModels(profile, apiKey ?? "");
|
|
1363
|
+
const latencyMs = Date.now() - started;
|
|
1364
|
+
if (!live.ok) {
|
|
1365
|
+
return jsonResponse({
|
|
1366
|
+
ok: false,
|
|
1367
|
+
latencyMs,
|
|
1368
|
+
error: `qoder discovery ${live.error}${live.detail ? `: ${live.detail}` : ""}`,
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
return jsonResponse({
|
|
1372
|
+
ok: true,
|
|
1373
|
+
latencyMs,
|
|
1374
|
+
models: live.models.length,
|
|
1375
|
+
message: `Connected. ${live.models.length} models.`,
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1353
1378
|
const project = prov.project ?? snapshot?.projectId;
|
|
1354
1379
|
if (antigravity && !project) {
|
|
1355
1380
|
return jsonResponse({ ok: false, latencyMs: 0, error: "Antigravity project unavailable — re-run `ocx login google-antigravity`" });
|
|
@@ -1404,13 +1429,19 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1404
1429
|
return jsonResponse({ ok: false, latencyMs, error: "upstream CCA model discovery returned an unexpected shape" });
|
|
1405
1430
|
}
|
|
1406
1431
|
// OpenAI-style lists (and Together top-level arrays) use the same validation/dedupe/filter
|
|
1407
|
-
// as catalog discovery. Google
|
|
1408
|
-
//
|
|
1432
|
+
// as catalog discovery. Google AI Studio parses the native `models[]` envelope and filters to
|
|
1433
|
+
// `generateContent`, while other providers fall back to generic envelope rows if they return `models[]`.
|
|
1409
1434
|
const record = bounded.value !== null && typeof bounded.value === "object" && !Array.isArray(bounded.value)
|
|
1410
1435
|
? bounded.value as Record<string, unknown>
|
|
1411
1436
|
: undefined;
|
|
1437
|
+
const isAiStudio = effectiveGoogleMode(name, prov) === "ai-studio";
|
|
1438
|
+
const googleAiStudio = !ccaModels && isAiStudio
|
|
1439
|
+
? extractGoogleAiStudioModelItems(bounded.value, discovery.maxModels)
|
|
1440
|
+
: undefined;
|
|
1412
1441
|
const extracted = ccaModels
|
|
1413
1442
|
? undefined
|
|
1443
|
+
: googleAiStudio?.ok
|
|
1444
|
+
? googleAiStudio
|
|
1414
1445
|
: Array.isArray(bounded.value) || Array.isArray(record?.data)
|
|
1415
1446
|
? extractProviderModelItems(bounded.value, discovery)
|
|
1416
1447
|
: extractModelEnvelopeRows(bounded.value, discovery.maxModels, ["models"]);
|
|
@@ -106,7 +106,9 @@ export async function handleRequestHistoryRoutes(ctx: ManagementContext): Promis
|
|
|
106
106
|
to,
|
|
107
107
|
}, cursor, limit);
|
|
108
108
|
return jsonResponse({
|
|
109
|
-
|
|
109
|
+
// The decode rate is a Logs-page metric; this endpoint shares the DTO but not its
|
|
110
|
+
// contract, so it opts out rather than silently widening its own response shape (#4038).
|
|
111
|
+
entries: page.rows.map(row => requestLogDto(requestLogEntryFromPersistedUsage(row), { includeDecodeRate: false })),
|
|
110
112
|
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
111
113
|
hasMore: page.hasMore,
|
|
112
114
|
index: {
|
|
@@ -184,7 +186,7 @@ export async function handleRequestHistoryRoutes(ctx: ManagementContext): Promis
|
|
|
184
186
|
if (!entry) {
|
|
185
187
|
return jsonResponse({ error: { code: "not_found", message: "unknown request" } }, 404, req, config);
|
|
186
188
|
}
|
|
187
|
-
return jsonResponse(requestLogDto(requestLogEntryFromPersistedUsage(entry)), 200, req, config);
|
|
189
|
+
return jsonResponse(requestLogDto(requestLogEntryFromPersistedUsage(entry), { includeDecodeRate: false }), 200, req, config);
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
return null;
|
|
@@ -95,6 +95,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
95
95
|
{ method: "PATCH", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api", mutates: true },
|
|
96
96
|
{ method: "POST", path: "/api/codex-auth/accounts", module: "codex/auth-api", mutates: true },
|
|
97
97
|
{ method: "POST", path: "/api/codex-auth/accounts/clear-cooldown", module: "codex/auth-api", mutates: true },
|
|
98
|
+
{ method: "POST", path: "/api/codex-auth/accounts/refresh", module: "codex/auth-api", mutates: true },
|
|
98
99
|
{ method: "POST", path: "/api/codex-auth/login", module: "codex/auth-api", mutates: true },
|
|
99
100
|
{ method: "POST", path: "/api/codex-auth/login/cancel", module: "codex/auth-api", mutates: true },
|
|
100
101
|
{ method: "POST", path: "/api/codex-auth/login/code", module: "codex/auth-api", mutates: true },
|
|
@@ -148,9 +149,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
148
149
|
{ method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
|
|
149
150
|
{ method: "PUT", path: "/api/client-integrations/aside/profiles/{profileId}", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" },
|
|
150
151
|
{ method: "GET", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
|
|
151
|
-
{ method: "DELETE", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside history deletion uses the dashboard journal cleanup; the CLI has history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/
|
|
152
|
+
{ method: "DELETE", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside history deletion uses the dashboard journal cleanup; the CLI has history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
|
|
152
153
|
{ method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
|
|
153
|
-
{ method: "DELETE", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside profile history deletion uses the dashboard journal cleanup; the CLI has scoped history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/
|
|
154
|
+
{ method: "DELETE", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside profile history deletion uses the dashboard journal cleanup; the CLI has scoped history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
|
|
154
155
|
{ method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/restore", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" },
|
|
155
156
|
// server/management/codex-prompt-routes
|
|
156
157
|
{ method: "GET", path: "/api/codex-prompt", module: "server/management/codex-prompt-routes", mutates: false },
|
|
@@ -186,7 +187,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
186
187
|
// server/management/integration-routes
|
|
187
188
|
{ method: "GET", path: "/api/client-integrations", module: "server/management/integration-routes", mutates: false },
|
|
188
189
|
{ method: "GET", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: false },
|
|
189
|
-
{ method: "DELETE", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Retiring one rollback row is a dashboard-local cleanup; the CLI verb that would drive it is owed by a later work-phase and is not implemented here.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/
|
|
190
|
+
{ method: "DELETE", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Retiring one rollback row is a dashboard-local cleanup; the CLI verb that would drive it is owed by a later work-phase and is not implemented here.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
|
|
190
191
|
{ method: "POST", path: "/api/client-integrations/restore", module: "server/management/integration-routes", mutates: true },
|
|
191
192
|
// server/management/lab-automation-routes
|
|
192
193
|
{ method: "GET", path: "/api/lab/automation", module: "server/management/lab-automation-routes", mutates: false, exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } },
|
|
@@ -292,7 +293,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
292
293
|
{ method: "PATCH", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
|
|
293
294
|
{ method: "POST", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
|
|
294
295
|
{ method: "POST", path: "/api/providers/test", module: "server/management/provider-routes", mutates: true },
|
|
295
|
-
{ method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Issue #3280 scopes this atomic batch endpoint to the GUI JSON editor; a matching CLI verb is outside wp5 and remains owed.", owner: "wp5-followup", ownerDoc: "devlog/
|
|
296
|
+
{ method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Issue #3280 scopes this atomic batch endpoint to the GUI JSON editor; a matching CLI verb is outside wp5 and remains owed.", owner: "wp5-followup", ownerDoc: "devlog/_fin/260903_bug_drawdown_bcda/050_phase5.md" } },
|
|
296
297
|
{ method: "PUT", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: true },
|
|
297
298
|
// server/management/quota-reset-routes
|
|
298
299
|
{ method: "GET", path: "/api/quota-resets", module: "server/management/quota-reset-routes", mutates: false, mechanism: "negated-guard" },
|
|
@@ -79,7 +79,8 @@ export function parseDebugLogQuery(url: URL): { after: number; limit: number } {
|
|
|
79
79
|
export type MetricUnavailableReason =
|
|
80
80
|
| "usage_missing" | "usage_unsupported" | "output_missing" | "invalid_duration"
|
|
81
81
|
| "price_unmatched" | "invalid_cache_breakdown"
|
|
82
|
-
| "invalid_usage" | "combo_attempt_unavailable"
|
|
82
|
+
| "invalid_usage" | "combo_attempt_unavailable"
|
|
83
|
+
| "ttft_missing" | "decode_window_too_short";
|
|
83
84
|
|
|
84
85
|
export type TokPerSecondResult =
|
|
85
86
|
| { kind: "value"; value: number; estimated: boolean }
|
|
@@ -96,7 +97,7 @@ export type CostResult =
|
|
|
96
97
|
| { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
|
|
97
98
|
| { kind: "unavailable"; reason: MetricUnavailableReason };
|
|
98
99
|
|
|
99
|
-
export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier" | "tierOutcome" | "routeDecision"> & {
|
|
100
|
+
export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "firstOutputMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier" | "tierOutcome" | "routeDecision"> & {
|
|
100
101
|
attempts?: readonly PersistedUsageAttempt[];
|
|
101
102
|
};
|
|
102
103
|
|
|
@@ -113,6 +114,51 @@ export function tokPerSecondResult(entry: Pick<MetricSource, "durationMs" | "usa
|
|
|
113
114
|
return { kind: "value", value, estimated: entry.usageStatus === "estimated" || entry.usage.estimated === true };
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Shortest post-TTFT window that can carry a decode-rate estimate (#4038).
|
|
119
|
+
*
|
|
120
|
+
* Below one second the window is dominated by things that are not decoding: TTFT jitter, the
|
|
121
|
+
* proxy's own buffering, and the granularity of the timestamps themselves. A 240-token response
|
|
122
|
+
* whose first token arrived 50 ms before the last one is not a 4800 tok/s model, and printing
|
|
123
|
+
* that number is worse than printing nothing — which is precisely why the earlier attempt at
|
|
124
|
+
* this metric (#4040) was closed as an unreliable estimate.
|
|
125
|
+
*/
|
|
126
|
+
export const MIN_DECODE_WINDOW_MS = 1_000;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Estimated DECODE throughput: output tokens over the window after the first token (#4038).
|
|
130
|
+
*
|
|
131
|
+
* Strictly additive. `tokensPerSecond`, `tokPerSecondResult`, `RequestLogEntry` and
|
|
132
|
+
* `usage.jsonl` are untouched, and the end-to-end rate beside it keeps meaning exactly what it
|
|
133
|
+
* has always meant — it is documented as end-to-end, so this is a missing metric rather than a
|
|
134
|
+
* miscalculated one.
|
|
135
|
+
*
|
|
136
|
+
* Always `estimated: true`. The proxy's TTFT is when the FIRST BYTE reached the proxy, which is
|
|
137
|
+
* not the provider's own generation start, so this can never be more than an estimate no matter
|
|
138
|
+
* how long the window is. Saying so in the payload is the honest half of the answer to #4040;
|
|
139
|
+
* MIN_DECODE_WINDOW_MS is the other half.
|
|
140
|
+
*/
|
|
141
|
+
export function decodeTokPerSecondResult(
|
|
142
|
+
entry: Pick<MetricSource, "durationMs" | "firstOutputMs" | "usageStatus" | "usage">,
|
|
143
|
+
): TokPerSecondResult {
|
|
144
|
+
if (!entry.usage) return { kind: "unavailable", reason: "usage_missing" };
|
|
145
|
+
if (entry.usageStatus === "unsupported") return { kind: "unavailable", reason: "usage_unsupported" };
|
|
146
|
+
if (entry.usage.outputTokens <= 0) return { kind: "unavailable", reason: "output_missing" };
|
|
147
|
+
// A row that predates TTFT capture, or a non-streaming turn that never recorded one, has no
|
|
148
|
+
// window to measure. That is a different fact from a bad duration, so it gets its own reason.
|
|
149
|
+
if (entry.firstOutputMs === undefined) return { kind: "unavailable", reason: "ttft_missing" };
|
|
150
|
+
if (!Number.isFinite(entry.firstOutputMs) || entry.firstOutputMs < 0 || !Number.isFinite(entry.durationMs)) {
|
|
151
|
+
return { kind: "unavailable", reason: "invalid_duration" };
|
|
152
|
+
}
|
|
153
|
+
const windowMs = entry.durationMs - entry.firstOutputMs;
|
|
154
|
+
// TTFT at or past the total duration means the two clocks disagree; there is no window.
|
|
155
|
+
if (windowMs <= 0) return { kind: "unavailable", reason: "invalid_duration" };
|
|
156
|
+
if (windowMs < MIN_DECODE_WINDOW_MS) return { kind: "unavailable", reason: "decode_window_too_short" };
|
|
157
|
+
const value = tokensPerSecond(entry.usage.outputTokens, windowMs);
|
|
158
|
+
if (value === null) return { kind: "unavailable", reason: "invalid_duration" };
|
|
159
|
+
return { kind: "value", value, estimated: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
116
162
|
export function unavailableCostReason(entry: MetricSource): MetricUnavailableReason {
|
|
117
163
|
// Normalizer-first classification: the landed normalizer recovers legacy
|
|
118
164
|
// cachedInputTokens=read+write rows via retry, so a raw read+write>input
|
|
@@ -152,11 +198,26 @@ export function costResult(entry: MetricSource): CostResult {
|
|
|
152
198
|
return { kind: "value", estimate, estimateReasons };
|
|
153
199
|
}
|
|
154
200
|
|
|
155
|
-
|
|
201
|
+
/**
|
|
202
|
+
* `/api/logs` row projection.
|
|
203
|
+
*
|
|
204
|
+
* `includeDecodeRate` exists because `/api/request-history` shares this DTO but not its
|
|
205
|
+
* contract (#4038). The value would be meaningful there — `firstOutputMs` does survive into a
|
|
206
|
+
* persisted-usage row — so this is a scope decision, not a correctness one: the decode rate is
|
|
207
|
+
* a Logs-page metric, and widening a separate endpoint's response shape is not this change's
|
|
208
|
+
* business. Flipping it on later is one argument.
|
|
209
|
+
*/
|
|
210
|
+
export function requestLogDto(
|
|
211
|
+
entry: RequestLogEntry,
|
|
212
|
+
{ includeDecodeRate = true }: { includeDecodeRate?: boolean } = {},
|
|
213
|
+
): Record<string, unknown> {
|
|
156
214
|
return {
|
|
157
215
|
...entry,
|
|
158
216
|
displayMetrics: {
|
|
159
217
|
tokPerSecond: tokPerSecondResult(entry),
|
|
218
|
+
// The parent uses the REQUEST's own TTFT. A combo parent must not borrow an attempt's,
|
|
219
|
+
// which would measure a window the parent never had.
|
|
220
|
+
...(includeDecodeRate ? { decodeTokPerSecond: decodeTokPerSecondResult(entry) } : {}),
|
|
160
221
|
cost: costResult(entry),
|
|
161
222
|
},
|
|
162
223
|
...(entry.attempts?.length
|
|
@@ -165,6 +226,8 @@ export function requestLogDto(entry: RequestLogEntry): Record<string, unknown> {
|
|
|
165
226
|
...attempt,
|
|
166
227
|
displayMetrics: {
|
|
167
228
|
tokPerSecond: tokPerSecondResult(attempt),
|
|
229
|
+
// Each attempt measures its own attempt-relative TTFT.
|
|
230
|
+
...(includeDecodeRate ? { decodeTokPerSecond: decodeTokPerSecondResult(attempt) } : {}),
|
|
168
231
|
cost: costResult({ ...attempt, attempts: undefined, routeDecision: entry.routeDecision, requestedServiceTier: entry.requestedServiceTier, configuredServiceTier: entry.configuredServiceTier, responseServiceTier: entry.responseServiceTier }),
|
|
169
232
|
},
|
|
170
233
|
})),
|
|
@@ -300,6 +300,20 @@ export async function handleManagementAPI(
|
|
|
300
300
|
message: "This proxy is managed by a Task Scheduler wrapper that can respawn it, so the stop must be run by `ocx stop`, which verifies the respawn window. Nothing was changed.",
|
|
301
301
|
}, 409, req, config);
|
|
302
302
|
}
|
|
303
|
+
if (respawnRisk === "self-unload") {
|
|
304
|
+
// This proxy IS the launchd/systemd job, so stopping the manager below would
|
|
305
|
+
// terminate the handler before the shared teardown at the end of this route restores
|
|
306
|
+
// the native Codex keys — the dashboard Stop button left `openai_base_url`,
|
|
307
|
+
// `experimental_realtime_ws_base_url` and `model_catalog_json` pointed at a dead
|
|
308
|
+
// proxy (#4023). Refuse before touching anything, like the Windows branch above.
|
|
309
|
+
// `ocx stop` is safe because it runs outside this process and owns the teardown
|
|
310
|
+
// through its receipt, which is why the receipt-backed caller never reaches here.
|
|
311
|
+
return jsonResponse({
|
|
312
|
+
success: false,
|
|
313
|
+
code: "self_unload_service",
|
|
314
|
+
message: "This proxy is running as the installed service, so stopping the manager from inside it would end this process before native Codex is restored. Run `ocx stop`, which stops the service from outside and completes the restore. Nothing was changed.",
|
|
315
|
+
}, 409, req, config);
|
|
316
|
+
}
|
|
303
317
|
if (respawnRisk === "unknown") {
|
|
304
318
|
// Do NOT send them to `ocx stop`: it maps the same unanswerable probe to a stop
|
|
305
319
|
// failure, so that advice would be a loop. The scheduler query itself is what needs
|
|
@@ -371,7 +385,7 @@ export async function handleManagementAPI(
|
|
|
371
385
|
const { ConfigMutationLockError } = await import("../config");
|
|
372
386
|
const { CodexCredentialRefreshLockTimeoutError } = await import("../codex/account-store");
|
|
373
387
|
try {
|
|
374
|
-
return await handleCodexAuthAPI(req, url, config, convergeCodexCatalog);
|
|
388
|
+
return await handleCodexAuthAPI(req, url, config, convergeCodexCatalog, principal);
|
|
375
389
|
} catch (error) {
|
|
376
390
|
// Credential writers remap ConfigMutationLockError to CodexCredentialRefreshLockTimeoutError;
|
|
377
391
|
// treat both as the same retryable busy response.
|