@bitkyc08/opencodex 2.6.32 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.ko.md +9 -5
  2. package/README.md +7 -4
  3. package/README.zh-CN.md +8 -4
  4. package/gui/dist/assets/index-BGdxwydf.js +34 -0
  5. package/gui/dist/assets/index-DANCQ2Jt.css +1 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic.ts +62 -1
  9. package/src/adapters/cursor/cursor-errors.ts +28 -1
  10. package/src/adapters/cursor/discovery.ts +56 -10
  11. package/src/adapters/cursor/effort-map.ts +35 -7
  12. package/src/adapters/cursor/live-models.ts +3 -0
  13. package/src/adapters/cursor/live-transport.ts +136 -7
  14. package/src/adapters/cursor/protobuf-request.ts +24 -1
  15. package/src/adapters/cursor/request-builder.ts +6 -5
  16. package/src/adapters/cursor/transport-retry.ts +22 -3
  17. package/src/adapters/cursor.ts +2 -1
  18. package/src/adapters/openai-chat.ts +75 -26
  19. package/src/bridge.ts +42 -3
  20. package/src/cli/debug.ts +203 -0
  21. package/src/cli/doctor.ts +11 -0
  22. package/src/cli/help.ts +11 -0
  23. package/src/cli/index.ts +10 -0
  24. package/src/cli/v2.ts +131 -0
  25. package/src/codex/auth-api.ts +7 -3
  26. package/src/codex/catalog.ts +334 -31
  27. package/src/codex/data/upstream-models.json +830 -0
  28. package/src/codex/features.ts +178 -0
  29. package/src/codex/project-config-warnings.ts +388 -0
  30. package/src/codex/sync.ts +8 -0
  31. package/src/codex/warmup.ts +62 -6
  32. package/src/config.ts +7 -5
  33. package/src/lib/debug-log-buffer.ts +42 -0
  34. package/src/lib/debug-settings.ts +84 -0
  35. package/src/lib/debug.ts +18 -9
  36. package/src/lib/errors.ts +104 -1
  37. package/src/oauth/cursor.ts +35 -12
  38. package/src/oauth/store.ts +4 -3
  39. package/src/providers/derive.ts +8 -0
  40. package/src/providers/registry.ts +56 -21
  41. package/src/reasoning-effort.ts +32 -9
  42. package/src/responses/parser.ts +7 -2
  43. package/src/router.ts +5 -0
  44. package/src/server/adapter-resolve.ts +1 -1
  45. package/src/server/index.ts +27 -3
  46. package/src/server/management-api.ts +168 -7
  47. package/src/server/relay.ts +2 -2
  48. package/src/server/request-log.ts +78 -0
  49. package/src/server/responses.ts +209 -0
  50. package/src/types.ts +28 -1
  51. package/src/usage/debug.ts +32 -5
  52. package/src/usage/summary.ts +6 -6
  53. package/src/web-search/index.ts +1 -1
  54. package/gui/dist/assets/index-ByGC8-Bm.css +0 -1
  55. package/gui/dist/assets/index-D_JZzI0r.js +0 -15
