@yansigit/opencodex 2.32.0 → 2.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/gui/dist/assets/index-CIDo4y4k.js +102 -0
- package/gui/dist/assets/index-DrSQdTRd.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +8 -5
- package/src/adapters/anthropic.ts +20 -6
- package/src/adapters/azure.ts +20 -4
- package/src/adapters/base.ts +3 -1
- package/src/adapters/command-code.ts +131 -17
- package/src/adapters/cursor/envelope-echo.ts +162 -0
- package/src/adapters/cursor/live-transport.ts +4 -2
- package/src/adapters/cursor/native-exec-fs.ts +13 -12
- package/src/adapters/cursor/native-exec-network.ts +3 -5
- package/src/adapters/cursor/native-exec-policy.ts +47 -0
- package/src/adapters/cursor/native-exec-shell.ts +13 -25
- package/src/adapters/cursor/native-exec.ts +18 -10
- package/src/adapters/cursor/protobuf-events.ts +186 -9
- package/src/adapters/cursor/protobuf-request.ts +53 -18
- package/src/adapters/cursor/request-builder.ts +11 -3
- package/src/adapters/cursor/tool-definitions.ts +49 -2
- package/src/adapters/cursor/tool-result-normalize.ts +21 -8
- package/src/adapters/cursor/types.ts +11 -3
- package/src/adapters/cursor.ts +123 -0
- package/src/adapters/google-aistudio-parser.ts +49 -0
- package/src/adapters/google-antigravity-replay.ts +2 -2
- package/src/adapters/google-antigravity-wire.ts +7 -0
- package/src/adapters/google-errors.ts +6 -2
- package/src/adapters/google-http.ts +30 -7
- package/src/adapters/google-truncation.ts +5 -0
- package/src/adapters/google-wire-compiler.ts +38 -6
- package/src/adapters/google.ts +242 -28
- package/src/adapters/kiro-tools.ts +20 -9
- package/src/adapters/openai-chat.ts +9 -0
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +112 -9
- package/src/chat/inbound.ts +15 -0
- package/src/claude/context-windows.ts +16 -9
- package/src/cli/doctor.ts +2 -2
- package/src/cli/index.ts +10 -3
- package/src/cli/provider.ts +6 -0
- package/src/cli/status.ts +23 -0
- package/src/codex/auth-api.ts +4 -2
- package/src/codex/autostart-health.ts +16 -0
- package/src/codex/catalog/aggregation.ts +12 -12
- package/src/codex/catalog/effort.ts +18 -3
- package/src/codex/catalog/metadata.ts +27 -1
- package/src/codex/catalog/model-metadata.ts +39 -12
- package/src/codex/catalog/parsing.ts +38 -27
- package/src/codex/catalog/provider-fetch.ts +232 -133
- package/src/codex/catalog/sync.ts +1 -1
- package/src/codex/convergence.ts +5 -0
- package/src/codex/shim.ts +56 -3
- package/src/config/provider-validation.ts +37 -0
- package/src/config.ts +78 -2
- package/src/generated/compatibility-version.json +187 -119
- package/src/generated/model-metadata.ts +3 -0
- package/src/images/loop.ts +37 -6
- package/src/lib/azure-identity.ts +154 -0
- package/src/lib/debug.ts +42 -0
- package/src/lib/errors.ts +14 -0
- package/src/lib/provider-outbound.ts +45 -33
- package/src/lib/provider-tls-profile.ts +309 -0
- package/src/lib/proxy-env.ts +49 -0
- package/src/lib/redact.ts +10 -1
- package/src/oauth/aistudio-native-daemon.ts +62 -0
- package/src/oauth/aistudio-session-sync.ts +95 -0
- package/src/oauth/antigravity-routing.ts +282 -236
- package/src/oauth/callback-server.ts +22 -2
- package/src/oauth/command-code.ts +5 -16
- package/src/oauth/google-aistudio-auth.ts +98 -0
- package/src/oauth/google-antigravity.ts +42 -5
- package/src/oauth/index.ts +15 -3
- package/src/oauth/key-providers.ts +8 -0
- package/src/oauth/kimi.ts +9 -1
- package/src/oauth/login-cli.ts +66 -1
- package/src/oauth/open-browser-choice.ts +26 -0
- package/src/oauth/store.ts +6 -0
- package/src/providers/antigravity-quota.ts +3 -1
- package/src/providers/api-keys.ts +2 -1
- package/src/providers/auto-compact-budget.ts +65 -0
- package/src/providers/derive.ts +5 -1
- package/src/providers/key-failover.ts +5 -1
- package/src/providers/openai-tiers.ts +5 -0
- package/src/providers/provider-id-rewrite.ts +1 -0
- package/src/providers/quota.ts +148 -50
- package/src/providers/registry.ts +27 -4
- package/src/providers/request-pacing.ts +33 -6
- package/src/providers/xai-transport.ts +21 -0
- package/src/responses/google-provider-options.ts +36 -0
- package/src/responses/namespace-tool-compat.ts +84 -4
- package/src/responses/parser.ts +11 -0
- package/src/responses/provider-opaque-metadata.ts +3 -3
- package/src/responses/schema.ts +37 -0
- package/src/responses/state.ts +94 -4
- package/src/router.ts +11 -2
- package/src/routing/account-pool/cooldown.ts +8 -0
- package/src/routing/account-pool/index.ts +1 -0
- package/src/server/aistudio-ws-hub.ts +295 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +2 -0
- package/src/server/images.ts +19 -35
- package/src/server/index.ts +94 -0
- package/src/server/management/agent-settings-routes.ts +205 -15
- package/src/server/management/combo-routes.ts +6 -0
- package/src/server/management/config-routes.ts +31 -5
- package/src/server/management/logs-usage-routes.ts +11 -5
- package/src/server/management/model-rows.ts +4 -0
- package/src/server/management/oauth-account-routes.ts +38 -7
- package/src/server/management/provider-routes.ts +113 -15
- package/src/server/management/routing-profile-routes.ts +3 -0
- package/src/server/port-reclaim.ts +19 -1
- package/src/server/request-log-conversation.ts +12 -0
- package/src/server/request-log.ts +23 -1
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/compact.ts +30 -1
- package/src/server/responses/core.ts +363 -156
- package/src/server/responses/empty-completion-guard.ts +35 -6
- package/src/server/responses/fetch-helpers.ts +18 -5
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/server/responses/v2-native-parent-override.ts +59 -0
- package/src/server/responses/ws-upstream.ts +75 -2
- package/src/server/responses-undeclared-tool-guard.ts +90 -8
- package/src/server/ws-bridge.ts +2 -1
- package/src/service.ts +1 -1
- package/src/smoke/fingerprint-cache.ts +133 -0
- package/src/smoke/live-scenarios.ts +33 -0
- package/src/smoke/runner.ts +119 -0
- package/src/types/config.ts +16 -1
- package/src/types/provider.ts +18 -1
- package/src/types/request.ts +30 -0
- package/src/types/tools.ts +51 -0
- package/src/types.ts +6 -0
- package/src/usage/command-code-manifest.ts +116 -0
- package/src/usage/cost.ts +2 -2
- package/src/usage/expected-prices.ts +83 -0
- package/src/usage/log.ts +2 -2
- package/src/usage/summary.ts +34 -12
- package/src/web-search/gemini-executor.ts +6 -4
- package/src/web-search/index.ts +16 -8
- package/src/web-search/loop.ts +42 -6
- package/gui/dist/assets/index-BG43zwVe.js +0 -102
- package/gui/dist/assets/index-CiSI-jrP.css +0 -1
|
@@ -153,9 +153,10 @@ export interface EmptyCompletionGuardOptions {
|
|
|
153
153
|
* Watch an adapter event stream for the empty-completion failure mode. Events
|
|
154
154
|
* are held until the turn produces content or ends: reasoning and other
|
|
155
155
|
* pre-content events stay buffered (released in order on first content), the
|
|
156
|
-
* terminal is withheld, and an empty terminal triggers one
|
|
157
|
-
* retry through `continuation`. Usage is merged across attempts
|
|
158
|
-
* and request log meter the whole turn, not just the attempt that
|
|
156
|
+
* terminal is withheld, and an empty terminal or pre-output EOF triggers one
|
|
157
|
+
* identical-turn retry through `continuation`. Usage is merged across attempts
|
|
158
|
+
* so the bridge and request log meter the whole turn, not just the attempt that
|
|
159
|
+
* succeeded.
|
|
159
160
|
*
|
|
160
161
|
* Heartbeats always pass through untouched: they feed the bridge's stall
|
|
161
162
|
* watchdog, so holding them behind the content gate would trip false
|
|
@@ -194,7 +195,11 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
194
195
|
if (sawContent || passthrough) {
|
|
195
196
|
// Buffered content is already flowing; everything downstream passes
|
|
196
197
|
// through. Every terminal carries usage merged across every attempt.
|
|
197
|
-
|
|
198
|
+
if (isTerminalEvent(event)) {
|
|
199
|
+
yield withUsage(event);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
yield event;
|
|
198
203
|
continue;
|
|
199
204
|
}
|
|
200
205
|
if (isContentEvent(event)) {
|
|
@@ -231,6 +236,13 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
231
236
|
return;
|
|
232
237
|
}
|
|
233
238
|
if (event.type === "error") {
|
|
239
|
+
// A local route/request validation failure from a continuation is already a complete,
|
|
240
|
+
// typed client error. Do not collapse it into the generic empty-completion retry failure.
|
|
241
|
+
if (event.status === 400 && event.errorType === "invalid_request_error") {
|
|
242
|
+
yield* releaseHeld();
|
|
243
|
+
yield withUsage(event);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
234
246
|
if (retries > 0 && event.status !== 499) {
|
|
235
247
|
// The retry failed upstream. Its body cannot reach the client (the
|
|
236
248
|
// 200 head went out with the first attempt), so state the failure in
|
|
@@ -267,8 +279,25 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
267
279
|
if (isReasoningEvent(event)) yield { type: "heartbeat" };
|
|
268
280
|
}
|
|
269
281
|
if (!terminalSeen) {
|
|
270
|
-
//
|
|
271
|
-
//
|
|
282
|
+
// A terminal-less EOF before text or a tool call is replay-safe: nothing
|
|
283
|
+
// actionable reached the client. Retry once, then surface a stated error
|
|
284
|
+
// instead of letting the bridge reduce the turn to adapter_eof.
|
|
285
|
+
if (!sawContent && !passthrough && retries < maxRetries) {
|
|
286
|
+
retries += 1;
|
|
287
|
+
try {
|
|
288
|
+
source = await options.continuation();
|
|
289
|
+
} catch {
|
|
290
|
+
yield emptyCompletionRetryFailedEvent(usage, true);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (!sawContent && retries > 0) {
|
|
296
|
+
yield emptyCompletionRetryFailedEvent(usage, true);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// Post-output EOF remains incomplete; replaying could duplicate text or
|
|
300
|
+
// executable tool calls.
|
|
272
301
|
yield* releaseHeld();
|
|
273
302
|
return;
|
|
274
303
|
}
|
|
@@ -9,6 +9,7 @@ import type { OcxProviderConfig } from "../../types";
|
|
|
9
9
|
import type { WsData } from "../ws-bridge";
|
|
10
10
|
import { waitForProviderRequestSlot } from "../../providers/request-pacing";
|
|
11
11
|
import { withUpstreamHttpVersion } from "../../lib/upstream-http-version";
|
|
12
|
+
import { providerTlsFetch } from "../../lib/provider-tls-profile";
|
|
12
13
|
|
|
13
14
|
export { withUpstreamHttpVersion };
|
|
14
15
|
|
|
@@ -64,14 +65,29 @@ export function providerFetch(
|
|
|
64
65
|
options: ProviderFetchOptions = {},
|
|
65
66
|
): ProviderFetch {
|
|
66
67
|
const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch;
|
|
68
|
+
const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
|
|
69
|
+
base.preconnect?.(...args);
|
|
70
|
+
};
|
|
71
|
+
const transport = options.providerName
|
|
72
|
+
? providerTlsFetch(options.providerName, provider, base)
|
|
73
|
+
: base;
|
|
74
|
+
const httpFetch = Object.assign(
|
|
75
|
+
(input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
|
|
76
|
+
transport(input, withUpstreamHttpVersion(input, init, provider)),
|
|
77
|
+
{ preconnect },
|
|
78
|
+
) as typeof globalThis.fetch;
|
|
67
79
|
// ChatGPT Codex backend: streaming turns ride the responses_websockets
|
|
68
80
|
// transport (measured ~3s faster TTFT than the SSE POST queue); everything
|
|
69
81
|
// else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
|
|
70
82
|
const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
|
|
71
83
|
if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime)) {
|
|
72
|
-
|
|
84
|
+
// The fallback has to be the same HTTP fetch the non-WS branch would have
|
|
85
|
+
// used, protocol pin included: a WS turn that falls back is serving the
|
|
86
|
+
// request over HTTP, and dropping the provider's `upstreamHttpVersion`
|
|
87
|
+
// there would silently negotiate a transport the operator ruled out.
|
|
88
|
+
return codexWsUpstreamFetch(input, init, httpFetch, runtime);
|
|
73
89
|
}
|
|
74
|
-
return
|
|
90
|
+
return httpFetch(input, init);
|
|
75
91
|
};
|
|
76
92
|
let pacingSlotAcquired = options.pacingSlotAcquired === true;
|
|
77
93
|
const waitForPacing = (signal?: AbortSignal) => {
|
|
@@ -87,9 +103,6 @@ export function providerFetch(
|
|
|
87
103
|
await waitForPacing(init?.signal ?? undefined);
|
|
88
104
|
return unpaced(input, init);
|
|
89
105
|
};
|
|
90
|
-
const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
|
|
91
|
-
base.preconnect?.(...args);
|
|
92
|
-
};
|
|
93
106
|
return Object.assign(wrapped, {
|
|
94
107
|
preconnect,
|
|
95
108
|
waitForPacing,
|
|
@@ -89,7 +89,7 @@ function finishFailedPolicyAttempt(logCtx: RequestLogContext, status: number): v
|
|
|
89
89
|
const attempt = logCtx.activeAttempt;
|
|
90
90
|
if (attempt) {
|
|
91
91
|
const startedAt = logCtx.activeAttemptStartedAt ?? Date.now();
|
|
92
|
-
finishRequestAttempt(attempt, status, Math.max(0, Date.now() - startedAt), attempt.usage ?? logCtx.usage);
|
|
92
|
+
finishRequestAttempt(attempt, status, Math.max(0, Date.now() - startedAt), attempt.usage ?? logCtx.usage, logCtx.upstreamError);
|
|
93
93
|
}
|
|
94
94
|
delete logCtx.activeAttempt;
|
|
95
95
|
delete logCtx.activeAttemptStartedAt;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { OcxConfig, OcxParsedRequest } from "../../types";
|
|
2
|
+
import { routeModel, type RouteResult } from "../../router";
|
|
3
|
+
import type { PolicyRequestEvidence } from "../../routing/evaluator";
|
|
4
|
+
import { isMultiAgentV2Enabled } from "../../codex/features";
|
|
5
|
+
import { collabSurface } from "./collaboration";
|
|
6
|
+
import { isThreadSpawnRequest } from "../effort-policy";
|
|
7
|
+
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
8
|
+
|
|
9
|
+
export type V2NativeParentOverrideDecision =
|
|
10
|
+
| { kind: "skip" }
|
|
11
|
+
| { kind: "reject"; message: string; trace?: unknown }
|
|
12
|
+
| { kind: "override"; route: RouteResult };
|
|
13
|
+
|
|
14
|
+
export function decideV2NativeParentOverride(args: {
|
|
15
|
+
kind: "responses" | "compact";
|
|
16
|
+
config: OcxConfig;
|
|
17
|
+
headers: Headers;
|
|
18
|
+
sourceRoute: RouteResult;
|
|
19
|
+
parsed?: OcxParsedRequest;
|
|
20
|
+
comboAttempt?: boolean;
|
|
21
|
+
targetEvidence?: PolicyRequestEvidence;
|
|
22
|
+
}): V2NativeParentOverrideDecision {
|
|
23
|
+
const { config, headers, sourceRoute } = args;
|
|
24
|
+
if (
|
|
25
|
+
args.comboAttempt
|
|
26
|
+
|| config.v2NativeParentOverride?.enabled !== true
|
|
27
|
+
|| config.multiAgentMode !== "v2"
|
|
28
|
+
|| config.keepNativeChatGptOnV1 === true
|
|
29
|
+
|| !isMultiAgentV2Enabled()
|
|
30
|
+
) return { kind: "skip" };
|
|
31
|
+
if (args.kind === "responses" && (
|
|
32
|
+
!args.parsed
|
|
33
|
+
|| (collabSurface(args.parsed) !== "v2"
|
|
34
|
+
&& !(args.parsed._compactionRequest === true && config.multiAgentMode === "v2"))
|
|
35
|
+
)) {
|
|
36
|
+
return { kind: "skip" };
|
|
37
|
+
}
|
|
38
|
+
if (isThreadSpawnRequest(headers) || headers.has("x-openai-subagent")) return { kind: "skip" };
|
|
39
|
+
if (!isCanonicalOpenAiForwardProvider(sourceRoute.provider)) return { kind: "skip" };
|
|
40
|
+
|
|
41
|
+
const targetModel = config.v2NativeParentOverride.model?.trim();
|
|
42
|
+
if (!targetModel) {
|
|
43
|
+
return { kind: "reject", message: "v2NativeParentOverride requires a configured model" };
|
|
44
|
+
}
|
|
45
|
+
let route: RouteResult;
|
|
46
|
+
try {
|
|
47
|
+
route = routeModel(config, targetModel, args.targetEvidence);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return {
|
|
50
|
+
kind: "reject",
|
|
51
|
+
message: error instanceof Error ? error.message : String(error),
|
|
52
|
+
trace: (error as { trace?: unknown }).trace,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (isCanonicalOpenAiForwardProvider(route.provider)) {
|
|
56
|
+
return { kind: "reject", message: "v2NativeParentOverride target must resolve to a noncanonical provider" };
|
|
57
|
+
}
|
|
58
|
+
return { kind: "override", route };
|
|
59
|
+
}
|
|
@@ -28,6 +28,25 @@ const UPGRADE_DEADLINE_MS = 10_000;
|
|
|
28
28
|
export const MAX_CODEX_WS_FRAME_BYTES = MAX_CLIENT_SSE_FRAME_BYTES;
|
|
29
29
|
export const MAX_CODEX_WS_QUEUE_BYTES = 8 * 1024 * 1024;
|
|
30
30
|
export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0";
|
|
31
|
+
// The backend drops any inbound message of 16 MiB or more: it closes the socket
|
|
32
|
+
// (1009) without a Responses terminal event, which reaches clients as a bare
|
|
33
|
+
// 502 upstream_server_error. Measured against the live endpoint 2026-08-23:
|
|
34
|
+
// 16,777,000 B completed, 16,777,300 B closed in ~1s, every time. The same
|
|
35
|
+
// request body succeeds over HTTP SSE, so the ceiling belongs to this transport
|
|
36
|
+
// alone (see #2426). A full-replay thread reaches it with ~11 pasted
|
|
37
|
+
// screenshots, and then never recovers, because each retry resends the frame.
|
|
38
|
+
export const MAX_CODEX_WS_CREATE_FRAME_BYTES = 16 * 1024 * 1024;
|
|
39
|
+
// Bun frames the payload it is handed, so the send-side budget is the JSON text
|
|
40
|
+
// itself, and nothing is appended between the check and the send. The margin is
|
|
41
|
+
// a conservative cushion, not a computed requirement: it covers RFC 6455 frame
|
|
42
|
+
// overhead in case the backend counts it (14 bytes at this payload size — an
|
|
43
|
+
// 8-byte extended length plus a 4-byte client mask, leaving ~65.5 KiB spare),
|
|
44
|
+
// and it leaves room for a future caller that appends to the frame.
|
|
45
|
+
const CODEX_WS_CREATE_FRAME_MARGIN_BYTES = 64 * 1024;
|
|
46
|
+
export const CODEX_WS_CREATE_FRAME_LIMIT_BYTES =
|
|
47
|
+
MAX_CODEX_WS_CREATE_FRAME_BYTES - CODEX_WS_CREATE_FRAME_MARGIN_BYTES;
|
|
48
|
+
/** Close code the backend uses for an oversized message (RFC 6455 "message too big"). */
|
|
49
|
+
const WS_CLOSE_MESSAGE_TOO_BIG = 1009;
|
|
31
50
|
|
|
32
51
|
export type BunRuntimeIdentity = {
|
|
33
52
|
version: string;
|
|
@@ -102,6 +121,52 @@ export function shouldUseCodexWsUpstream(
|
|
|
102
121
|
}
|
|
103
122
|
}
|
|
104
123
|
|
|
124
|
+
const CLOSED_BEFORE_TERMINAL = "codex websocket closed before a Responses terminal event";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The close code is the only thing that separates "the backend refused this
|
|
128
|
+
* payload" from "the network dropped", and both used to reach the caller as the
|
|
129
|
+
* same bare 502. Naming the oversized case here puts that distinction in the
|
|
130
|
+
* message the client receives.
|
|
131
|
+
*
|
|
132
|
+
* It does NOT reach the request log as a typed code. The eager relay turns any
|
|
133
|
+
* stream error into a generic `upstream_reset` synthetic terminal
|
|
134
|
+
* (`relay.ts`, `relay-eager.ts`) without feeding that frame back through the
|
|
135
|
+
* inspector, so `/api/logs` keeps neither this message nor a specific code —
|
|
136
|
+
* only `streamAborted`. Machine-readable typing would mean changing the error
|
|
137
|
+
* taxonomy, which is deliberately out of scope for this transport fix.
|
|
138
|
+
*/
|
|
139
|
+
function closedBeforeTerminalMessage(event: unknown): string {
|
|
140
|
+
const detail = event as { code?: unknown; reason?: unknown } | null | undefined;
|
|
141
|
+
const code = typeof detail?.code === "number" ? detail.code : null;
|
|
142
|
+
const reason = typeof detail?.reason === "string" ? detail.reason.trim() : "";
|
|
143
|
+
if (code === null) return CLOSED_BEFORE_TERMINAL;
|
|
144
|
+
const suffix = reason ? ` ${code} ${reason}` : ` ${code}`;
|
|
145
|
+
if (code === WS_CLOSE_MESSAGE_TOO_BIG) {
|
|
146
|
+
return `codex websocket rejected the request frame as too large (close${suffix});`
|
|
147
|
+
+ ` requests at or above ${MAX_CODEX_WS_CREATE_FRAME_BYTES} bytes must use the HTTP SSE transport`;
|
|
148
|
+
}
|
|
149
|
+
return `${CLOSED_BEFORE_TERMINAL} (close${suffix})`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* True when the `response.create` frame is at or above the backend's inbound
|
|
154
|
+
* message ceiling, so this turn must take the HTTP SSE path instead.
|
|
155
|
+
*
|
|
156
|
+
* Sizing a 16 MiB string should not cost a 16 MiB copy. UTF-8 never encodes
|
|
157
|
+
* below one byte per UTF-16 code unit and never above three, so both tails are
|
|
158
|
+
* settled from the string length alone; only the narrow band between them pays
|
|
159
|
+
* for a real byte count, and `Buffer.byteLength` measures without allocating.
|
|
160
|
+
*/
|
|
161
|
+
export function codexWsCreateFrameExceedsLimit(
|
|
162
|
+
frameText: string,
|
|
163
|
+
limitBytes: number = CODEX_WS_CREATE_FRAME_LIMIT_BYTES,
|
|
164
|
+
): boolean {
|
|
165
|
+
if (frameText.length >= limitBytes) return true;
|
|
166
|
+
if (frameText.length * 3 < limitBytes) return false;
|
|
167
|
+
return Buffer.byteLength(frameText, "utf8") >= limitBytes;
|
|
168
|
+
}
|
|
169
|
+
|
|
105
170
|
export function codexWsUpstreamFetch(
|
|
106
171
|
url: string,
|
|
107
172
|
init: RequestInit,
|
|
@@ -127,6 +192,14 @@ export function codexWsUpstreamFetch(
|
|
|
127
192
|
return sseFallback(url, init);
|
|
128
193
|
}
|
|
129
194
|
|
|
195
|
+
// Decide before dialing. Once the socket is open the caller already holds a
|
|
196
|
+
// streaming Response, so the oversized close can only be surfaced as a stream
|
|
197
|
+
// error — and a resend at that point could double-generate. Measuring the
|
|
198
|
+
// frame we are about to send keeps the whole failure mode unreachable.
|
|
199
|
+
if (codexWsCreateFrameExceedsLimit(frameText)) {
|
|
200
|
+
return sseFallback(url, init);
|
|
201
|
+
}
|
|
202
|
+
|
|
130
203
|
const headers: Record<string, string> = {};
|
|
131
204
|
new Headers(init.headers ?? {}).forEach((value, key) => {
|
|
132
205
|
// HTTP-body framing headers do not apply to a WS handshake.
|
|
@@ -279,7 +352,7 @@ export function codexWsUpstreamFetch(
|
|
|
279
352
|
}
|
|
280
353
|
});
|
|
281
354
|
|
|
282
|
-
ws.addEventListener("close", () => {
|
|
355
|
+
ws.addEventListener("close", (event: unknown) => {
|
|
283
356
|
signal?.removeEventListener("abort", onAbort);
|
|
284
357
|
if (!opened) {
|
|
285
358
|
if (settledPreOpen) return;
|
|
@@ -297,7 +370,7 @@ export function codexWsUpstreamFetch(
|
|
|
297
370
|
// here would reach clients with no response.completed/failed at all —
|
|
298
371
|
// relaySseWithFailedTail() only synthesizes a failed terminal when the
|
|
299
372
|
// body read THROWS. Error the stream like a reset TCP socket.
|
|
300
|
-
try { controller.error(new Error(
|
|
373
|
+
try { controller.error(new Error(closedBeforeTerminalMessage(event))); } catch { /* stream already done */ }
|
|
301
374
|
}
|
|
302
375
|
});
|
|
303
376
|
|
|
@@ -1,9 +1,31 @@
|
|
|
1
|
-
import { namespacedToolName } from "../types";
|
|
1
|
+
import { namespacedToolName, normalizeDeclaredToolName } from "../types";
|
|
2
2
|
import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
|
|
3
3
|
|
|
4
4
|
/** Item types the client executes through a request-declared wire name. */
|
|
5
5
|
const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]);
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Hosted declarations whose response items the PROVIDER executes, keyed by the request
|
|
9
|
+
* declaration type. These need no client answer, so their names are deliberately absent from
|
|
10
|
+
* the request catalog and must not be read as an undeclared client tool.
|
|
11
|
+
*
|
|
12
|
+
* xAI surfaces hosted `x_search` as `custom_tool_call`. Probed 2026-08-23 against the OAuth CLI
|
|
13
|
+
* destination: its hosted calls use an `xs_call-` call-id prefix. Observed call names were
|
|
14
|
+
* `x_keyword_search`, `x_semantic_search`, and `x_user_search` — three literals for one tool,
|
|
15
|
+
* which is why authorization keys on the declaration, item type, and call-id prefix, never on
|
|
16
|
+
* the name.
|
|
17
|
+
*/
|
|
18
|
+
export type ProviderExecutedCallType = Readonly<{
|
|
19
|
+
itemType: string;
|
|
20
|
+
callIdPrefix: string;
|
|
21
|
+
}>;
|
|
22
|
+
|
|
23
|
+
type ProviderExecutedCallTypes = ReadonlySet<ProviderExecutedCallType>;
|
|
24
|
+
|
|
25
|
+
export const PROVIDER_EXECUTED_DECLARATION_CALL_TYPES = new Map<string, ProviderExecutedCallType>([
|
|
26
|
+
["x_search", { itemType: "custom_tool_call", callIdPrefix: "xs_call-" }],
|
|
27
|
+
]);
|
|
28
|
+
|
|
7
29
|
/** Nameless declaration kinds whose response items still require client execution. */
|
|
8
30
|
const NAMELESS_CLIENT_DECLARATION_CALL_TYPES = new Map([
|
|
9
31
|
["local_shell", "local_shell_call"],
|
|
@@ -19,6 +41,7 @@ const NAMELESS_CLIENT_CALL_DISPLAY_NAMES = new Map([
|
|
|
19
41
|
]);
|
|
20
42
|
|
|
21
43
|
const EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES: ReadonlySet<string> = new Set();
|
|
44
|
+
const EMPTY_PROVIDER_EXECUTED_CALL_TYPES: ReadonlySet<ProviderExecutedCallType> = new Set();
|
|
22
45
|
|
|
23
46
|
/** Supported hosted/private declarations that carry no client-executable wire name. */
|
|
24
47
|
const NAMELESS_TOOL_SPEC_TYPES = new Set([
|
|
@@ -127,6 +150,53 @@ function addNamelessClientCallTypes(callTypes: Set<string>, specs: unknown): voi
|
|
|
127
150
|
}
|
|
128
151
|
}
|
|
129
152
|
|
|
153
|
+
function addProviderExecutedCallTypes(
|
|
154
|
+
callTypes: Set<ProviderExecutedCallType>,
|
|
155
|
+
specs: unknown,
|
|
156
|
+
): void {
|
|
157
|
+
if (!Array.isArray(specs)) return;
|
|
158
|
+
for (const spec of specs) {
|
|
159
|
+
if (!isPlainObject(spec) || typeof spec.type !== "string") continue;
|
|
160
|
+
const callType = PROVIDER_EXECUTED_DECLARATION_CALL_TYPES.get(spec.type);
|
|
161
|
+
if (callType) callTypes.add(callType);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Item types this turn's hosted declarations authorize the PROVIDER to emit unnamed.
|
|
167
|
+
*
|
|
168
|
+
* Caller must gate this on the destination actually being that provider; a declaration alone
|
|
169
|
+
* is not authority, or any upstream could claim a hosted shape it never serves.
|
|
170
|
+
*/
|
|
171
|
+
export function collectProviderExecutedCallTypes(body: unknown): Set<ProviderExecutedCallType> {
|
|
172
|
+
const callTypes = new Set<ProviderExecutedCallType>();
|
|
173
|
+
if (!isPlainObject(body)) return callTypes;
|
|
174
|
+
addProviderExecutedCallTypes(callTypes, body.tools);
|
|
175
|
+
if (Array.isArray(body.input)) {
|
|
176
|
+
for (const item of body.input) {
|
|
177
|
+
if (
|
|
178
|
+
isPlainObject(item)
|
|
179
|
+
&& (item.type === "additional_tools" || item.type === "tool_search_output")
|
|
180
|
+
) addProviderExecutedCallTypes(callTypes, item.tools);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return callTypes;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isAuthorizedProviderExecutedCall(
|
|
187
|
+
item: Record<string, unknown>,
|
|
188
|
+
callTypes: ProviderExecutedCallTypes,
|
|
189
|
+
): boolean {
|
|
190
|
+
if (typeof item.call_id !== "string") return false;
|
|
191
|
+
for (const callType of callTypes) {
|
|
192
|
+
if (
|
|
193
|
+
item.type === callType.itemType
|
|
194
|
+
&& item.call_id.startsWith(callType.callIdPrefix)
|
|
195
|
+
) return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
130
200
|
/** Nameless client-call item types authorized by supported request tool declarations. */
|
|
131
201
|
export function collectDeclaredNamelessClientCallTypes(body: unknown): Set<string> {
|
|
132
202
|
const callTypes = new Set<string>();
|
|
@@ -190,9 +260,14 @@ function undeclaredNameInItem(
|
|
|
190
260
|
item: unknown,
|
|
191
261
|
declared: ReadonlySet<string>,
|
|
192
262
|
declaredNamelessClientCallTypes: ReadonlySet<string>,
|
|
263
|
+
providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
|
|
193
264
|
): string | undefined {
|
|
194
265
|
if (!isPlainObject(item)) return undefined;
|
|
195
266
|
if (typeof item.type !== "string") return undefined;
|
|
267
|
+
// The provider executes this exact measured shape itself, so there is no client name to
|
|
268
|
+
// authorize. The caller supplies these signatures only for the matching destination and
|
|
269
|
+
// declarations; the item must additionally carry the hosted call-id prefix.
|
|
270
|
+
if (isAuthorizedProviderExecutedCall(item, providerExecutedCallTypes)) return undefined;
|
|
196
271
|
const namelessDisplayName = NAMELESS_CLIENT_CALL_DISPLAY_NAMES.get(item.type);
|
|
197
272
|
if (namelessDisplayName !== undefined) {
|
|
198
273
|
// Only Codex's explicit `execution: "client"` form delegates tool search to the client.
|
|
@@ -202,10 +277,14 @@ function undeclaredNameInItem(
|
|
|
202
277
|
if (!CLIENT_EXECUTED_CALL_TYPES.has(item.type)) return undefined;
|
|
203
278
|
const name = item.name;
|
|
204
279
|
if (typeof name !== "string" || name.length === 0) return undefined;
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
280
|
+
if (typeof item.namespace === "string") {
|
|
281
|
+
// Namespaced calls are matched by their full wire name only — never legacy-normalize
|
|
282
|
+
// them, or an undeclared namespaced `exec_command` could slip through as bare `exec`.
|
|
283
|
+
if (declared.has(namespacedToolName(item.namespace, name))) return undefined;
|
|
284
|
+
return name;
|
|
208
285
|
}
|
|
286
|
+
const effectiveName = normalizeDeclaredToolName(name, declared);
|
|
287
|
+
if (declared.has(effectiveName)) return undefined;
|
|
209
288
|
return name;
|
|
210
289
|
}
|
|
211
290
|
|
|
@@ -214,14 +293,15 @@ export function undeclaredToolCallName(
|
|
|
214
293
|
payload: unknown,
|
|
215
294
|
declared: ReadonlySet<string>,
|
|
216
295
|
declaredNamelessClientCallTypes: ReadonlySet<string> = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES,
|
|
296
|
+
providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
|
|
217
297
|
): string | undefined {
|
|
218
298
|
if (!isPlainObject(payload)) return undefined;
|
|
219
299
|
if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") {
|
|
220
|
-
return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes);
|
|
300
|
+
return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes);
|
|
221
301
|
}
|
|
222
302
|
// Sparse gateways skip incremental items and only ever ship the terminal snapshot.
|
|
223
303
|
if (payload.type === "response.completed" || payload.type === "response.incomplete") {
|
|
224
|
-
return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes);
|
|
304
|
+
return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes);
|
|
225
305
|
}
|
|
226
306
|
return undefined;
|
|
227
307
|
}
|
|
@@ -231,10 +311,11 @@ export function undeclaredToolCallNameInResponse(
|
|
|
231
311
|
response: unknown,
|
|
232
312
|
declared: ReadonlySet<string>,
|
|
233
313
|
declaredNamelessClientCallTypes: ReadonlySet<string> = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES,
|
|
314
|
+
providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
|
|
234
315
|
): string | undefined {
|
|
235
316
|
if (!isPlainObject(response) || !Array.isArray(response.output)) return undefined;
|
|
236
317
|
for (const item of response.output) {
|
|
237
|
-
const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes);
|
|
318
|
+
const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes);
|
|
238
319
|
if (name !== undefined) return name;
|
|
239
320
|
}
|
|
240
321
|
return undefined;
|
|
@@ -274,6 +355,7 @@ function failedBlocks(name: string, newline: string): readonly string[] {
|
|
|
274
355
|
export function createUndeclaredToolCallGuardBlockRewrite(
|
|
275
356
|
declared: ReadonlySet<string>,
|
|
276
357
|
declaredNamelessClientCallTypes: ReadonlySet<string> = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES,
|
|
358
|
+
providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
|
|
277
359
|
): SseBlockRewrite {
|
|
278
360
|
let tripped = false;
|
|
279
361
|
return (block: string) => {
|
|
@@ -286,7 +368,7 @@ export function createUndeclaredToolCallGuardBlockRewrite(
|
|
|
286
368
|
} catch {
|
|
287
369
|
return [block];
|
|
288
370
|
}
|
|
289
|
-
const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes);
|
|
371
|
+
const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes);
|
|
290
372
|
if (name === undefined) return [block];
|
|
291
373
|
tripped = true;
|
|
292
374
|
return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n");
|
package/src/server/ws-bridge.ts
CHANGED
|
@@ -35,7 +35,8 @@ export interface WsData {
|
|
|
35
35
|
cancel?: () => void; // cancels the in-flight stream reader/fetch
|
|
36
36
|
turnId?: number; // monotonically increasing per socket; prevents stale frames after replacement turns
|
|
37
37
|
/** Discriminator: Responses reframing vs transparent live/realtime sideband relay. */
|
|
38
|
-
kind?: "responses" | "live-sideband";
|
|
38
|
+
kind?: "responses" | "live-sideband" | "aistudio-relay";
|
|
39
|
+
aistudioSessionId?: string;
|
|
39
40
|
liveUpstream?: WebSocket;
|
|
40
41
|
liveUpstreamUrl?: string;
|
|
41
42
|
liveUpstreamHeaders?: Record<string, string>;
|
package/src/service.ts
CHANGED
|
@@ -555,7 +555,7 @@ export function installedServiceListenPort(): number {
|
|
|
555
555
|
?? resolveServiceListenPort();
|
|
556
556
|
}
|
|
557
557
|
|
|
558
|
-
export const SERVICE_INSTALL_HEALTH_MS =
|
|
558
|
+
export const SERVICE_INSTALL_HEALTH_MS = Number(process.env.OCX_SERVICE_HEALTH_TIMEOUT_MS) || 30_000;
|
|
559
559
|
|
|
560
560
|
/**
|
|
561
561
|
* Whether a proxy actually answers on the port this install/start just produced.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
export interface SmokeCacheEntry {
|
|
7
|
+
fingerprint: string;
|
|
8
|
+
timestamp: number;
|
|
9
|
+
status: "passed" | "skipped" | "failed";
|
|
10
|
+
reason?: string;
|
|
11
|
+
modelsTested?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SmokeCacheData {
|
|
15
|
+
version: number;
|
|
16
|
+
providers: Record<string, SmokeCacheEntry>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CACHE_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
export function defaultSmokeCachePath(): string {
|
|
22
|
+
return join(homedir(), ".opencodex", "live-inference-cache.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function defaultCachePath(customPath?: string): string {
|
|
26
|
+
return customPath ?? defaultSmokeCachePath();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function emptyCache(): SmokeCacheData {
|
|
30
|
+
return { version: CACHE_VERSION, providers: {} };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function walkTs(root: string, relDir: string): string[] {
|
|
34
|
+
const paths: string[] = [];
|
|
35
|
+
const fullDir = join(root, relDir);
|
|
36
|
+
if (!existsSync(fullDir)) return paths;
|
|
37
|
+
for (const entry of readdirSync(fullDir, { withFileTypes: true })) {
|
|
38
|
+
const relPath = join(relDir, entry.name);
|
|
39
|
+
if (entry.isDirectory()) {
|
|
40
|
+
paths.push(...walkTs(root, relPath));
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (entry.isFile() && entry.name.endsWith(".ts")) {
|
|
44
|
+
paths.push(relPath);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return paths;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function collectProviderSourcePaths(provider: string, root: string): string[] {
|
|
51
|
+
const smokePaths = ["src/smoke/runner.ts", "src/smoke/live-scenarios.ts"];
|
|
52
|
+
let paths: string[];
|
|
53
|
+
switch (provider) {
|
|
54
|
+
case "google":
|
|
55
|
+
case "google-antigravity":
|
|
56
|
+
case "google-aistudio":
|
|
57
|
+
paths = [...smokePaths, "src/adapters/google.ts", "src/adapters/google-wire-compiler.ts", "src/adapters/google-tool-schema.ts", "src/adapters/google-antigravity-wire.ts", "src/types/tools.ts", "src/server/responses-undeclared-tool-guard.ts"];
|
|
58
|
+
break;
|
|
59
|
+
case "cursor":
|
|
60
|
+
paths = [...smokePaths, "src/adapters/cursor.ts", ...walkTs(root, "src/adapters/cursor")];
|
|
61
|
+
break;
|
|
62
|
+
case "command-code":
|
|
63
|
+
paths = [...smokePaths, "src/adapters/command-code.ts", "src/adapters/command-code-project-context.ts", "src/providers/command-code-efforts.ts"];
|
|
64
|
+
break;
|
|
65
|
+
case "openai":
|
|
66
|
+
case "openai-responses":
|
|
67
|
+
paths = [...smokePaths, "src/adapters/openai-responses.ts", "src/adapters/responses-tool-schema.ts"];
|
|
68
|
+
break;
|
|
69
|
+
default:
|
|
70
|
+
paths = [...smokePaths, `src/adapters/${provider}.ts`];
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
return [...new Set(paths)].filter(path => existsSync(join(root, path))).sort();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function computeProviderSourceFingerprint(
|
|
77
|
+
provider: string,
|
|
78
|
+
projectRoot?: string,
|
|
79
|
+
): Promise<string> {
|
|
80
|
+
const root = resolve(projectRoot ?? process.cwd());
|
|
81
|
+
const hash = createHash("sha256");
|
|
82
|
+
const paths = collectProviderSourcePaths(provider, root);
|
|
83
|
+
if (paths.length === 0) return hash.update(`empty:${provider}`, "utf8").digest("hex");
|
|
84
|
+
for (const relPath of paths) {
|
|
85
|
+
hash.update(`${relPath}\0`, "utf8");
|
|
86
|
+
hash.update(readFileSync(join(root, relPath)));
|
|
87
|
+
hash.update("\0");
|
|
88
|
+
}
|
|
89
|
+
return hash.digest("hex");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function loadSmokeCache(customPath?: string): Promise<SmokeCacheData> {
|
|
93
|
+
const cachePath = defaultCachePath(customPath);
|
|
94
|
+
if (!existsSync(cachePath)) return emptyCache();
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(readFileSync(cachePath, "utf8")) as SmokeCacheData;
|
|
97
|
+
if (typeof parsed !== "object" || parsed === null) return emptyCache();
|
|
98
|
+
return {
|
|
99
|
+
version: typeof parsed.version === "number" ? parsed.version : CACHE_VERSION,
|
|
100
|
+
providers: typeof parsed.providers === "object" && parsed.providers !== null ? parsed.providers : {},
|
|
101
|
+
};
|
|
102
|
+
} catch {
|
|
103
|
+
return emptyCache();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function saveSmokeCache(data: SmokeCacheData, customPath?: string): Promise<void> {
|
|
108
|
+
const cachePath = defaultCachePath(customPath);
|
|
109
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
110
|
+
writeFileSync(cachePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function shouldRunSmokeForProvider(
|
|
114
|
+
provider: string,
|
|
115
|
+
currentFingerprint: string,
|
|
116
|
+
options?: { force?: boolean; cache?: SmokeCacheData },
|
|
117
|
+
): boolean {
|
|
118
|
+
if (options?.force) return true;
|
|
119
|
+
const entry = options?.cache?.providers[provider];
|
|
120
|
+
if (!entry) return true;
|
|
121
|
+
if (entry.fingerprint !== currentFingerprint) return true;
|
|
122
|
+
return entry.status !== "passed";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function recordSmokeResult(
|
|
126
|
+
provider: string,
|
|
127
|
+
entry: SmokeCacheEntry,
|
|
128
|
+
customPath?: string,
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
const cache = await loadSmokeCache(customPath);
|
|
131
|
+
cache.providers[provider] = entry;
|
|
132
|
+
await saveSmokeCache(cache, customPath);
|
|
133
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type SmokeLevel = 1 | 2 | 3;
|
|
2
|
+
|
|
3
|
+
export interface SmokeScenario {
|
|
4
|
+
level: SmokeLevel;
|
|
5
|
+
name: string;
|
|
6
|
+
buildRequest: (modelId: string, options?: { previousResponseId?: string; toolResult?: string }) => Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildSmokeScenarioRequest(
|
|
10
|
+
level: SmokeLevel,
|
|
11
|
+
modelId: string,
|
|
12
|
+
options: { previousResponseId?: string; toolCallId?: string; toolResult?: string } = {},
|
|
13
|
+
): Record<string, unknown> {
|
|
14
|
+
const common = { model: modelId, stream: true };
|
|
15
|
+
if (level === 1) return {
|
|
16
|
+
...common,
|
|
17
|
+
input: [{ role: "user", content: [{ type: "input_text", text: "Reply with exactly five words and include a brief reasoning process." }] }],
|
|
18
|
+
};
|
|
19
|
+
if (level === 2) return {
|
|
20
|
+
...common,
|
|
21
|
+
input: [{ role: "user", content: [{ type: "input_text", text: "Use the provided exec_command tool to run echo \"smoke_test_123\"." }] }],
|
|
22
|
+
tools: [{ type: "function", name: "exec_command", description: "Execute a shell command.", parameters: { type: "object", properties: { cmd: { type: "string" } }, required: ["cmd"] } }],
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
...common,
|
|
26
|
+
...(options.previousResponseId ? { previous_response_id: options.previousResponseId } : {}),
|
|
27
|
+
input: [{ type: "function_call_output", call_id: options.toolCallId ?? "smoke_call", output: options.toolResult ?? "smoke_test_123" }],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const BasicStreamingTurn: SmokeScenario = { level: 1, name: "BasicStreamingTurn", buildRequest: (model, options) => buildSmokeScenarioRequest(1, model, options) };
|
|
32
|
+
export const ToolCallingTurn: SmokeScenario = { level: 2, name: "ToolCallingTurn", buildRequest: (model, options) => buildSmokeScenarioRequest(2, model, options) };
|
|
33
|
+
export const MultiTurnContinuity: SmokeScenario = { level: 3, name: "MultiTurnContinuity", buildRequest: (model, options) => buildSmokeScenarioRequest(3, model, options) };
|