@bitkyc08/opencodex 2.28.0 → 2.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/gui/dist/assets/index-BNESwCzn.js +102 -0
- package/gui/dist/assets/index-CH7ncHCC.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/base.ts +3 -1
- package/src/adapters/cursor/checkpoint-store.ts +303 -0
- package/src/adapters/cursor/discovery.ts +25 -0
- package/src/adapters/cursor/live-transport.ts +19 -2
- package/src/adapters/cursor/native-exec.ts +47 -2
- package/src/adapters/cursor/protobuf-request.ts +80 -23
- package/src/adapters/cursor/request-builder.ts +119 -5
- package/src/adapters/cursor/transport.ts +5 -0
- package/src/adapters/cursor/types.ts +13 -0
- package/src/adapters/cursor.ts +109 -3
- package/src/adapters/google-antigravity-replay.ts +31 -4
- package/src/adapters/google.ts +323 -38
- package/src/adapters/openai-chat.ts +19 -0
- package/src/adapters/openai-responses.ts +218 -37
- package/src/bridge.ts +38 -20
- package/src/claude/desktop-3p.ts +15 -6
- package/src/cli/agent.ts +44 -1
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/dispatch.ts +24 -1
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +40 -38
- package/src/cli/integrations.ts +1 -1
- package/src/cli/registry.ts +2 -2
- package/src/clients/config-export.ts +119 -20
- package/src/codex/affinity-debug.ts +162 -0
- package/src/codex/inject.ts +46 -14
- package/src/codex/journal.ts +22 -8
- package/src/config.ts +1 -0
- package/src/generated/compatibility-version.json +134 -66
- package/src/integrations/mutation-flight.ts +71 -0
- package/src/integrations/owned-refresh.ts +74 -0
- package/src/integrations/registry.ts +25 -2
- package/src/integrations/writer.ts +32 -1
- package/src/lab/public/signature.ts +25 -1
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/redact.ts +2 -2
- package/src/oauth/log.ts +3 -1
- package/src/providers/derive.ts +9 -0
- package/src/providers/fastwire.ts +24 -18
- package/src/providers/openai-tiers.ts +60 -1
- package/src/providers/registry.ts +14 -7
- package/src/providers/xai-responses-opt-in.ts +15 -0
- package/src/responses/compaction.ts +18 -0
- package/src/responses/custom-tool-compat.ts +70 -5
- package/src/responses/namespace-tool-compat.ts +356 -0
- package/src/responses/parser.ts +2 -2
- package/src/responses/provider-continuation.ts +98 -0
- package/src/responses/reasoning-replay-cache.ts +125 -7
- package/src/responses/spill-store.ts +6 -1
- package/src/responses/state.ts +11 -0
- package/src/router.ts +14 -0
- package/src/routing/compatibility/behavior.ts +1 -0
- package/src/server/auth-cors.ts +4 -0
- package/src/server/management/agent-settings-routes.ts +57 -9
- package/src/server/management/config-routes.ts +134 -15
- package/src/server/management/integration-routes.ts +8 -55
- package/src/server/management/model-routes.ts +23 -1
- package/src/server/management/provider-routes.ts +22 -0
- package/src/server/management/vision-sidecar-options.ts +18 -11
- package/src/server/management/web-search-sidecar-options.ts +120 -0
- package/src/server/responses/core.ts +588 -78
- package/src/server/responses/responses-field-backfill.ts +7 -11
- package/src/server/responses/terminal-guard.ts +22 -11
- package/src/server/responses-custom-tool-repair.ts +3 -1
- package/src/server/responses-reasoning-summary-rewrite.ts +7 -0
- package/src/server/responses-tool-search-repair.ts +64 -14
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/types/config.ts +26 -5
- package/src/types/provider.ts +18 -0
- package/src/types/request.ts +26 -0
- package/src/types.ts +1 -0
- package/src/usage/log.ts +2 -0
- package/src/vision/index.ts +8 -9
- package/src/web-search/backends.ts +108 -0
- package/src/web-search/exa-executor.ts +88 -0
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +139 -12
- package/src/web-search/loop.ts +45 -5
- package/src/web-search/parse.ts +34 -23
- package/src/web-search/sources.ts +60 -0
- package/src/web-search/xai-executor.ts +219 -0
- package/gui/dist/assets/index-D2sP-biU.js +0 -102
- package/gui/dist/assets/index-DQsMZzI5.css +0 -1
|
@@ -25,6 +25,7 @@ import { isAbsolute, join, resolve } from "node:path";
|
|
|
25
25
|
import { shouldInjectApiAuthHeader } from "../codex/inject";
|
|
26
26
|
import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize";
|
|
27
27
|
import { providerCodexAccountMode } from "../providers/registry";
|
|
28
|
+
import { sanitizeCodexReasoningEfforts } from "../reasoning-effort";
|
|
28
29
|
import { probeHostname } from "../server/proxy-liveness";
|
|
29
30
|
import type { OcxConfig } from "../types";
|
|
30
31
|
|
|
@@ -192,6 +193,27 @@ function ompProfileName(env: OpencodeLaunchEnv): string | undefined {
|
|
|
192
193
|
return profile;
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Pi resolves its agent directory from `PI_CODING_AGENT_DIR`, falling back to
|
|
198
|
+
* `~/.pi/agent`. `ompAgentDir` below already reads that variable — OMP is a Pi
|
|
199
|
+
* derivative — so Pi's own resolver honoring it is what makes the two agree
|
|
200
|
+
* rather than a new claim about Pi's contract.
|
|
201
|
+
*
|
|
202
|
+
* A relative override is refused for the same reason MCode's and ZCode's are: a
|
|
203
|
+
* background proxy and a foreground client can have different working
|
|
204
|
+
* directories, and would otherwise disagree about which file is named.
|
|
205
|
+
*/
|
|
206
|
+
export function piAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
|
|
207
|
+
const override = env.PI_CODING_AGENT_DIR?.trim();
|
|
208
|
+
if (override) return absoluteClientPath(override, home, "PI_CODING_AGENT_DIR");
|
|
209
|
+
return join(home, ".pi", "agent");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Pi's canonical custom-provider catalog. */
|
|
213
|
+
export function piConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
|
|
214
|
+
return join(piAgentDir(env, home), "models.json");
|
|
215
|
+
}
|
|
216
|
+
|
|
195
217
|
/** Resolve the global Oh My Pi agent directory using OMP's own env precedence. */
|
|
196
218
|
export function ompAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
|
|
197
219
|
const profile = ompProfileName(env);
|
|
@@ -432,6 +454,26 @@ export function zcodeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri
|
|
|
432
454
|
return join(zcodeHomeDir(env, home), "v2", "config.json");
|
|
433
455
|
}
|
|
434
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Prime Agent resolves its agent directory from `PRIME_AGENT_CODING_AGENT_DIR`
|
|
459
|
+
* — the brand-derived spelling of the `PI_CODING_AGENT_DIR` that `ompAgentDir`
|
|
460
|
+
* already honors, because the agent builds that variable name from its own
|
|
461
|
+
* `piConfig.name` — and otherwise falls back to `~/.prime/agent`. Relative
|
|
462
|
+
* overrides are refused for the same reason as MCode's and ZCode's: a
|
|
463
|
+
* background proxy and a foreground client can have different working
|
|
464
|
+
* directories.
|
|
465
|
+
*/
|
|
466
|
+
export function primeAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
|
|
467
|
+
const override = env.PRIME_AGENT_CODING_AGENT_DIR?.trim();
|
|
468
|
+
if (override) return absoluteClientPath(override, home, "PRIME_AGENT_CODING_AGENT_DIR");
|
|
469
|
+
return join(home, ".prime", "agent");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Prime Agent's canonical custom-provider catalog. */
|
|
473
|
+
export function primeConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
|
|
474
|
+
return join(primeAgentDir(env, home), "models.json");
|
|
475
|
+
}
|
|
476
|
+
|
|
435
477
|
/**
|
|
436
478
|
* One proxy-routed model destined for a client config. Deliberately narrower than
|
|
437
479
|
* `CatalogModel` so a serializer cannot reach for a field that does not survive the
|
|
@@ -473,7 +515,8 @@ export type ExportClientId =
|
|
|
473
515
|
| "gajae"
|
|
474
516
|
| "dsh"
|
|
475
517
|
| "mcode"
|
|
476
|
-
| "zcode"
|
|
518
|
+
| "zcode"
|
|
519
|
+
| "prime";
|
|
477
520
|
|
|
478
521
|
export interface ExportClientSpec {
|
|
479
522
|
id: ExportClientId;
|
|
@@ -886,7 +929,14 @@ export interface McodeProviderBlock {
|
|
|
886
929
|
baseURL: string;
|
|
887
930
|
authMode: "api-key";
|
|
888
931
|
};
|
|
889
|
-
models: Record<string,
|
|
932
|
+
models: Record<string, McodeModelEntry>;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
export interface McodeModelEntry {
|
|
936
|
+
/** MCode uses this value for context accounting and compaction. */
|
|
937
|
+
limit?: { context: number };
|
|
938
|
+
/** MCode exposes these exact levels in `/model` and sends the selected effort. */
|
|
939
|
+
thinking?: { effortOptions: string[] };
|
|
890
940
|
}
|
|
891
941
|
|
|
892
942
|
export interface McodeGeneratedConfig {
|
|
@@ -895,10 +945,10 @@ export interface McodeGeneratedConfig {
|
|
|
895
945
|
|
|
896
946
|
/**
|
|
897
947
|
* ZCode's `~/.zcode/v2/config.json` provider entry (observed schema, validated
|
|
898
|
-
* live against ZCode 3.7.7). `kind: "
|
|
899
|
-
*
|
|
900
|
-
* keeps ZCode's UI from prompting for a key it does not need on
|
|
901
|
-
* serialized key is always the non-secret loopback placeholder.
|
|
948
|
+
* live against ZCode 3.7.7 / 3.8.1). `kind: "openai-compatible"` selects the
|
|
949
|
+
* OpenAI Chat Completions protocol, which the proxy serves at `/v1/chat/completions`.
|
|
950
|
+
* `apiKeyRequired` keeps ZCode's UI from prompting for a key it does not need on
|
|
951
|
+
* loopback; the serialized key is always the non-secret loopback placeholder.
|
|
902
952
|
*/
|
|
903
953
|
export interface ZcodeModelEntry {
|
|
904
954
|
name?: string;
|
|
@@ -908,7 +958,7 @@ export interface ZcodeModelEntry {
|
|
|
908
958
|
|
|
909
959
|
export interface ZcodeProviderBlock {
|
|
910
960
|
name: "OpenCodex";
|
|
911
|
-
kind: "
|
|
961
|
+
kind: "openai-compatible";
|
|
912
962
|
enabled: true;
|
|
913
963
|
source: "custom";
|
|
914
964
|
options: {
|
|
@@ -1231,12 +1281,29 @@ function buildDshClientConfig(ctx: ExportContext): DshGeneratedConfig {
|
|
|
1231
1281
|
|
|
1232
1282
|
/**
|
|
1233
1283
|
* MiniMax Code's `provider add` command persists custom providers under
|
|
1234
|
-
* `custom_provider.<id>`.
|
|
1235
|
-
*
|
|
1284
|
+
* `custom_provider.<id>`. Its current model schema reads `limit.context` for
|
|
1285
|
+
* context accounting and `thinking.effortOptions` for the `/model` effort
|
|
1286
|
+
* control. Do not emit the removed `thinking.effort` / `defaultEffort` fields:
|
|
1287
|
+
* MCode 0.1.6 migrates those into options and keeps the selected effort in the
|
|
1288
|
+
* session. Do not emit `defaultModel` either: connecting a client must not
|
|
1289
|
+
* silently replace the user's current model selection.
|
|
1236
1290
|
*/
|
|
1237
1291
|
function buildMcodeClientConfig(ctx: ExportContext): McodeGeneratedConfig {
|
|
1238
|
-
const models: Record<string,
|
|
1239
|
-
for (const model of normalizeExportModels(ctx.models))
|
|
1292
|
+
const models: Record<string, McodeModelEntry> = {};
|
|
1293
|
+
for (const model of normalizeExportModels(ctx.models)) {
|
|
1294
|
+
const entry: McodeModelEntry = {};
|
|
1295
|
+
const context = authoritativeContextWindow(model.contextWindow);
|
|
1296
|
+
if (context !== undefined) entry.limit = { context };
|
|
1297
|
+
// `none` is an internal Codex catalog sentinel, not an MCode effort. MCode
|
|
1298
|
+
// forwards every option as `output_config.effort` while keeping adaptive
|
|
1299
|
+
// thinking enabled, and the Anthropic ingress deliberately accepts only
|
|
1300
|
+
// minimal..ultra. Advertising `none` would therefore create a selectable
|
|
1301
|
+
// value that cannot disable reasoning and is not forwarded as an effort.
|
|
1302
|
+
const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts)
|
|
1303
|
+
?.filter(effort => effort !== "none");
|
|
1304
|
+
if (efforts && efforts.length > 0) entry.thinking = { effortOptions: efforts };
|
|
1305
|
+
models[model.namespaced] = entry;
|
|
1306
|
+
}
|
|
1240
1307
|
return {
|
|
1241
1308
|
custom_provider: {
|
|
1242
1309
|
[OPENCODE_PROVIDER_ID]: {
|
|
@@ -1256,10 +1323,10 @@ function buildMcodeClientConfig(ctx: ExportContext): McodeGeneratedConfig {
|
|
|
1256
1323
|
}
|
|
1257
1324
|
|
|
1258
1325
|
/**
|
|
1259
|
-
* ZCode dials the
|
|
1260
|
-
*
|
|
1261
|
-
*
|
|
1262
|
-
* `provider/id` selectors, which `/v1/
|
|
1326
|
+
* ZCode dials the OpenAI Chat Completions surface (`openai-compatible`), which
|
|
1327
|
+
* appends `/chat/completions` to `baseURL`. We supply `baseURL` with the `/v1`
|
|
1328
|
+
* suffix so requests land on `/v1/chat/completions`. Model ids are the proxy's canonical
|
|
1329
|
+
* `provider/id` selectors, which `/v1/chat/completions` resolves directly. Context
|
|
1263
1330
|
* limits follow the authoritative-window rule: a model without one ships
|
|
1264
1331
|
* without `limit` rather than guessing. Modalities are ZCode's observed
|
|
1265
1332
|
* `text`-floor vocabulary; image-capable rows advertise image input.
|
|
@@ -1288,12 +1355,12 @@ function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig {
|
|
|
1288
1355
|
provider: {
|
|
1289
1356
|
[OPENCODE_PROVIDER_ID]: {
|
|
1290
1357
|
name: "OpenCodex",
|
|
1291
|
-
kind: "
|
|
1358
|
+
kind: "openai-compatible",
|
|
1292
1359
|
enabled: true,
|
|
1293
1360
|
source: "custom",
|
|
1294
1361
|
options: {
|
|
1295
1362
|
apiKey: LOOPBACK_API_KEY_PLACEHOLDER,
|
|
1296
|
-
baseURL: ctx.baseUrl.replace(/\/v1\/?$/, ""),
|
|
1363
|
+
baseURL: ctx.baseUrl.replace(/\/v1\/?$/, "") + "/v1",
|
|
1297
1364
|
apiKeyRequired: true,
|
|
1298
1365
|
},
|
|
1299
1366
|
models,
|
|
@@ -1353,8 +1420,7 @@ function summarizeDsh(document: unknown): { modelCount: number; modelsWithoutLim
|
|
|
1353
1420
|
|
|
1354
1421
|
function summarizeMcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
|
|
1355
1422
|
const models = Object.values((document as McodeGeneratedConfig | undefined)?.custom_provider?.[OPENCODE_PROVIDER_ID]?.models ?? {});
|
|
1356
|
-
|
|
1357
|
-
return { modelCount: models.length, modelsWithoutLimits: 0 };
|
|
1423
|
+
return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length };
|
|
1358
1424
|
}
|
|
1359
1425
|
|
|
1360
1426
|
function summarizeZcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
|
|
@@ -1428,6 +1494,24 @@ function buildZcodeContribution(ctx: ExportContext): ManagedContribution {
|
|
|
1428
1494
|
return singleFragment("zcode", ["provider", OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]);
|
|
1429
1495
|
}
|
|
1430
1496
|
|
|
1497
|
+
/**
|
|
1498
|
+
* Prime Agent (PrimeIntellect) is the pi coding agent shipped under a different
|
|
1499
|
+
* brand rather than a lookalike: its package declares a `piConfig` block, and
|
|
1500
|
+
* the agent derives its config directory (`.prime/agent`) and env prefix from
|
|
1501
|
+
* that block alone. `models.json` is therefore the SAME contract Pi reads, so
|
|
1502
|
+
* this client reuses Pi's builder and summarizer verbatim. Restating the shape
|
|
1503
|
+
* here would create a second copy of one fact, which is exactly how the
|
|
1504
|
+
* "anything that is not OpenCode must be Pi" summarizer bug happened.
|
|
1505
|
+
*
|
|
1506
|
+
* The one thing that could still differ is the path we own, and it does not:
|
|
1507
|
+
* Prime keeps our entries under the same `providers.<id>` key. So the only new
|
|
1508
|
+
* code is stamping the right client id on the ownership record.
|
|
1509
|
+
*/
|
|
1510
|
+
function buildPrimeContribution(ctx: ExportContext): ManagedContribution {
|
|
1511
|
+
const doc = buildPiClientConfig(ctx);
|
|
1512
|
+
return singleFragment("prime", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1431
1515
|
export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
|
|
1432
1516
|
opencode: {
|
|
1433
1517
|
id: "opencode",
|
|
@@ -1445,7 +1529,7 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
|
|
|
1445
1529
|
pi: {
|
|
1446
1530
|
id: "pi",
|
|
1447
1531
|
filename: "pi-models.json",
|
|
1448
|
-
destination:
|
|
1532
|
+
destination: env => piConfigPath(env),
|
|
1449
1533
|
apiKeyEnv: "",
|
|
1450
1534
|
exportHint: "Pi reads a non-secret placeholder from models.json; loopback needs no key.",
|
|
1451
1535
|
build: buildPiClientConfig,
|
|
@@ -1566,6 +1650,21 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
|
|
|
1566
1650
|
// remote binds refuse — same reasoning as MCode.
|
|
1567
1651
|
loopbackOnly: true,
|
|
1568
1652
|
},
|
|
1653
|
+
prime: {
|
|
1654
|
+
id: "prime",
|
|
1655
|
+
filename: "prime-models.json",
|
|
1656
|
+
destination: env => primeConfigPath(env),
|
|
1657
|
+
apiKeyEnv: "",
|
|
1658
|
+
exportHint: "Prime Agent reads a non-secret placeholder from models.json; loopback needs no key.",
|
|
1659
|
+
build: buildPiClientConfig,
|
|
1660
|
+
format: "json",
|
|
1661
|
+
summarize: summarizePi,
|
|
1662
|
+
buildContribution: buildPrimeContribution,
|
|
1663
|
+
// Prime's provider block does accept `headers`, so a dedicated admission
|
|
1664
|
+
// header has somewhere to live, but remote credential wiring is deferred
|
|
1665
|
+
// from this initial loopback-only integration — same stance as OMP's.
|
|
1666
|
+
loopbackOnly: true,
|
|
1667
|
+
},
|
|
1569
1668
|
};
|
|
1570
1669
|
|
|
1571
1670
|
export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[];
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in Codex affinity diagnostics for account-switch compatibility failures.
|
|
3
|
+
*
|
|
4
|
+
* The provider debug stream may compare values only within the current process. It never emits
|
|
5
|
+
* raw header values, credentials, account identifiers, or durable unsalted hashes.
|
|
6
|
+
*/
|
|
7
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
8
|
+
import { isDebugEnabled } from "../lib/debug-settings";
|
|
9
|
+
import { debugProviderDiagnostic } from "../lib/debug";
|
|
10
|
+
|
|
11
|
+
const MAX_TAGGED_VALUE_BYTES = 16 * 1024;
|
|
12
|
+
const RUN_KEY = randomBytes(32);
|
|
13
|
+
let nextSequence = 0;
|
|
14
|
+
|
|
15
|
+
const SAFE_AFFINITY_HEADERS = [
|
|
16
|
+
"openai-beta",
|
|
17
|
+
"originator",
|
|
18
|
+
"session_id",
|
|
19
|
+
"session-id",
|
|
20
|
+
"thread-id",
|
|
21
|
+
"x-client-request-id",
|
|
22
|
+
"x-codex-beta-features",
|
|
23
|
+
"x-codex-installation-id",
|
|
24
|
+
"x-codex-parent-thread-id",
|
|
25
|
+
"x-codex-turn-metadata",
|
|
26
|
+
"x-codex-turn-state",
|
|
27
|
+
"x-codex-window-id",
|
|
28
|
+
"x-openai-subagent",
|
|
29
|
+
"x-responsesapi-include-timing-metrics",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
const KNOWN_TURN_FIELDS = [
|
|
33
|
+
"forked_from_thread_id",
|
|
34
|
+
"parent_thread_id",
|
|
35
|
+
"request_kind",
|
|
36
|
+
"session_id",
|
|
37
|
+
"subagent_kind",
|
|
38
|
+
"thread_id",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
type SizeBucket = "0" | "1-31" | "32-127" | "128-511" | "512-2047" | "2048-16384" | "oversized";
|
|
42
|
+
|
|
43
|
+
export interface CodexAffinityValueTag {
|
|
44
|
+
name: string;
|
|
45
|
+
size: SizeBucket;
|
|
46
|
+
tag?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CodexAffinityJsonSummary {
|
|
50
|
+
shape: "absent" | "malformed" | "oversized" | "array" | "scalar" | "object";
|
|
51
|
+
knownFields?: Array<CodexAffinityValueTag & { kind: "string" | "number" | "boolean" | "null" | "array" | "object" }>;
|
|
52
|
+
unknownFieldCount?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CodexAffinityDiagnosticInput {
|
|
56
|
+
inboundHeaders: Headers;
|
|
57
|
+
outboundHeaders: HeadersInit;
|
|
58
|
+
authKind: "main" | "pool" | "main-pool";
|
|
59
|
+
accountMode: "direct" | "pool" | undefined;
|
|
60
|
+
fixedAccount: boolean;
|
|
61
|
+
credentialSubstituted: boolean;
|
|
62
|
+
accountGatedModel: boolean;
|
|
63
|
+
wireModelNormalized: boolean;
|
|
64
|
+
status: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sizeBucket(bytes: number): SizeBucket {
|
|
68
|
+
if (bytes === 0) return "0";
|
|
69
|
+
if (bytes <= 31) return "1-31";
|
|
70
|
+
if (bytes <= 127) return "32-127";
|
|
71
|
+
if (bytes <= 511) return "128-511";
|
|
72
|
+
if (bytes <= 2047) return "512-2047";
|
|
73
|
+
if (bytes <= MAX_TAGGED_VALUE_BYTES) return "2048-16384";
|
|
74
|
+
return "oversized";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function valueTag(name: string, value: string): CodexAffinityValueTag {
|
|
78
|
+
const bytes = Buffer.byteLength(value, "utf8");
|
|
79
|
+
const size = sizeBucket(bytes);
|
|
80
|
+
if (size === "oversized") return { name, size };
|
|
81
|
+
const tag = createHmac("sha256", RUN_KEY)
|
|
82
|
+
.update(name)
|
|
83
|
+
.update("\0")
|
|
84
|
+
.update(value)
|
|
85
|
+
.digest("hex")
|
|
86
|
+
.slice(0, 12);
|
|
87
|
+
return { name, size, tag };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function headerTags(headers: Headers): CodexAffinityValueTag[] {
|
|
91
|
+
const rows: CodexAffinityValueTag[] = [];
|
|
92
|
+
for (const name of SAFE_AFFINITY_HEADERS) {
|
|
93
|
+
const value = headers.get(name);
|
|
94
|
+
if (value !== null) rows.push(valueTag(name, value));
|
|
95
|
+
}
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function jsonSummary(headers: Headers, name: "x-codex-turn-metadata" | "x-codex-turn-state"): CodexAffinityJsonSummary {
|
|
100
|
+
const raw = headers.get(name);
|
|
101
|
+
if (raw === null) return { shape: "absent" };
|
|
102
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_TAGGED_VALUE_BYTES) return { shape: "oversized" };
|
|
103
|
+
let parsed: unknown;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(raw) as unknown;
|
|
106
|
+
} catch {
|
|
107
|
+
return { shape: "malformed" };
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(parsed)) return { shape: "array" };
|
|
110
|
+
if (!parsed || typeof parsed !== "object") return { shape: "scalar" };
|
|
111
|
+
const record = parsed as Record<string, unknown>;
|
|
112
|
+
const knownFields: NonNullable<CodexAffinityJsonSummary["knownFields"]> = [];
|
|
113
|
+
for (const field of KNOWN_TURN_FIELDS) {
|
|
114
|
+
if (!Object.hasOwn(record, field)) continue;
|
|
115
|
+
const value = record[field];
|
|
116
|
+
if (value === null) {
|
|
117
|
+
knownFields.push({ name: field, kind: "null", size: "0" });
|
|
118
|
+
} else {
|
|
119
|
+
const scalarKind = typeof value;
|
|
120
|
+
if (scalarKind === "string" || scalarKind === "number" || scalarKind === "boolean") {
|
|
121
|
+
knownFields.push({ ...valueTag(`${name}.${field}`, String(value)), name: field, kind: scalarKind });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
knownFields.push({ name: field, kind: Array.isArray(value) ? "array" : "object", size: "0" });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const known = new Set<string>(KNOWN_TURN_FIELDS);
|
|
128
|
+
const unknownFieldCount = Object.keys(record).filter(key => !known.has(key)).length;
|
|
129
|
+
return {
|
|
130
|
+
shape: "object",
|
|
131
|
+
...(knownFields.length > 0 ? { knownFields } : {}),
|
|
132
|
+
...(unknownFieldCount > 0 ? { unknownFieldCount } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Emit one observation-only, privacy-bounded provider-debug record. */
|
|
137
|
+
export function captureCodexAffinityDiagnostic(input: CodexAffinityDiagnosticInput): void {
|
|
138
|
+
if (!isDebugEnabled()) return;
|
|
139
|
+
try {
|
|
140
|
+
const outbound = new Headers(input.outboundHeaders);
|
|
141
|
+
debugProviderDiagnostic("codex", "affinity", {
|
|
142
|
+
sequence: ++nextSequence,
|
|
143
|
+
authKind: input.authKind,
|
|
144
|
+
accountMode: input.accountMode ?? "unset",
|
|
145
|
+
fixedAccount: input.fixedAccount,
|
|
146
|
+
credentialSubstituted: input.credentialSubstituted,
|
|
147
|
+
accountGatedModel: input.accountGatedModel,
|
|
148
|
+
wireModelNormalized: input.wireModelNormalized,
|
|
149
|
+
status: input.status,
|
|
150
|
+
inbound: headerTags(input.inboundHeaders),
|
|
151
|
+
outbound: headerTags(outbound),
|
|
152
|
+
inboundTurnMetadata: jsonSummary(input.inboundHeaders, "x-codex-turn-metadata"),
|
|
153
|
+
outboundTurnMetadata: jsonSummary(outbound, "x-codex-turn-metadata"),
|
|
154
|
+
inboundTurnState: jsonSummary(input.inboundHeaders, "x-codex-turn-state"),
|
|
155
|
+
outboundTurnState: jsonSummary(outbound, "x-codex-turn-state"),
|
|
156
|
+
});
|
|
157
|
+
} catch {
|
|
158
|
+
// Diagnostics must never affect request handling.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const CODEX_AFFINITY_DEBUG_SAFE_HEADERS = SAFE_AFFINITY_HEADERS;
|
package/src/codex/inject.ts
CHANGED
|
@@ -482,25 +482,47 @@ function setRootModelProvider(content: string): string {
|
|
|
482
482
|
}
|
|
483
483
|
|
|
484
484
|
function readRootModelCatalogPath(content: string): string | null {
|
|
485
|
-
|
|
485
|
+
const lines = content.split("\n");
|
|
486
|
+
const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
|
|
487
|
+
const rootEnd = firstTable === -1 ? lines.length : firstTable;
|
|
488
|
+
const modelCatalogAssignment = tomlStringPattern("model_catalog_json");
|
|
489
|
+
let ownedCatalogPath: string | null = null;
|
|
490
|
+
for (let index = 0; index < rootEnd; index += 1) {
|
|
491
|
+
const match = modelCatalogAssignment.exec(lines[index]);
|
|
492
|
+
if (!match) continue;
|
|
493
|
+
const catalogPath = parseTomlString(match[1]);
|
|
494
|
+
if (!isOpencodexCatalogPath(catalogPath)) return catalogPath;
|
|
495
|
+
ownedCatalogPath ??= catalogPath;
|
|
496
|
+
}
|
|
497
|
+
return ownedCatalogPath;
|
|
486
498
|
}
|
|
487
499
|
|
|
488
500
|
function setRootModelCatalogPath(content: string, catalogPath: string): string {
|
|
489
501
|
const lines = content.split("\n");
|
|
490
502
|
const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
|
|
491
503
|
const key = `model_catalog_json = ${tomlString(catalogPath)}`;
|
|
504
|
+
const modelCatalogAssignment = tomlStringPattern("model_catalog_json");
|
|
492
505
|
const rootEnd = firstTable === -1 ? lines.length : firstTable;
|
|
506
|
+
const ownedAssignments: number[] = [];
|
|
507
|
+
let hasUserAssignment = false;
|
|
493
508
|
for (let i = 0; i < rootEnd; i++) {
|
|
494
|
-
const m = lines[i]
|
|
495
|
-
/^\s*model_catalog_json\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*$/,
|
|
496
|
-
);
|
|
509
|
+
const m = modelCatalogAssignment.exec(lines[i]);
|
|
497
510
|
if (!m) continue;
|
|
498
511
|
const existing = parseTomlString(m[1]);
|
|
499
512
|
if (isOpencodexCatalogPath(existing)) {
|
|
500
|
-
|
|
501
|
-
|
|
513
|
+
ownedAssignments.push(i);
|
|
514
|
+
} else {
|
|
515
|
+
hasUserAssignment = true;
|
|
502
516
|
}
|
|
503
|
-
|
|
517
|
+
}
|
|
518
|
+
if (hasUserAssignment) {
|
|
519
|
+
const owned = new Set(ownedAssignments);
|
|
520
|
+
return lines.filter((_, index) => !owned.has(index)).join("\n");
|
|
521
|
+
}
|
|
522
|
+
if (ownedAssignments.length > 0) {
|
|
523
|
+
lines[ownedAssignments[0]] = key;
|
|
524
|
+
const duplicates = new Set(ownedAssignments.slice(1));
|
|
525
|
+
return lines.filter((_, index) => !duplicates.has(index)).join("\n");
|
|
504
526
|
}
|
|
505
527
|
if (firstTable === -1) {
|
|
506
528
|
return content.replace(/\n+$/, "") + "\n" + key + "\n";
|
|
@@ -583,12 +605,14 @@ function isOpencodexCatalogPath(path: string): boolean {
|
|
|
583
605
|
}
|
|
584
606
|
|
|
585
607
|
function stripOpencodexCatalogPath(content: string): string {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
608
|
+
const modelCatalogAssignment = tomlStringPattern("model_catalog_json");
|
|
609
|
+
const lines = content.split("\n");
|
|
610
|
+
const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
|
|
611
|
+
const rootEnd = firstTable === -1 ? lines.length : firstTable;
|
|
612
|
+
return lines
|
|
613
|
+
.filter((line, index) => {
|
|
614
|
+
if (index >= rootEnd) return true;
|
|
615
|
+
const m = modelCatalogAssignment.exec(line);
|
|
592
616
|
return !m || !isOpencodexCatalogPath(parseTomlString(m[1]));
|
|
593
617
|
})
|
|
594
618
|
.join("\n");
|
|
@@ -894,7 +918,15 @@ export async function injectCodexConfig(
|
|
|
894
918
|
});
|
|
895
919
|
atomicWriteFile(CODEX_CONFIG_PATH, content);
|
|
896
920
|
atomicWriteFile(CODEX_PROFILE_PATH, profileContent);
|
|
897
|
-
markJournalInjectedState(content, profileContent
|
|
921
|
+
markJournalInjectedState(content, profileContent, {
|
|
922
|
+
// A root override is ours only in loopback Design B when no user-owned value won.
|
|
923
|
+
injectedOpenaiBaseUrl: legacyMode || keptUserBaseUrl
|
|
924
|
+
? null
|
|
925
|
+
: rootTomlString(content, "openai_base_url"),
|
|
926
|
+
// This is the catalog artifact selected for this injection, even when config.toml
|
|
927
|
+
// already points at that path and therefore needs no textual rewrite.
|
|
928
|
+
injectedCatalogPath: catalogPath,
|
|
929
|
+
});
|
|
898
930
|
};
|
|
899
931
|
|
|
900
932
|
/*
|
package/src/codex/journal.ts
CHANGED
|
@@ -2,7 +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
|
|
5
|
+
import { hasInjectedCodexRouting } from "./injected-marker";
|
|
6
6
|
import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -108,16 +108,30 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
|
|
|
108
108
|
atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
export
|
|
111
|
+
export interface InjectedJournalOwnership {
|
|
112
|
+
injectedOpenaiBaseUrl: string | null;
|
|
113
|
+
injectedCatalogPath: string | null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function markJournalInjectedState(
|
|
117
|
+
config: string,
|
|
118
|
+
profile: string | null,
|
|
119
|
+
ownership: InjectedJournalOwnership,
|
|
120
|
+
): void {
|
|
112
121
|
const journal = readJournal();
|
|
113
122
|
if (!journal) return;
|
|
114
|
-
|
|
115
|
-
|
|
123
|
+
// The first exact injected config is the only safe whole-snapshot restore boundary for
|
|
124
|
+
// the first native snapshot. A later reinjection may preserve user edits made while routed;
|
|
125
|
+
// hashing those newer bytes and restoring the first snapshot would delete those edits.
|
|
126
|
+
// Keep the first hash so changed/reinjected configs take the owned-field fallback path.
|
|
127
|
+
journal.injectedConfigHash ??= sha256(config) ?? undefined;
|
|
128
|
+
// The profile file is wholly generated by OpenCodex, so its latest exact hash remains safe
|
|
129
|
+
// to refresh and lets restore remove the latest generated profile after a port change.
|
|
116
130
|
journal.injectedProfileHash = sha256(profile);
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
journal.injectedOpenaiBaseUrl =
|
|
120
|
-
journal.injectedCatalogPath =
|
|
131
|
+
// Only the caller knows which values it actually owns. Deriving these from the final TOML
|
|
132
|
+
// would mistake a preserved user override for injected routing.
|
|
133
|
+
journal.injectedOpenaiBaseUrl = ownership.injectedOpenaiBaseUrl;
|
|
134
|
+
journal.injectedCatalogPath = ownership.injectedCatalogPath;
|
|
121
135
|
atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
|
|
122
136
|
}
|
|
123
137
|
|
package/src/config.ts
CHANGED
|
@@ -715,6 +715,7 @@ const providerConfigSchema = z.object({
|
|
|
715
715
|
supportsServiceTier: z.boolean().optional(),
|
|
716
716
|
modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
|
|
717
717
|
preserveResponsesReasoningContent: z.boolean().optional(),
|
|
718
|
+
decodesNativeCompactionBlobs: z.boolean().optional(),
|
|
718
719
|
allowPrivateNetwork: z.boolean().optional(),
|
|
719
720
|
// The management API accepts `null` as "clear this", so a config written before the POST
|
|
720
721
|
// canonicalization below can hold one on disk. Rejecting it here would send the operator
|