@@ -0,0 +1,178 @@
1
+ /**
2
+ * features.ts — codex feature-flag view for $CODEX_HOME/config.toml.
3
+ *
4
+ * Used by the catalog v2-gated-ultra policy (devlog/260709_v2_gated_ultra) and the
5
+ * `ocx v2` toggle surface. The FLAG itself is never written here — toggling goes
6
+ * through the official `codex features enable|disable` CLI (format-preserving).
7
+ * The one write this module owns is the numeric
8
+ * `features.multi_agent_v2.max_concurrent_threads_per_session` scalar
9
+ * (setMaxConcurrentThreads): the codex CLI has no persisted setter for nested
10
+ * feature config (`-c` is per-invocation only), so ocx does a scoped,
11
+ * EOL-preserving line edit — same practice as codex/inject.ts.
12
+ *
13
+ * CODEX_HOME is resolved at CALL time (activeCodexConfigPath pattern, mirrors
14
+ * catalog.ts:40-54) so tests can point fixtures via env or the explicit
15
+ * `configPath` parameter without fighting the module-load-time const in paths.ts.
16
+ */
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { join, resolve } from "node:path";
19
+ import { realpathSync } from "node:fs";
20
+ import { atomicWriteFile, expandUserPath } from "../config";
21
+ import { CODEX_CONFIG_PATH } from "./paths";
22
+
23
+ // EOL preservation, local copies of inject.ts dominantEol/applyEol: importing
24
+ // inject here would close a module cycle (features -> inject -> catalog -> features).
25
+ function dominantEol(content: string): "\r\n" | "\n" {
26
+ const crlf = (content.match(/\r\n/g) ?? []).length;
27
+ if (crlf === 0) return "\n";
28
+ const bareLf = (content.match(/\n/g) ?? []).length - crlf;
29
+ return crlf >= bareLf ? "\r\n" : "\n";
30
+ }
31
+
32
+ function applyEol(content: string, eol: "\r\n" | "\n"): string {
33
+ const normalized = content.replace(/\r\n/g, "\n");
34
+ return eol === "\n" ? normalized : normalized.replace(/\n/g, "\r\n");
35
+ }
36
+
37
+ function activeCodexConfigPath(): string {
38
+ const raw = process.env.CODEX_HOME?.trim();
39
+ if (!raw) return CODEX_CONFIG_PATH;
40
+ const path = resolve(expandUserPath(raw));
41
+ try {
42
+ return join(realpathSync.native(path), "config.toml");
43
+ } catch {
44
+ return join(path, "config.toml");
45
+ }
46
+ }
47
+
48
+ function readConfigText(configPath?: string): string | null {
49
+ const path = configPath ?? activeCodexConfigPath();
50
+ try {
51
+ if (!existsSync(path)) return null;
52
+ return readFileSync(path, "utf8");
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** Body lines of a TOML table `[header]` up to (not including) the next table header. */
59
+ function tomlTableBody(content: string, header: string): string | null {
60
+ const lines = content.split("\n");
61
+ const escaped = header.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
62
+ const start = lines.findIndex(l => new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`).test(l));
63
+ if (start === -1) return null;
64
+ const rest = lines.slice(start + 1);
65
+ const end = rest.findIndex(l => /^\s*\[/.test(l));
66
+ return (end === -1 ? rest : rest.slice(0, end)).join("\n");
67
+ }
68
+
69
+ function tomlBoolInBody(body: string, key: string): boolean | null {
70
+ const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(true|false)\\s*(?:#.*)?$`, "m"));
71
+ return m ? m[1] === "true" : null;
72
+ }
73
+
74
+ /**
75
+ * TRUE when the codex `multi_agent_v2` feature is enabled in config.toml.
76
+ * Recognizes both shipped forms (codex-rs features/src/tests.rs):
77
+ * [features.multi_agent_v2] [features]
78
+ * enabled = true multi_agent_v2 = true
79
+ * plus the inline-table form `multi_agent_v2 = { enabled = true, ... }`.
80
+ * Missing file/key -> false (upstream default_enabled = false).
81
+ */
82
+ export function isMultiAgentV2Enabled(configPath?: string): boolean {
83
+ const content = readConfigText(configPath);
84
+ if (content === null) return false;
85
+
86
+ const table = tomlTableBody(content, "features.multi_agent_v2");
87
+ if (table !== null) {
88
+ const enabled = tomlBoolInBody(table, "enabled");
89
+ if (enabled !== null) return enabled;
90
+ // A bare [features.multi_agent_v2] table without `enabled` counts as on
91
+ // (FeatureToml::Config with enabled: None materializes as enabled upstream
92
+ // only when set; be conservative and require the boolean).
93
+ return false;
94
+ }
95
+
96
+ const features = tomlTableBody(content, "features");
97
+ if (features !== null) {
98
+ const bool = tomlBoolInBody(features, "multi_agent_v2");
99
+ if (bool !== null) return bool;
100
+ const inline = features.match(/^\s*multi_agent_v2\s*=\s*\{([^}]*)\}/m);
101
+ if (inline) {
102
+ const enabled = inline[1].match(/enabled\s*=\s*(true|false)/);
103
+ if (enabled) return enabled[1] === "true";
104
+ }
105
+ }
106
+ return false;
107
+ }
108
+
109
+ /**
110
+ * TRUE when config.toml still carries `[agents] max_threads` — codex-rs REFUSES to
111
+ * boot with that key while multi_agent_v2 is enabled ("agents.max_threads cannot be
112
+ * set when features.multi_agent_v2 is enabled", core/src/config/mod.rs:1421). The
113
+ * `ocx v2 on` flow warns about it instead of editing config itself.
114
+ */
115
+ export function hasAgentsMaxThreads(configPath?: string): boolean {
116
+ const content = readConfigText(configPath);
117
+ if (content === null) return false;
118
+ const agents = tomlTableBody(content, "agents");
119
+ if (agents === null) return false;
120
+ return /^\s*max_threads\s*=/m.test(agents);
121
+ }
122
+
123
+ /**
124
+ * Current `features.multi_agent_v2.max_concurrent_threads_per_session`, or null when
125
+ * the table/key is absent (codex-rs then applies its own default).
126
+ */
127
+ export function getMaxConcurrentThreads(configPath?: string): number | null {
128
+ const content = readConfigText(configPath);
129
+ if (content === null) return null;
130
+ const table = tomlTableBody(content, "features.multi_agent_v2");
131
+ if (table === null) return null;
132
+ const m = table.match(/^\s*max_concurrent_threads_per_session\s*=\s*(\d+)\s*(?:#.*)?$/m);
133
+ if (!m) return null;
134
+ const value = Number(m[1]);
135
+ return Number.isFinite(value) && value >= 1 ? value : null;
136
+ }
137
+
138
+ /**
139
+ * Persist `features.multi_agent_v2.max_concurrent_threads_per_session = value`.
140
+ * Scoped line edit inside the existing `[features.multi_agent_v2]` table only:
141
+ * replaces the key line when present, else inserts it right under the header.
142
+ * Refuses (returns an error string) when the table is missing — creating it next
143
+ * to a boolean-form `multi_agent_v2 = true` would be a TOML key conflict, and the
144
+ * table is exactly what `codex features enable multi_agent_v2` materializes, so
145
+ * "enable first" is the honest remedy. Idempotent: equal value -> no write.
146
+ */
147
+ export function setMaxConcurrentThreads(value: number, configPath?: string): { ok: true; changed: boolean } | { ok: false; error: string } {
148
+ if (!Number.isInteger(value) || value < 1) {
149
+ return { ok: false, error: "max_concurrent_threads_per_session must be an integer >= 1" };
150
+ }
151
+ const path = configPath ?? activeCodexConfigPath();
152
+ const content = readConfigText(path);
153
+ if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
154
+
155
+ const eol = dominantEol(content);
156
+ const lines = content.split(/\r?\n/);
157
+ const headerRe = /^\s*\[features\.multi_agent_v2\]\s*(?:#.*)?$/;
158
+ const headerIdx = lines.findIndex(l => headerRe.test(l));
159
+ if (headerIdx === -1) {
160
+ return { ok: false, error: "[features.multi_agent_v2] table not found — enable v2 first (ocx v2 on)" };
161
+ }
162
+ let end = lines.length;
163
+ for (let i = headerIdx + 1; i < lines.length; i++) {
164
+ if (/^\s*\[/.test(lines[i])) { end = i; break; }
165
+ }
166
+ const keyRe = /^(\s*)max_concurrent_threads_per_session\s*=\s*(\d+)(\s*#.*)?$/;
167
+ for (let i = headerIdx + 1; i < end; i++) {
168
+ const m = lines[i].match(keyRe);
169
+ if (!m) continue;
170
+ if (Number(m[2]) === value) return { ok: true, changed: false };
171
+ lines[i] = `${m[1]}max_concurrent_threads_per_session = ${value}${m[3] ?? ""}`;
172
+ atomicWriteFile(path, applyEol(lines.join("\n"), eol));
173
+ return { ok: true, changed: true };
174
+ }
175
+ lines.splice(headerIdx + 1, 0, `max_concurrent_threads_per_session = ${value}`);
176
+ atomicWriteFile(path, applyEol(lines.join("\n"), eol));
177
+ return { ok: true, changed: true };
178
+ }
@@ -0,0 +1,388 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { expandUserPath } from "../config";
4
+ import { defaultCodexHome } from "./home";
5
+ import { readRootTomlString } from "./paths";
6
+
7
+ const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
8
+ const DIAGNOSTICS_CACHE_TTL_MS = 30_000;
9
+
10
+ function resolveCodexConfigPath(): string {
11
+ const raw = process.env.CODEX_HOME?.trim();
12
+ const home = raw ? resolve(expandUserPath(raw)) : defaultCodexHome();
13
+ return join(home, "config.toml");
14
+ }
15
+
16
+ export type ProjectCodexConfigIssueCode = "model_providers_table" | "profile_selector" | "model_provider_root";
17
+
18
+ export interface ProjectCodexConfigWarning {
19
+ path: string;
20
+ code: ProjectCodexConfigIssueCode;
21
+ /** Effective provider id that bypasses OpenCodex. */
22
+ detail: string;
23
+ /** Profile name when the bypass is selected via profile = "…". */
24
+ profileName?: string;
25
+ message: string;
26
+ }
27
+
28
+ interface TomlDocument {
29
+ root: Record<string, string>;
30
+ sections: Map<string, Record<string, string>>;
31
+ }
32
+
33
+ let diagnosticsCache: { at: number; warnings: ProjectCodexConfigWarning[] } | null = null;
34
+
35
+ function hasInjectedOpenaiBaseUrl(content: string): boolean {
36
+ const lines = content.split("\n");
37
+ const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
38
+ const rootEnd = firstTable === -1 ? lines.length : firstTable;
39
+ for (let i = 1; i < rootEnd; i++) {
40
+ if (/^\s*openai_base_url\s*=/.test(lines[i]) && lines[i - 1].includes(OCX_SECTION_MARKER)) return true;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ function parseTomlString(raw: string): string {
46
+ if (raw.startsWith("\"")) {
47
+ try {
48
+ return JSON.parse(raw) as string;
49
+ } catch {
50
+ return raw.slice(1, -1);
51
+ }
52
+ }
53
+ return raw.slice(1, -1);
54
+ }
55
+
56
+ /** Lightweight TOML parse for root keys and [section] tables (Codex config shape). */
57
+ export function parseTomlDocument(content: string): TomlDocument {
58
+ const root: Record<string, string> = {};
59
+ const sections = new Map<string, Record<string, string>>();
60
+ let current = root;
61
+
62
+ for (const line of content.split("\n")) {
63
+ const table = line.match(/^\s*\[([^\]]+)\]\s*$/);
64
+ if (table) {
65
+ const name = table[1]!.trim();
66
+ const section = sections.get(name) ?? {};
67
+ sections.set(name, section);
68
+ current = section;
69
+ continue;
70
+ }
71
+ const kv = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=\s*("(?:\\.|[^"])*"|'[^']*'|[^\s#]+)\s*(?:#.*)?$/);
72
+ if (kv) current[kv[1]!] = parseTomlString(kv[2]!);
73
+ }
74
+
75
+ return { root, sections };
76
+ }
77
+
78
+ function profileSectionKeys(profileName: string): string[] {
79
+ return [
80
+ `profiles.${profileName}`,
81
+ `profiles."${profileName}"`,
82
+ `profiles.'${profileName}'`,
83
+ ];
84
+ }
85
+
86
+ function readProfileModelProvider(sections: Map<string, Record<string, string>>, profileName: string): string | null {
87
+ for (const key of profileSectionKeys(profileName)) {
88
+ const provider = sections.get(key)?.model_provider;
89
+ if (provider) return provider;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function hasModelProviderTable(sections: Map<string, Record<string, string>>, provider: string): boolean {
95
+ return sections.has(`model_providers.${provider}`);
96
+ }
97
+
98
+ /** Built-in openai provider still routes through the proxy under Design B (marker-owned openai_base_url). */
99
+ function isProxyCompatibleProvider(provider: string): boolean {
100
+ return provider === "opencodex" || provider === "openai";
101
+ }
102
+
103
+ export interface EffectiveProjectModelRouting {
104
+ provider: string | null;
105
+ profileName: string | null;
106
+ via: "profile" | "root" | null;
107
+ }
108
+
109
+ /** Resolve the provider Codex would actually use from a project .codex/config.toml. */
110
+ export function resolveEffectiveProjectModelProvider(content: string): EffectiveProjectModelRouting {
111
+ const { root, sections } = parseTomlDocument(content);
112
+ const rootProfile = root.profile ?? null;
113
+ const rootProvider = root.model_provider ?? null;
114
+
115
+ if (rootProfile) {
116
+ const fromProfile = readProfileModelProvider(sections, rootProfile);
117
+ if (fromProfile) {
118
+ return { provider: fromProfile, profileName: rootProfile, via: "profile" };
119
+ }
120
+ if (rootProvider) {
121
+ return { provider: rootProvider, profileName: rootProfile, via: "root" };
122
+ }
123
+ return { provider: null, profileName: rootProfile, via: null };
124
+ }
125
+
126
+ if (rootProvider) {
127
+ return { provider: rootProvider, profileName: null, via: "root" };
128
+ }
129
+
130
+ return { provider: null, profileName: null, via: null };
131
+ }
132
+
133
+ /** True when global Codex config routes through the opencodex proxy. */
134
+ export function isGlobalOpencodexRoutingActive(
135
+ codexConfigPath: string = resolveCodexConfigPath(),
136
+ content?: string,
137
+ ): boolean {
138
+ let text = content;
139
+ if (text === undefined) {
140
+ if (!existsSync(codexConfigPath)) return false;
141
+ try {
142
+ text = readFileSync(codexConfigPath, "utf-8");
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+ if (hasInjectedOpenaiBaseUrl(text)) return true;
148
+ if (readRootTomlString(text, "model_provider") === "opencodex") return true;
149
+ return false;
150
+ }
151
+
152
+ export function parseTrustedProjectPathsFromCodexConfig(content: string): string[] {
153
+ const { sections } = parseTomlDocument(content);
154
+ const paths: string[] = [];
155
+
156
+ for (const [name, keys] of sections) {
157
+ const quoted = name.match(/^projects\.(?:'([^']*)'|"([^"]*)")$/);
158
+ if (!quoted) continue;
159
+ const raw = (quoted[1] ?? quoted[2] ?? "").trim();
160
+ if (!raw) continue;
161
+ if ((keys.trust_level ?? "").toLowerCase() !== "trusted") continue;
162
+ paths.push(raw);
163
+ }
164
+
165
+ return paths;
166
+ }
167
+
168
+ export function analyzeProjectCodexConfig(content: string, configPath: string): ProjectCodexConfigWarning[] {
169
+ const { sections } = parseTomlDocument(content);
170
+ const routing = resolveEffectiveProjectModelProvider(content);
171
+ const provider = routing.provider;
172
+
173
+ if (!provider || isProxyCompatibleProvider(provider)) return [];
174
+
175
+ const rel = relPath(configPath);
176
+ if (hasModelProviderTable(sections, provider)) {
177
+ return [{
178
+ path: configPath,
179
+ code: "model_providers_table",
180
+ detail: provider,
181
+ profileName: routing.profileName ?? undefined,
182
+ message:
183
+ `Project Codex config selects provider "${provider}" via `
184
+ + `${routing.via === "profile" ? `profile = "${routing.profileName}"` : "model_provider"} and defines `
185
+ + `[model_providers.${provider}] (${rel}). That routes this trusted project away from the OpenCodex proxy.`,
186
+ }];
187
+ }
188
+
189
+ if (routing.via === "profile" && routing.profileName) {
190
+ return [{
191
+ path: configPath,
192
+ code: "profile_selector",
193
+ detail: provider,
194
+ profileName: routing.profileName,
195
+ message:
196
+ `Project Codex config profile "${routing.profileName}" sets model_provider = "${provider}" (${rel}). `
197
+ + "That routes this trusted project away from the OpenCodex proxy.",
198
+ }];
199
+ }
200
+
201
+ return [{
202
+ path: configPath,
203
+ code: "model_provider_root",
204
+ detail: provider,
205
+ message:
206
+ `Project Codex config sets model_provider = "${provider}" (${rel}). `
207
+ + "Use global ~/.codex/config.toml for OpenCodex routing instead of a project-local provider override.",
208
+ }];
209
+ }
210
+
211
+ /** profile/model_provider that selects an already-flagged [model_providers.X] table is one bypass, not two. */
212
+ export function dedupeRelatedProjectCodexWarnings(
213
+ warnings: ProjectCodexConfigWarning[],
214
+ ): ProjectCodexConfigWarning[] {
215
+ const providerTables = new Set(
216
+ warnings.filter(w => w.code === "model_providers_table").map(w => w.detail),
217
+ );
218
+ if (providerTables.size === 0) return warnings;
219
+ return warnings.filter(w => {
220
+ if (w.code === "profile_selector" && providerTables.has(w.detail)) return false;
221
+ if (w.code === "model_provider_root" && providerTables.has(w.detail)) return false;
222
+ return true;
223
+ });
224
+ }
225
+
226
+ function relPath(abs: string): string {
227
+ const home = process.env.USERPROFILE ?? process.env.HOME ?? "";
228
+ if (home && abs.toLowerCase().startsWith(home.toLowerCase())) {
229
+ return `~${abs.slice(home.length).replace(/\\/g, "/")}`;
230
+ }
231
+ return abs;
232
+ }
233
+
234
+ export function discoverProjectCodexConfigPaths(options: {
235
+ cwd?: string;
236
+ codexConfigPath?: string;
237
+ maxWalkParents?: number;
238
+ } = {}): string[] {
239
+ const found = new Set<string>();
240
+ const codexConfigPath = options.codexConfigPath ?? resolveCodexConfigPath();
241
+ const addIfExists = (projectRoot: string) => {
242
+ const path = join(resolve(projectRoot), ".codex", "config.toml");
243
+ if (existsSync(path)) found.add(path);
244
+ };
245
+
246
+ let cwd = resolve(options.cwd ?? process.cwd());
247
+ const maxWalk = options.maxWalkParents ?? 12;
248
+ for (let depth = 0; depth < maxWalk; depth++) {
249
+ addIfExists(cwd);
250
+ const parent = dirname(cwd);
251
+ if (parent === cwd) break;
252
+ cwd = parent;
253
+ }
254
+
255
+ if (existsSync(codexConfigPath)) {
256
+ try {
257
+ const global = readFileSync(codexConfigPath, "utf-8");
258
+ for (const projectPath of parseTrustedProjectPathsFromCodexConfig(global)) {
259
+ addIfExists(projectPath);
260
+ }
261
+ } catch {
262
+ /* ignore unreadable global config */
263
+ }
264
+ }
265
+
266
+ return [...found];
267
+ }
268
+
269
+ export function collectProjectCodexConfigWarnings(options: {
270
+ cwd?: string;
271
+ codexConfigPath?: string;
272
+ requireOpencodexRouting?: boolean;
273
+ } = {}): ProjectCodexConfigWarning[] {
274
+ const codexConfigPath = options.codexConfigPath ?? resolveCodexConfigPath();
275
+ const requireRouting = options.requireOpencodexRouting ?? true;
276
+ if (requireRouting && !isGlobalOpencodexRoutingActive(codexConfigPath)) return [];
277
+
278
+ const warnings: ProjectCodexConfigWarning[] = [];
279
+ for (const path of discoverProjectCodexConfigPaths({ cwd: options.cwd, codexConfigPath })) {
280
+ try {
281
+ const content = readFileSync(path, "utf-8");
282
+ warnings.push(...analyzeProjectCodexConfig(content, path));
283
+ } catch {
284
+ /* skip unreadable project config */
285
+ }
286
+ }
287
+ return warnings;
288
+ }
289
+
290
+ export function invalidateProjectConfigDiagnosticsCache(): void {
291
+ diagnosticsCache = null;
292
+ }
293
+
294
+ export function getCachedProjectConfigDiagnostics(): {
295
+ warnings: ProjectCodexConfigWarning[];
296
+ grouped: ProjectCodexConfigWarningGroup[];
297
+ } {
298
+ const now = Date.now();
299
+ if (!diagnosticsCache || now - diagnosticsCache.at > DIAGNOSTICS_CACHE_TTL_MS) {
300
+ diagnosticsCache = { at: now, warnings: collectProjectCodexConfigWarnings() };
301
+ }
302
+ const warnings = diagnosticsCache.warnings;
303
+ return { warnings, grouped: groupProjectCodexConfigWarningsByPath(warnings) };
304
+ }
305
+
306
+ export function summarizeProjectCodexIssue(warning: ProjectCodexConfigWarning): string {
307
+ switch (warning.code) {
308
+ case "model_providers_table":
309
+ return `[model_providers.${warning.detail}]`;
310
+ case "profile_selector":
311
+ return warning.profileName ? `profile="${warning.profileName}"` : `model_provider="${warning.detail}"`;
312
+ case "model_provider_root":
313
+ return `model_provider="${warning.detail}"`;
314
+ }
315
+ }
316
+
317
+ function humanizeProviderDetail(detail: string): string {
318
+ if (detail === "opencode_go") return "OpenCode Go";
319
+ if (detail.startsWith("opencode")) return "OpenCode";
320
+ if (detail === "opencodex") return "OpenCodex";
321
+ return detail;
322
+ }
323
+
324
+ /** Short "why" line: what this project config overrides and where traffic goes instead. */
325
+ export function explainProjectConfigBypass(warnings: ProjectCodexConfigWarning[]): string {
326
+ const targets = [...new Set(warnings.map(w => humanizeProviderDetail(w.detail)))];
327
+ const via = targets.length === 1 ? targets[0]! : targets.join(" / ");
328
+ return `Overrides OpenCodex — Codex uses ${via} for this repo instead of the proxy (~/.codex/config.toml).`;
329
+ }
330
+
331
+ export interface ProjectCodexConfigWarningGroup {
332
+ path: string;
333
+ issues: string[];
334
+ bypass: string;
335
+ }
336
+
337
+ export function groupProjectCodexConfigWarningsByPath(
338
+ warnings: ProjectCodexConfigWarning[],
339
+ ): ProjectCodexConfigWarningGroup[] {
340
+ const grouped = new Map<string, ProjectCodexConfigWarning[]>();
341
+ for (const warning of warnings) {
342
+ const list = grouped.get(warning.path) ?? [];
343
+ list.push(warning);
344
+ grouped.set(warning.path, list);
345
+ }
346
+ return [...grouped.entries()].map(([path, pathWarnings]) => ({
347
+ path,
348
+ issues: pathWarnings.map(summarizeProjectCodexIssue),
349
+ bypass: explainProjectConfigBypass(pathWarnings),
350
+ }));
351
+ }
352
+
353
+ export function formatProjectCodexConfigWarningsForDoctor(warnings: ProjectCodexConfigWarning[]): string[] {
354
+ const grouped = groupProjectCodexConfigWarningsByPath(warnings);
355
+ if (grouped.length === 0) return [];
356
+ const lines: string[] = [];
357
+ for (const { path, issues, bypass } of grouped) {
358
+ lines.push(` -- ${relPath(path)} — ${issues.join(", ")}`);
359
+ lines.push(` ${bypass}`);
360
+ }
361
+ lines.push(" fix: remove those entries so OpenCodex proxy routing applies in this project");
362
+ return lines;
363
+ }
364
+
365
+ export function formatProjectCodexConfigWarningsForConsole(warnings: ProjectCodexConfigWarning[]): string[] {
366
+ const grouped = groupProjectCodexConfigWarningsByPath(warnings);
367
+ if (grouped.length === 0) return [];
368
+ const lines = ["⚠️ Project Codex config bypasses OpenCodex:"];
369
+ for (const { path, issues, bypass } of grouped) {
370
+ lines.push(` ${relPath(path)} — ${issues.join(", ")}`);
371
+ lines.push(` ${bypass}`);
372
+ }
373
+ lines.push(" fix: remove those entries so OpenCodex proxy routing applies in this project");
374
+ return lines;
375
+ }
376
+
377
+ export function printProjectCodexConfigWarnings(
378
+ log?: Pick<Console, "log"> | null,
379
+ options?: Parameters<typeof collectProjectCodexConfigWarnings>[0],
380
+ ): ProjectCodexConfigWarning[] {
381
+ const warnings = collectProjectCodexConfigWarnings(options);
382
+ if (log) {
383
+ for (const line of formatProjectCodexConfigWarningsForConsole(warnings)) {
384
+ log.log(line);
385
+ }
386
+ }
387
+ return warnings;
388
+ }
package/src/codex/sync.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { injectCodexConfig } from "./inject";
2
+ import { printProjectCodexConfigWarnings, groupProjectCodexConfigWarningsByPath, type ProjectCodexConfigWarning } from "./project-config-warnings";
2
3
  import { refreshCodexModelCatalog } from "./refresh";
3
4
  import { applyProxyEnv, loadConfig } from "../config";
4
5
  import type { OcxConfig } from "../types";
@@ -11,6 +12,8 @@ export interface CodexSyncResult {
11
12
  cacheSynced: boolean;
12
13
  message: string;
13
14
  warning?: string;
15
+ projectConfigWarnings?: ProjectCodexConfigWarning[];
16
+ projectConfigGrouped?: { path: string; issues: string[]; bypass: string }[];
14
17
  }
15
18
 
16
19
  interface CodexSyncDeps {
@@ -58,6 +61,7 @@ export async function syncModelsToCodex(
58
61
 
59
62
  const result = await deps.injectCodexConfig(p, config, { catalogPath: catalogPathForInjection });
60
63
  log?.log(result.message);
64
+ const projectConfigWarnings = printProjectCodexConfigWarnings(log, { cwd: process.cwd() });
61
65
  return {
62
66
  ok: result.success,
63
67
  added,
@@ -66,5 +70,9 @@ export async function syncModelsToCodex(
66
70
  cacheSynced,
67
71
  message: result.message,
68
72
  ...(warning ? { warning } : {}),
73
+ ...(projectConfigWarnings.length > 0 ? {
74
+ projectConfigWarnings,
75
+ projectConfigGrouped: groupProjectCodexConfigWarningsByPath(projectConfigWarnings),
76
+ } : {}),
69
77
  };
70
78
  }