@bitkyc08/opencodex 2.7.40 → 2.7.41
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 +63 -12
- package/assets/claude-code-models.gif +0 -0
- package/gui/dist/assets/index-B2J4t3te.css +1 -0
- package/gui/dist/assets/index-BmvM6wRb.js +65 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/google.ts +48 -9
- package/src/adapters/kiro-events.ts +15 -3
- package/src/adapters/kiro.ts +292 -28
- package/src/adapters/openai-chat.ts +63 -15
- package/src/adapters/openai-responses.ts +18 -0
- package/src/bridge.ts +76 -21
- package/src/chat/outbound.ts +66 -34
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p.ts +121 -21
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/inbound-debug.ts +4 -0
- package/src/claude/model-info.ts +9 -3
- package/src/cli/account-extended.ts +34 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/claude-desktop.ts +152 -0
- package/src/cli/claude.ts +37 -3
- package/src/cli/doctor.ts +187 -6
- package/src/cli/help.ts +13 -1
- package/src/cli/index.ts +151 -57
- package/src/cli/status-oauth.ts +68 -0
- package/src/cli/status.ts +4 -0
- package/src/codex/account-lifecycle.ts +31 -0
- package/src/codex/auth-api.ts +133 -26
- package/src/codex/auth-collision.ts +55 -11
- package/src/codex/auth-context.ts +50 -6
- package/src/codex/catalog/provider-fetch.ts +6 -2
- package/src/codex/home.ts +61 -1
- package/src/codex/inject.ts +28 -66
- package/src/codex/injected-marker.ts +72 -0
- package/src/codex/journal.ts +39 -3
- package/src/codex/main-account-cache.ts +25 -0
- package/src/codex/model-cache.ts +20 -1
- package/src/codex/paths.ts +5 -0
- package/src/codex/routing.ts +138 -11
- package/src/codex/subagent-model-fallback.ts +455 -0
- package/src/codex/sync.ts +17 -0
- package/src/combos/failover.ts +10 -2
- package/src/combos/index.ts +1 -0
- package/src/combos/types.ts +9 -0
- package/src/config.ts +117 -0
- package/src/grok/inject.ts +339 -0
- package/src/grok/status.ts +88 -0
- package/src/grok/sync.ts +66 -0
- package/src/lib/destination-policy.ts +13 -0
- package/src/lib/errors.ts +59 -0
- package/src/lib/privacy.ts +9 -0
- package/src/lib/process-control.ts +57 -4
- package/src/oauth/health.ts +375 -0
- package/src/oauth/index.ts +69 -22
- package/src/oauth/kiro.ts +5 -3
- package/src/oauth/log.ts +48 -0
- package/src/oauth/store.ts +55 -6
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +143 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/api-keys.ts +5 -5
- package/src/providers/derive.ts +22 -1
- package/src/providers/free-directory.ts +181 -0
- package/src/providers/key-failover.ts +2 -2
- package/src/providers/kiro-models.ts +3 -2
- package/src/providers/openai-tiers.ts +1 -1
- package/src/providers/provider-id-rewrite.ts +150 -0
- package/src/providers/registry.ts +49 -2
- package/src/responses/parser.ts +49 -20
- package/src/responses/state.ts +156 -2
- package/src/router.ts +73 -5
- package/src/server/chat-completions.ts +78 -22
- package/src/server/claude-messages.ts +8 -0
- package/src/server/images.ts +2 -1
- package/src/server/index.ts +50 -30
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +337 -18
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/combo-routes.ts +3 -3
- package/src/server/management/config-routes.ts +4 -4
- package/src/server/management/logs-usage-routes.ts +64 -4
- package/src/server/management/model-routes.ts +114 -8
- package/src/server/management/oauth-account-routes.ts +31 -5
- package/src/server/management/provider-routes.ts +42 -11
- package/src/server/management/shared.ts +81 -2
- package/src/server/management/system-routes.ts +6 -1
- package/src/server/management-api.ts +24 -7
- package/src/server/port-reclaim.ts +261 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/collaboration.ts +11 -6
- package/src/server/responses/compact.ts +2 -1
- package/src/server/responses/core.ts +358 -155
- package/src/server/responses/passthrough-error.ts +53 -0
- package/src/server/search.ts +2 -1
- package/src/server/system-env.ts +23 -6
- package/src/server/windows-tcp-drop.ts +174 -0
- package/src/service.ts +43 -3
- package/src/types.ts +74 -6
- package/src/update/index.ts +30 -19
- package/src/update/job.ts +30 -15
- package/src/usage/log.ts +158 -2
- package/src/usage/summary.ts +8 -4
- package/src/web-search/loop.ts +4 -1
- package/gui/dist/assets/index-CMip1DzF.css +0 -1
- package/gui/dist/assets/index-cydcmbzC.js +0 -52
package/src/codex/home.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join, posix, resolve } from "node:path";
|
|
3
|
+
import { join, posix, resolve, win32 } from "node:path";
|
|
4
4
|
import { expandUserPath } from "../config";
|
|
5
|
+
import { redactUserPath } from "../lib/redact";
|
|
5
6
|
|
|
6
7
|
export type CodexHomeDeps = {
|
|
7
8
|
env?: NodeJS.ProcessEnv;
|
|
@@ -144,3 +145,62 @@ export function resolveCodexHomeDir(deps: CodexHomeDeps = {}): string {
|
|
|
144
145
|
if (raw) return resolve(expandUserPath(raw));
|
|
145
146
|
return defaultCodexHome(deps);
|
|
146
147
|
}
|
|
148
|
+
|
|
149
|
+
export type OrcaCodexHomeDiagnostic = {
|
|
150
|
+
applicable: boolean;
|
|
151
|
+
mismatch: boolean;
|
|
152
|
+
effectiveCodexHome: string;
|
|
153
|
+
appCodexHome: string;
|
|
154
|
+
orcaCodexHome: string | null;
|
|
155
|
+
warning: string | null;
|
|
156
|
+
action: string | null;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
type OrcaCodexHomeDeps = CodexHomeDeps & {
|
|
160
|
+
effectiveCodexHome?: string;
|
|
161
|
+
appCodexHome?: string;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
function normalizedWindowsPath(path: string): string {
|
|
165
|
+
return path.trim().replaceAll("/", "\\").replace(/\\+$/, "").toLowerCase();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* High-confidence Orca/ChatGPT dual-home diagnosis. Explicit CODEX_HOME remains
|
|
170
|
+
* authoritative; this only explains when an Orca-owned shell targets a home the
|
|
171
|
+
* Windows ChatGPT/Codex app does not read.
|
|
172
|
+
*/
|
|
173
|
+
export function collectOrcaCodexHomeDiagnostic(deps: OrcaCodexHomeDeps = {}): OrcaCodexHomeDiagnostic {
|
|
174
|
+
const platform = deps.platform ?? process.platform;
|
|
175
|
+
const env = deps.env ?? process.env;
|
|
176
|
+
const effectiveCodexHome = deps.effectiveCodexHome ?? resolveCodexHomeDir(deps);
|
|
177
|
+
const appCodexHome = deps.appCodexHome
|
|
178
|
+
?? (platform === "win32" ? win32.join((deps.homedir ?? homedir)(), ".codex") : join((deps.homedir ?? homedir)(), ".codex"));
|
|
179
|
+
const explicitHome = env.CODEX_HOME?.trim() ?? "";
|
|
180
|
+
const orcaCodexHome = env.ORCA_CODEX_HOME?.trim() || null;
|
|
181
|
+
const normalizedEffective = normalizedWindowsPath(effectiveCodexHome);
|
|
182
|
+
const normalizedOrca = orcaCodexHome ? normalizedWindowsPath(orcaCodexHome) : "";
|
|
183
|
+
const normalizedApp = normalizedWindowsPath(appCodexHome);
|
|
184
|
+
const applicable = platform === "win32"
|
|
185
|
+
&& !!explicitHome
|
|
186
|
+
&& !!orcaCodexHome
|
|
187
|
+
&& normalizedEffective === normalizedOrca
|
|
188
|
+
&& /(?:^|\\)orca\\codex-runtime-home\\home$/i.test(normalizedOrca);
|
|
189
|
+
const mismatch = applicable && normalizedEffective !== normalizedApp;
|
|
190
|
+
const displayEffective = redactUserPath(effectiveCodexHome);
|
|
191
|
+
const displayApp = redactUserPath(appCodexHome);
|
|
192
|
+
const displayOrca = orcaCodexHome ? redactUserPath(orcaCodexHome) : null;
|
|
193
|
+
return {
|
|
194
|
+
applicable,
|
|
195
|
+
mismatch,
|
|
196
|
+
effectiveCodexHome: displayEffective,
|
|
197
|
+
appCodexHome: displayApp,
|
|
198
|
+
orcaCodexHome: displayOrca,
|
|
199
|
+
warning: mismatch
|
|
200
|
+
? `CODEX_HOME targets Orca's runtime home (${displayEffective}), while the Windows ChatGPT/Codex app uses ${displayApp}; OpenCodex injection will not reach that app.`
|
|
201
|
+
: null,
|
|
202
|
+
action: mismatch
|
|
203
|
+
? "If a service was installed from Orca, run 'ocx service uninstall' in that original Orca shell first. Then in Command Prompt run set \"ORCA_CODEX_HOME=\" and set \"CODEX_HOME=%USERPROFILE%\\.codex\"; or in PowerShell run Remove-Item Env:ORCA_CODEX_HOME -ErrorAction SilentlyContinue; $env:CODEX_HOME = Join-Path $env:USERPROFILE '.codex'. Rerun the command, then reinstall with 'ocx service install'."
|
|
204
|
+
: null,
|
|
205
|
+
};
|
|
206
|
+
}
|
package/src/codex/inject.ts
CHANGED
|
@@ -3,11 +3,23 @@ import { atomicWriteFile, loadConfig, websocketsEnabled } from "../config";
|
|
|
3
3
|
import { markJournalInjectedState, removeJournal, restoreJournalState, writeJournal } from "./journal";
|
|
4
4
|
import { restoreCodexCatalog } from "./catalog";
|
|
5
5
|
import { migrateHistoryToOpenai, syncCodexHistoryProvider } from "./history-provider";
|
|
6
|
+
import {
|
|
7
|
+
OCX_SECTION_MARKER,
|
|
8
|
+
hasInjectedCodexRouting,
|
|
9
|
+
hasInjectedOpenaiBaseUrl,
|
|
10
|
+
isRootOpenaiBaseUrlLine,
|
|
11
|
+
providerTableStart,
|
|
12
|
+
providerTableString,
|
|
13
|
+
rootTomlString,
|
|
14
|
+
tomlStringPattern,
|
|
15
|
+
} from "./injected-marker";
|
|
6
16
|
import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./paths";
|
|
7
17
|
import { resolveEffectiveProjectModelProvider } from "./project-config-warnings";
|
|
8
18
|
import type { OcxConfig } from "../types";
|
|
9
19
|
|
|
10
|
-
|
|
20
|
+
// Ownership predicates live in `./injected-marker` so `journal.ts` can reach them
|
|
21
|
+
// without importing this module back. Re-exported for existing external callers.
|
|
22
|
+
export { hasInjectedCodexRouting, hasInjectedOpenaiBaseUrl };
|
|
11
23
|
|
|
12
24
|
export function externalCodexModelProvider(content: string): string | null {
|
|
13
25
|
const provider = resolveEffectiveProjectModelProvider(content).provider;
|
|
@@ -64,12 +76,18 @@ export interface InjectCodexOptions {
|
|
|
64
76
|
* whatever `[table]` happened to be open last (e.g. `[plugins."chrome@openai-bundled"]`), so Codex
|
|
65
77
|
* never saw a global model_provider and silently fell back to the `openai` (ChatGPT) provider.
|
|
66
78
|
*/
|
|
67
|
-
|
|
79
|
+
/**
|
|
80
|
+
* True only for hostnames that bind loopback ONLY. Wildcard binds ("0.0.0.0", "::") are NOT
|
|
81
|
+
* loopback: they expose the proxy on every interface and therefore require the admission token.
|
|
82
|
+
* Do not use `providerBaseHost` for this decision — it folds wildcards to 127.0.0.1 because it
|
|
83
|
+
* answers "what address do I dial", which is a different question from "is this exposed".
|
|
84
|
+
*/
|
|
85
|
+
export function isLoopbackHostname(hostname: string | undefined): boolean {
|
|
68
86
|
const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
|
|
69
87
|
return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
|
|
70
88
|
}
|
|
71
89
|
|
|
72
|
-
function providerBaseHost(hostname: string | undefined): string {
|
|
90
|
+
export function providerBaseHost(hostname: string | undefined): string {
|
|
73
91
|
const trimmed = (hostname ?? "127.0.0.1").trim();
|
|
74
92
|
const lower = trimmed.toLowerCase();
|
|
75
93
|
// Match what the server actually binds. Writing "localhost" while binding IPv4-only
|
|
@@ -106,10 +124,6 @@ export function buildOpenaiBaseUrlLine(port: number, hostname?: string): string
|
|
|
106
124
|
return `openai_base_url = "http://${providerBaseHost(hostname)}:${port}/v1"`;
|
|
107
125
|
}
|
|
108
126
|
|
|
109
|
-
function isRootOpenaiBaseUrlLine(line: string): boolean {
|
|
110
|
-
return /^\s*openai_base_url\s*=/.test(line);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
127
|
/**
|
|
114
128
|
* Design B root-key injection: place `OCX_SECTION_MARKER` + `openai_base_url` at the document
|
|
115
129
|
* ROOT (before the first table header). Idempotent: an existing marker-owned line is rewritten
|
|
@@ -162,55 +176,8 @@ export function stripInjectedOpenaiBaseUrl(content: string): string {
|
|
|
162
176
|
return lines.filter((_, i) => !drop.has(i)).join("\n");
|
|
163
177
|
}
|
|
164
178
|
|
|
165
|
-
export function hasInjectedOpenaiBaseUrl(content: string): boolean {
|
|
166
|
-
const lines = content.split("\n");
|
|
167
|
-
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
168
|
-
const rootEnd = firstTable === -1 ? lines.length : firstTable;
|
|
169
|
-
for (let i = 1; i < rootEnd; i++) {
|
|
170
|
-
if (isRootOpenaiBaseUrlLine(lines[i]) && lines[i - 1].includes(OCX_SECTION_MARKER)) return true;
|
|
171
|
-
}
|
|
172
|
-
return false;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
179
|
export type CodexRoutingKind = "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown";
|
|
176
180
|
|
|
177
|
-
function tomlStringPattern(key: string): RegExp {
|
|
178
|
-
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
179
|
-
const keyToken = `(?:${escaped}|\"${escaped}\"|'${escaped}')`;
|
|
180
|
-
return new RegExp(`^\\s*${keyToken}\\s*=\\s*[\"']([^\"']+)[\"']\\s*(?:#.*)?$`);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function rootTomlString(content: string, key: string): string | null {
|
|
184
|
-
const lines = content.split("\n");
|
|
185
|
-
const firstTable = lines.findIndex(line => /^\s*\[/.test(line));
|
|
186
|
-
const rootLines = lines.slice(0, firstTable === -1 ? lines.length : firstTable);
|
|
187
|
-
const pattern = tomlStringPattern(key);
|
|
188
|
-
for (const line of rootLines) {
|
|
189
|
-
const match = pattern.exec(line);
|
|
190
|
-
if (match?.[1]) return match[1].trim();
|
|
191
|
-
}
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function providerTableStart(lines: string[], provider: string): number {
|
|
196
|
-
const escapedProvider = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
197
|
-
const providerToken = `(?:${escapedProvider}|\"${escapedProvider}\"|'${escapedProvider}')`;
|
|
198
|
-
const header = new RegExp(`^\\s*\\[\\s*(?:model_providers|\"model_providers\"|'model_providers')\\s*\\.\\s*${providerToken}\\s*\\]\\s*(?:#.*)?$`);
|
|
199
|
-
return lines.findIndex(line => header.test(line));
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function providerTableString(content: string, provider: string, key: string): string | null {
|
|
203
|
-
const lines = content.split("\n");
|
|
204
|
-
const start = providerTableStart(lines, provider);
|
|
205
|
-
if (start === -1) return null;
|
|
206
|
-
const pattern = tomlStringPattern(key);
|
|
207
|
-
for (let index = start + 1; index < lines.length && !/^\s*\[/.test(lines[index]); index += 1) {
|
|
208
|
-
const match = pattern.exec(lines[index]);
|
|
209
|
-
if (match?.[1]) return match[1].trim();
|
|
210
|
-
}
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
181
|
type RoutingEndpointKind = "local" | "remote" | "unknown";
|
|
215
182
|
|
|
216
183
|
function ipv4Octets(hostname: string): number[] | null {
|
|
@@ -271,17 +238,6 @@ export function classifyCodexRouting(content: string): CodexRoutingKind {
|
|
|
271
238
|
return "native";
|
|
272
239
|
}
|
|
273
240
|
|
|
274
|
-
/**
|
|
275
|
-
* True when the active Codex config is owned by opencodex routing. Covers the
|
|
276
|
-
* loopback Design B root override and the legacy/non-loopback provider table.
|
|
277
|
-
* A user-owned `openai_base_url` is intentionally not classified as injected.
|
|
278
|
-
*/
|
|
279
|
-
export function hasInjectedCodexRouting(content: string): boolean {
|
|
280
|
-
if (hasInjectedOpenaiBaseUrl(content)) return true;
|
|
281
|
-
return rootTomlString(content, "model_provider") === "opencodex"
|
|
282
|
-
&& providerTableString(content, "opencodex", "base_url") !== null;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
241
|
/** Read-only probe used by status, doctor, and the dashboard. */
|
|
286
242
|
export function isCodexRoutingInjected(): boolean {
|
|
287
243
|
const path = CODEX_CONFIG_PATH;
|
|
@@ -523,7 +479,13 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
|
|
|
523
479
|
};
|
|
524
480
|
}
|
|
525
481
|
|
|
526
|
-
|
|
482
|
+
// Classify and journal the same bytes: a native config is a valid original and
|
|
483
|
+
// supersedes a stale snapshot (#477), while an injected one must never become
|
|
484
|
+
// one — that is how opencodex routing would survive `ocx stop`.
|
|
485
|
+
writeJournal({
|
|
486
|
+
currentStateIsNative: !hasInjectedCodexRouting(rawContent),
|
|
487
|
+
configContent: rawContent,
|
|
488
|
+
});
|
|
527
489
|
// EOL boundary: transforms below are LF-pure; preserve the file's dominant ending on write.
|
|
528
490
|
const eol = dominantEol(rawContent);
|
|
529
491
|
let content = applyEol(rawContent, "\n");
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership predicates for `~/.codex/config.toml`: does opencodex own the routing
|
|
3
|
+
* currently written there?
|
|
4
|
+
*
|
|
5
|
+
* These live in their own leaf module rather than in `inject.ts` because
|
|
6
|
+
* `journal.ts` needs them and `inject.ts` already imports `journal.ts`. Keeping
|
|
7
|
+
* them here breaks that cycle. `inject.ts` imports them back and re-exports the
|
|
8
|
+
* two public predicates, so external callers see no change.
|
|
9
|
+
*/
|
|
10
|
+
export const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
|
|
11
|
+
|
|
12
|
+
export function isRootOpenaiBaseUrlLine(line: string): boolean {
|
|
13
|
+
return /^\s*openai_base_url\s*=/.test(line);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function tomlStringPattern(key: string): RegExp {
|
|
17
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
18
|
+
const keyToken = `(?:${escaped}|"${escaped}"|'${escaped}')`;
|
|
19
|
+
return new RegExp(`^\\s*${keyToken}\\s*=\\s*["']([^"']+)["']\\s*(?:#.*)?$`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function rootTomlString(content: string, key: string): string | null {
|
|
23
|
+
const lines = content.split("\n");
|
|
24
|
+
const firstTable = lines.findIndex(line => /^\s*\[/.test(line));
|
|
25
|
+
const rootLines = lines.slice(0, firstTable === -1 ? lines.length : firstTable);
|
|
26
|
+
const pattern = tomlStringPattern(key);
|
|
27
|
+
for (const line of rootLines) {
|
|
28
|
+
const match = pattern.exec(line);
|
|
29
|
+
if (match?.[1]) return match[1].trim();
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function providerTableStart(lines: string[], provider: string): number {
|
|
35
|
+
const escapedProvider = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
36
|
+
const providerToken = `(?:${escapedProvider}|"${escapedProvider}"|'${escapedProvider}')`;
|
|
37
|
+
const header = new RegExp(`^\\s*\\[\\s*(?:model_providers|"model_providers"|'model_providers')\\s*\\.\\s*${providerToken}\\s*\\]\\s*(?:#.*)?$`);
|
|
38
|
+
return lines.findIndex(line => header.test(line));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function providerTableString(content: string, provider: string, key: string): string | null {
|
|
42
|
+
const lines = content.split("\n");
|
|
43
|
+
const start = providerTableStart(lines, provider);
|
|
44
|
+
if (start === -1) return null;
|
|
45
|
+
const pattern = tomlStringPattern(key);
|
|
46
|
+
for (let index = start + 1; index < lines.length && !/^\s*\[/.test(lines[index]); index += 1) {
|
|
47
|
+
const match = pattern.exec(lines[index]);
|
|
48
|
+
if (match?.[1]) return match[1].trim();
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function hasInjectedOpenaiBaseUrl(content: string): boolean {
|
|
54
|
+
const lines = content.split("\n");
|
|
55
|
+
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
56
|
+
const rootEnd = firstTable === -1 ? lines.length : firstTable;
|
|
57
|
+
for (let i = 1; i < rootEnd; i++) {
|
|
58
|
+
if (isRootOpenaiBaseUrlLine(lines[i]!) && lines[i - 1]!.includes(OCX_SECTION_MARKER)) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* True when the active Codex config is owned by opencodex routing. Covers the
|
|
65
|
+
* loopback Design B root override and the legacy/non-loopback provider table.
|
|
66
|
+
* A user-owned `openai_base_url` is intentionally not classified as injected.
|
|
67
|
+
*/
|
|
68
|
+
export function hasInjectedCodexRouting(content: string): boolean {
|
|
69
|
+
if (hasInjectedOpenaiBaseUrl(content)) return true;
|
|
70
|
+
return rootTomlString(content, "model_provider") === "opencodex"
|
|
71
|
+
&& providerTableString(content, "opencodex", "base_url") !== null;
|
|
72
|
+
}
|
package/src/codex/journal.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { atomicWriteFile } from "../config";
|
|
5
|
+
import { hasInjectedCodexRouting } from "./injected-marker";
|
|
5
6
|
import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
|
|
6
7
|
|
|
7
8
|
const JOURNAL_PATH = join(CODEX_HOME, "opencodex-journal.json");
|
|
@@ -28,10 +29,45 @@ function sha256(content: string | null): string | null {
|
|
|
28
29
|
return content === null ? null : createHash("sha256").update(content).digest("hex");
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
export
|
|
32
|
-
|
|
32
|
+
export interface WriteJournalOptions {
|
|
33
|
+
/**
|
|
34
|
+
* The caller's verdict on the config it is about to transform: false when
|
|
35
|
+
* `hasInjectedCodexRouting` matched. This does NOT decide whether the content
|
|
36
|
+
* may be journaled — that is checked below, from the bytes themselves. It only
|
|
37
|
+
* authorizes REPLACING an existing snapshot, which is why omitting it still
|
|
38
|
+
* allows a first snapshot but never an overwrite.
|
|
39
|
+
*/
|
|
40
|
+
currentStateIsNative?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* The exact bytes the caller classified. Journaling these rather than re-reading
|
|
43
|
+
* the file keeps the snapshot and the verdict describing the same content when
|
|
44
|
+
* another process rewrites config.toml mid-flight.
|
|
45
|
+
*/
|
|
46
|
+
configContent?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Snapshot the pre-injection Codex state.
|
|
51
|
+
*
|
|
52
|
+
* Only native (non-opencodex-owned) config may be journaled, and native config
|
|
53
|
+
* always supersedes an older snapshot. The first half stops a re-inject from
|
|
54
|
+
* recording opencodex's own routing as the user's original — which would survive
|
|
55
|
+
* `ocx stop` and make the injection unremovable. The second half is the #477 fix:
|
|
56
|
+
* without it the first snapshot a machine ever takes is the only one it ever has,
|
|
57
|
+
* so an unclean shutdown days later replays a day-one config over the user's
|
|
58
|
+
* plugins, model choice, and trusted projects.
|
|
59
|
+
*/
|
|
60
|
+
export function writeJournal(options: WriteJournalOptions = {}): void {
|
|
33
61
|
if (!existsSync(CODEX_CONFIG_PATH)) return;
|
|
34
|
-
const config = readFileSync(CODEX_CONFIG_PATH, "utf-8");
|
|
62
|
+
const config = options.configContent ?? readFileSync(CODEX_CONFIG_PATH, "utf-8");
|
|
63
|
+
// Ownership is decided HERE, from the bytes about to be journaled — never taken
|
|
64
|
+
// on the caller's word. A caller that says "native" about injected content would
|
|
65
|
+
// otherwise make opencodex's own routing the user's permanent "original".
|
|
66
|
+
if (hasInjectedCodexRouting(config)) return;
|
|
67
|
+
// The caller's verdict only authorizes REPLACEMENT. It is weaker evidence than
|
|
68
|
+
// the check above (it may describe bytes read a moment earlier), so an
|
|
69
|
+
// unclassified call creates a first snapshot but never overwrites one.
|
|
70
|
+
if (existsSync(JOURNAL_PATH) && readJournal() && options.currentStateIsNative !== true) return;
|
|
35
71
|
const profile = existsSync(CODEX_PROFILE_PATH)
|
|
36
72
|
? readFileSync(CODEX_PROFILE_PATH, "utf-8")
|
|
37
73
|
: null;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { StoredAccountQuota } from "./quota";
|
|
2
|
+
|
|
3
|
+
export interface MainAccountInfo {
|
|
4
|
+
email: string | null;
|
|
5
|
+
plan: string | null;
|
|
6
|
+
quota: Omit<StoredAccountQuota, "updatedAt"> | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CachedMainAccountInfo extends MainAccountInfo {
|
|
10
|
+
ts: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let cachedMainAccountInfo: CachedMainAccountInfo | null = null;
|
|
14
|
+
|
|
15
|
+
export function getMainAccountInfoCache(): CachedMainAccountInfo | null {
|
|
16
|
+
return cachedMainAccountInfo;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setMainAccountInfoCache(value: CachedMainAccountInfo): void {
|
|
20
|
+
cachedMainAccountInfo = value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function clearMainAccountInfoCache(): void {
|
|
24
|
+
cachedMainAccountInfo = null;
|
|
25
|
+
}
|
package/src/codex/model-cache.ts
CHANGED
|
@@ -47,13 +47,23 @@ export const MODELS_FETCH_FAILURE_COOLDOWN_MS = 30_000;
|
|
|
47
47
|
|
|
48
48
|
const failureAt = new Map<string, number>();
|
|
49
49
|
const discoveryStatus = new Map<string, ProviderModelDiscoveryStatus>();
|
|
50
|
+
/**
|
|
51
|
+
* How many models the last successful discovery actually returned, before any configured-alias
|
|
52
|
+
* augmentation or custom-model merge. Consumers cannot recover this by subtracting known custom
|
|
53
|
+
* ids: a custom id that later also appears upstream would make a real live catalog look
|
|
54
|
+
* custom-only, and the provider's configured fallback would wrongly stay authoritative.
|
|
55
|
+
*/
|
|
56
|
+
const liveModelCounts = new Map<string, number>();
|
|
50
57
|
|
|
51
58
|
export function markModelsFetchFailure(provider: string, now = Date.now()): void {
|
|
52
59
|
failureAt.set(provider, now);
|
|
53
60
|
}
|
|
54
61
|
|
|
55
|
-
|
|
62
|
+
/** `liveModelCount` is required so a caller that forgets to pass it fails typecheck instead of
|
|
63
|
+
* silently recording zero and misclassifying the provider as having no live catalog. */
|
|
64
|
+
export function markProviderDiscoveryOk(provider: string, liveModelCount: number): void {
|
|
56
65
|
discoveryStatus.set(provider, { status: "ok" });
|
|
66
|
+
liveModelCounts.set(provider, Math.max(0, Math.floor(liveModelCount)));
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
export function markProviderDiscoveryFailed(
|
|
@@ -88,12 +98,19 @@ export function shouldLogDiscoveryFailure(
|
|
|
88
98
|
|
|
89
99
|
export function clearProviderDiscoveryStatus(provider: string): void {
|
|
90
100
|
discoveryStatus.delete(provider);
|
|
101
|
+
liveModelCounts.delete(provider);
|
|
91
102
|
}
|
|
92
103
|
|
|
93
104
|
export function getProviderDiscoveryStatus(provider: string): ProviderModelDiscoveryStatus | undefined {
|
|
94
105
|
return discoveryStatus.get(provider);
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
/** Undefined when discovery has never succeeded for this provider. A failed refresh keeps the
|
|
109
|
+
* last successful count so a stale catalog is still recognised as live-origin. */
|
|
110
|
+
export function getProviderLiveModelCount(provider: string): number | undefined {
|
|
111
|
+
return liveModelCounts.get(provider);
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
export function isModelsFetchCoolingDown(provider: string, cooldownMs = MODELS_FETCH_FAILURE_COOLDOWN_MS, now = Date.now()): boolean {
|
|
98
115
|
const at = failureAt.get(provider);
|
|
99
116
|
return at !== undefined && now - at < cooldownMs;
|
|
@@ -121,9 +138,11 @@ export function clearModelCache(provider?: string): void {
|
|
|
121
138
|
cache.delete(provider);
|
|
122
139
|
failureAt.delete(provider);
|
|
123
140
|
discoveryStatus.delete(provider);
|
|
141
|
+
liveModelCounts.delete(provider);
|
|
124
142
|
} else {
|
|
125
143
|
cache.clear();
|
|
126
144
|
failureAt.clear();
|
|
127
145
|
discoveryStatus.clear();
|
|
146
|
+
liveModelCounts.clear();
|
|
128
147
|
}
|
|
129
148
|
}
|
package/src/codex/paths.ts
CHANGED
|
@@ -29,6 +29,11 @@ export const CODEX_PROFILE_PATH = join(CODEX_HOME, "opencodex.config.toml");
|
|
|
29
29
|
export const DEFAULT_CATALOG_PATH = join(CODEX_HOME, "opencodex-catalog.json");
|
|
30
30
|
export const CODEX_MODELS_CACHE_PATH = join(CODEX_HOME, "models_cache.json");
|
|
31
31
|
|
|
32
|
+
/** Runtime CODEX_HOME lookup (honors CODEX_HOME env changes after import). */
|
|
33
|
+
export function getCodexHome(): string {
|
|
34
|
+
return resolveCodexHome();
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
export function tomlString(value: string): string {
|
|
33
38
|
return JSON.stringify(value);
|
|
34
39
|
}
|
package/src/codex/routing.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { saveConfigPreservingClaudeCode } from "../config";
|
|
3
3
|
import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
|
|
4
4
|
import { codexAccountLogLabel } from "./account-label";
|
|
5
5
|
import { isCodexAccountUsable } from "./account-usability";
|
|
@@ -235,14 +235,8 @@ export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}):
|
|
|
235
235
|
* Returns the lease id, or null when no probe may go out right now.
|
|
236
236
|
*/
|
|
237
237
|
export function tryAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): string | null {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const cooldownUntil = health.cooldownUntil;
|
|
241
|
-
if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
|
|
242
|
-
if (health.cooldownSource === "retry-after") return null;
|
|
243
|
-
if (health.probeLeaseId !== undefined) return null;
|
|
244
|
-
const origin = health.lastProbeAt ?? health.cooldownSince ?? cooldownUntil;
|
|
245
|
-
if (now - origin < CODEX_QUOTA_PROBE_INTERVAL_MS) return null;
|
|
238
|
+
if (!canAcquireCodexQuotaProbeLease(accountId, now)) return null;
|
|
239
|
+
const health = upstreamHealth.get(accountId)!;
|
|
246
240
|
const probeLeaseId = randomUUID();
|
|
247
241
|
upstreamHealth.set(accountId, {
|
|
248
242
|
...health,
|
|
@@ -253,6 +247,18 @@ export function tryAcquireCodexQuotaProbeLease(accountId: string, now = Date.now
|
|
|
253
247
|
return probeLeaseId;
|
|
254
248
|
}
|
|
255
249
|
|
|
250
|
+
/** Side-effect-free check mirroring {@link tryAcquireCodexQuotaProbeLease} eligibility. */
|
|
251
|
+
export function canAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): boolean {
|
|
252
|
+
const health = upstreamHealth.get(accountId);
|
|
253
|
+
if (!health) return false;
|
|
254
|
+
const cooldownUntil = health.cooldownUntil;
|
|
255
|
+
if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return false;
|
|
256
|
+
if (health.cooldownSource === "retry-after") return false;
|
|
257
|
+
if (health.probeLeaseId !== undefined) return false;
|
|
258
|
+
const origin = health.lastProbeAt ?? health.cooldownSince ?? cooldownUntil;
|
|
259
|
+
return now - origin >= CODEX_QUOTA_PROBE_INTERVAL_MS;
|
|
260
|
+
}
|
|
261
|
+
|
|
256
262
|
/**
|
|
257
263
|
* Hand a probe lease back without recording an upstream outcome. Used by paths
|
|
258
264
|
* that take a lease and then fail before any request reaches upstream.
|
|
@@ -304,10 +310,63 @@ export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()
|
|
|
304
310
|
return typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now ? cooldownUntil : null;
|
|
305
311
|
}
|
|
306
312
|
|
|
313
|
+
/** Read-only cooldown snapshot for shared OAuth health projection (no write side effects). */
|
|
314
|
+
export function getCodexAccountHealthSnapshot(accountId: string, now = Date.now()): {
|
|
315
|
+
cooldownUntil?: number;
|
|
316
|
+
cooldownSource?: CodexCooldownSource;
|
|
317
|
+
} | null {
|
|
318
|
+
const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
|
|
319
|
+
if (cooldownUntil === null) return null;
|
|
320
|
+
const source = upstreamHealth.get(accountId)?.cooldownSource;
|
|
321
|
+
return {
|
|
322
|
+
cooldownUntil,
|
|
323
|
+
...(source ? { cooldownSource: source } : {}),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
307
327
|
export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean {
|
|
308
328
|
return getCodexAccountCooldownUntil(accountId, now) !== null;
|
|
309
329
|
}
|
|
310
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Manually lift a hard quota cooldown without touching failure history.
|
|
333
|
+
*
|
|
334
|
+
* Injected Codex routing makes this proxy the ONLY model path for Codex Desktop, so a
|
|
335
|
+
* cooldown that outlives the real upstream limit reads to the user as "the whole app is
|
|
336
|
+
* broken" with no escape but editing config.toml. This is that escape hatch.
|
|
337
|
+
*
|
|
338
|
+
* Deliberately narrow:
|
|
339
|
+
* - Failure counters and softAvoid survive. Clearing a cooldown says "the quota window
|
|
340
|
+
* moved", not "this account is healthy"; failover must keep its knowledge.
|
|
341
|
+
* - Dropping `probeLeaseId` is what stops a stale in-flight probe from later "proving"
|
|
342
|
+
* recovery against a NEWER cooldown: {@link ownsProbeLease} needs the id to match.
|
|
343
|
+
* `cooldownGeneration` is preserved and bumped as redundancy only — a fresh 429 already
|
|
344
|
+
* bumps it in {@link recordCodexUpstreamOutcome}, so the bump here is not load-bearing
|
|
345
|
+
* today and is kept so the invariant survives a future change that retains the lease.
|
|
346
|
+
*
|
|
347
|
+
* Returns false when the account carried no live cooldown (already expired or never set).
|
|
348
|
+
*/
|
|
349
|
+
export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean {
|
|
350
|
+
const health = upstreamHealth.get(accountId);
|
|
351
|
+
if (!health) return false;
|
|
352
|
+
const cooldownUntil = health.cooldownUntil;
|
|
353
|
+
if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return false;
|
|
354
|
+
const {
|
|
355
|
+
cooldownUntil: _until,
|
|
356
|
+
cooldownSince: _since,
|
|
357
|
+
cooldownSource: _source,
|
|
358
|
+
probeLeaseId: _leaseId,
|
|
359
|
+
probeLeaseGeneration: _leaseGeneration,
|
|
360
|
+
...rest
|
|
361
|
+
} = health;
|
|
362
|
+
upstreamHealth.set(accountId, {
|
|
363
|
+
...rest,
|
|
364
|
+
cooldownGeneration: (health.cooldownGeneration ?? 0) + 1,
|
|
365
|
+
lastProbeAt: now,
|
|
366
|
+
});
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
|
|
311
370
|
export function getCodexAccountSoftAvoidUntil(accountId: string, now = Date.now()): number | null {
|
|
312
371
|
const softAvoidUntil = upstreamHealth.get(accountId)?.softAvoidUntil;
|
|
313
372
|
return typeof softAvoidUntil === "number" && Number.isFinite(softAvoidUntil) && softAvoidUntil > now
|
|
@@ -391,7 +450,7 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da
|
|
|
391
450
|
return ids;
|
|
392
451
|
}
|
|
393
452
|
|
|
394
|
-
function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined {
|
|
453
|
+
export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined {
|
|
395
454
|
if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan();
|
|
396
455
|
return (config.codexAccounts ?? []).find(account => !account.isMain && account.id === accountId)?.plan;
|
|
397
456
|
}
|
|
@@ -425,7 +484,7 @@ export function pickLowestUsageCodexAccount(config: OcxConfig, excludeId?: strin
|
|
|
425
484
|
function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
|
|
426
485
|
if (config.activeCodexAccountId === accountId) return;
|
|
427
486
|
config.activeCodexAccountId = accountId;
|
|
428
|
-
|
|
487
|
+
saveConfigPreservingClaudeCode(config);
|
|
429
488
|
}
|
|
430
489
|
|
|
431
490
|
function isUnknownUsage(usage: number): boolean {
|
|
@@ -499,6 +558,74 @@ export function resolveCodexAccountForThread(
|
|
|
499
558
|
return resolution.status === "selected" ? resolution.accountId : null;
|
|
500
559
|
}
|
|
501
560
|
|
|
561
|
+
/**
|
|
562
|
+
* Side-effect-free preview of the Codex pool account native routing would prefer.
|
|
563
|
+
* Used for subagent fallback quota decisions before final auth.
|
|
564
|
+
*
|
|
565
|
+
* Does not mutate activeCodexAccountId, thread affinity, config on disk, or probe leases.
|
|
566
|
+
* Mirrors {@link resolveCodexAccountForThreadDetailed} account choice, including returning a
|
|
567
|
+
* configured cooled account so callers can evaluate probe/quota availability.
|
|
568
|
+
*/
|
|
569
|
+
export function previewCodexAccountForRequest(
|
|
570
|
+
threadId: string | null,
|
|
571
|
+
config: OcxConfig,
|
|
572
|
+
now = Date.now(),
|
|
573
|
+
): string | null {
|
|
574
|
+
if (threadId && threadAccountMap.has(threadId)) {
|
|
575
|
+
const entry = threadAccountMap.get(threadId)!;
|
|
576
|
+
if (
|
|
577
|
+
!isThreadAffinityExpired(entry, now)
|
|
578
|
+
&& isThreadAffinityGenerationLive(entry)
|
|
579
|
+
&& isCodexAccountSelectable(config, entry.accountId, now)
|
|
580
|
+
&& !shouldFailover(config, entry.accountId, now)
|
|
581
|
+
) {
|
|
582
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
583
|
+
if (threshold > 0) {
|
|
584
|
+
const usage = computeCodexUsageScore(
|
|
585
|
+
getAccountQuota(entry.accountId),
|
|
586
|
+
getPoolAccountPlan(config, entry.accountId),
|
|
587
|
+
);
|
|
588
|
+
if (usage >= threshold) {
|
|
589
|
+
const best = pickLowerUsageAccount(config, entry.accountId, usage, now);
|
|
590
|
+
if (best !== entry.accountId) return best;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return entry.accountId;
|
|
594
|
+
}
|
|
595
|
+
// Stale/unusable affinity is ignored for preview (no map mutation).
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
let active = config.activeCodexAccountId ?? null;
|
|
599
|
+
if (!active) {
|
|
600
|
+
return pickLowestUsageCodexAccount(config, undefined, now);
|
|
601
|
+
}
|
|
602
|
+
if (!isCodexAccountSelectable(config, active, now)) {
|
|
603
|
+
const fallback = pickLowestUsageCodexAccount(config, active, now);
|
|
604
|
+
if (fallback) active = fallback;
|
|
605
|
+
else if (hasConfiguredPoolAccount(config, active)) return active;
|
|
606
|
+
else return null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
610
|
+
if (threshold > 0) {
|
|
611
|
+
const usage = computeCodexUsageScore(getAccountQuota(active), getPoolAccountPlan(config, active));
|
|
612
|
+
if (usage >= threshold) {
|
|
613
|
+
active = pickLowerUsageAccount(config, active, usage, now);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (shouldFailover(config, active, now)) {
|
|
617
|
+
const best = pickLowestUsageCodexAccount(config, active, now);
|
|
618
|
+
if (best) active = best;
|
|
619
|
+
}
|
|
620
|
+
if (!isCodexAccountUsable(config, active)) {
|
|
621
|
+
return hasConfiguredPoolAccount(config, active) ? active : null;
|
|
622
|
+
}
|
|
623
|
+
if (isCodexAccountInCooldown(active, now)) {
|
|
624
|
+
return hasConfiguredPoolAccount(config, active) ? active : null;
|
|
625
|
+
}
|
|
626
|
+
return active;
|
|
627
|
+
}
|
|
628
|
+
|
|
502
629
|
export function resolveCodexAccountForThreadDetailed(
|
|
503
630
|
threadId: string | null,
|
|
504
631
|
config: OcxConfig,
|