@bitkyc08/opencodex 2.6.26-preview.20260705 → 2.6.28-preview.20260707
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/README.md +1 -0
- package/bin/ocx.mjs +4 -4
- package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
- package/gui/dist/assets/index-CkV5xFA8.js +15 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/anthropic-image-guard.ts +195 -0
- package/src/adapters/anthropic.ts +85 -14
- package/src/adapters/cursor/cursor-errors.ts +2 -2
- package/src/adapters/cursor/live-transport.ts +1 -1
- package/src/adapters/cursor/transport-retry.ts +2 -2
- package/src/adapters/google-errors.ts +1 -1
- package/src/adapters/google-http.ts +1 -1
- package/src/adapters/google-truncation.ts +1 -1
- package/src/adapters/google.ts +1 -1
- package/src/adapters/kiro-errors.ts +1 -1
- package/src/adapters/kiro-retry.ts +1 -1
- package/src/adapters/kiro-truncation.ts +1 -1
- package/src/adapters/kiro.ts +1 -1
- package/src/adapters/openai-chat.ts +12 -2
- package/src/adapters/openai-responses.ts +126 -3
- package/src/bridge.ts +164 -7
- package/src/{doctor.ts → cli/doctor.ts} +21 -4
- package/src/{cli-help.ts → cli/help.ts} +1 -1
- package/src/cli/index.ts +584 -0
- package/src/{init.ts → cli/init.ts} +6 -6
- package/src/{cli-models.ts → cli/models.ts} +2 -2
- package/src/{cli-provider.ts → cli/provider.ts} +12 -8
- package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
- package/src/{cli-status.ts → cli/status.ts} +7 -7
- package/src/cli.ts +9 -575
- package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
- package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
- package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
- package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
- package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
- package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
- package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
- package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
- package/src/codex/history-migration-guardian.ts +102 -0
- package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
- package/src/{codex-home.ts → codex/home.ts} +1 -1
- package/src/{codex-inject.ts → codex/inject.ts} +204 -26
- package/src/{codex-journal.ts → codex/journal.ts} +2 -2
- package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
- package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
- package/src/{codex-paths.ts → codex/paths.ts} +2 -2
- package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
- package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
- package/src/{codex-routing.ts → codex/routing.ts} +8 -8
- package/src/{codex-shim.ts → codex/shim.ts} +6 -5
- package/src/{codex-sync.ts → codex/sync.ts} +4 -4
- package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
- package/src/config.ts +2 -0
- package/src/generated/jawcode-model-metadata.ts +2 -0
- package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
- package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
- package/src/{process-control.ts → lib/process-control.ts} +1 -1
- package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
- package/src/oauth/callback-server.ts +1 -1
- package/src/oauth/google-antigravity.ts +7 -4
- package/src/oauth/index.ts +67 -16
- package/src/oauth/login-cli.ts +2 -2
- package/src/oauth/store.ts +236 -20
- package/src/oauth/token-guardian.ts +24 -20
- package/src/oauth/types.ts +16 -0
- package/src/providers/api-keys.ts +121 -0
- package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
- package/src/providers/derive.ts +2 -0
- package/src/providers/key-failover.ts +145 -0
- package/src/{provider-label.ts → providers/label.ts} +1 -1
- package/src/{provider-quota.ts → providers/quota.ts} +12 -7
- package/src/providers/registry.ts +66 -4
- package/src/responses/compaction.ts +117 -0
- package/src/responses/parser.ts +89 -13
- package/src/responses/reasoning-envelope.ts +52 -0
- package/src/responses/schema.ts +15 -3
- package/src/responses/state.ts +117 -2
- package/src/router.ts +2 -0
- package/src/server/auth-cors.ts +231 -0
- package/src/server/index.ts +523 -0
- package/src/server/lifecycle.ts +73 -0
- package/src/server/management-api.ts +628 -0
- package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
- package/src/server/relay.ts +534 -0
- package/src/server/request-decompress.ts +46 -0
- package/src/server/request-log.ts +310 -0
- package/src/server/responses.ts +775 -0
- package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
- package/src/service.ts +19 -11
- package/src/types.ts +27 -0
- package/src/{update.ts → update/index.ts} +5 -5
- package/src/{update-job.ts → update/job.ts} +7 -6
- package/src/{update-notify.ts → update/notify.ts} +3 -3
- package/src/{usage-debug.ts → usage/debug.ts} +3 -3
- package/src/{usage-log.ts → usage/log.ts} +3 -3
- package/src/{usage-summary.ts → usage/summary.ts} +3 -3
- package/src/{usage-totals.ts → usage/totals.ts} +1 -1
- package/src/vision/describe.ts +3 -3
- package/src/vision/index.ts +21 -1
- package/src/web-search/executor.ts +4 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +84 -24
- package/gui/dist/assets/index-BcHhxo1I.css +0 -1
- package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
- package/src/server.ts +0 -2501
- /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
- /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
- /package/src/{abort.ts → lib/abort.ts} +0 -0
- /package/src/{debug.ts → lib/debug.ts} +0 -0
- /package/src/{errors.ts → lib/errors.ts} +0 -0
- /package/src/{open-url.ts → lib/open-url.ts} +0 -0
- /package/src/{privacy.ts → lib/privacy.ts} +0 -0
- /package/src/{redact.ts → lib/redact.ts} +0 -0
- /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
- /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
- /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
- /package/src/{ports.ts → server/ports.ts} +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { loadConfig, saveConfig } from "
|
|
2
|
-
import { withCodexAccountLogLabel } from "./
|
|
1
|
+
import { loadConfig, saveConfig } from "../config";
|
|
2
|
+
import { withCodexAccountLogLabel } from "./account-label";
|
|
3
3
|
import {
|
|
4
4
|
getCodexAccountCredential,
|
|
5
5
|
getValidCodexToken,
|
|
@@ -7,12 +7,12 @@ import {
|
|
|
7
7
|
CodexCredentialGenerationConflictError,
|
|
8
8
|
CodexCredentialRefreshLockTimeoutError,
|
|
9
9
|
TokenRefreshError,
|
|
10
|
-
} from "./
|
|
11
|
-
import { deleteCodexAccount } from "./
|
|
12
|
-
import { checkAccountIdCollision, readCodexTokens } from "./
|
|
13
|
-
export { checkAccountIdCollision, getMainChatgptAccountId } from "./
|
|
14
|
-
export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./
|
|
15
|
-
import { clearAccountNeedsReauth, isAccountNeedsReauth } from "./
|
|
10
|
+
} from "./account-store";
|
|
11
|
+
import { deleteCodexAccount } from "./account-lifecycle";
|
|
12
|
+
import { checkAccountIdCollision, readCodexTokens } from "./auth-collision";
|
|
13
|
+
export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
|
|
14
|
+
export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
15
|
+
import { clearAccountNeedsReauth, isAccountNeedsReauth } from "./account-runtime-state";
|
|
16
16
|
import {
|
|
17
17
|
clearAccountQuota,
|
|
18
18
|
getAccountQuota,
|
|
@@ -21,13 +21,13 @@ import {
|
|
|
21
21
|
updateAccountQuota,
|
|
22
22
|
type StoredAccountQuota,
|
|
23
23
|
type WhamUsageResponse,
|
|
24
|
-
} from "./
|
|
25
|
-
export { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota } from "./
|
|
26
|
-
import { extractAccountId, decodeJwtPayload } from "
|
|
27
|
-
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./
|
|
28
|
-
import { maskEmail } from "
|
|
29
|
-
export { maskEmail } from "
|
|
30
|
-
import type { CodexAccount, OcxConfig } from "
|
|
24
|
+
} from "./quota";
|
|
25
|
+
export { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota } from "./quota";
|
|
26
|
+
import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
|
|
27
|
+
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
|
|
28
|
+
import { maskEmail } from "../lib/privacy";
|
|
29
|
+
export { maskEmail } from "../lib/privacy";
|
|
30
|
+
import type { CodexAccount, OcxConfig } from "../types";
|
|
31
31
|
|
|
32
32
|
function jsonResponse(data: unknown, status = 200): Response {
|
|
33
33
|
return new Response(JSON.stringify(data), {
|
|
@@ -541,7 +541,7 @@ export async function handleCodexAuthAPI(
|
|
|
541
541
|
}
|
|
542
542
|
const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
543
543
|
try {
|
|
544
|
-
const { startLoginFlow, getLoginStatus } = await import("
|
|
544
|
+
const { startLoginFlow, getLoginStatus } = await import("../oauth");
|
|
545
545
|
const result = await startLoginFlow("chatgpt", { forceLogin: true });
|
|
546
546
|
|
|
547
547
|
(async () => {
|
|
@@ -550,7 +550,7 @@ export async function handleCodexAuthAPI(
|
|
|
550
550
|
await new Promise(r => setTimeout(r, 2000));
|
|
551
551
|
const st = getLoginStatus("chatgpt");
|
|
552
552
|
if (st.done && st.loggedIn) {
|
|
553
|
-
const { getCredential } = await import("
|
|
553
|
+
const { getCredential } = await import("../oauth/store");
|
|
554
554
|
const cred = getCredential("chatgpt");
|
|
555
555
|
if (cred) {
|
|
556
556
|
const oauthAccountId = cred.accountId;
|
|
@@ -652,7 +652,7 @@ export async function handleCodexAuthAPI(
|
|
|
652
652
|
|
|
653
653
|
if (url.pathname === "/api/codex-auth/login/cancel" && req.method === "POST") {
|
|
654
654
|
const body = (await req.json().catch(() => ({}))) as { flowId?: string };
|
|
655
|
-
const { cancelLoginFlow } = await import("
|
|
655
|
+
const { cancelLoginFlow } = await import("../oauth");
|
|
656
656
|
const cancelled = cancelLoginFlow("chatgpt");
|
|
657
657
|
expireCodexAuthFlow(body.flowId ?? null);
|
|
658
658
|
return jsonResponse({ ok: true, cancelled });
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { getCodexAccountCredential } from "./
|
|
4
|
-
import { loadConfig } from "
|
|
5
|
-
import { resolveCodexHomeDir } from "./
|
|
6
|
-
import { extractAccountId } from "
|
|
3
|
+
import { getCodexAccountCredential } from "./account-store";
|
|
4
|
+
import { loadConfig } from "../config";
|
|
5
|
+
import { resolveCodexHomeDir } from "./home";
|
|
6
|
+
import { extractAccountId } from "../oauth/chatgpt";
|
|
7
7
|
|
|
8
8
|
export function readCodexTokens(): { access_token: string; account_id: string; id_token?: string } | null {
|
|
9
9
|
try {
|
|
@@ -3,14 +3,14 @@ import {
|
|
|
3
3
|
CodexCredentialRefreshLockTimeoutError,
|
|
4
4
|
getValidCodexToken,
|
|
5
5
|
isCodexAccountGenerationLive,
|
|
6
|
-
} from "./
|
|
7
|
-
import { markAccountNeedsReauth } from "./
|
|
8
|
-
import { isCodexAccountUsable } from "./
|
|
9
|
-
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./
|
|
10
|
-
import { getCodexAccountCooldownUntil, resolveCodexAccountForThreadDetailed } from "./
|
|
11
|
-
import { getAccountQuota } from "./
|
|
12
|
-
import type { OcxConfig, OcxProviderConfig } from "
|
|
13
|
-
import { FORWARD_HEADERS } from "
|
|
6
|
+
} from "./account-store";
|
|
7
|
+
import { markAccountNeedsReauth } from "./account-runtime-state";
|
|
8
|
+
import { isCodexAccountUsable } from "./account-usability";
|
|
9
|
+
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
|
|
10
|
+
import { getCodexAccountCooldownUntil, resolveCodexAccountForThreadDetailed } from "./routing";
|
|
11
|
+
import { getAccountQuota } from "./quota";
|
|
12
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
13
|
+
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
14
14
|
|
|
15
15
|
export type CodexAuthContext =
|
|
16
16
|
| { kind: "main"; accountId: null }
|
|
@@ -83,7 +83,7 @@ export async function resolveCodexAuthContext(headers: Headers, config: OcxConfi
|
|
|
83
83
|
// blocks the current request, and the helper's single-flight guard collapses
|
|
84
84
|
// repeated triggers into one pass.
|
|
85
85
|
if (!getAccountQuota(accountId)) {
|
|
86
|
-
import("./
|
|
86
|
+
import("./auth-api")
|
|
87
87
|
.then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route"))
|
|
88
88
|
.catch(() => {});
|
|
89
89
|
}
|
|
@@ -2,18 +2,19 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
4
4
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
5
|
-
import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "
|
|
6
|
-
import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "./
|
|
5
|
+
import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../config";
|
|
6
|
+
import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "./paths";
|
|
7
7
|
import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "./model-cache";
|
|
8
|
-
import { buildModelsRequest, resolveModelsAuthToken } from "
|
|
9
|
-
import { effectiveGoogleMode } from "
|
|
10
|
-
import type { OcxConfig, OcxProviderConfig } from "
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
8
|
+
import { buildModelsRequest, resolveModelsAuthToken } from "../oauth";
|
|
9
|
+
import { effectiveGoogleMode } from "../providers/registry";
|
|
10
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
11
|
+
import { modelInList } from "../types";
|
|
12
|
+
import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../reasoning-effort";
|
|
13
|
+
import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../generated/jawcode-model-metadata";
|
|
14
|
+
import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../providers/derive";
|
|
15
|
+
import { applyProviderContextCap, providerContextCap } from "../providers/context-cap";
|
|
16
|
+
import { CODEX_GPT5_IDENTITY_LINE } from "../adapters/identity";
|
|
17
|
+
import { fetchCursorUsableModels } from "../adapters/cursor/live-models";
|
|
17
18
|
|
|
18
19
|
const BUNDLED_CATALOG_CACHE_MS = 60_000;
|
|
19
20
|
let bundledCatalogCache: { expiresAt: number; value: RawCatalog | null } | null = null;
|
|
@@ -103,7 +104,7 @@ function isUnsupportedOpenAiNativeSlug(slug: string): boolean {
|
|
|
103
104
|
|
|
104
105
|
const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: number; maxContextWindow?: number }> = {
|
|
105
106
|
"gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 },
|
|
106
|
-
"gpt-5.4": { maxContextWindow: 1_000_000 },
|
|
107
|
+
"gpt-5.4": { contextWindow: 1_000_000, maxContextWindow: 1_000_000 },
|
|
107
108
|
"gpt-5.3-codex-spark": { contextWindow: 128_000, maxContextWindow: 128_000 },
|
|
108
109
|
};
|
|
109
110
|
|
|
@@ -273,13 +274,21 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry): RawEntry {
|
|
|
273
274
|
delete entry.service_tier;
|
|
274
275
|
delete entry.service_tiers;
|
|
275
276
|
delete entry.default_service_tier;
|
|
277
|
+
const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/");
|
|
276
278
|
// Routed providers use opencodex sidecars and client-executed tool discovery. The sidecar
|
|
277
|
-
// runs through native gpt-5.4-mini, so image search is available and verbalized for text-only
|
|
278
|
-
|
|
279
|
-
|
|
279
|
+
// runs through native gpt-5.4-mini, so image search is available and verbalized for text-only
|
|
280
|
+
// models. EXCEPT cursor: its runTurn transport bypasses the web-search plan entirely and
|
|
281
|
+
// rejects server search queries — advertising the tool would make models call into a void.
|
|
282
|
+
if (isCursorEntry) {
|
|
283
|
+
delete entry.web_search_tool_type;
|
|
284
|
+
entry.supports_search_tool = false;
|
|
285
|
+
} else {
|
|
286
|
+
entry.web_search_tool_type = "text_and_image";
|
|
287
|
+
entry.supports_search_tool = true;
|
|
288
|
+
}
|
|
280
289
|
// Cursor's transport already serializes overlapping tool calls into atomic Responses tool events.
|
|
281
290
|
// Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI.
|
|
282
|
-
entry.supports_parallel_tool_calls =
|
|
291
|
+
entry.supports_parallel_tool_calls = isCursorEntry;
|
|
283
292
|
return ensureStrictCatalogFields(entry);
|
|
284
293
|
}
|
|
285
294
|
|
|
@@ -365,7 +374,7 @@ function codexShimCommandCandidates(): string[] {
|
|
|
365
374
|
* `.cmd`/`.bat` launchers (npm's `codex.cmd`) cannot be spawned shell-less — Node ≥18.20
|
|
366
375
|
* and Bun refuse with EINVAL (CVE-2024-27980 hardening), which the probe loop silently
|
|
367
376
|
* swallowed, so npm-only Codex installs never loaded the bundled catalog on Windows.
|
|
368
|
-
* Route those through the shell (repo convention — see src/update.ts, bin/ocx.mjs) and
|
|
377
|
+
* Route those through the shell (repo convention — see src/update/index.ts, bin/ocx.mjs) and
|
|
369
378
|
* pre-quote the path: shell:true joins file+args verbatim, so an unquoted path with
|
|
370
379
|
* spaces (`C:\Users\John Doe\...`) would split. Windows paths cannot contain `"`.
|
|
371
380
|
*/
|
|
@@ -677,10 +686,18 @@ function configuredInputModalities(prov: OcxProviderConfig, id: string): string[
|
|
|
677
686
|
return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined;
|
|
678
687
|
}
|
|
679
688
|
|
|
680
|
-
function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel {
|
|
689
|
+
export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel {
|
|
681
690
|
void name;
|
|
682
691
|
const configuredCap = configuredContextWindow(prov, model.id);
|
|
683
|
-
|
|
692
|
+
let inputModalities = configuredInputModalities(prov, model.id);
|
|
693
|
+
// Vision-sidecar coverage: `noVisionModels` marks models whose images the PROXY describes
|
|
694
|
+
// (src/vision/index.ts). The catalog must still advertise image input for them — the Codex app
|
|
695
|
+
// gates attachments client-side on input_modalities, and a text-only entry would block images
|
|
696
|
+
// before the sidecar ever runs ("This model does not support image inputs").
|
|
697
|
+
if (modelInList(prov.noVisionModels, model.id)) {
|
|
698
|
+
const base = inputModalities ?? model.inputModalities ?? ["text"];
|
|
699
|
+
inputModalities = base.includes("image") ? [...base] : [...base, "image"];
|
|
700
|
+
}
|
|
684
701
|
const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
|
|
685
702
|
const hinted = {
|
|
686
703
|
...model,
|
|
@@ -847,7 +864,19 @@ export function filterCatalogVisibleModels(
|
|
|
847
864
|
*/
|
|
848
865
|
export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogModel[]> {
|
|
849
866
|
const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
|
|
850
|
-
|
|
867
|
+
// Persisted provider entries can predate newer registry fields (noVisionModels,
|
|
868
|
+
// modelInputModalities, ...). The ROUTER merges registry seeds at request time
|
|
869
|
+
// (routedProviderConfig), so the proxy behaves correctly — the catalog listing must see the
|
|
870
|
+
// same merged view or its advertisements drift from actual proxy behavior (e.g. a
|
|
871
|
+
// vision-sidecar model advertised text-only, blocking image attachments app-side).
|
|
872
|
+
// Enrich a CLONE: hydrated defaults must never leak into the persisted config.
|
|
873
|
+
const activeProviders = Object.entries(config.providers)
|
|
874
|
+
.filter(([, prov]) => prov.disabled !== true)
|
|
875
|
+
.map(([name, prov]): [string, OcxProviderConfig] => {
|
|
876
|
+
const enriched = { ...prov };
|
|
877
|
+
enrichProviderFromRegistry(name, enriched);
|
|
878
|
+
return [name, enriched];
|
|
879
|
+
});
|
|
851
880
|
const lists = await Promise.all(
|
|
852
881
|
activeProviders.map(([name, prov]) => fetchProviderModels(name, prov, ttlMs, providerContextCap(config, name))),
|
|
853
882
|
);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { countPendingOpencodexHistory, migrateHistoryToOpenai } from "./history-provider";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Daemon-side retry for the one-time Design-B history migration.
|
|
5
|
+
*
|
|
6
|
+
* Most upgrades run `ocx start` while the Codex app still holds `state_5.sqlite`,
|
|
7
|
+
* so the inject-time migration often fails on the FIRST start — exactly the moment
|
|
8
|
+
* every legacy thread is still tagged `opencodex` and invisible to the app. Instead
|
|
9
|
+
* of asking the user to close the app and rerun start, this guardian keeps retrying
|
|
10
|
+
* in the background until the migration lands.
|
|
11
|
+
*
|
|
12
|
+
* Design constraints (audit-driven):
|
|
13
|
+
* - Ticks use `{ attempts: 1 }`: no sleepSync inside the daemon event loop; the tick
|
|
14
|
+
* cadence IS the retry. Worst case per tick is one sqlite busy wait.
|
|
15
|
+
* - Timers are unref'd so the guardian never keeps the process alive.
|
|
16
|
+
* - Started ONLY from `ocx start` (cli handleStart), never from injectCodexConfig —
|
|
17
|
+
* `/api/sync` re-runs inject and must not double-start loops.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface HistoryMigrationGuardianHandle {
|
|
21
|
+
stop(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface HistoryMigrationGuardianDeps {
|
|
25
|
+
countFn?: typeof countPendingOpencodexHistory;
|
|
26
|
+
migrateFn?: () => ReturnType<typeof migrateHistoryToOpenai>;
|
|
27
|
+
log?: Pick<Console, "log">;
|
|
28
|
+
tickMs?: number;
|
|
29
|
+
maxTicks?: number;
|
|
30
|
+
/** Test hook: schedule fn after ms; return a cancel handle. Defaults to setTimeout. */
|
|
31
|
+
scheduleFn?: (fn: () => void, ms: number) => { cancel(): void };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_TICK_MS = 60_000;
|
|
35
|
+
const DEFAULT_MAX_TICKS = 60; // give up after ~an hour; doctor still surfaces the pending state
|
|
36
|
+
|
|
37
|
+
function defaultSchedule(fn: () => void, ms: number): { cancel(): void } {
|
|
38
|
+
const timer = setTimeout(fn, ms);
|
|
39
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
40
|
+
return { cancel: () => clearTimeout(timer) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps = {}): HistoryMigrationGuardianHandle {
|
|
44
|
+
const countFn = deps.countFn ?? countPendingOpencodexHistory;
|
|
45
|
+
const migrateFn = deps.migrateFn ?? (() => migrateHistoryToOpenai(undefined, undefined, { attempts: 1 }));
|
|
46
|
+
const log = deps.log ?? console;
|
|
47
|
+
const tickMs = deps.tickMs ?? DEFAULT_TICK_MS;
|
|
48
|
+
const maxTicks = deps.maxTicks ?? DEFAULT_MAX_TICKS;
|
|
49
|
+
|
|
50
|
+
let stopped = false;
|
|
51
|
+
let pending: { cancel(): void } | undefined;
|
|
52
|
+
let ticks = 0;
|
|
53
|
+
|
|
54
|
+
const schedule = () => {
|
|
55
|
+
if (stopped) return;
|
|
56
|
+
pending = (deps.scheduleFn ?? defaultSchedule)(tick, tickMs);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const tick = () => {
|
|
60
|
+
if (stopped) return;
|
|
61
|
+
ticks++;
|
|
62
|
+
try {
|
|
63
|
+
const count = countFn();
|
|
64
|
+
if (!count.failed && count.pendingRows === 0 && count.backupEntries === 0) {
|
|
65
|
+
stopped = true; // nothing left to migrate — normal steady state, no log noise
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Locked probe or pending work: attempt one migration pass.
|
|
69
|
+
const result = migrateFn();
|
|
70
|
+
if (!result.failed) {
|
|
71
|
+
const moved = result.rows + (result.ejectedRows ?? 0);
|
|
72
|
+
if (moved > 0) {
|
|
73
|
+
log.log(`🩹 history-migration: ${moved} legacy opencodex thread(s) migrated back to openai.`);
|
|
74
|
+
}
|
|
75
|
+
// A "successful" zero-row migration can also mean the DB does not exist YET while a
|
|
76
|
+
// backup manifest still holds restore work (fresh reinstall race). Only stop when a
|
|
77
|
+
// re-count proves nothing is pending; otherwise keep ticking within the budget.
|
|
78
|
+
const after = countFn();
|
|
79
|
+
if (moved > 0 || (!after.failed && after.pendingRows === 0 && after.backupEntries === 0)) {
|
|
80
|
+
stopped = true;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
/* hard errors are not retryable state — fall through to the tick budget */
|
|
86
|
+
}
|
|
87
|
+
if (ticks >= maxTicks) {
|
|
88
|
+
stopped = true;
|
|
89
|
+
log.log("⚠️ history-migration: Codex history DB stayed locked; legacy threads not yet migrated. Close the Codex app and run 'ocx sync' (or check 'ocx doctor').");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
schedule();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
schedule();
|
|
96
|
+
return {
|
|
97
|
+
stop() {
|
|
98
|
+
stopped = true;
|
|
99
|
+
pending?.cancel();
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -2,8 +2,8 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, unlinkSync, writeSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { Database } from "bun:sqlite";
|
|
5
|
-
import { CODEX_HOME } from "./
|
|
6
|
-
import { atomicWriteFile, getConfigDir } from "
|
|
5
|
+
import { CODEX_HOME } from "./paths";
|
|
6
|
+
import { atomicWriteFile, getConfigDir } from "../config";
|
|
7
7
|
|
|
8
8
|
const STATE_DB_PATH = join(CODEX_HOME, "state_5.sqlite");
|
|
9
9
|
function historyBackupPathFor(stateDbPath: string): string {
|
|
@@ -20,10 +20,21 @@ const RESUMABLE_SOURCES = ["cli", "vscode"] as const;
|
|
|
20
20
|
* connection pool into a half-applied checkpoint. The app opens this DB with `busy_timeout=5s`
|
|
21
21
|
* (see codex-rs `state::runtime::base_sqlite_options`); we mirror that here.
|
|
22
22
|
*/
|
|
23
|
+
let historyDbBusyTimeoutMs = 5000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Test-only knob: Windows CI can spend the FULL busy timeout on a transient file lock, which
|
|
27
|
+
* alone exceeds bun's 5s default per-test timeout. Tests shrink this so a busy DB fails fast
|
|
28
|
+
* into withHistoryRetry instead of stalling; production keeps the codex-rs-matching 5s.
|
|
29
|
+
*/
|
|
30
|
+
export function setHistoryDbBusyTimeoutForTests(ms: number): void {
|
|
31
|
+
historyDbBusyTimeoutMs = ms;
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
function openStateDb(stateDbPath: string): Database {
|
|
24
35
|
const db = new Database(stateDbPath);
|
|
25
36
|
try {
|
|
26
|
-
db.exec(
|
|
37
|
+
db.exec(`PRAGMA busy_timeout = ${historyDbBusyTimeoutMs}`);
|
|
27
38
|
} catch {
|
|
28
39
|
/* best-effort: an older sqlite without busy_timeout still works, just less politely */
|
|
29
40
|
}
|
|
@@ -369,6 +380,7 @@ export function isRecoverableHistoryError(error: unknown): boolean {
|
|
|
369
380
|
}
|
|
370
381
|
|
|
371
382
|
const HISTORY_RETRY_DELAY_MS = 500;
|
|
383
|
+
const HISTORY_RETRY_ATTEMPTS = 2;
|
|
372
384
|
|
|
373
385
|
/**
|
|
374
386
|
* Run a history mutation with one retry across recoverable lock/busy errors (the app's own
|
|
@@ -377,20 +389,47 @@ const HISTORY_RETRY_DELAY_MS = 500;
|
|
|
377
389
|
* error — callers surface that as `failed: true` instead of a silent no-op. Hard errors
|
|
378
390
|
* (corruption, programming bugs) still throw.
|
|
379
391
|
*/
|
|
380
|
-
export function withHistoryRetry<T>(fn: () => T, io: { sleepFn?: (ms: number) => void } = {}): T | null {
|
|
392
|
+
export function withHistoryRetry<T>(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}): T | null {
|
|
381
393
|
const sleepFn = io.sleepFn ?? Bun.sleepSync;
|
|
394
|
+
const attempts = Math.max(1, io.attempts ?? HISTORY_RETRY_ATTEMPTS);
|
|
395
|
+
const delayMs = io.delayMs ?? HISTORY_RETRY_DELAY_MS;
|
|
382
396
|
for (let attempt = 0; ; attempt++) {
|
|
383
397
|
try {
|
|
384
398
|
return fn();
|
|
385
399
|
} catch (error) {
|
|
386
400
|
if (!isRecoverableHistoryError(error)) throw error;
|
|
387
|
-
if (attempt >= 1) return null;
|
|
388
|
-
try { sleepFn(
|
|
401
|
+
if (attempt >= attempts - 1) return null;
|
|
402
|
+
try { sleepFn(delayMs); } catch { /* sleep is best-effort */ }
|
|
389
403
|
}
|
|
390
404
|
}
|
|
391
405
|
}
|
|
392
406
|
|
|
393
|
-
|
|
407
|
+
/**
|
|
408
|
+
* True when a READONLY probe proves the openai-direction restore would be a no-op:
|
|
409
|
+
* zero threads still tagged opencodex AND an empty backup manifest. Used to skip the
|
|
410
|
+
* write-open entirely in the Design B steady state — on Windows the Codex app holds
|
|
411
|
+
* `state_5.sqlite` (WAL, busy_timeout 5s), so an unnecessary write open can stall for
|
|
412
|
+
* seconds and surface a false lock warning, while WAL always admits readers. A failed
|
|
413
|
+
* probe (locked even for readers / schema drift) returns false so callers fall through
|
|
414
|
+
* to the write attempt and keep today's behavior for genuinely unknown state.
|
|
415
|
+
*/
|
|
416
|
+
function openaiRestoreIsNoop(stateDbPath: string, backupPath: string): boolean {
|
|
417
|
+
const pending = countPendingOpencodexHistory(stateDbPath, backupPath);
|
|
418
|
+
return !pending.failed && pending.pendingRows === 0 && pending.backupEntries === 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function syncCodexHistoryProvider(
|
|
422
|
+
provider: CodexHistoryProvider,
|
|
423
|
+
stateDbPath = STATE_DB_PATH,
|
|
424
|
+
backupPath = HISTORY_BACKUP_PATH,
|
|
425
|
+
opts: { skipWhenProvablyNoop?: boolean } = {},
|
|
426
|
+
): CodexHistorySyncResult {
|
|
427
|
+
// Opt-in steady-state gate (Design B loopback callers only): default semantics of
|
|
428
|
+
// this exported API are unchanged — legacy stop/restore paths never pass the flag.
|
|
429
|
+
if (opts.skipWhenProvablyNoop && provider === "openai" && existsSync(stateDbPath)
|
|
430
|
+
&& openaiRestoreIsNoop(stateDbPath, backupPath)) {
|
|
431
|
+
return { rows: 0, files: 0 };
|
|
432
|
+
}
|
|
394
433
|
return withHistoryRetry(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath))
|
|
395
434
|
?? { rows: 0, files: 0, failed: true };
|
|
396
435
|
}
|
|
@@ -526,3 +565,68 @@ export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): { rows:
|
|
|
526
565
|
}
|
|
527
566
|
}) ?? { rows: 0, files: 0, failed: true };
|
|
528
567
|
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* One-time Design-B migration: restore backed-up originals, then eject any remaining
|
|
571
|
+
* opencodex-tagged threads to openai. Thin wrapper over the restore path with a
|
|
572
|
+
* configurable retry budget — the daemon migration guardian uses `{ attempts: 1 }`
|
|
573
|
+
* per tick so a locked DB never stalls the event loop beyond one sqlite busy wait.
|
|
574
|
+
*/
|
|
575
|
+
export function migrateHistoryToOpenai(
|
|
576
|
+
stateDbPath = STATE_DB_PATH,
|
|
577
|
+
backupPath = HISTORY_BACKUP_PATH,
|
|
578
|
+
opts: { attempts?: number; delayMs?: number; sleepFn?: (ms: number) => void } = {},
|
|
579
|
+
): CodexHistorySyncResult {
|
|
580
|
+
if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
|
|
581
|
+
// Steady-state gate: this migration is Design-B-specific (inject + guardian callers),
|
|
582
|
+
// and after the one-time migration every start would otherwise write-open the DB for
|
|
583
|
+
// nothing. A missing DB with a leftover backup manifest does NOT satisfy the gate
|
|
584
|
+
// (backupEntries > 0), so the guardian's fresh-reinstall re-count protection holds.
|
|
585
|
+
if (openaiRestoreIsNoop(stateDbPath, backupPath)) return { rows: 0, files: 0 };
|
|
586
|
+
return withHistoryRetry(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts)
|
|
587
|
+
?? { rows: 0, files: 0, failed: true };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export interface PendingHistoryCount {
|
|
591
|
+
/** Threads still tagged opencodex that the eject path WOULD move (mirrors its WHERE). */
|
|
592
|
+
pendingRows: number;
|
|
593
|
+
/** Entries still recorded in the backup manifest (restore targets). */
|
|
594
|
+
backupEntries: number;
|
|
595
|
+
/** Set when the DB could not be opened/read (locked); counts are then unknown, not zero. */
|
|
596
|
+
failed?: true;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Read-only migration progress probe for the guardian and `ocx doctor`. Opens sqlite
|
|
601
|
+
* readonly with a SHORT busy timeout so a locked DB cannot stall a daemon tick. The
|
|
602
|
+
* pending predicate mirrors ejectRemainingOpencodexHistory exactly — rows eject ignores
|
|
603
|
+
* (empty first_user_message) are not counted, so 0 really means "migration done".
|
|
604
|
+
*/
|
|
605
|
+
export function countPendingOpencodexHistory(stateDbPath = STATE_DB_PATH, backupPath = HISTORY_BACKUP_PATH): PendingHistoryCount {
|
|
606
|
+
let backupEntries = 0;
|
|
607
|
+
try {
|
|
608
|
+
const manifest = readBackup(backupPath, stateDbPath);
|
|
609
|
+
backupEntries = Object.keys(manifest.entries).length;
|
|
610
|
+
} catch { /* unreadable manifest counts as 0 — restore treats it the same way */ }
|
|
611
|
+
|
|
612
|
+
if (!existsSync(stateDbPath)) return { pendingRows: 0, backupEntries };
|
|
613
|
+
try {
|
|
614
|
+
const db = new Database(stateDbPath, { readonly: true });
|
|
615
|
+
try {
|
|
616
|
+
db.exec("PRAGMA busy_timeout = 100");
|
|
617
|
+
const row = db.query<{ n: number }, []>(`
|
|
618
|
+
SELECT count(*) AS n
|
|
619
|
+
FROM threads
|
|
620
|
+
WHERE model_provider = 'opencodex'
|
|
621
|
+
AND trim(coalesce(first_user_message, '')) != ''
|
|
622
|
+
`).get();
|
|
623
|
+
return { pendingRows: row?.n ?? 0, backupEntries };
|
|
624
|
+
} finally {
|
|
625
|
+
db.close();
|
|
626
|
+
}
|
|
627
|
+
} catch (error) {
|
|
628
|
+
if (isRecoverableHistoryError(error)) return { pendingRows: 0, backupEntries, failed: true };
|
|
629
|
+
// Schema drift (e.g. a future codex renames the table) is a "cannot know" too, not a crash.
|
|
630
|
+
return { pendingRows: 0, backupEntries, failed: true };
|
|
631
|
+
}
|
|
632
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
-
import { expandUserPath } from "
|
|
4
|
+
import { expandUserPath } from "../config";
|
|
5
5
|
|
|
6
6
|
export type CodexHomeDeps = {
|
|
7
7
|
env?: NodeJS.ProcessEnv;
|