@bitkyc08/opencodex 2.18.2 → 2.19.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/gui/dist/assets/{index-DUCH59lJ.css → index-CQ7bIKee.css} +1 -1
- package/gui/dist/assets/{index-CXI1262_.js → index-D_JUZLEC.js} +16 -16
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/client-fingerprint.ts +14 -10
- package/src/adapters/google-antigravity-wire.ts +4 -3
- package/src/adapters/google.ts +1 -1
- package/src/chat/inbound.ts +24 -1
- package/src/codex/shim.ts +100 -5
- package/src/generated/compatibility-version.json +21 -17
- package/src/lib/errors.ts +27 -0
- package/src/providers/registry.ts +2 -0
- package/src/responses/spill-store.ts +20 -1
- package/src/responses/state.ts +159 -3
- package/src/server/chat-completions.ts +4 -2
- package/src/server/effort-policy.ts +18 -0
- package/src/server/responses/core.ts +21 -2
- package/src/server/responses/input-admission.ts +169 -0
- package/src/service-manager-probe.ts +2 -3
- package/src/types.ts +10 -2
- package/src/vision/index.ts +21 -4
- package/src/web-search/index.ts +2 -1
package/src/responses/state.ts
CHANGED
|
@@ -39,6 +39,8 @@ interface ResidentResponseState {
|
|
|
39
39
|
createdAt: number;
|
|
40
40
|
clientThreadId?: string;
|
|
41
41
|
items: unknown[];
|
|
42
|
+
/** Index in `items` where provider output begins; see clientCarriedPrefixLength. */
|
|
43
|
+
providerOutputStart?: number;
|
|
42
44
|
providers?: OcxProviderContinuationState;
|
|
43
45
|
sizeBytes: number;
|
|
44
46
|
}
|
|
@@ -47,6 +49,8 @@ interface SpilledResponseState {
|
|
|
47
49
|
kind: "spill";
|
|
48
50
|
createdAt: number;
|
|
49
51
|
clientThreadId?: string;
|
|
52
|
+
/** Mirrors the spilled payload boundary so a spilled entry keeps its anchor. */
|
|
53
|
+
providerOutputStart?: number;
|
|
50
54
|
providers?: OcxProviderContinuationState;
|
|
51
55
|
spill: ResponseSpillRef;
|
|
52
56
|
sizeBytes: number;
|
|
@@ -130,6 +134,7 @@ function measureResidentEntry(id: string, entry: ResidentInput): ResidentRespons
|
|
|
130
134
|
createdAt: entry.createdAt,
|
|
131
135
|
...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
|
|
132
136
|
items: entry.items,
|
|
137
|
+
...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
|
|
133
138
|
...(entry.providers ? { providers: entry.providers } : {}),
|
|
134
139
|
});
|
|
135
140
|
return sizeBytes === null ? null : { kind: "resident", ...entry, sizeBytes };
|
|
@@ -252,12 +257,14 @@ function replaceSpillEntryAtomically(
|
|
|
252
257
|
createdAt: candidate.createdAt,
|
|
253
258
|
...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
|
|
254
259
|
items: candidate.items,
|
|
260
|
+
...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
|
|
255
261
|
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
256
262
|
});
|
|
257
263
|
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
258
264
|
kind: "spill",
|
|
259
265
|
createdAt: candidate.createdAt,
|
|
260
266
|
...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
|
|
267
|
+
...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
|
|
261
268
|
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
262
269
|
spill: ref,
|
|
263
270
|
};
|
|
@@ -332,6 +339,7 @@ function admitOversizedCandidate(
|
|
|
332
339
|
createdAt: candidate.createdAt,
|
|
333
340
|
...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
|
|
334
341
|
items: candidate.items,
|
|
342
|
+
...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
|
|
335
343
|
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
336
344
|
});
|
|
337
345
|
// Enforce the ceiling against the REAL envelope: the spill payload adds
|
|
@@ -373,9 +381,11 @@ function admitOversizedCandidate(
|
|
|
373
381
|
}
|
|
374
382
|
}
|
|
375
383
|
|
|
376
|
-
//
|
|
384
|
+
// Replay provenance must stay proxy-private: a WeakMap distinguishes replayed history from the
|
|
377
385
|
// newly appended input suffix without adding an unknown field that native passthrough could send
|
|
378
|
-
// upstream. The parser uses this boundary to acknowledge historical compaction markers exactly
|
|
386
|
+
// upstream. The parser uses this boundary to acknowledge historical compaction markers exactly
|
|
387
|
+
// once. It records the boundary whether the proxy prepended the history or the client already
|
|
388
|
+
// carried it — the boundary is the same either way, and only its provenance differs.
|
|
379
389
|
const replayedInputPrefixLengths = new WeakMap<object, number>();
|
|
380
390
|
const replayFailures = new WeakMap<object, PreviousResponseReplayFailure>();
|
|
381
391
|
let loaded = false;
|
|
@@ -419,12 +429,23 @@ function loadSnapshotEntry(id: string, value: unknown): void {
|
|
|
419
429
|
const clientThreadId = typeof rec.clientThreadId === "string" && rec.clientThreadId.trim().length > 0
|
|
420
430
|
? rec.clientThreadId.trim()
|
|
421
431
|
: undefined;
|
|
432
|
+
// A malformed boundary degrades to "never skip" rather than to a bad index: an untrusted
|
|
433
|
+
// snapshot must not be able to authorize dropping conversation history.
|
|
434
|
+
const anchorFor = (itemCount: number): number | undefined => {
|
|
435
|
+
const raw = (rec as { providerOutputStart?: unknown }).providerOutputStart;
|
|
436
|
+
return Number.isSafeInteger(raw) && (raw as number) >= 0 && (raw as number) <= itemCount
|
|
437
|
+
? raw as number
|
|
438
|
+
: undefined;
|
|
439
|
+
};
|
|
422
440
|
if (rec.kind === "spill") {
|
|
423
441
|
if (!isSpillRef(rec.spill)) return;
|
|
424
442
|
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
425
443
|
kind: "spill",
|
|
426
444
|
createdAt: rec.createdAt,
|
|
427
445
|
...(clientThreadId ? { clientThreadId } : {}),
|
|
446
|
+
// Item count is unknown until materialization, so accept any non-negative integer
|
|
447
|
+
// here; the spill payload validator re-checks it against the real array.
|
|
448
|
+
...(anchorFor(Number.MAX_SAFE_INTEGER) !== undefined ? { providerOutputStart: anchorFor(Number.MAX_SAFE_INTEGER) } : {}),
|
|
428
449
|
...(rec.providers ? { providers: rec.providers } : {}),
|
|
429
450
|
spill: rec.spill,
|
|
430
451
|
};
|
|
@@ -451,6 +472,7 @@ function loadSnapshotEntry(id: string, value: unknown): void {
|
|
|
451
472
|
createdAt: rec.createdAt,
|
|
452
473
|
...(clientThreadId ? { clientThreadId } : {}),
|
|
453
474
|
items: rec.items,
|
|
475
|
+
...(anchorFor(rec.items.length) !== undefined ? { providerOutputStart: anchorFor(rec.items.length) } : {}),
|
|
454
476
|
...(providers ? { providers } : {}),
|
|
455
477
|
});
|
|
456
478
|
if (!resident) {
|
|
@@ -740,6 +762,94 @@ function inputItems(input: unknown): unknown[] {
|
|
|
740
762
|
return [input];
|
|
741
763
|
}
|
|
742
764
|
|
|
765
|
+
/** Hard cap for canonicalizing ANY item. Past it, the item is not comparable. */
|
|
766
|
+
const REPLAY_FINGERPRINT_MAX_BYTES = 8 * 1024;
|
|
767
|
+
/** Depth ceiling so a pathologically nested item cannot blow the canonicalizer. */
|
|
768
|
+
const REPLAY_FINGERPRINT_MAX_DEPTH = 64;
|
|
769
|
+
|
|
770
|
+
let replayOverlapSkips = 0;
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Canonical, order-stable fingerprint for one input item, or null when the item cannot be
|
|
774
|
+
* compared safely.
|
|
775
|
+
*
|
|
776
|
+
* Byte-counted DURING the walk rather than serialize-then-measure: a tool result can be
|
|
777
|
+
* megabytes and this runs on the request path, so the point of the cap is to stop early,
|
|
778
|
+
* not to discover afterwards that we should have. Object keys are sorted so two
|
|
779
|
+
* semantically identical items cannot differ by key order alone.
|
|
780
|
+
*
|
|
781
|
+
* The cap applies to EVERY item. An `id`/`call_id` is additional occurrence evidence, never
|
|
782
|
+
* a substitute for content equality, so an over-cap identified tool item is non-comparable
|
|
783
|
+
* exactly like an over-cap message.
|
|
784
|
+
*/
|
|
785
|
+
function replayItemFingerprint(item: unknown): string | null {
|
|
786
|
+
const out: string[] = [];
|
|
787
|
+
let bytes = 0;
|
|
788
|
+
const push = (text: string): boolean => {
|
|
789
|
+
bytes += Buffer.byteLength(text, "utf8");
|
|
790
|
+
if (bytes > REPLAY_FINGERPRINT_MAX_BYTES) return false;
|
|
791
|
+
out.push(text);
|
|
792
|
+
return true;
|
|
793
|
+
};
|
|
794
|
+
const walk = (value: unknown, depth: number): boolean => {
|
|
795
|
+
if (depth > REPLAY_FINGERPRINT_MAX_DEPTH) return false;
|
|
796
|
+
if (value === null || typeof value !== "object") return push(JSON.stringify(value) ?? "null");
|
|
797
|
+
if (Array.isArray(value)) {
|
|
798
|
+
if (!push("[")) return false;
|
|
799
|
+
for (const element of value) {
|
|
800
|
+
if (!walk(element, depth + 1)) return false;
|
|
801
|
+
if (!push(",")) return false;
|
|
802
|
+
}
|
|
803
|
+
return push("]");
|
|
804
|
+
}
|
|
805
|
+
if (!push("{")) return false;
|
|
806
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
807
|
+
if (!push(JSON.stringify(key))) return false;
|
|
808
|
+
if (!walk((value as Record<string, unknown>)[key], depth + 1)) return false;
|
|
809
|
+
if (!push(",")) return false;
|
|
810
|
+
}
|
|
811
|
+
return push("}");
|
|
812
|
+
};
|
|
813
|
+
return walk(item, 0) ? out.join("") : null;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** Non-empty provider-issued `id`/`call_id` on an item, else null. */
|
|
817
|
+
function providerIssuedIdentity(item: unknown): string | null {
|
|
818
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
|
819
|
+
const record = item as { id?: unknown; call_id?: unknown };
|
|
820
|
+
for (const candidate of [record.id, record.call_id]) {
|
|
821
|
+
if (typeof candidate === "string" && candidate.trim().length > 0) return candidate;
|
|
822
|
+
}
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Number of leading stored items the client already carries verbatim, or 0.
|
|
828
|
+
*
|
|
829
|
+
* Requires an exact ordered run: every stored item must match the client input item at the
|
|
830
|
+
* same index. Any not-comparable item aborts to 0 — skipping just that item could align two
|
|
831
|
+
* different occurrences and manufacture a false positive, and a false positive here deletes
|
|
832
|
+
* real conversation history.
|
|
833
|
+
*
|
|
834
|
+
* Known gap (FU-2): stored input can contain proxy-injected guidance the client never saw,
|
|
835
|
+
* and ids repaired after recording. Those sessions do not match here and expand as before.
|
|
836
|
+
*/
|
|
837
|
+
function clientCarriedPrefixLength(stored: readonly unknown[], clientInput: readonly unknown[]): number {
|
|
838
|
+
if (stored.length === 0 || clientInput.length < stored.length) return 0;
|
|
839
|
+
for (let index = 0; index < stored.length; index += 1) {
|
|
840
|
+
const storedPrint = replayItemFingerprint(stored[index]);
|
|
841
|
+
if (storedPrint === null) return 0;
|
|
842
|
+
const clientPrint = replayItemFingerprint(clientInput[index]);
|
|
843
|
+
if (clientPrint === null || storedPrint !== clientPrint) return 0;
|
|
844
|
+
}
|
|
845
|
+
return stored.length;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Test-only: replay prepends skipped because the client already carried the history. */
|
|
849
|
+
export function replayOverlapSkipsForTests(): number {
|
|
850
|
+
return replayOverlapSkips;
|
|
851
|
+
}
|
|
852
|
+
|
|
743
853
|
function pruneResponses(at = now()): void {
|
|
744
854
|
for (const [id, state] of states) {
|
|
745
855
|
if (at - state.createdAt > RESPONSE_TTL_MS) deleteEntry(id);
|
|
@@ -765,6 +875,7 @@ function pruneResponses(at = now()): void {
|
|
|
765
875
|
createdAt: entry.createdAt,
|
|
766
876
|
...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
|
|
767
877
|
items: entry.items,
|
|
878
|
+
...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
|
|
768
879
|
...(entry.providers ? { providers: entry.providers } : {}),
|
|
769
880
|
});
|
|
770
881
|
if (swapResidentForSpill(oldestId, entry, ref)) spillCounters.writes += 1;
|
|
@@ -807,6 +918,7 @@ export function evictOldestResponseContinuationForBudget(): number {
|
|
|
807
918
|
createdAt: entry.createdAt,
|
|
808
919
|
...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
|
|
809
920
|
items: entry.items,
|
|
921
|
+
...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
|
|
810
922
|
...(entry.providers ? { providers: entry.providers } : {}),
|
|
811
923
|
});
|
|
812
924
|
if (swapResidentForSpill(id, entry, ref)) spillCounters.writes += 1;
|
|
@@ -848,6 +960,9 @@ function materializeEntry(
|
|
|
848
960
|
createdAt: result.payload.createdAt,
|
|
849
961
|
...(result.payload.clientThreadId ? { clientThreadId: result.payload.clientThreadId } : {}),
|
|
850
962
|
items: result.payload.items,
|
|
963
|
+
...(result.payload.providerOutputStart !== undefined
|
|
964
|
+
? { providerOutputStart: result.payload.providerOutputStart }
|
|
965
|
+
: {}),
|
|
851
966
|
...(result.payload.providers ? { providers: result.payload.providers } : {}),
|
|
852
967
|
});
|
|
853
968
|
if (!state) {
|
|
@@ -892,6 +1007,39 @@ export function expandPreviousResponseInput(body: unknown, clientThreadId?: stri
|
|
|
892
1007
|
replayScopeMismatchDrops += 1;
|
|
893
1008
|
return freshRequest;
|
|
894
1009
|
}
|
|
1010
|
+
// The client already replayed this history verbatim. Prepending the stored copy would
|
|
1011
|
+
// double it, and the doubled turn is stored again, so the next turn triples (#1412 saw
|
|
1012
|
+
// 127k of real context reach 1.3M tokens this way).
|
|
1013
|
+
//
|
|
1014
|
+
// Three conditions, all required. The run must cover the whole stored entry; it must reach
|
|
1015
|
+
// the provider-output region; and some matched item in that region must carry a
|
|
1016
|
+
// provider-issued id. The last one is the load-bearing part: content equality alone proves
|
|
1017
|
+
// two items look alike, not that they are the same occurrence, so a client that merely
|
|
1018
|
+
// repeats its own message would otherwise authorize a skip that deletes real history.
|
|
1019
|
+
// There is no invariant that provider output always carries ids, so an entry whose output
|
|
1020
|
+
// has none simply never skips.
|
|
1021
|
+
{
|
|
1022
|
+
const clientInput = inputItems(request.input);
|
|
1023
|
+
const stored = materialized.state.items;
|
|
1024
|
+
const anchor = materialized.state.providerOutputStart;
|
|
1025
|
+
const carried = clientCarriedPrefixLength(stored, clientInput);
|
|
1026
|
+
if (
|
|
1027
|
+
carried === stored.length
|
|
1028
|
+
&& anchor !== undefined
|
|
1029
|
+
&& carried > anchor
|
|
1030
|
+
&& stored.slice(anchor, carried).some(item => providerIssuedIdentity(item) !== null)
|
|
1031
|
+
) {
|
|
1032
|
+
replayOverlapSkips += 1;
|
|
1033
|
+
// Keep previous_response_id: Kiro and Cursor recover their conversation ids from it
|
|
1034
|
+
// (kiro-wire.ts, cursor/request-builder.ts). Only the concatenation is skipped.
|
|
1035
|
+
const unchanged = { ...request };
|
|
1036
|
+
// Same provenance boundary a real expansion would record, so the replayed prefix does
|
|
1037
|
+
// not re-acknowledge historical compaction markers (parser.ts) and stays visible to
|
|
1038
|
+
// guidance de-duplication (collaboration.ts).
|
|
1039
|
+
replayedInputPrefixLengths.set(unchanged, carried);
|
|
1040
|
+
return unchanged;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
895
1043
|
const expanded = {
|
|
896
1044
|
...request,
|
|
897
1045
|
input: [...materialized.state.items, ...inputItems(request.input)],
|
|
@@ -1044,10 +1192,17 @@ export function rememberResponseState(
|
|
|
1044
1192
|
});
|
|
1045
1193
|
}
|
|
1046
1194
|
const clientThreadId = normalizedClientThreadId(opts?.clientThreadId);
|
|
1195
|
+
// Compute the normalized array once and reuse it for both fields, so the recorded
|
|
1196
|
+
// boundary can never disagree with the items it indexes.
|
|
1197
|
+
const requestItems = inputItems(request.input);
|
|
1047
1198
|
setResidentEntry(response.id, {
|
|
1048
1199
|
createdAt: now(),
|
|
1049
1200
|
...(clientThreadId ? { clientThreadId } : {}),
|
|
1050
|
-
items: [...
|
|
1201
|
+
items: [...requestItems, ...response.output],
|
|
1202
|
+
// Where response.output begins. A replay skip requires a matched item at or past this
|
|
1203
|
+
// index that also carries a provider-issued id — position alone proves only that an item
|
|
1204
|
+
// sits on the provider side, not that the provider authored it.
|
|
1205
|
+
providerOutputStart: requestItems.length,
|
|
1051
1206
|
// Always preserve the Cursor conversation id so the next tool-result turn can continue the SAME
|
|
1052
1207
|
// Cursor conversation (multi-turn continuation). Separately track whether Cursor's own
|
|
1053
1208
|
// checkpoint/cache is safe to reuse: a turn that ended with a pending client tool call produced an
|
|
@@ -1093,6 +1248,7 @@ export function clearResponseStateMemoryForTests(): void {
|
|
|
1093
1248
|
spillCounters.writeFailures = 0;
|
|
1094
1249
|
spillCounters.readFailures = 0;
|
|
1095
1250
|
replayScopeMismatchDrops = 0;
|
|
1251
|
+
replayOverlapSkips = 0;
|
|
1096
1252
|
persistAttemptHookForTests = null;
|
|
1097
1253
|
loaded = false;
|
|
1098
1254
|
}
|
|
@@ -140,9 +140,11 @@ async function handleChatCompletionsWithBudget(
|
|
|
140
140
|
logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel));
|
|
141
141
|
}
|
|
142
142
|
if (internalBody.reasoning !== undefined) {
|
|
143
|
-
const { supportedLadderFor } = await import("./effort-policy");
|
|
143
|
+
const { stripEmptyLadderEffort, supportedLadderFor } = await import("./effort-policy");
|
|
144
144
|
const ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId });
|
|
145
|
-
|
|
145
|
+
const next = stripEmptyLadderEffort(internalBody.reasoning, ladder);
|
|
146
|
+
if (next === undefined) delete internalBody.reasoning;
|
|
147
|
+
else internalBody.reasoning = next;
|
|
146
148
|
}
|
|
147
149
|
} catch (err) {
|
|
148
150
|
if (err instanceof NoEligiblePolicyCandidateError) {
|
|
@@ -98,6 +98,24 @@ export function effortCapAppliesTo(
|
|
|
98
98
|
* validates against that backend. A custom responses provider (key mode) serving a
|
|
99
99
|
* native-looking bare id must NOT inherit the unrelated native ladder.
|
|
100
100
|
*/
|
|
101
|
+
/**
|
|
102
|
+
* Empty ladders mean "no effort control", not "this model cannot emit reasoning".
|
|
103
|
+
* Drop only `effort` so a Chat Completions `include_reasoning` / `reasoning.summary`
|
|
104
|
+
* request still reaches parseRequest and is not hidden by hideThinkingSummary.
|
|
105
|
+
*/
|
|
106
|
+
export function stripEmptyLadderEffort(
|
|
107
|
+
reasoning: unknown,
|
|
108
|
+
ladder: readonly string[] | undefined,
|
|
109
|
+
): unknown {
|
|
110
|
+
if (ladder === undefined || ladder.length > 0) return reasoning;
|
|
111
|
+
if (reasoning === undefined || reasoning === null || typeof reasoning !== "object" || Array.isArray(reasoning)) {
|
|
112
|
+
return reasoning;
|
|
113
|
+
}
|
|
114
|
+
const next = { ...(reasoning as Record<string, unknown>) };
|
|
115
|
+
delete next.effort;
|
|
116
|
+
return Object.keys(next).length > 0 ? next : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
101
119
|
export function supportedLadderFor(route: { provider: OcxProviderConfig; modelId: string }): string[] | undefined {
|
|
102
120
|
const { provider, modelId } = route;
|
|
103
121
|
if (modelInList(provider.noReasoningModels, modelId)) return [];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
2
|
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
|
|
3
3
|
import { formatPassthroughUpstreamError } from "./passthrough-error";
|
|
4
|
+
import { checkInputAdmission } from "./input-admission";
|
|
4
5
|
import { describeUpstreamConnectFailure } from "./upstream-error";
|
|
5
6
|
import {
|
|
6
7
|
getConfigPath,
|
|
@@ -76,7 +77,7 @@ import {
|
|
|
76
77
|
} from "../../oauth/anthropic-routing";
|
|
77
78
|
import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
|
|
78
79
|
import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
|
|
79
|
-
import { describeImagesInPlace, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
|
|
80
|
+
import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
|
|
80
81
|
import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
|
|
81
82
|
import {
|
|
82
83
|
applyCodexAuthContextToProvider,
|
|
@@ -1853,6 +1854,24 @@ async function handleResponsesInner(
|
|
|
1853
1854
|
}
|
|
1854
1855
|
|
|
1855
1856
|
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
1857
|
+
// Refuse an input that cannot plausibly fit the model context window before spending auth,
|
|
1858
|
+
// circuit budget, or upstream bandwidth on a turn the provider will reject anyway (#1412).
|
|
1859
|
+
//
|
|
1860
|
+
// Compaction turns are exempt: Codex sends compaction_trigger BECAUSE context is full, so
|
|
1861
|
+
// refusing the turn that shrinks the context would deadlock the client against the very
|
|
1862
|
+
// limit this gate reports — it would be told to compact and then denied the compaction.
|
|
1863
|
+
if (parsed._compactionRequest !== true) {
|
|
1864
|
+
const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId);
|
|
1865
|
+
if (!inputAdmission.admitted) {
|
|
1866
|
+
return formatErrorResponse(
|
|
1867
|
+
413,
|
|
1868
|
+
"request_too_large",
|
|
1869
|
+
`Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window `
|
|
1870
|
+
+ `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a `
|
|
1871
|
+
+ `model with a larger context window.`,
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1856
1875
|
const preAuthHostKey = preAuthUpstreamHostCircuitKey(route, config);
|
|
1857
1876
|
if (preAuthHostKey) {
|
|
1858
1877
|
const admission = acquireUpstreamHostAdmission(
|
|
@@ -2076,7 +2095,7 @@ async function handleResponsesInner(
|
|
|
2076
2095
|
recordSidecarOutcome,
|
|
2077
2096
|
translatorBudget,
|
|
2078
2097
|
);
|
|
2079
|
-
} else if (
|
|
2098
|
+
} else if (isModelTextOnly(route.provider, route.modelId)) {
|
|
2080
2099
|
// Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar
|
|
2081
2100
|
// disabled): fail closed — never forward raw images to a text-only upstream.
|
|
2082
2101
|
stripImagesInPlace(parsed, translatorBudget);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-dispatch input admission (#1412).
|
|
3
|
+
*
|
|
4
|
+
* Refuses a turn whose estimated input cannot plausibly fit the model context window,
|
|
5
|
+
* BEFORE auth resolution, circuit admission, or any upstream I/O. #1412 reported ~127k of
|
|
6
|
+
* real context compounding to 1.3M-1.6M tokens and crashing the proxy; the provider would
|
|
7
|
+
* reject such a turn anyway, so paying for the round trip buys nothing.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately narrow. This is not a context manager and not a compaction trigger: it
|
|
10
|
+
* catches the pathological case and stays out of the way otherwise. Every uncertainty
|
|
11
|
+
* resolves toward admitting.
|
|
12
|
+
*/
|
|
13
|
+
import { nativeOpenAiContextWindow } from "../../codex/catalog/metadata";
|
|
14
|
+
import { estimateTokens } from "../../lib/token-estimate";
|
|
15
|
+
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
16
|
+
import type { OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../../types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Multiplier applied to the ceiling before refusing.
|
|
20
|
+
*
|
|
21
|
+
* 2.5, not something tighter, because `estimateTokens` can overshoot by 1.6x on its own.
|
|
22
|
+
* `cjkRatio` samples every `stride`-th character, so a payload of fixed-width records whose
|
|
23
|
+
* length aligns with the stride samples as 100% CJK while being ~1.6% CJK, firing the
|
|
24
|
+
* 2.5-chars/token clamp instead of the 4.0 default. Measured on Bun 1.3.14: 126,046 chars,
|
|
25
|
+
* true CJK ratio 0.0161, sampled ratio 1.0, estimate inflated 1.6x. Since 4.0 / 2.5 = 1.6
|
|
26
|
+
* is that branch maximum divergence, a threshold at or under 1.6 would convert the
|
|
27
|
+
* estimator error bar into false 413s.
|
|
28
|
+
*
|
|
29
|
+
* 2.5 sits above it with room for the ~10% model-family ratio spread, and still refuses the
|
|
30
|
+
* #1412 shape (10x) four times over.
|
|
31
|
+
*/
|
|
32
|
+
export const ADMISSION_TOLERANCE = 2.5;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Token cost charged for a remote image URL. The bytes are not in this request — the
|
|
36
|
+
* provider fetches them — so the URL own length is not the cost. A small flat charge
|
|
37
|
+
* acknowledges the tiles the image will occupy without pretending to know its dimensions.
|
|
38
|
+
*/
|
|
39
|
+
const REMOTE_IMAGE_TOKENS = 850;
|
|
40
|
+
|
|
41
|
+
/** Decoded image bytes per token. Coarse tile-count proxy, not a provider formula. */
|
|
42
|
+
const IMAGE_BYTES_PER_TOKEN = 750;
|
|
43
|
+
|
|
44
|
+
export interface InputAdmissionResult {
|
|
45
|
+
admitted: boolean;
|
|
46
|
+
estimatedTokens: number;
|
|
47
|
+
/** Resolved ceiling, or null when nothing could be resolved (=> always admitted). */
|
|
48
|
+
ceiling: number | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positive(value: unknown): number | null {
|
|
52
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Charge a `data:` URL by its DECODED size rather than its character length: base64 inflates
|
|
57
|
+
* by 4/3, so charging the string would overcount by a third. A remote URL is charged flat.
|
|
58
|
+
*/
|
|
59
|
+
function imageTokens(imageUrl: string): number {
|
|
60
|
+
if (!imageUrl.startsWith("data:")) return REMOTE_IMAGE_TOKENS;
|
|
61
|
+
const comma = imageUrl.indexOf(",");
|
|
62
|
+
if (comma < 0) return REMOTE_IMAGE_TOKENS;
|
|
63
|
+
const payload = imageUrl.length - comma - 1;
|
|
64
|
+
if (payload <= 0) return 0;
|
|
65
|
+
const decoded = Math.floor((payload * 3) / 4);
|
|
66
|
+
return Math.max(1, Math.ceil(decoded / IMAGE_BYTES_PER_TOKEN));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function contentPartTokens(part: OcxContentPart, modelId: string): number {
|
|
70
|
+
return part.type === "image" ? imageTokens(part.imageUrl) : estimateTokens(part.text, modelId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number {
|
|
74
|
+
if (typeof content === "string") return estimateTokens(content, modelId);
|
|
75
|
+
let total = 0;
|
|
76
|
+
for (const part of content) total += contentPartTokens(part, modelId);
|
|
77
|
+
return total;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Estimate the input tokens of a parsed request.
|
|
82
|
+
*
|
|
83
|
+
* Walks the whole `OcxMessage` union rather than user text alone. Assistant turns carry
|
|
84
|
+
* their content as `OcxAssistantContentPart[]` — text, thinking blocks, and tool calls whose
|
|
85
|
+
* JSON arguments are frequently the largest single item in an agent conversation. A walk
|
|
86
|
+
* that counted only `{type:"text"}` would undercount exactly the turns that trigger this
|
|
87
|
+
* gate.
|
|
88
|
+
*/
|
|
89
|
+
export function estimateInputTokens(parsed: OcxParsedRequest, modelId: string): number {
|
|
90
|
+
const { context } = parsed;
|
|
91
|
+
let total = 0;
|
|
92
|
+
|
|
93
|
+
for (const prompt of context.systemPrompt ?? []) total += estimateTokens(prompt, modelId);
|
|
94
|
+
|
|
95
|
+
for (const message of context.messages) {
|
|
96
|
+
if (message.role === "assistant") {
|
|
97
|
+
for (const part of message.content) {
|
|
98
|
+
if (part.type === "text") total += estimateTokens(part.text, modelId);
|
|
99
|
+
else if (part.type === "thinking") total += estimateTokens(part.thinking, modelId);
|
|
100
|
+
else total += estimateTokens(part.name, modelId) + estimateTokens(JSON.stringify(part.arguments), modelId);
|
|
101
|
+
}
|
|
102
|
+
// Opaque provider blob replayed verbatim upstream, so it costs real input tokens.
|
|
103
|
+
if (message.kiroRedactedReasoning) total += estimateTokens(message.kiroRedactedReasoning, modelId);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
total += contentTokens(message.content, modelId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Tool schemas ride every turn: name, description, and the JSON parameter schema all
|
|
110
|
+
// reach the upstream, and a large MCP catalog can dominate a short conversation.
|
|
111
|
+
for (const tool of context.tools ?? []) {
|
|
112
|
+
total += estimateTokens(tool.name, modelId)
|
|
113
|
+
+ estimateTokens(tool.description, modelId)
|
|
114
|
+
+ estimateTokens(JSON.stringify(tool.parameters), modelId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return total;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the admission ceiling. Pure: no filesystem, no catalog, no registry scan.
|
|
122
|
+
*
|
|
123
|
+
* `provider` must be the ROUTED config (`route.provider`), which `routedProviderConfig`
|
|
124
|
+
* has already transport-guarded and merged. Re-deriving from `config.providers[name]` would
|
|
125
|
+
* reject a user-defined provider that merely shares a built-in name using limits that
|
|
126
|
+
* belong to a different service.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveInputCeiling(
|
|
129
|
+
provider: OcxProviderConfig,
|
|
130
|
+
providerName: string,
|
|
131
|
+
modelId: string,
|
|
132
|
+
): number | null {
|
|
133
|
+
const configured = positive(provider.modelContextWindows?.[modelId]) ?? positive(provider.contextWindow);
|
|
134
|
+
|
|
135
|
+
// The canonical `openai` registry entry declares no context fields, so without this the
|
|
136
|
+
// gate would be inert on the default Codex route. All three clauses are load-bearing: a
|
|
137
|
+
// transport-mismatched custom provider named "openai" is preserved verbatim by routing
|
|
138
|
+
// and must not inherit built-in native limits, and a routed `provider/model` id is not a
|
|
139
|
+
// native slug. Static maps only — no catalog read.
|
|
140
|
+
const native = configured === null
|
|
141
|
+
&& providerName === OPENAI_CODEX_PROVIDER_ID
|
|
142
|
+
&& isCanonicalOpenAiForwardProvider(provider)
|
|
143
|
+
&& !modelId.includes("/")
|
|
144
|
+
? positive(nativeOpenAiContextWindow(modelId))
|
|
145
|
+
: null;
|
|
146
|
+
|
|
147
|
+
const window = configured ?? native;
|
|
148
|
+
// modelMaxInputTokens is an input-only cap, so it can only tighten the window.
|
|
149
|
+
const maxInput = positive(provider.modelMaxInputTokens?.[modelId]);
|
|
150
|
+
if (window === null) return maxInput;
|
|
151
|
+
return maxInput === null ? window : Math.min(window, maxInput);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Fail-open when no ceiling is known; refuse only past `ceiling * ADMISSION_TOLERANCE`.
|
|
156
|
+
*
|
|
157
|
+
* The caller is responsible for skipping compaction turns — see the call site in core.ts.
|
|
158
|
+
*/
|
|
159
|
+
export function checkInputAdmission(
|
|
160
|
+
parsed: OcxParsedRequest,
|
|
161
|
+
provider: OcxProviderConfig,
|
|
162
|
+
providerName: string,
|
|
163
|
+
modelId: string,
|
|
164
|
+
): InputAdmissionResult {
|
|
165
|
+
const ceiling = resolveInputCeiling(provider, providerName, modelId);
|
|
166
|
+
if (ceiling === null) return { admitted: true, estimatedTokens: 0, ceiling: null };
|
|
167
|
+
const estimatedTokens = estimateInputTokens(parsed, modelId);
|
|
168
|
+
return { admitted: estimatedTokens <= ceiling * ADMISSION_TOLERANCE, estimatedTokens, ceiling };
|
|
169
|
+
}
|
|
@@ -264,9 +264,8 @@ function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): Servic
|
|
|
264
264
|
"--user", "show", TASK,
|
|
265
265
|
"-p", "LoadState", "-p", "ActiveState", "-p", "FragmentPath", "-p", "NeedDaemonReload",
|
|
266
266
|
]);
|
|
267
|
-
if (shown.spawnFailed
|
|
268
|
-
|
|
269
|
-
}
|
|
267
|
+
if (shown.spawnFailed) return { kind: "absent" };
|
|
268
|
+
if (shown.timedOut) return unknown("systemctl could not be asked: timed out");
|
|
270
269
|
if (shown.status !== 0) {
|
|
271
270
|
// A missing unit still exits ZERO and says not-found; a non-zero status means
|
|
272
271
|
// the question never reached the bus.
|
package/src/types.ts
CHANGED
|
@@ -32,11 +32,19 @@ export interface OcxParsedRequest {
|
|
|
32
32
|
stream: boolean;
|
|
33
33
|
options: OcxRequestOptions;
|
|
34
34
|
_rawBody?: unknown;
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Boundary between replayed history and this turn's newly appended input. Usually the
|
|
37
|
+
* items the proxy restored from local previous_response_id state; also set when the
|
|
38
|
+
* CLIENT already carried that history verbatim and the proxy skipped the prepend.
|
|
39
|
+
*/
|
|
36
40
|
_replayPrefixLen?: number;
|
|
37
41
|
/** Parsed-message index before the first conversational item in a continuation's current delta. */
|
|
38
42
|
_continuationConversationMessageIndex?: number;
|
|
39
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* True when the full history for a previous_response_id request is present in the input —
|
|
45
|
+
* whether the proxy expanded it or the client already sent it. Consumers read this as
|
|
46
|
+
* "this request is self-contained", never as "the proxy mutated it".
|
|
47
|
+
*/
|
|
40
48
|
_previousResponseInputExpanded?: boolean;
|
|
41
49
|
/** Provider-private stable Cursor conversation id resolved from the Responses previous_response_id chain. */
|
|
42
50
|
_cursorConversationId?: string;
|
package/src/vision/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { OcxConfig, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent } from "../types";
|
|
3
3
|
import { modelInList } from "../types";
|
|
4
|
+
import { modelRecordValue } from "../reasoning-effort";
|
|
4
5
|
import type { VisionReasoningEffort } from "../reasoning-effort";
|
|
5
6
|
import { describeImage, type DescribeOutcome, type VisionSettings } from "./describe";
|
|
6
7
|
import { describeImageAnthropic } from "./anthropic-describe";
|
|
@@ -18,6 +19,22 @@ import {
|
|
|
18
19
|
} from "./timeout-bounds";
|
|
19
20
|
|
|
20
21
|
export { describeImage } from "./describe";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* True when the model is explicitly known to be text-only — either listed in
|
|
25
|
+
* `noVisionModels` or declared with `modelInputModalities` that exclude "image".
|
|
26
|
+
* Returns false for unknown models (no evidence either way) so they fall through
|
|
27
|
+
* to native image passthrough, which is the safe default for an unclassified model.
|
|
28
|
+
*/
|
|
29
|
+
export function isModelTextOnly(
|
|
30
|
+
provider: OcxProviderConfig,
|
|
31
|
+
modelId: string,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (modelInList(provider.noVisionModels, modelId)) return true;
|
|
34
|
+
const modalities = modelRecordValue(provider.modelInputModalities, modelId);
|
|
35
|
+
if (Array.isArray(modalities) && modalities.length > 0 && !modalities.includes("image")) return true;
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
21
38
|
export { describeImageAnthropic, parseAnthropicVisionSSE } from "./anthropic-describe";
|
|
22
39
|
export {
|
|
23
40
|
BASELINE_VISION_MODELS,
|
|
@@ -245,10 +262,10 @@ function messagesHaveImage(parsed: OcxParsedRequest): boolean {
|
|
|
245
262
|
export function shouldResolveOpenAiVisionSidecar(
|
|
246
263
|
config: OcxConfig,
|
|
247
264
|
provider: OcxProviderConfig,
|
|
248
|
-
|
|
249
|
-
|
|
265
|
+
modelId: string,
|
|
266
|
+
parsed: OcxParsedRequest,
|
|
250
267
|
): boolean {
|
|
251
|
-
if (!
|
|
268
|
+
if (!isModelTextOnly(provider, modelId) || !messagesHaveImage(parsed)) return false;
|
|
252
269
|
const cfg = config.visionSidecar ?? {};
|
|
253
270
|
if (cfg.enabled === false) return false;
|
|
254
271
|
return resolveVisionBackend(cfg.backend, findAnthropicVisionProvider(config)) === "openai";
|
|
@@ -275,7 +292,7 @@ export function planVisionSidecar(
|
|
|
275
292
|
parsed: OcxParsedRequest,
|
|
276
293
|
openAiSidecar?: ResolvedOpenAiForwardSidecar,
|
|
277
294
|
): VisionPlan | undefined {
|
|
278
|
-
if (!
|
|
295
|
+
if (!isModelTextOnly(provider, modelId)) return undefined;
|
|
279
296
|
if (!messagesHaveImage(parsed)) return undefined;
|
|
280
297
|
const cfg = config.visionSidecar ?? {};
|
|
281
298
|
if (cfg.enabled === false) return undefined;
|
package/src/web-search/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
|
|
2
2
|
import { modelInList, toolChoiceToolPredicate } from "../types";
|
|
3
|
+
import { isModelTextOnly } from "../vision";
|
|
3
4
|
import type { SidecarSettings } from "./executor";
|
|
4
5
|
import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
|
|
5
6
|
import { getAccountSet } from "../oauth/store";
|
|
@@ -166,7 +167,7 @@ export function planWebSearch(
|
|
|
166
167
|
timeoutMs,
|
|
167
168
|
);
|
|
168
169
|
// The routed model being text-only means the search model must verbalize image results (either backend).
|
|
169
|
-
const describeImages =
|
|
170
|
+
const describeImages = isModelTextOnly(provider, modelId);
|
|
170
171
|
const reasoning = cfg.reasoning ?? DEFAULT_SIDECAR_REASONING;
|
|
171
172
|
const streamRoutedModelOutput = cfg.streamRoutedModelOutput === true;
|
|
172
173
|
|