@bitkyc08/opencodex 2.55.0 → 2.57.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/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +4 -3
- package/src/adapters/base.ts +21 -0
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +495 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +40 -1452
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +642 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +58 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +630 -0
- package/src/bridge/sse.ts +1462 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +184 -35
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +566 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +431 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +252 -35
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +305 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +714 -0
- package/src/codex/catalog/routed-gather.ts +895 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +567 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +89 -1444
- package/src/codex/lineage.ts +458 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +161 -0
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +285 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +780 -0
- package/src/codex/routing/thread-affinity.ts +586 -0
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +370 -2271
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +649 -121
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +198 -23
- package/src/lib/spend-reservation-ledger.ts +958 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +132 -21
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/workflow-budget.ts +553 -30
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1228 -0
- package/src/providers/registry/entries-extended.ts +1213 -0
- package/src/providers/registry/model-seeds.ts +912 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +456 -0
- package/src/routing/probe-lease.ts +613 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +601 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +339 -0
- package/src/server/index.ts +45 -2552
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +372 -4
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +307 -0
- package/src/server/responses/adapter-continuation.ts +540 -0
- package/src/server/responses/adapter-delivery.ts +208 -0
- package/src/server/responses/adapter-dispatch.ts +1042 -0
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +113 -17
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +529 -0
- package/src/server/responses/core-codex-account.ts +907 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +787 -0
- package/src/server/responses/core-errors.ts +170 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +298 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +869 -0
- package/src/server/responses/passthrough-dispatch.ts +1494 -0
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +1080 -0
- package/src/server/responses/request-send-budget.ts +259 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +803 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +476 -0
- package/src/server/responses/sidecar-execution.ts +463 -0
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/workflow-refusal.ts +84 -0
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +34 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +147 -1
- package/src/usage/summary.ts +171 -21
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** Bounds diagnostic retention, never the bytes delivered to the caller. */
|
|
2
|
+
export const MAX_RESPONSE_LOG_INSPECTION_BYTES = 32 * 1024 * 1024;
|
|
3
|
+
export const MAX_NON_JSON_ERROR_INSPECTION_BYTES = 8 * 1024;
|
|
4
|
+
const INSPECTION_BLOCK_BYTES = 64 * 1024;
|
|
5
|
+
|
|
6
|
+
export type ResponseLogBodyEnd = "eof" | "read_error" | "cancel";
|
|
7
|
+
|
|
8
|
+
export interface ResponseLogBodyOptions {
|
|
9
|
+
json: boolean;
|
|
10
|
+
inspect: (text: string) => void;
|
|
11
|
+
finalize: (reason: ResponseLogBodyEnd) => void;
|
|
12
|
+
/** Internal test seam; this is not a provider-controlled limit. */
|
|
13
|
+
maxInspectionBytes?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Fixed-size blocks bound both retained bytes and per-chunk bookkeeping. */
|
|
17
|
+
class ResponseLogInspection {
|
|
18
|
+
private blocks: Uint8Array[] = [];
|
|
19
|
+
private bytes = 0;
|
|
20
|
+
private overflowed = false;
|
|
21
|
+
|
|
22
|
+
constructor(private readonly json: boolean, private readonly limit: number) {}
|
|
23
|
+
|
|
24
|
+
append(chunk: Uint8Array): void {
|
|
25
|
+
if (this.overflowed || chunk.byteLength === 0) return;
|
|
26
|
+
if (this.json && chunk.byteLength > this.limit - this.bytes) {
|
|
27
|
+
// A JSON prefix is not an authoritative response. Drop it immediately,
|
|
28
|
+
// without either truncating the delivery stream or retaining later bytes.
|
|
29
|
+
this.overflowed = true;
|
|
30
|
+
this.dispose();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
let remaining = Math.min(chunk.byteLength, this.limit - this.bytes);
|
|
34
|
+
let offset = 0;
|
|
35
|
+
while (remaining > 0) {
|
|
36
|
+
const blockOffset = this.bytes % INSPECTION_BLOCK_BYTES;
|
|
37
|
+
if (blockOffset === 0) {
|
|
38
|
+
this.blocks.push(new Uint8Array(Math.min(INSPECTION_BLOCK_BYTES, this.limit - this.bytes)));
|
|
39
|
+
}
|
|
40
|
+
const block = this.blocks[this.blocks.length - 1]!;
|
|
41
|
+
const length = Math.min(remaining, block.byteLength - blockOffset);
|
|
42
|
+
block.set(chunk.subarray(offset, offset + length), blockOffset);
|
|
43
|
+
this.bytes += length;
|
|
44
|
+
offset += length;
|
|
45
|
+
remaining -= length;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
text(reason: ResponseLogBodyEnd): string | undefined {
|
|
50
|
+
// Even a syntactically valid JSON prefix must not update usage/model
|
|
51
|
+
// metadata when the transport did not reach EOF.
|
|
52
|
+
if (this.json && (reason !== "eof" || this.overflowed)) return undefined;
|
|
53
|
+
const combined = new Uint8Array(this.bytes);
|
|
54
|
+
let offset = 0;
|
|
55
|
+
for (const block of this.blocks) {
|
|
56
|
+
const length = Math.min(block.byteLength, this.bytes - offset);
|
|
57
|
+
combined.set(block.subarray(0, length), offset);
|
|
58
|
+
offset += length;
|
|
59
|
+
}
|
|
60
|
+
return new TextDecoder().decode(combined);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
dispose(): void {
|
|
64
|
+
this.blocks.length = 0;
|
|
65
|
+
this.bytes = 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Optional diagnostics must not change the response's transport outcome. */
|
|
70
|
+
function bestEffort(callback: () => void): void {
|
|
71
|
+
try {
|
|
72
|
+
callback();
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Forward one upstream read per downstream pull, with bounded side inspection.
|
|
80
|
+
* EOF, read failure and cancellation each finalize at most once. Cancellation
|
|
81
|
+
* does not await the upstream cancel promise: one branch of a tee can otherwise
|
|
82
|
+
* wait for a sibling that the same caller intends to consume or cancel later.
|
|
83
|
+
*/
|
|
84
|
+
export function createBoundedResponseLogBody(
|
|
85
|
+
body: ReadableStream<Uint8Array>,
|
|
86
|
+
options: ResponseLogBodyOptions,
|
|
87
|
+
): ReadableStream<Uint8Array> {
|
|
88
|
+
const limit = options.maxInspectionBytes ?? (options.json
|
|
89
|
+
? MAX_RESPONSE_LOG_INSPECTION_BYTES
|
|
90
|
+
: MAX_NON_JSON_ERROR_INSPECTION_BYTES);
|
|
91
|
+
if (!Number.isSafeInteger(limit) || limit < 0) {
|
|
92
|
+
throw new RangeError("Response log inspection limit must be a non-negative safe integer");
|
|
93
|
+
}
|
|
94
|
+
const inspection = new ResponseLogInspection(options.json, limit);
|
|
95
|
+
const reader = body.getReader();
|
|
96
|
+
let ended = false;
|
|
97
|
+
let inspectionFailed = false;
|
|
98
|
+
|
|
99
|
+
const release = () => bestEffort(() => reader.releaseLock());
|
|
100
|
+
const finish = (reason: ResponseLogBodyEnd) => {
|
|
101
|
+
if (ended) return;
|
|
102
|
+
ended = true; // Set before callbacks or a pending read resumes.
|
|
103
|
+
try {
|
|
104
|
+
if (!inspectionFailed) {
|
|
105
|
+
bestEffort(() => {
|
|
106
|
+
const text = inspection.text(reason);
|
|
107
|
+
if (text !== undefined) options.inspect(text);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
} finally {
|
|
111
|
+
inspection.dispose();
|
|
112
|
+
bestEffort(() => options.finalize(reason));
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return new ReadableStream<Uint8Array>({
|
|
117
|
+
async pull(controller) {
|
|
118
|
+
if (ended) return;
|
|
119
|
+
let result: Awaited<ReturnType<typeof reader.read>>;
|
|
120
|
+
try {
|
|
121
|
+
result = await reader.read();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (ended) return; // Cancellation owns its pending-read settlement.
|
|
124
|
+
finish("read_error");
|
|
125
|
+
release();
|
|
126
|
+
controller.error(error);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (ended) return;
|
|
130
|
+
if (result.done) {
|
|
131
|
+
finish("eof");
|
|
132
|
+
release();
|
|
133
|
+
controller.close();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!inspectionFailed) {
|
|
137
|
+
try {
|
|
138
|
+
inspection.append(result.value);
|
|
139
|
+
} catch {
|
|
140
|
+
inspectionFailed = true;
|
|
141
|
+
inspection.dispose();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Do not decode/re-encode transport bytes, including malformed UTF-8.
|
|
145
|
+
controller.enqueue(result.value);
|
|
146
|
+
},
|
|
147
|
+
cancel(reason) {
|
|
148
|
+
finish("cancel");
|
|
149
|
+
bestEffort(() => { void reader.cancel(reason).catch(() => undefined); });
|
|
150
|
+
release();
|
|
151
|
+
},
|
|
152
|
+
}, { highWaterMark: 0 });
|
|
153
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex pool account-change conversation-state portability (#4546).
|
|
3
|
+
*
|
|
4
|
+
* OpenAI `encrypted_content` blobs and `previous_response_id` are bound to the
|
|
5
|
+
* account that minted them. When pool routing serves a live conversation on a
|
|
6
|
+
* different account, the next turn must drop that state once before dispatch so
|
|
7
|
+
* the new account can continue from readable history instead of rejecting the
|
|
8
|
+
* ciphertext.
|
|
9
|
+
*
|
|
10
|
+
* The issuer association lives next to thread affinity in `src/codex/routing.ts`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CodexAuthContext } from "../../codex/auth-context";
|
|
14
|
+
import {
|
|
15
|
+
peekConversationStateIssuer,
|
|
16
|
+
rememberConversationStateIssuer,
|
|
17
|
+
} from "../../codex/routing";
|
|
18
|
+
import type { OcxParsedRequest } from "../../types";
|
|
19
|
+
import type { RequestLogContext } from "../request-log";
|
|
20
|
+
import { formatErrorResponse } from "../../bridge/errors";
|
|
21
|
+
|
|
22
|
+
export type ConversationStateScrubReason = "account-change";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cause AND remedy, because this refusal does not clear itself.
|
|
26
|
+
*
|
|
27
|
+
* Dropping a `previous_response_id` costs one cold turn and the conversation continues. This is
|
|
28
|
+
* not that. The file reference lives in the conversation's history, so once pool rotation has
|
|
29
|
+
* moved a conversation carrying an attachment, every following turn presents the same reference
|
|
30
|
+
* and is refused the same way. A caller told only that the reference is invalid will send the
|
|
31
|
+
* same request back and watch the conversation appear dead, which is the one outcome a refusal
|
|
32
|
+
* is supposed to prevent. So the text says what happened, that it will keep happening, and the
|
|
33
|
+
* two things that actually end it.
|
|
34
|
+
*
|
|
35
|
+
* Names no account id, no file id, and no conversation id.
|
|
36
|
+
*/
|
|
37
|
+
export const ACCOUNT_CHANGE_FILE_SCOPE_MESSAGE =
|
|
38
|
+
"This conversation is now being served by a different account than the one its uploaded files "
|
|
39
|
+
+ "were sent to, and an uploaded file can only be read by the account that received it. The "
|
|
40
|
+
+ "request was not sent upstream, and no file was removed from it. Because the references stay "
|
|
41
|
+
+ "in this conversation's history, later turns will be refused the same way until this is "
|
|
42
|
+
+ "resolved: re-upload the files so they are issued by the account now serving this "
|
|
43
|
+
+ "conversation, or start a new conversation for them. Sending the same request again "
|
|
44
|
+
+ "unchanged will not clear it.";
|
|
45
|
+
|
|
46
|
+
export type PortabilityDenial =
|
|
47
|
+
| "previous-response-id"
|
|
48
|
+
| "provider-conversation-id"
|
|
49
|
+
| "uploaded-file-ids"
|
|
50
|
+
| "encrypted-reasoning";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The parts of a request that bind it to the credential that produced them.
|
|
54
|
+
* Presence is what matters; the values stay opaque so nothing here logs ids.
|
|
55
|
+
*/
|
|
56
|
+
export interface ConversationStateCarriers {
|
|
57
|
+
readonly previousResponseId?: string | null;
|
|
58
|
+
readonly providerConversationId?: string | null;
|
|
59
|
+
readonly fileIds?: readonly string[];
|
|
60
|
+
readonly encryptedReasoning?: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type PortabilityVerdict =
|
|
64
|
+
| { readonly portable: true }
|
|
65
|
+
| { readonly portable: false; readonly reason: PortabilityDenial };
|
|
66
|
+
|
|
67
|
+
function present(value: unknown): boolean {
|
|
68
|
+
if (value === undefined || value === null) return false;
|
|
69
|
+
if (typeof value === "string" || Array.isArray(value)) return value.length > 0;
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether a request's conversational state can move credentials at all.
|
|
75
|
+
*
|
|
76
|
+
* `src/routing/identity-domains.ts` owns this decision once that module lands
|
|
77
|
+
* on this integration line (#4546). Keep the check in this one function so it
|
|
78
|
+
* can be swapped for the shared export without hunting call sites.
|
|
79
|
+
*/
|
|
80
|
+
export function canPortConversationState(
|
|
81
|
+
state: ConversationStateCarriers,
|
|
82
|
+
): PortabilityVerdict {
|
|
83
|
+
if (present(state.previousResponseId)) {
|
|
84
|
+
return { portable: false, reason: "previous-response-id" };
|
|
85
|
+
}
|
|
86
|
+
if (present(state.providerConversationId)) {
|
|
87
|
+
return { portable: false, reason: "provider-conversation-id" };
|
|
88
|
+
}
|
|
89
|
+
if (present(state.fileIds)) {
|
|
90
|
+
return { portable: false, reason: "uploaded-file-ids" };
|
|
91
|
+
}
|
|
92
|
+
if (present(state.encryptedReasoning)) {
|
|
93
|
+
return { portable: false, reason: "encrypted-reasoning" };
|
|
94
|
+
}
|
|
95
|
+
return { portable: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function providerConversationIdFromBody(body: Record<string, unknown>): string | undefined {
|
|
99
|
+
const conversation = body.conversation;
|
|
100
|
+
if (typeof conversation === "string" && conversation.trim()) return conversation.trim();
|
|
101
|
+
if (conversation && typeof conversation === "object" && !Array.isArray(conversation)) {
|
|
102
|
+
const id = (conversation as { id?: unknown }).id;
|
|
103
|
+
if (typeof id === "string" && id.trim()) return id.trim();
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function collectFileIds(input: unknown): string[] {
|
|
109
|
+
const ids: string[] = [];
|
|
110
|
+
if (!Array.isArray(input)) return ids;
|
|
111
|
+
for (const item of input) {
|
|
112
|
+
if (!item || typeof item !== "object") continue;
|
|
113
|
+
const record = item as Record<string, unknown>;
|
|
114
|
+
if (typeof record.file_id === "string" && record.file_id.trim()) ids.push(record.file_id);
|
|
115
|
+
if (Array.isArray(record.file_ids)) {
|
|
116
|
+
for (const id of record.file_ids) {
|
|
117
|
+
if (typeof id === "string" && id.trim()) ids.push(id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const key of ["content", "output"]) {
|
|
121
|
+
const parts = record[key];
|
|
122
|
+
if (!Array.isArray(parts)) continue;
|
|
123
|
+
for (const part of parts) {
|
|
124
|
+
if (!part || typeof part !== "object") continue;
|
|
125
|
+
const partRecord = part as Record<string, unknown>;
|
|
126
|
+
if (typeof partRecord.file_id === "string" && partRecord.file_id.trim()) {
|
|
127
|
+
ids.push(partRecord.file_id);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return ids;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function hasEncryptedReasoning(input: unknown): boolean {
|
|
136
|
+
if (!Array.isArray(input)) return false;
|
|
137
|
+
for (const item of input) {
|
|
138
|
+
if (!item || typeof item !== "object") continue;
|
|
139
|
+
const record = item as Record<string, unknown>;
|
|
140
|
+
if (typeof record.encrypted_content === "string" && record.encrypted_content.length > 0) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
for (const key of ["content", "output"]) {
|
|
144
|
+
const parts = record[key];
|
|
145
|
+
if (!Array.isArray(parts)) continue;
|
|
146
|
+
for (const part of parts) {
|
|
147
|
+
if (!part || typeof part !== "object") continue;
|
|
148
|
+
const encrypted = (part as { encrypted_content?: unknown }).encrypted_content;
|
|
149
|
+
if (typeof encrypted === "string" && encrypted.length > 0) return true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function collectConversationStateCarriers(body: unknown): ConversationStateCarriers {
|
|
157
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
|
|
158
|
+
const record = body as Record<string, unknown>;
|
|
159
|
+
const previousResponseId = typeof record.previous_response_id === "string"
|
|
160
|
+
? record.previous_response_id
|
|
161
|
+
: undefined;
|
|
162
|
+
return {
|
|
163
|
+
previousResponseId,
|
|
164
|
+
providerConversationId: providerConversationIdFromBody(record),
|
|
165
|
+
fileIds: collectFileIds(record.input),
|
|
166
|
+
encryptedReasoning: hasEncryptedReasoning(record.input) ? true : undefined,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Does this body reference an uploaded file?
|
|
172
|
+
*
|
|
173
|
+
* Answerable from the body alone, which is what lets an alternate-account path ask BEFORE it
|
|
174
|
+
* resolves an alternate: the answer cannot depend on which account is chosen, because an
|
|
175
|
+
* uploaded file is readable only by the account it was sent to (#4710).
|
|
176
|
+
*
|
|
177
|
+
* `fileIds` is optional on the carrier type, so the emptiness test lives here rather than being
|
|
178
|
+
* rewritten at each caller. One of those rewrites already dereferenced it directly.
|
|
179
|
+
*/
|
|
180
|
+
export function conversationCarriesUploadedFiles(body: unknown): boolean {
|
|
181
|
+
const fileIds = collectConversationStateCarriers(body).fileIds;
|
|
182
|
+
return fileIds !== undefined && fileIds.length > 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Drop account-bound continuation from a request body in place. Readable user
|
|
188
|
+
* messages and plaintext survive; ciphertext and continuation ids do not.
|
|
189
|
+
*/
|
|
190
|
+
export function scrubUnportableConversationStateInPlace(body: unknown): boolean {
|
|
191
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return false;
|
|
192
|
+
const record = body as Record<string, unknown>;
|
|
193
|
+
let changed = false;
|
|
194
|
+
if (typeof record.previous_response_id === "string") {
|
|
195
|
+
delete record.previous_response_id;
|
|
196
|
+
changed = true;
|
|
197
|
+
}
|
|
198
|
+
if (record.conversation != null) {
|
|
199
|
+
delete record.conversation;
|
|
200
|
+
changed = true;
|
|
201
|
+
}
|
|
202
|
+
// Encrypted reasoning and compaction ciphertext are deliberately NOT touched here. #2247
|
|
203
|
+
// already strips them when a pooled thread moves accounts, and in a specific shape: the
|
|
204
|
+
// reasoning item keeps its readable summary with an emptied content array, and the compaction
|
|
205
|
+
// item becomes an operator-readable note. Stripping again from this side produced a different
|
|
206
|
+
// shape and broke that contract for no gain. What #2247 does not cover, and what this function
|
|
207
|
+
// owns, is the continuation state naming server-side objects the new account cannot read:
|
|
208
|
+
// `previous_response_id` and a provider-side conversation id.
|
|
209
|
+
return changed;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function conversationStateBindingFromAuth(
|
|
213
|
+
authCtx: CodexAuthContext,
|
|
214
|
+
fallbackAffinityKey?: string | null,
|
|
215
|
+
): { accountId: string; bindingKey: string } | null {
|
|
216
|
+
if (authCtx.kind !== "pool" && authCtx.kind !== "main-pool") return null;
|
|
217
|
+
const bindingKey = authCtx.affinityKey ?? fallbackAffinityKey ?? undefined;
|
|
218
|
+
if (!bindingKey || !authCtx.accountId) return null;
|
|
219
|
+
return { accountId: authCtx.accountId, bindingKey };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function rememberServingConversationStateIssuer(
|
|
223
|
+
authCtx: CodexAuthContext,
|
|
224
|
+
fallbackAffinityKey?: string | null,
|
|
225
|
+
): void {
|
|
226
|
+
const binding = conversationStateBindingFromAuth(authCtx, fallbackAffinityKey);
|
|
227
|
+
if (!binding) return;
|
|
228
|
+
rememberConversationStateIssuer(binding.bindingKey, binding.accountId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface ApplyAccountChangeConversationStateScrubArgs {
|
|
232
|
+
body: unknown;
|
|
233
|
+
bindingKey: string;
|
|
234
|
+
servingAccountId: string;
|
|
235
|
+
/** Account this request body was prepared for, when this is an in-request move. */
|
|
236
|
+
priorAccountId?: string | null;
|
|
237
|
+
parsed?: Pick<OcxParsedRequest, "previousResponseId" | "_stripReasoningEncryptedContent">;
|
|
238
|
+
logCtx?: RequestLogContext;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* If the serving account is not the issuer of the carried state, strip that
|
|
243
|
+
* state from the outbound body before dispatch. One cold turn, not a permanent
|
|
244
|
+
* downgrade: the next successful serve records the new issuer.
|
|
245
|
+
*/
|
|
246
|
+
export function applyAccountChangeConversationStateScrub(
|
|
247
|
+
args: ApplyAccountChangeConversationStateScrubArgs,
|
|
248
|
+
): boolean {
|
|
249
|
+
const { body, bindingKey, servingAccountId, priorAccountId, parsed, logCtx } = args;
|
|
250
|
+
if (!servingAccountId || !bindingKey) return false;
|
|
251
|
+
const issuer = peekConversationStateIssuer(bindingKey);
|
|
252
|
+
const accountChanged = (issuer != null && issuer !== servingAccountId)
|
|
253
|
+
|| (priorAccountId != null && priorAccountId !== servingAccountId);
|
|
254
|
+
if (!accountChanged) return false;
|
|
255
|
+
if (canPortConversationState(collectConversationStateCarriers(body)).portable) return false;
|
|
256
|
+
const scrubbed = scrubUnportableConversationStateInPlace(body);
|
|
257
|
+
if (!scrubbed) return false;
|
|
258
|
+
if (parsed) {
|
|
259
|
+
delete parsed.previousResponseId;
|
|
260
|
+
parsed._stripReasoningEncryptedContent = true;
|
|
261
|
+
}
|
|
262
|
+
if (logCtx && logCtx.conversationStateScrub !== "account-change") {
|
|
263
|
+
console.warn(
|
|
264
|
+
"[opencodex] dropped continuation state after a Codex pool account change; continuing fresh",
|
|
265
|
+
);
|
|
266
|
+
logCtx.conversationStateScrub = "account-change";
|
|
267
|
+
} else if (logCtx) {
|
|
268
|
+
logCtx.conversationStateScrub = "account-change";
|
|
269
|
+
}
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Refuse an account change that would carry an uploaded-file reference to an account that
|
|
275
|
+
* cannot read it (#4710).
|
|
276
|
+
*
|
|
277
|
+
* The classifier has always called `file_id` account-bound, and the scrubber has always removed
|
|
278
|
+
* only `previous_response_id` and `conversation`. A body whose ONLY account-bound state was an
|
|
279
|
+
* uploaded file therefore reported nothing scrubbed and was replayed unchanged against the new
|
|
280
|
+
* account, which is the one case the safety fix was supposed to cover.
|
|
281
|
+
*
|
|
282
|
+
* Deleting the references is not the fix. A file reference is not continuation state the model
|
|
283
|
+
* can do without: it is content the caller attached, and silently dropping it answers a
|
|
284
|
+
* different question than the one that was asked, with no way for the caller to tell. Pinning
|
|
285
|
+
* the request to the issuing account is not available either — every call site resolves and
|
|
286
|
+
* materialises its credential before reaching here, and the retry sites are reached precisely
|
|
287
|
+
* because the issuing account just refused the request.
|
|
288
|
+
*
|
|
289
|
+
* So the move is refused, before dispatch, and the caller is told exactly what to do about it.
|
|
290
|
+
* A 400 rather than a 409: re-uploading is required, and a retryable status would invite the
|
|
291
|
+
* same request back unchanged.
|
|
292
|
+
*/
|
|
293
|
+
export function accountChangeFileReferenceRefusal(
|
|
294
|
+
args: Pick<ApplyAccountChangeConversationStateScrubArgs, "body" | "bindingKey" | "servingAccountId" | "priorAccountId">,
|
|
295
|
+
): Response | undefined {
|
|
296
|
+
const { body, bindingKey, servingAccountId, priorAccountId } = args;
|
|
297
|
+
if (!servingAccountId || !bindingKey) return undefined;
|
|
298
|
+
const issuer = peekConversationStateIssuer(bindingKey);
|
|
299
|
+
const accountChanged = (issuer != null && issuer !== servingAccountId)
|
|
300
|
+
|| (priorAccountId != null && priorAccountId !== servingAccountId);
|
|
301
|
+
if (!accountChanged) return undefined;
|
|
302
|
+
// Checked against the carriers directly rather than through the portability verdict: that
|
|
303
|
+
// verdict reports the FIRST reason it finds, so a body carrying both a previous response id
|
|
304
|
+
// and a file reference reports only the former and the file would slip through the scrub.
|
|
305
|
+
if (!conversationCarriesUploadedFiles(body)) return undefined;
|
|
306
|
+
return formatErrorResponse(400, "invalid_request_error", ACCOUNT_CHANGE_FILE_SCOPE_MESSAGE);
|
|
307
|
+
}
|