@bitkyc08/opencodex 2.56.0 → 2.58.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-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
- package/src/adapters/command-code.ts +12 -3
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +65 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/live-transport.ts +5 -1
- package/src/adapters/cursor/protobuf-events.ts +110 -11
- package/src/adapters/cursor/protobuf-request.ts +19 -1
- package/src/adapters/cursor/text-toolcall.ts +230 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +5 -0
- package/src/adapters/cursor.ts +55 -5
- package/src/adapters/google-http.ts +38 -13
- package/src/adapters/google.ts +7 -7
- 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/mimo-free.ts +32 -17
- package/src/adapters/ollama-native.ts +42 -8
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +25 -7
- package/src/adapters/openai-chat.ts +8 -8
- package/src/adapters/openai-responses/passthrough.ts +62 -5
- package/src/adapters/openai-responses/request-strips.ts +43 -0
- package/src/adapters/physical-send.ts +50 -0
- package/src/bridge/errors.ts +26 -2
- package/src/bridge/response-json.ts +8 -2
- package/src/bridge/sse.ts +20 -2
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +32 -4
- 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/config-command.ts +35 -18
- package/src/cli/dispatch.ts +17 -4
- package/src/cli/index.ts +92 -7
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +74 -5
- package/src/cli/uninstall-client-state.ts +12 -0
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-store.ts +113 -26
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/login-flow.ts +14 -2
- package/src/codex/auth-api/reset-credit-service.ts +11 -2
- package/src/codex/auth-context.ts +199 -15
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/model-visibility.ts +1 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/retained-sync.ts +9 -1
- package/src/codex/catalog/routed-gather.ts +38 -1
- 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/desktop-switches.ts +145 -0
- package/src/codex/history-job.ts +5 -1
- package/src/codex/history-provider.ts +33 -4
- package/src/codex/history-worker.ts +14 -1
- package/src/codex/inject/remove.ts +145 -7
- package/src/codex/inject/restore.ts +231 -32
- package/src/codex/inject.ts +12 -16
- package/src/codex/loopback-target.ts +9 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/native-profile-startup.ts +64 -20
- package/src/codex/pool-refresh-backoff.ts +12 -3
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +10 -0
- package/src/codex/routing/selection.ts +79 -2
- package/src/codex/routing/thread-affinity.ts +50 -2
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +29 -49
- 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/atomic-write.ts +83 -8
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +272 -180
- package/src/images/loop.ts +1 -1
- package/src/lib/bounded-subprocess.ts +62 -10
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +147 -21
- package/src/lib/spend-reservation-ledger.ts +18 -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 +77 -10
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/windows-secret-acl.ts +151 -15
- package/src/lib/windows-user-principal.ts +5 -1
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/derive.ts +6 -0
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +35 -7
- package/src/providers/registry/entries-core.ts +18 -0
- package/src/providers/registry/entries-extended.ts +59 -28
- package/src/providers/registry/model-seeds.ts +71 -17
- package/src/providers/registry/types.ts +9 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/spill-store.ts +17 -0
- package/src/responses/state/body-policy.ts +25 -0
- package/src/responses/state/spill-queue.ts +8 -6
- package/src/responses/state.ts +3 -22
- package/src/router.ts +4 -0
- package/src/routing/identity-domains.ts +21 -14
- package/src/routing/probe-lease.ts +103 -1
- package/src/server/auth-cors.ts +1 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/live-sideband.ts +37 -1
- package/src/server/index/websocket-handler.ts +54 -3
- package/src/server/index.ts +5 -5
- 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/config-routes.ts +27 -5
- package/src/server/models-capabilities.ts +24 -3
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log.ts +127 -3
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +74 -0
- package/src/server/responses/adapter-continuation.ts +33 -7
- package/src/server/responses/adapter-delivery.ts +5 -11
- package/src/server/responses/adapter-dispatch.ts +84 -13
- package/src/server/responses/codex-ws-exchange.ts +65 -4
- 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/combo-stream-preflight.ts +68 -5
- package/src/server/responses/compact.ts +54 -13
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-codex-account.ts +51 -3
- package/src/server/responses/core-combo.ts +129 -23
- package/src/server/responses/core-errors.ts +18 -0
- package/src/server/responses/core-options.ts +3 -0
- package/src/server/responses/core-replay.ts +105 -32
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/fetch-helpers.ts +4 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/native-injection-protocol.ts +42 -0
- package/src/server/responses/native-injection-replay.ts +105 -0
- package/src/server/responses/native-injection.ts +242 -0
- package/src/server/responses/native-response-control.ts +56 -0
- package/src/server/responses/native-response-json.ts +14 -0
- package/src/server/responses/native-response-output.ts +37 -0
- package/src/server/responses/native-steering-log.ts +44 -0
- package/src/server/responses/native-steering-policy.ts +49 -0
- package/src/server/responses/native-steering-replay.ts +126 -0
- package/src/server/responses/native-steering-settings.ts +76 -0
- package/src/server/responses/native-steering.ts +400 -0
- package/src/server/responses/native-tool-results.ts +130 -0
- package/src/server/responses/passthrough-delivery.ts +30 -6
- package/src/server/responses/passthrough-dispatch.ts +61 -11
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/request-prepare.ts +173 -22
- package/src/server/responses/request-send-budget.ts +97 -2
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +62 -3
- package/src/server/responses/run-turn-execution.ts +59 -31
- package/src/server/responses/sidecar-execution.ts +7 -13
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses/ws-upstream.ts +21 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/stop-teardown.ts +8 -1
- package/src/server/ws-bridge.ts +16 -1
- package/src/service/cli.ts +13 -1
- 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 +8 -1
- package/src/types/provider.ts +13 -0
- 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 +1 -1
- 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
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
* catches the pathological case and stays out of the way otherwise. Every uncertainty
|
|
11
11
|
* resolves toward admitting.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
nativeOpenAiContextWindow,
|
|
15
|
+
nativeOpenAiMaxInputTokens,
|
|
16
|
+
nativeOpenAiMaxOutputTokens,
|
|
17
|
+
type NativeContextLimitsInput,
|
|
18
|
+
} from "../../codex/catalog/metadata";
|
|
19
|
+
import { getModelMetadata } from "../../generated/model-metadata";
|
|
14
20
|
import { estimateTokens } from "../../lib/token-estimate";
|
|
15
21
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
16
22
|
import { modelRecordValue } from "../../reasoning-effort";
|
|
@@ -54,6 +60,8 @@ export interface InputAdmissionResult {
|
|
|
54
60
|
estimatedTokens: number;
|
|
55
61
|
/** Resolved ceiling, or null when nothing could be resolved (=> always admitted). */
|
|
56
62
|
ceiling: number | null;
|
|
63
|
+
/** Output space reserved by the combo preflight; absent on the loose direct gate. */
|
|
64
|
+
requiredOutputHeadroom?: number;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
function positive(value: unknown): number | null {
|
|
@@ -135,14 +143,19 @@ export function estimateInputTokens(parsed: OcxParsedRequest, modelId: string):
|
|
|
135
143
|
* reject a user-defined provider that merely shares a built-in name using limits that
|
|
136
144
|
* belong to a different service.
|
|
137
145
|
*/
|
|
138
|
-
|
|
146
|
+
interface ResolvedContextLimits {
|
|
147
|
+
/** The target's total context window: input and output share it. */
|
|
148
|
+
window: number | null;
|
|
149
|
+
/** Largest admissible input, which input-only caps may tighten below the window. */
|
|
150
|
+
ceiling: number | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveContextLimits(
|
|
139
154
|
provider: OcxProviderConfig,
|
|
140
155
|
providerName: string,
|
|
141
156
|
modelId: string,
|
|
142
|
-
// Operator cap for the canonical native provider. Passed in rather than read from a
|
|
143
|
-
// config here so this stays pure: no filesystem, no catalog, no registry scan.
|
|
144
157
|
nativeContextCap?: NativeContextLimitsInput,
|
|
145
|
-
):
|
|
158
|
+
): ResolvedContextLimits {
|
|
146
159
|
// `modelRecordValue`, not a bare lookup: the catalog resolves these same two maps that
|
|
147
160
|
// way, so a `gpt-oss` entry covers `gpt-oss:120b`. Reading raw here made the gate fall
|
|
148
161
|
// back to the provider-wide window and refuse turns the model can plainly hold.
|
|
@@ -168,13 +181,120 @@ export function resolveInputCeiling(
|
|
|
168
181
|
: null;
|
|
169
182
|
const nativeMaxInput = canonicalNativeBare ? positive(nativeOpenAiMaxInputTokens(modelId, nativeLimits)) : null;
|
|
170
183
|
|
|
171
|
-
const window = canonicalNativeBare ? native : configured;
|
|
184
|
+
const window = canonicalNativeBare ? (native ?? generatedNativeWindow(modelId, configured, nativeContextCap)) : configured;
|
|
172
185
|
// modelMaxInputTokens is an input-only cap, so it can only tighten the window.
|
|
173
186
|
const configuredMaxInput = positive(modelRecordValue(provider.modelMaxInputTokens, modelId));
|
|
174
187
|
const limits = [window, configuredMaxInput, nativeMaxInput].filter((v): v is number => v !== null);
|
|
188
|
+
return { window, ceiling: limits.length === 0 ? null : Math.min(...limits) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Generated-catalog keys, not routing provider names. `OPENAI_CODEX_PROVIDER_ID` is the string
|
|
193
|
+
* `"openai"` -- the canonical Codex forward route -- so using it to index the generated bundle
|
|
194
|
+
* would silently skip the native Codex rows and read the public API rows instead.
|
|
195
|
+
*/
|
|
196
|
+
const NATIVE_METADATA_CATALOGS = ["openai-codex", "openai"] as const;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Static in-tree metadata for a canonical native slug the narrower override and pinned-native
|
|
200
|
+
* tables do not carry. Falling through to null made input admission completely blind for
|
|
201
|
+
* exactly those models, which is how a 128k target accepted a turn it could not finish.
|
|
202
|
+
*
|
|
203
|
+
* This deliberately covers slugs that are no longer offered in the picker: a retired slug is
|
|
204
|
+
* still dispatchable when an operator names it explicitly in a combo target, and that is the
|
|
205
|
+
* configuration where the gate was inert. This is a generated bundle compiled into the binary,
|
|
206
|
+
* not a live catalog read, so it adds no I/O. Explicit provider and operator caps may only
|
|
207
|
+
* narrow the result, never widen it.
|
|
208
|
+
*/
|
|
209
|
+
function generatedNativeWindow(
|
|
210
|
+
modelId: string,
|
|
211
|
+
configured: number | null,
|
|
212
|
+
nativeContextCap: NativeContextLimitsInput | undefined,
|
|
213
|
+
): number | null {
|
|
214
|
+
let generated: number | null = null;
|
|
215
|
+
for (const catalog of NATIVE_METADATA_CATALOGS) {
|
|
216
|
+
generated = positive(getModelMetadata(catalog, modelId)?.contextWindow);
|
|
217
|
+
if (generated !== null) break;
|
|
218
|
+
}
|
|
219
|
+
if (generated === null) return null;
|
|
220
|
+
const cap = typeof nativeContextCap === "number"
|
|
221
|
+
? positive(nativeContextCap)
|
|
222
|
+
: positive(nativeContextCap?.cap);
|
|
223
|
+
return Math.min(generated, configured ?? generated, cap ?? generated);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function resolveInputCeiling(
|
|
227
|
+
provider: OcxProviderConfig,
|
|
228
|
+
providerName: string,
|
|
229
|
+
modelId: string,
|
|
230
|
+
// Operator cap for the canonical native provider. Passed in rather than read from a
|
|
231
|
+
// config here so this stays pure: no filesystem, no catalog, no registry scan.
|
|
232
|
+
nativeContextCap?: NativeContextLimitsInput,
|
|
233
|
+
): number | null {
|
|
234
|
+
return resolveContextLimits(provider, providerName, modelId, nativeContextCap).ceiling;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Largest output the concrete target can emit. Used only to avoid reserving MORE than the
|
|
239
|
+
* target could ever produce when a client asks for a bigger allowance than the model has.
|
|
240
|
+
* Unknown stays unknown rather than inventing a capability.
|
|
241
|
+
*/
|
|
242
|
+
export function resolveOutputCeiling(
|
|
243
|
+
provider: OcxProviderConfig,
|
|
244
|
+
providerName: string,
|
|
245
|
+
modelId: string,
|
|
246
|
+
): number | null {
|
|
247
|
+
const configured = positive(modelRecordValue(provider.modelMaxOutputTokens, modelId))
|
|
248
|
+
?? positive(provider.defaultMaxOutputTokens);
|
|
249
|
+
const canonicalNativeBare = providerName === OPENAI_CODEX_PROVIDER_ID
|
|
250
|
+
&& isCanonicalOpenAiForwardProvider(provider)
|
|
251
|
+
&& !modelId.includes("/");
|
|
252
|
+
const native = canonicalNativeBare ? positive(nativeOpenAiMaxOutputTokens(modelId)) : null;
|
|
253
|
+
const limits = [configured, native].filter((v): v is number => v !== null);
|
|
175
254
|
return limits.length === 0 ? null : Math.min(...limits);
|
|
176
255
|
}
|
|
177
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Combo-only admission. A fallback must be able to satisfy the caller's declared output
|
|
259
|
+
* allowance inside its OWN context window. Otherwise it returns 200, emits a few hundred
|
|
260
|
+
* tokens, and terminates on `finish_reason: length` — which the Anthropic surface renders as
|
|
261
|
+
* "response exceeded the output token maximum" even though the real cause was the total
|
|
262
|
+
* window. By then the next target cannot be tried, because output has already committed.
|
|
263
|
+
*
|
|
264
|
+
* Two budgets are checked separately so the reserve is counted exactly once. `ceiling` is an
|
|
265
|
+
* input-only budget once `modelMaxInputTokens` tightens it below the window, so the output
|
|
266
|
+
* reserve belongs against `window`, not against `ceiling`.
|
|
267
|
+
*
|
|
268
|
+
* Direct and single-target requests keep the deliberately loose 2.5x pathological-input gate.
|
|
269
|
+
* This stricter rule applies only to synthetic combo children, where skipping one known-small
|
|
270
|
+
* target is safe and the ladder continues before any upstream bytes are sent. Unknown context
|
|
271
|
+
* stays fail-open, and a caller that declared no output allowance is unaffected.
|
|
272
|
+
*/
|
|
273
|
+
export function checkComboTargetInputAdmission(
|
|
274
|
+
parsed: OcxParsedRequest,
|
|
275
|
+
provider: OcxProviderConfig,
|
|
276
|
+
providerName: string,
|
|
277
|
+
modelId: string,
|
|
278
|
+
nativeContextCap?: NativeContextLimitsInput,
|
|
279
|
+
): InputAdmissionResult {
|
|
280
|
+
const { window, ceiling } = resolveContextLimits(provider, providerName, modelId, nativeContextCap);
|
|
281
|
+
const requestedOutput = positive(parsed.options.maxOutputTokens);
|
|
282
|
+
if (window === null || ceiling === null || requestedOutput === null) {
|
|
283
|
+
return checkInputAdmission(parsed, provider, providerName, modelId, nativeContextCap);
|
|
284
|
+
}
|
|
285
|
+
const targetOutput = resolveOutputCeiling(provider, providerName, modelId);
|
|
286
|
+
const requiredOutputHeadroom = targetOutput === null
|
|
287
|
+
? requestedOutput
|
|
288
|
+
: Math.min(requestedOutput, targetOutput);
|
|
289
|
+
const estimatedTokens = estimateInputTokens(parsed, modelId);
|
|
290
|
+
return {
|
|
291
|
+
admitted: estimatedTokens <= ceiling && estimatedTokens + requiredOutputHeadroom <= window,
|
|
292
|
+
estimatedTokens,
|
|
293
|
+
ceiling,
|
|
294
|
+
requiredOutputHeadroom,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
178
298
|
/**
|
|
179
299
|
* Fail-open when no ceiling is known; refuse only past `ceiling * ADMISSION_TOLERANCE`.
|
|
180
300
|
*
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { nativeResponseRecord as injectionRecord } from "./native-response-json";
|
|
2
|
+
export { nativeResponseRecord as injectionRecord, nativeResponseFingerprint as injectionFingerprint } from "./native-response-json";
|
|
3
|
+
import { CODEX_WS_ID_MAX_BYTES } from "./codex-ws-correlation";
|
|
4
|
+
import { NativeSteeringError } from "./native-steering";
|
|
5
|
+
|
|
6
|
+
export type InjectionFrame = Record<string, unknown>;
|
|
7
|
+
export type FunctionResult = { type: "function_call_output"; call_id: string; output: string };
|
|
8
|
+
export const MAX_NATIVE_INJECTIONS = 32;
|
|
9
|
+
export const MAX_NATIVE_INJECTION_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
export const MAX_NATIVE_INJECTION_CALLS = 1024;
|
|
11
|
+
export const NATIVE_INJECTION_ACK_MS = 90_000;
|
|
12
|
+
export const NATIVE_INJECTION_TOOL_MS = 30 * 60_000;
|
|
13
|
+
|
|
14
|
+
/** Bound identities and exclude control characters, without changing their spelling. */
|
|
15
|
+
export function injectionId(value: unknown): value is string {
|
|
16
|
+
return typeof value === "string" && value.length > 0 && Buffer.byteLength(value) <= CODEX_WS_ID_MAX_BYTES
|
|
17
|
+
&& !/[\u0000-\u001f\u007f]/.test(value);
|
|
18
|
+
}
|
|
19
|
+
/** Throw only fixed, content-free errors, never tool output or caller identifiers. */
|
|
20
|
+
export function injectionError(code: string, message: string): never {
|
|
21
|
+
throw new NativeSteeringError(code, message);
|
|
22
|
+
}
|
|
23
|
+
/** First-version contract: nonempty arrays of string-valued, client-owned function results. */
|
|
24
|
+
export function injectionResults(value: unknown): FunctionResult[] {
|
|
25
|
+
if (!Array.isArray(value) || !value.length || value.length > MAX_NATIVE_INJECTION_CALLS) {
|
|
26
|
+
injectionError("invalid_injection", "Supply a bounded, nonempty array of saved function results.");
|
|
27
|
+
}
|
|
28
|
+
const seen = new Set<string>();
|
|
29
|
+
for (const item of value) {
|
|
30
|
+
if (!injectionRecord(item) || item.type !== "function_call_output" || !injectionId(item.call_id)
|
|
31
|
+
|| typeof item.output !== "string" || Object.keys(item).some(key => !["type", "call_id", "output"].includes(key))) {
|
|
32
|
+
injectionError("invalid_injection", "Only function_call_output with call_id and string output is supported; no messages or hosted-tool results.");
|
|
33
|
+
}
|
|
34
|
+
if (seen.has(item.call_id)) injectionError("duplicate_injection", "A function result must occur exactly once.");
|
|
35
|
+
seen.add(item.call_id);
|
|
36
|
+
}
|
|
37
|
+
return value as FunctionResult[];
|
|
38
|
+
}
|
|
39
|
+
/** Detect explicit multi-agent opt-in without inferring it from the model name. */
|
|
40
|
+
export function isInjectionRequest(frame: InjectionFrame): boolean {
|
|
41
|
+
return injectionRecord(frame.multi_agent) && frame.multi_agent.enabled === true;
|
|
42
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { MAX_NATIVE_STEERING_REPLAY_BYTES, type NativeSteeringReplayObserver } from "./native-steering-replay";
|
|
2
|
+
import { injectionRecord as record, type InjectionFrame as Frame, type FunctionResult } from "./native-injection-protocol";
|
|
3
|
+
|
|
4
|
+
import { nativeResultFingerprint } from "./native-tool-results";
|
|
5
|
+
import { nativeResponseOutput } from "./native-response-output";
|
|
6
|
+
|
|
7
|
+
/** A bounded journal of accepted tool results, independent of user steering history. */
|
|
8
|
+
export class NativeInjectionReplay implements NativeSteeringReplayObserver {
|
|
9
|
+
private prefix: unknown[];
|
|
10
|
+
private bytes = 0;
|
|
11
|
+
private current?: string;
|
|
12
|
+
private output = new Map<number, Frame>();
|
|
13
|
+
private accepted = new Map<string, FunctionResult>();
|
|
14
|
+
private pending?: FunctionResult[];
|
|
15
|
+
private pendingBytes = 0;
|
|
16
|
+
private acceptedBatchBytes: number[] = [];
|
|
17
|
+
private explicit: unknown[] = [];
|
|
18
|
+
private previous: unknown[] = [];
|
|
19
|
+
|
|
20
|
+
/** Capture a private initial prefix; existing persistence eligibility is checked by the caller. */
|
|
21
|
+
constructor(input: unknown, private readonly remember: (input: unknown[], response: Frame) => void) {
|
|
22
|
+
this.prefix = typeof input === "string"
|
|
23
|
+
? [{ type: "message", role: "user", content: [{ type: "input_text", text: input }] }]
|
|
24
|
+
: Array.isArray(input) ? [...input] : [];
|
|
25
|
+
this.reserve(this.prefix);
|
|
26
|
+
}
|
|
27
|
+
/** Charge serialized bytes, refusing rather than truncating an over-budget transcript. */
|
|
28
|
+
private reserve(value: unknown): number {
|
|
29
|
+
const bytes = Buffer.byteLength(JSON.stringify(value));
|
|
30
|
+
if (this.bytes + bytes > MAX_NATIVE_STEERING_REPLAY_BYTES) throw new Error("Native injection replay exceeded its history budget.");
|
|
31
|
+
this.bytes += bytes;
|
|
32
|
+
return bytes;
|
|
33
|
+
}
|
|
34
|
+
/** Journal before physical send, with rollback usable only for a known unsent frame. */
|
|
35
|
+
submitted(frame: Frame): () => void {
|
|
36
|
+
const input = Array.isArray(frame.input) ? structuredClone(frame.input) : [];
|
|
37
|
+
const bytes = this.reserve(input);
|
|
38
|
+
if (frame.type === "response.inject") { this.pending = input as FunctionResult[]; this.pendingBytes = bytes; }
|
|
39
|
+
else this.explicit = input;
|
|
40
|
+
return () => {
|
|
41
|
+
if (frame.type === "response.inject") { this.pending = undefined; this.pendingBytes = 0; }
|
|
42
|
+
else this.explicit = [];
|
|
43
|
+
this.bytes -= bytes;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Keep wire output order and insert each accepted result after its owning function call. */
|
|
47
|
+
private completedOutput(response: Frame): unknown[] {
|
|
48
|
+
const output = nativeResponseOutput(this.output, response.output);
|
|
49
|
+
const echoed = new Set<string>();
|
|
50
|
+
for (const item of output) {
|
|
51
|
+
if (!record(item) || item.type !== "function_call_output" || typeof item.call_id !== "string") continue;
|
|
52
|
+
const accepted = this.accepted.get(item.call_id);
|
|
53
|
+
if (accepted) {
|
|
54
|
+
if (echoed.has(item.call_id) || nativeResultFingerprint(item as FunctionResult) !== nativeResultFingerprint(accepted)) throw new Error("Native injection replay result mismatch.");
|
|
55
|
+
echoed.add(item.call_id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const merged: unknown[] = [];
|
|
59
|
+
const found = new Set(echoed);
|
|
60
|
+
for (const item of output) {
|
|
61
|
+
merged.push(item);
|
|
62
|
+
if (!record(item) || item.type !== "function_call" || typeof item.call_id !== "string") continue;
|
|
63
|
+
const accepted = this.accepted.get(item.call_id);
|
|
64
|
+
if (accepted && !found.has(item.call_id)) { merged.push(accepted); found.add(item.call_id); }
|
|
65
|
+
}
|
|
66
|
+
if (found.size !== this.accepted.size) throw new Error("Native injection replay is missing an accepted result's call.");
|
|
67
|
+
return merged;
|
|
68
|
+
}
|
|
69
|
+
/** Terminals are supplied by the owner only after all injection acknowledgements settle. */
|
|
70
|
+
observe(frame: Frame): void {
|
|
71
|
+
if (frame.type === "response.created") {
|
|
72
|
+
if (this.current) {
|
|
73
|
+
for (const item of this.previous) this.prefix.push(item);
|
|
74
|
+
for (const item of this.explicit) this.prefix.push(item);
|
|
75
|
+
}
|
|
76
|
+
this.current = String(record(frame.response) ? frame.response.id : "");
|
|
77
|
+
this.output.clear(); this.accepted.clear(); this.acceptedBatchBytes = []; this.explicit = []; this.previous = [];
|
|
78
|
+
} else if (frame.type === "response.inject.created" || frame.type === "response.inject.failed") {
|
|
79
|
+
if (!this.pending) throw new Error("Native injection replay acknowledgement has no pending input.");
|
|
80
|
+
if (frame.type === "response.inject.created") {
|
|
81
|
+
for (const item of this.pending) this.accepted.set(item.call_id, item);
|
|
82
|
+
this.acceptedBatchBytes.push(this.pendingBytes);
|
|
83
|
+
} else this.bytes -= this.pendingBytes;
|
|
84
|
+
this.pending = undefined; this.pendingBytes = 0;
|
|
85
|
+
} else if (frame.type === "response.output_item.done") {
|
|
86
|
+
if (!Number.isSafeInteger(frame.output_index) || (frame.output_index as number) < 0
|
|
87
|
+
|| (frame.output_index as number) > 10_000 || !record(frame.item)) throw new Error("Native injection replay output identity is invalid.");
|
|
88
|
+
const old = this.output.get(frame.output_index as number);
|
|
89
|
+
if (old) this.bytes -= Buffer.byteLength(JSON.stringify(old));
|
|
90
|
+
this.reserve(frame.item); this.output.set(frame.output_index as number, structuredClone(frame.item));
|
|
91
|
+
} else if (record(frame.response) && ["response.completed", "response.failed", "response.incomplete"].includes(String(frame.type))) {
|
|
92
|
+
if (this.pending) throw new Error("Native injection replay cannot commit an unacknowledged result.");
|
|
93
|
+
const output = this.completedOutput(frame.response);
|
|
94
|
+
for (const item of this.output.values()) this.bytes -= Buffer.byteLength(JSON.stringify(item));
|
|
95
|
+
for (const bytes of this.acceptedBatchBytes) this.bytes -= bytes;
|
|
96
|
+
this.reserve(output); this.output.clear(); this.accepted.clear(); this.acceptedBatchBytes = []; this.previous = output;
|
|
97
|
+
if (frame.type === "response.completed") this.remember(this.prefix, { ...frame.response, output });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Drop all retained bodies at cancellation, connection teardown or unknown delivery. */
|
|
101
|
+
dispose(): void {
|
|
102
|
+
this.prefix = []; this.output.clear(); this.accepted.clear(); this.pending = undefined; this.pendingBytes = 0; this.acceptedBatchBytes = [];
|
|
103
|
+
this.explicit = []; this.previous = []; this.bytes = 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { CodexWsCorrelation } from "./codex-ws-correlation";
|
|
2
|
+
import type { NativeResponseControl } from "./native-response-control";
|
|
3
|
+
import type { NativeSteeringReplayObserver } from "./native-steering-replay";
|
|
4
|
+
import {
|
|
5
|
+
injectionError, injectionFingerprint, injectionId, injectionRecord as record, injectionResults,
|
|
6
|
+
isInjectionRequest, MAX_NATIVE_INJECTIONS, MAX_NATIVE_INJECTION_BYTES, MAX_NATIVE_INJECTION_CALLS,
|
|
7
|
+
NATIVE_INJECTION_ACK_MS, NATIVE_INJECTION_TOOL_MS,
|
|
8
|
+
type FunctionResult, type InjectionFrame as Frame,
|
|
9
|
+
} from "./native-injection-protocol";
|
|
10
|
+
|
|
11
|
+
import { nativeResultKey, nativeResultMatches, nativeResultFingerprint, nativeSavedResults, nativeToolRequirement,
|
|
12
|
+
type NativeToolRequirement } from "./native-tool-results";
|
|
13
|
+
|
|
14
|
+
type Call = { requirement: NativeToolRequirement; state: "available" | "queued" | "accepted" | "failed"; result?: string; recoverable?: boolean };
|
|
15
|
+
type Submission = { frame: Frame; results: FunctionResult[]; bytes: number };
|
|
16
|
+
const ENVELOPE = new Set(["type", "input", "previous_response_id", "stream", "stream_id"]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Injection-only multi-agent owner. Exactly one injection is on the physical wire
|
|
20
|
+
* at a time because created acknowledgements contain a response ID, not a request
|
|
21
|
+
* ID. Queued submissions, terminal delivery and caller-owned recovery are distinct.
|
|
22
|
+
*/
|
|
23
|
+
export class NativeInjectionChannel implements NativeResponseControl {
|
|
24
|
+
readonly kind = "injection" as const;
|
|
25
|
+
relayActive = false;
|
|
26
|
+
replayFactory?: () => NativeSteeringReplayObserver;
|
|
27
|
+
private replay?: NativeSteeringReplayObserver;
|
|
28
|
+
private send?: (frame: Frame) => void;
|
|
29
|
+
private onFailure?: (error: Error) => void;
|
|
30
|
+
private currentId?: string;
|
|
31
|
+
private correlation?: CodexWsCorrelation;
|
|
32
|
+
private terminal?: Frame;
|
|
33
|
+
private terminalRecorded = false;
|
|
34
|
+
private continuationSent = false;
|
|
35
|
+
private finished = false;
|
|
36
|
+
private everAttached = false;
|
|
37
|
+
private readonly seen = new Set<string>();
|
|
38
|
+
private readonly calls = new Map<string, Call>();
|
|
39
|
+
private callBytes = 0;
|
|
40
|
+
private readonly queue: Submission[] = [];
|
|
41
|
+
private queueBytes = 0;
|
|
42
|
+
private inFlight?: Submission;
|
|
43
|
+
private lastAckSequence = -1;
|
|
44
|
+
private ackTimer?: ReturnType<typeof setTimeout>;
|
|
45
|
+
private idleTimer?: ReturnType<typeof setTimeout>;
|
|
46
|
+
private readonly settings = new Map<string, string>();
|
|
47
|
+
private readonly lane: unknown;
|
|
48
|
+
|
|
49
|
+
/** Pin the original settings and lane; construction never opens a connection. */
|
|
50
|
+
constructor(initial: Frame, private readonly idleMs = 300_000,
|
|
51
|
+
private readonly deadlines = { ackMs: NATIVE_INJECTION_ACK_MS, toolMs: NATIVE_INJECTION_TOOL_MS }) {
|
|
52
|
+
if (!isInjectionRequest(initial)) injectionError("injection_not_supported", "Native injection requires explicit multi_agent.enabled.");
|
|
53
|
+
this.lane = initial.stream_id ?? undefined;
|
|
54
|
+
for (const [key, value] of Object.entries(initial)) if (!ENVELOPE.has(key)) this.settings.set(key, injectionFingerprint(value));
|
|
55
|
+
}
|
|
56
|
+
/** Report that the real dispatch boundary has selected this owner. */
|
|
57
|
+
get attached(): boolean { return this.everAttached; }
|
|
58
|
+
/** A terminal is not final until submitted results have acknowledgements. */
|
|
59
|
+
get ended(): boolean { return this.finished; }
|
|
60
|
+
|
|
61
|
+
/** Attach once, after routing/auth/admission, retaining no global response-ID lookup. */
|
|
62
|
+
attach(send: (frame: Frame) => void, fail: (error: Error) => void): () => void {
|
|
63
|
+
if (this.everAttached) throw new Error("Native injection transport is already owned.");
|
|
64
|
+
this.replay = this.replayFactory?.();
|
|
65
|
+
this.send = send; this.onFailure = fail; this.everAttached = true;
|
|
66
|
+
return () => {
|
|
67
|
+
if (this.send !== send) return;
|
|
68
|
+
this.send = undefined; this.onFailure = undefined; this.finished = true;
|
|
69
|
+
clearTimeout(this.ackTimer); clearTimeout(this.idleTimer);
|
|
70
|
+
this.correlation?.finish(); this.calls.clear(); this.seen.clear(); this.queue.length = 0;
|
|
71
|
+
this.inFlight = undefined; this.queueBytes = 0; this.callBytes = 0; this.terminal = undefined;
|
|
72
|
+
this.replay?.dispose(); this.replay = undefined;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Never reinterpret user steering as a function result, or silently mix beta modes. */
|
|
76
|
+
steer(_frame: Frame): never {
|
|
77
|
+
return injectionError("native_control_mode_mismatch", "This multi-agent turn owns an injection-only channel; start a separate turn for steering.");
|
|
78
|
+
}
|
|
79
|
+
/** Abort unknown-delivery state without HTTP fallback, resends or invented acceptance. */
|
|
80
|
+
private fail(): void {
|
|
81
|
+
this.finished = true;
|
|
82
|
+
clearTimeout(this.ackTimer); clearTimeout(this.idleTimer);
|
|
83
|
+
this.onFailure?.(new Error("Native injection transport failed or timed out; delivery is unknown. Do not automatically resend or rerun tools."));
|
|
84
|
+
}
|
|
85
|
+
/** Require the same live owner; an unbound or detached channel cannot authorize a send. */
|
|
86
|
+
private live(): void {
|
|
87
|
+
if (!this.send || this.finished) injectionError("injection_not_supported", "No live native injection transport is available on this route.");
|
|
88
|
+
}
|
|
89
|
+
/** Advertise client-owned function/custom calls and approvals, never hosted execution. */
|
|
90
|
+
private advertise(item: unknown): void {
|
|
91
|
+
const requirement = nativeToolRequirement(item);
|
|
92
|
+
if (!requirement) return;
|
|
93
|
+
const old = this.calls.get(requirement.key);
|
|
94
|
+
if (old) {
|
|
95
|
+
if (old.requirement.identity !== requirement.identity) throw new Error("Native result call identity was reused.");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const bytes = Buffer.byteLength(JSON.stringify(requirement));
|
|
99
|
+
if (this.calls.size >= MAX_NATIVE_INJECTION_CALLS || this.callBytes + bytes > 256 * 1024) throw new Error("Native injection call budget exceeded.");
|
|
100
|
+
this.calls.set(requirement.key, { requirement, state: "available" }); this.callBytes += bytes;
|
|
101
|
+
}
|
|
102
|
+
/** Queue validated saved results; reserve each call before any possibly synchronous send. */
|
|
103
|
+
inject(frame: Frame): void {
|
|
104
|
+
this.live();
|
|
105
|
+
if (frame.type !== "response.inject" || !injectionId(frame.response_id)
|
|
106
|
+
|| Object.keys(frame).some(key => !["type", "response_id", "input", "stream_id"].includes(key))) {
|
|
107
|
+
injectionError("invalid_injection", "Invalid native injection envelope.");
|
|
108
|
+
}
|
|
109
|
+
if (frame.response_id !== this.currentId || (frame.stream_id ?? undefined) !== this.lane || this.continuationSent) {
|
|
110
|
+
injectionError("injection_response_mismatch", "Injection must target the current response and lane on this connection.");
|
|
111
|
+
}
|
|
112
|
+
const results = injectionResults(frame.input);
|
|
113
|
+
for (const item of results) {
|
|
114
|
+
const call = this.calls.get(nativeResultKey(item));
|
|
115
|
+
if (!call || !nativeResultMatches(item, call.requirement)) injectionError("injection_call_not_found", "The result does not match a completed function call on this connection.");
|
|
116
|
+
if (call.state !== "available") injectionError("duplicate_injection", "This function result was already submitted; do not replay it.");
|
|
117
|
+
}
|
|
118
|
+
const text = JSON.stringify(frame);
|
|
119
|
+
const bytes = Buffer.byteLength(text);
|
|
120
|
+
if (this.queue.length >= MAX_NATIVE_INJECTIONS || bytes + this.queueBytes > MAX_NATIVE_INJECTION_BYTES) {
|
|
121
|
+
injectionError("injection_queue_full", "Native injection queue count or byte limit reached; no result was sent.");
|
|
122
|
+
}
|
|
123
|
+
// Detach from caller-owned objects before keeping data across asynchronous callbacks.
|
|
124
|
+
const copy = JSON.parse(text) as Frame;
|
|
125
|
+
const submission = { frame: copy, results: copy.input as FunctionResult[], bytes };
|
|
126
|
+
for (const item of results) this.calls.get(nativeResultKey(item))!.state = "queued";
|
|
127
|
+
this.queue.push(submission); this.queueBytes += bytes;
|
|
128
|
+
this.pump();
|
|
129
|
+
}
|
|
130
|
+
/** Dispatch one queued frame; unrelated output cannot reset its acknowledgement deadline. */
|
|
131
|
+
private pump(): void {
|
|
132
|
+
if (this.inFlight || !this.queue.length || this.finished) return;
|
|
133
|
+
this.live();
|
|
134
|
+
const submission = this.queue[0];
|
|
135
|
+
this.inFlight = submission;
|
|
136
|
+
this.ackTimer = setTimeout(() => this.fail(), this.deadlines.ackMs);
|
|
137
|
+
this.ackTimer.unref?.();
|
|
138
|
+
try {
|
|
139
|
+
this.replay?.submitted(submission.frame);
|
|
140
|
+
this.send!(submission.frame);
|
|
141
|
+
} catch {
|
|
142
|
+
this.fail();
|
|
143
|
+
injectionError("injection_delivery_unknown", "Injection dispatch failed; do not automatically resend or rerun tools.");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Match an acknowledgement to the sole in-flight frame, before releasing the next send. */
|
|
147
|
+
private acknowledge(event: Frame): void {
|
|
148
|
+
const pending = this.inFlight;
|
|
149
|
+
if (!pending || event.response_id !== this.currentId || !Number.isSafeInteger(event.sequence_number)
|
|
150
|
+
|| (event.sequence_number as number) <= this.lastAckSequence) throw new Error("Native injection acknowledgement identity mismatch.");
|
|
151
|
+
const failed = event.type === "response.inject.failed";
|
|
152
|
+
if (failed && (!record(event.error) || typeof event.error.code !== "string"
|
|
153
|
+
|| injectionFingerprint(event.input) !== injectionFingerprint(pending.results))) throw new Error("Native injection rejection does not match submitted results.");
|
|
154
|
+
this.replay?.observe(event);
|
|
155
|
+
this.lastAckSequence = event.sequence_number as number;
|
|
156
|
+
for (const item of pending.results) {
|
|
157
|
+
const call = this.calls.get(nativeResultKey(item))!;
|
|
158
|
+
call.state = failed ? "failed" : "accepted";
|
|
159
|
+
// Retain a digest, not another result body, for an explicitly rejected continuation.
|
|
160
|
+
call.recoverable = failed && record(event.error) && event.error.code === "response_already_completed";
|
|
161
|
+
if (call.recoverable) call.result = nativeResultFingerprint(item);
|
|
162
|
+
}
|
|
163
|
+
clearTimeout(this.ackTimer); this.ackTimer = undefined;
|
|
164
|
+
this.inFlight = undefined; this.queue.shift(); this.queueBytes -= pending.bytes;
|
|
165
|
+
// Do not let a synchronous fake peer publish the next ack before this event is relayed.
|
|
166
|
+
if (this.queue.length) queueMicrotask(() => { try { this.pump(); } catch { this.fail(); } });
|
|
167
|
+
}
|
|
168
|
+
/** Commit terminal replay only when no submitted injection can change its accepted inputs. */
|
|
169
|
+
private recordTerminal(): void {
|
|
170
|
+
if (this.terminal && !this.terminalRecorded) { this.replay?.observe(this.terminal); this.terminalRecorded = true; }
|
|
171
|
+
}
|
|
172
|
+
/** Recover saved, explicitly rejected results on the same socket only when the client asks. */
|
|
173
|
+
continue(frame: Frame): boolean {
|
|
174
|
+
if (!this.send || this.finished) return false;
|
|
175
|
+
if (this.queue.length || this.continuationSent) injectionError("injection_pending", "Wait for every injection acknowledgement before creating another response.");
|
|
176
|
+
if (!this.terminal && frame.previous_response_id === this.currentId) {
|
|
177
|
+
injectionError("injection_pending", "Wait for the response terminal before sending saved-result continuations.");
|
|
178
|
+
}
|
|
179
|
+
if (!this.terminal || frame.previous_response_id !== this.currentId) return false;
|
|
180
|
+
if (this.terminal.type !== "response.completed") injectionError("injection_response_failed", "The parent response did not complete successfully.");
|
|
181
|
+
if ((frame.stream_id ?? undefined) !== this.lane || frame.generate === false) injectionError("invalid_injection", "Use the same lane for an injection continuation.");
|
|
182
|
+
for (const [key, value] of Object.entries(frame)) {
|
|
183
|
+
if (!ENVELOPE.has(key) && this.settings.get(key) !== injectionFingerprint(value)) injectionError("injection_settings_changed", "A native injection continuation cannot change the pinned model or settings.");
|
|
184
|
+
}
|
|
185
|
+
for (const key of this.settings.keys()) {
|
|
186
|
+
if (!Object.hasOwn(frame, key)) injectionError("injection_settings_changed", "A native injection continuation cannot change the pinned model or settings.");
|
|
187
|
+
}
|
|
188
|
+
const results = nativeSavedResults(frame.input);
|
|
189
|
+
const required = [...this.calls.entries()].filter(([, call]) => call.state !== "accepted");
|
|
190
|
+
if (!required.length || results.length !== required.length) injectionError("invalid_injection", "Supply every outstanding saved tool result exactly once.");
|
|
191
|
+
for (const item of results) {
|
|
192
|
+
const call = this.calls.get(nativeResultKey(item));
|
|
193
|
+
if (!call || !nativeResultMatches(item, call.requirement) || call.state === "accepted" || call.state === "queued"
|
|
194
|
+
|| (call.state === "failed" && (!call.recoverable || call.result !== nativeResultFingerprint(item)))) {
|
|
195
|
+
injectionError("invalid_injection", "Continuation input must match unsent or explicitly completion-rejected tool results.");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (Buffer.byteLength(JSON.stringify(frame)) > MAX_NATIVE_INJECTION_BYTES) injectionError("invalid_injection", "Native injection continuation exceeds its byte limit.");
|
|
199
|
+
this.continuationSent = true;
|
|
200
|
+
try { this.recordTerminal(); const copy = JSON.parse(JSON.stringify(frame)) as Frame; this.replay?.submitted(copy); this.send(copy); }
|
|
201
|
+
catch { this.fail(); injectionError("injection_delivery_unknown", "Continuation delivery is unknown; do not automatically resend results."); }
|
|
202
|
+
if (!this.finished) this.armIdle(this.deadlines.ackMs);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
/** Manage response/tool liveness independently of the non-resettable acknowledgement timer. */
|
|
206
|
+
private armIdle(ms: number): void {
|
|
207
|
+
clearTimeout(this.idleTimer);
|
|
208
|
+
this.idleTimer = setTimeout(() => this.fail(), ms); this.idleTimer.unref?.();
|
|
209
|
+
}
|
|
210
|
+
/** Validate ordered upstream events while allowing acknowledgements after a response terminal. */
|
|
211
|
+
observe(event: Frame): boolean {
|
|
212
|
+
if ((event.stream_id ?? undefined) !== this.lane && !(event.type === "error" && event.stream_id == null)) throw new Error("Native injection lane mismatch.");
|
|
213
|
+
const type = event.type;
|
|
214
|
+
if (type === "error") { this.finished = true; return true; }
|
|
215
|
+
if (type === "response.inject.created" || type === "response.inject.failed") this.acknowledge(event);
|
|
216
|
+
else {
|
|
217
|
+
if (typeof type === "string" && (type.startsWith("response.inject.") || type.startsWith("response.steer."))) throw new Error("Unsupported native injection control event.");
|
|
218
|
+
const response = record(event.response) ? event.response : undefined;
|
|
219
|
+
if (type === "response.created") {
|
|
220
|
+
if (!injectionId(response?.id) || this.seen.has(response.id) || this.seen.size >= 128) throw new Error("Native injection response identity or chain limit violated.");
|
|
221
|
+
if (this.currentId && (!this.continuationSent || !this.terminal || this.queue.length
|
|
222
|
+
|| (response.previous_response_id != null && response.previous_response_id !== this.currentId))) throw new Error("Unexpected native injection successor.");
|
|
223
|
+
this.currentId = response.id; this.seen.add(response.id); this.calls.clear(); this.callBytes = 0;
|
|
224
|
+
this.terminal = undefined; this.terminalRecorded = false; this.continuationSent = false; this.lastAckSequence = -1;
|
|
225
|
+
this.correlation?.finish(); this.correlation = new CodexWsCorrelation(true, () => false);
|
|
226
|
+
} else if (!this.currentId || this.terminal) throw new Error("Unexpected native injection event outside an active response.");
|
|
227
|
+
this.correlation?.accept({ ...event, stream_id: undefined });
|
|
228
|
+
if (type === "response.output_item.done") this.advertise(event.item);
|
|
229
|
+
if (["response.completed", "response.failed", "response.incomplete"].includes(String(type))) {
|
|
230
|
+
if (!this.currentId || response?.id !== this.currentId) throw new Error("Native injection terminal identity mismatch.");
|
|
231
|
+
this.terminal = event;
|
|
232
|
+
if (Array.isArray(response.output)) for (const item of response.output) this.advertise(item);
|
|
233
|
+
} else this.replay?.observe(event);
|
|
234
|
+
}
|
|
235
|
+
const unresolved = [...this.calls.values()].some(call => call.state !== "accepted");
|
|
236
|
+
this.finished = Boolean(this.terminal && !this.queue.length && !this.continuationSent
|
|
237
|
+
&& (this.terminal.type !== "response.completed" || !unresolved));
|
|
238
|
+
if (this.finished) { this.recordTerminal(); clearTimeout(this.idleTimer); }
|
|
239
|
+
else this.armIdle(this.continuationSent ? this.deadlines.ackMs : unresolved ? this.deadlines.toolMs : this.idleMs);
|
|
240
|
+
return this.finished;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { OcxProviderConfig } from "../../types";
|
|
2
|
+
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
3
|
+
import type { NativeSteeringReplayObserver } from "./native-steering-replay";
|
|
4
|
+
|
|
5
|
+
import { isInjectionRequest } from "./native-injection-protocol";
|
|
6
|
+
|
|
7
|
+
/** Shared transport ownership, not a shared steer/inject protocol state machine. */
|
|
8
|
+
export interface NativeResponseControl {
|
|
9
|
+
readonly kind?: "steering" | "injection";
|
|
10
|
+
relayActive: boolean;
|
|
11
|
+
normalizeContinuation?: (frame: Record<string, unknown>) => Record<string, unknown>;
|
|
12
|
+
replayFactory?: () => NativeSteeringReplayObserver;
|
|
13
|
+
readonly attached: boolean;
|
|
14
|
+
readonly ended: boolean;
|
|
15
|
+
attach(send: (frame: Record<string, unknown>) => void, fail: (error: Error) => void): () => void;
|
|
16
|
+
observe(frame: Record<string, unknown>): boolean;
|
|
17
|
+
steer(frame: Record<string, unknown>): void;
|
|
18
|
+
inject?(frame: Record<string, unknown>): void;
|
|
19
|
+
continue(frame: Record<string, unknown>): boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const OPENAI_API_RESPONSES_URL = "https://api.openai.com/v1/responses";
|
|
23
|
+
|
|
24
|
+
const nativeControlResponses = new WeakSet<Response>();
|
|
25
|
+
/** Mark the exact response for multi-response delivery without serializing a wire field. */
|
|
26
|
+
export function markNativeControlResponse(response: Response): Response { nativeControlResponses.add(response); return response; }
|
|
27
|
+
/** Recognize a marked native response by identity, not by caller-controlled content. */
|
|
28
|
+
export function isNativeControlResponse(response: Response): boolean { return nativeControlResponses.has(response); }
|
|
29
|
+
|
|
30
|
+
/** Preserve canonical ChatGPT eligibility; public API controls require an explicit provider WebSocket opt-in. */
|
|
31
|
+
export function nativeResponseControlEligible(provider: OcxProviderConfig, control?: NativeResponseControl): boolean {
|
|
32
|
+
if (isCanonicalOpenAiForwardProvider(provider)) return true;
|
|
33
|
+
return (control?.kind === "injection" || control?.kind === "steering") && provider.adapter === "openai-responses"
|
|
34
|
+
&& provider.upstreamWebsocket === true && provider.authMode !== "forward"
|
|
35
|
+
&& provider.baseUrl?.replace(/\/+$/, "") === "https://api.openai.com/v1";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Select by execution mode, never model name; a multi-agent request cannot acquire steering. */
|
|
39
|
+
export function nativeResponseControlMode(frame: Record<string, unknown>, flags: {
|
|
40
|
+
codexNativeInjection?: boolean; codexNativeSteering?: boolean;
|
|
41
|
+
}): "injection" | "steering" | undefined {
|
|
42
|
+
if (isInjectionRequest(frame)) return flags.codexNativeInjection === true ? "injection" : undefined;
|
|
43
|
+
return nativeSteeringUnavailableReason(frame, flags.codexNativeSteering) === undefined ? "steering" : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Explain documented execution-mode exclusions without claiming model entitlement. */
|
|
47
|
+
export function nativeSteeringUnavailableReason(frame: Record<string, unknown>, enabled?: boolean): string | undefined {
|
|
48
|
+
if (enabled !== true) return "Native steering is disabled; enable codexNativeSteering and WebSockets for a supported route.";
|
|
49
|
+
if (isInjectionRequest(frame)) return "Multi-agent execution does not support single-agent response.steer; use a later client request.";
|
|
50
|
+
if (frame.conversation != null) return "Conversation-bound responses do not support native steering.";
|
|
51
|
+
if (Array.isArray(frame.context_management) && frame.context_management.some(item =>
|
|
52
|
+
item && typeof item === "object" && (item as Record<string, unknown>).type === "compaction")) {
|
|
53
|
+
return "Automatic API compaction and native steering cannot share an active response.";
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Narrow JSON object envelopes independently of either native control owner. */
|
|
4
|
+
export function nativeResponseRecord(value: unknown): value is Record<string, unknown> {
|
|
5
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Compare JSON content by value: object-key order is irrelevant, array order is not. */
|
|
9
|
+
export function nativeResponseFingerprint(value: unknown): string {
|
|
10
|
+
const canonical = (item: unknown): string => Array.isArray(item) ? `[${item.map(canonical).join(",")}]`
|
|
11
|
+
: nativeResponseRecord(item) ? `{${Object.keys(item).sort().map(key => `${JSON.stringify(key)}:${canonical(item[key])}`).join(",")}}`
|
|
12
|
+
: JSON.stringify(item) ?? "null";
|
|
13
|
+
return createHash("sha256").update(canonical(value)).digest("hex");
|
|
14
|
+
}
|