@bitkyc08/opencodex 2.89.0 → 2.90.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/App-DgxkE6Md.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +48 -6
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +8 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/generated/compatibility-version.json +168 -108
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/providers/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +6 -2
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- package/src/server/chat-native.ts +8 -7
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +2 -0
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +3 -0
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/service/orchestration.ts +17 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
package/src/providers/quota.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { listCodexAuthAccountsSnapshot } from "../codex/auth-api";
|
|
2
2
|
import { isAnthropicInstanceId, configuredAnthropicInstance } from "./anthropic-instance";
|
|
3
3
|
import { fetchAnthropicAccountQuota } from "./quota/anthropic-account-quota";
|
|
4
|
+
import { thrownQuotaFailure } from "./quota/failure-codes";
|
|
4
5
|
import { resolveEnvValue } from "../config";
|
|
5
6
|
import { getAccountCredential, getAccountSet } from "../oauth/store";
|
|
6
7
|
import { apiKeyPoolEntryId } from "./api-keys";
|
|
@@ -515,7 +516,10 @@ async function fetchAccountQuota(
|
|
|
515
516
|
}
|
|
516
517
|
return entry;
|
|
517
518
|
} catch (error) {
|
|
518
|
-
|
|
519
|
+
// The probe itself reports HTTP and transport failures as results; what throws here is the
|
|
520
|
+
// credential side (missing/paused account, failed refresh, no project). A refresh that hit
|
|
521
|
+
// its deadline is a timeout to retry, not an account the operator must re-sign.
|
|
522
|
+
if (provider === "google-antigravity") quotaFailure = thrownQuotaFailure(error) === "timeout" ? "timeout" : "account_unavailable";
|
|
519
523
|
return unavailable();
|
|
520
524
|
}
|
|
521
525
|
})().finally(() => {
|
|
@@ -1020,7 +1020,12 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
|
|
|
1020
1020
|
},
|
|
1021
1021
|
},
|
|
1022
1022
|
// FREEZE 2026-07-10: codestral-latest is unconfirmed behind auth. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
|
|
1023
|
-
{
|
|
1023
|
+
{
|
|
1024
|
+
id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key",
|
|
1025
|
+
dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest", promptCacheKey: true,
|
|
1026
|
+
// Native text-only input enrolls this model in the existing vision sidecar.
|
|
1027
|
+
modelInputModalities: { "zai-glm-5-3": ["text"] },
|
|
1028
|
+
},
|
|
1024
1029
|
{
|
|
1025
1030
|
id: "minimax", label: "MiniMax — Coding Plan", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key",
|
|
1026
1031
|
dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS,
|
|
@@ -134,7 +134,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
|
|
|
134
134
|
const repairLegacyMimoFreeAuth = providerName === "mimo-free"
|
|
135
135
|
&& staticModelCatalog
|
|
136
136
|
&& (provider.authMode === undefined || provider.authMode === "local");
|
|
137
|
-
const explicitKeyOverride = providerUsesKeyAuthOverride(registryEntry, provider
|
|
137
|
+
const explicitKeyOverride = providerUsesKeyAuthOverride(registryEntry, provider);
|
|
138
138
|
const canonicalAuthMode = explicitKeyOverride
|
|
139
139
|
? "key"
|
|
140
140
|
: repairLegacyMimoFreeAuth
|
|
@@ -3,6 +3,7 @@ import type { OcxProviderConfig } from "../types";
|
|
|
3
3
|
import { isEgressTransparentExecutor, markEgressTransparentExecutor } from "../lib/provider-egress";
|
|
4
4
|
import { configuredOutboundFetch } from "../lib/proxy-env";
|
|
5
5
|
import { resolveGithubCopilotTransport } from "./github-copilot-transport";
|
|
6
|
+
import { resolveMistralTransport } from "./mistral-transport";
|
|
6
7
|
|
|
7
8
|
export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
|
|
8
9
|
|
|
@@ -121,7 +122,7 @@ export function resolveProviderTransport(
|
|
|
121
122
|
if (providerName === "github-copilot") {
|
|
122
123
|
return resolveGithubCopilotTransport(provider, apiBaseUrl);
|
|
123
124
|
}
|
|
124
|
-
if (providerName !== "xai") return provider;
|
|
125
|
+
if (providerName !== "xai") return resolveMistralTransport(provider);
|
|
125
126
|
|
|
126
127
|
const cacheKey = promptCacheKey?.trim();
|
|
127
128
|
const affinity = cacheKey ? deriveXaiConvId(cacheKey) : undefined;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { OcxAudioContent } from "../types";
|
|
2
|
+
|
|
3
|
+
export type ChatAudioFormat = OcxAudioContent["format"];
|
|
4
|
+
|
|
5
|
+
const MIME_FORMATS: Readonly<Record<string, ChatAudioFormat>> = {
|
|
6
|
+
"audio/mp3": "mp3",
|
|
7
|
+
"audio/mpeg": "mp3",
|
|
8
|
+
"audio/wav": "wav",
|
|
9
|
+
"audio/wave": "wav",
|
|
10
|
+
"audio/x-wav": "wav",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function normalizedFormat(value: unknown): ChatAudioFormat | undefined {
|
|
14
|
+
if (typeof value !== "string") return undefined;
|
|
15
|
+
const format = value.trim().toLowerCase();
|
|
16
|
+
return format === "mp3" || format === "wav" ? format : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function validBase64(value: string): boolean {
|
|
20
|
+
return value.length > 0
|
|
21
|
+
&& value.length % 4 === 0
|
|
22
|
+
&& /^[A-Za-z0-9+/]+={0,2}$/.test(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse the inline audio shape Codex puts in a Responses content block. Chat Completions
|
|
27
|
+
* needs bare base64 plus `wav`/`mp3`, while Codex supplies an RFC 2397 data URL. A raw
|
|
28
|
+
* base64 value with an explicit supported format is accepted as well; remote URLs are not.
|
|
29
|
+
*/
|
|
30
|
+
export function audioContentFromRaw(audioUrl: unknown, declaredFormat?: unknown): OcxAudioContent | undefined {
|
|
31
|
+
if (typeof audioUrl !== "string" || audioUrl.length === 0) return undefined;
|
|
32
|
+
const dataUrl = /^data:([^;,]+);base64,([\s\S]*)$/.exec(audioUrl);
|
|
33
|
+
let data: string;
|
|
34
|
+
let format: ChatAudioFormat | undefined;
|
|
35
|
+
if (dataUrl) {
|
|
36
|
+
const declared = normalizedFormat(declaredFormat);
|
|
37
|
+
const mime = dataUrl[1].trim().toLowerCase();
|
|
38
|
+
format = MIME_FORMATS[mime] ?? (mime === "application/octet-stream" ? declared : undefined);
|
|
39
|
+
data = dataUrl[2].replace(/\s+/g, "");
|
|
40
|
+
} else {
|
|
41
|
+
format = normalizedFormat(declaredFormat);
|
|
42
|
+
data = audioUrl.replace(/\s+/g, "");
|
|
43
|
+
}
|
|
44
|
+
if (!format || !validBase64(data)) return undefined;
|
|
45
|
+
return { type: "audio", data, format, text: `[audio: ${format}]` };
|
|
46
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isInlineDocumentDataUrl } from "./inline-document";
|
|
2
|
+
import { audioContentFromRaw } from "./audio";
|
|
2
3
|
|
|
3
4
|
/** Input kinds for which the normalized request has no lossless content carrier. */
|
|
4
5
|
export type UntranslatedInputMedia = "audio" | "file";
|
|
@@ -10,23 +11,35 @@ function isRecord(value: unknown): value is RecordValue {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
* Whether the position being scanned has a converter that builds a
|
|
14
|
+
* Whether the position being scanned has a converter that builds a lossless media part.
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* User content carries documents and inline audio; developer content carries documents only.
|
|
17
|
+
* Tool output carries inline audio through the Chat adapter's follow-up user message. System and
|
|
18
|
+
* assistant content have no lossless translated media carrier, so exempting an attachment there
|
|
19
|
+
* would turn today's explicit refusal into the silent drop this scanner exists to prevent.
|
|
18
20
|
*/
|
|
19
|
-
type CarrierPosition = "user-content" | "flattened";
|
|
21
|
+
type CarrierPosition = "user-content" | "developer-content" | "tool-output" | "flattened";
|
|
20
22
|
|
|
21
|
-
function mediaKind(value: unknown, position: CarrierPosition): UntranslatedInputMedia | undefined {
|
|
23
|
+
function mediaKind(value: unknown, position: CarrierPosition, allowAudio: boolean): UntranslatedInputMedia | undefined {
|
|
22
24
|
if (!isRecord(value)) return undefined;
|
|
23
|
-
if (value.type === "input_audio" || value.type === "audio")
|
|
25
|
+
if (value.type === "input_audio" || value.type === "audio") {
|
|
26
|
+
// A Chat content part has a nested input_audio object, while Codex's Responses
|
|
27
|
+
// block uses audio_url. Only the latter is carried by the translated Chat adapter; the
|
|
28
|
+
// Chat-shaped `audio` spelling has no parser path and must never pass the guard.
|
|
29
|
+
return value.type === "input_audio"
|
|
30
|
+
&& allowAudio
|
|
31
|
+
&& (position === "user-content" || position === "tool-output")
|
|
32
|
+
&& audioContentFromRaw(value.audio_url, value.format)
|
|
33
|
+
? undefined
|
|
34
|
+
: "audio";
|
|
35
|
+
}
|
|
24
36
|
if (value.type === "input_file" || value.type === "file" || value.type === "document") {
|
|
25
37
|
// A document that carries its own bytes has a lossless carrier in user content, so refusing
|
|
26
38
|
// it there would reject the very request #5212 exists to preserve. A reference with no
|
|
27
39
|
// payload has none anywhere: translated adapters cannot dereference a file_id or a remote
|
|
28
40
|
// source.
|
|
29
|
-
return position === "user-content"
|
|
41
|
+
return (position === "user-content" || position === "developer-content")
|
|
42
|
+
&& carriesInlineDocumentBytes(value) ? undefined : "file";
|
|
30
43
|
}
|
|
31
44
|
// A file-id-only image is not pixels: translated adapters cannot dereference it.
|
|
32
45
|
if (value.type === "input_image" && typeof value.file_id === "string" && value.file_id.length > 0
|
|
@@ -46,10 +59,10 @@ function carriesInlineDocumentBytes(value: RecordValue): boolean {
|
|
|
46
59
|
&& value.source.data.length > 0;
|
|
47
60
|
}
|
|
48
61
|
|
|
49
|
-
function contentMedia(content: unknown, position: CarrierPosition): UntranslatedInputMedia | undefined {
|
|
62
|
+
function contentMedia(content: unknown, position: CarrierPosition, allowAudio: boolean): UntranslatedInputMedia | undefined {
|
|
50
63
|
if (!Array.isArray(content)) return undefined;
|
|
51
64
|
for (const part of content) {
|
|
52
|
-
const kind = mediaKind(part, position);
|
|
65
|
+
const kind = mediaKind(part, position, allowAudio);
|
|
53
66
|
if (kind) return kind;
|
|
54
67
|
}
|
|
55
68
|
return undefined;
|
|
@@ -60,20 +73,29 @@ function contentMedia(content: unknown, position: CarrierPosition): Untranslated
|
|
|
60
73
|
* arguments, schema properties, or arbitrary nested objects. No payload is copied,
|
|
61
74
|
* decoded, fetched or included in the returned value.
|
|
62
75
|
*/
|
|
63
|
-
export function untranslatedResponsesInputMedia(
|
|
76
|
+
export function untranslatedResponsesInputMedia(
|
|
77
|
+
body: unknown,
|
|
78
|
+
options: { allowAudio?: boolean } = {},
|
|
79
|
+
): UntranslatedInputMedia | undefined {
|
|
64
80
|
if (!isRecord(body) || !Array.isArray(body.input)) return undefined;
|
|
65
81
|
for (const item of body.input) {
|
|
66
82
|
if (!isRecord(item)) continue;
|
|
67
|
-
const direct = mediaKind(item, "flattened");
|
|
83
|
+
const direct = mediaKind(item, "flattened", options.allowAudio === true);
|
|
68
84
|
if (direct) return direct;
|
|
69
85
|
if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
|
|
70
|
-
const kind = contentMedia(item.output, "
|
|
86
|
+
const kind = contentMedia(item.output, "tool-output", options.allowAudio === true);
|
|
71
87
|
if (kind) return kind;
|
|
72
88
|
} else if (item.type === "message" || item.type === undefined) {
|
|
73
89
|
// `inputContentParts` runs for user and developer messages only; a system message is
|
|
74
90
|
// flattened to text by the parser.
|
|
75
91
|
const role = item.role;
|
|
76
|
-
const kind = contentMedia(
|
|
92
|
+
const kind = contentMedia(
|
|
93
|
+
item.content,
|
|
94
|
+
role === "user" ? "user-content"
|
|
95
|
+
: role === "developer" ? "developer-content"
|
|
96
|
+
: "flattened",
|
|
97
|
+
options.allowAudio === true,
|
|
98
|
+
);
|
|
77
99
|
if (kind) return kind;
|
|
78
100
|
}
|
|
79
101
|
}
|
|
@@ -87,7 +109,7 @@ export function untranslatedChatInputMedia(body: unknown): UntranslatedInputMedi
|
|
|
87
109
|
if (!isRecord(message)) continue;
|
|
88
110
|
// Only the `user` branch of the Chat projection builds content blocks. `system`,
|
|
89
111
|
// `developer`, `assistant` and `tool` all reduce their content to a string.
|
|
90
|
-
const kind = contentMedia(message.content, message.role === "user" ? "user-content" : "flattened");
|
|
112
|
+
const kind = contentMedia(message.content, message.role === "user" ? "user-content" : "flattened", false);
|
|
91
113
|
if (kind) return kind;
|
|
92
114
|
}
|
|
93
115
|
return undefined;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OcxContentPart, OcxTextContent } from "../types";
|
|
2
|
+
import { audioContentFromRaw } from "./audio";
|
|
2
3
|
import { inlineDocumentFromDataUrl } from "./inline-document";
|
|
3
4
|
|
|
4
5
|
export function isObj(v: unknown): v is Record<string, unknown> {
|
|
@@ -65,23 +66,15 @@ export function inputContentParts(blocks: unknown): string | OcxContentPart[] {
|
|
|
65
66
|
const processing = nonEmptyString((block as { processing?: string }).processing);
|
|
66
67
|
if (videoUrl) parts.push({ type: "video", videoUrl, ...(processing ? { processing } : {}) });
|
|
67
68
|
} else if (block.type === "input_audio") {
|
|
68
|
-
// Upstream Codex sends input_audio with an audio_url (codex-rs
|
|
69
|
-
// protocol/src/models.rs). The IR has no audio carrier and no adapter consumes
|
|
70
|
-
// one, so this part used to vanish with no trace at all.
|
|
71
|
-
//
|
|
72
|
-
// This records PRESENCE only and is NOT audio support: never the payload, which
|
|
73
|
-
// is large base64 and would explode the token count, and never the URL, which
|
|
74
|
-
// can carry a signed token. This parser must stay non-throwing — the native
|
|
75
|
-
// Responses passthrough also runs through parseRequest before the adapter
|
|
76
|
-
// forwards _rawBody, so refusing here would regress legitimate raw passthrough.
|
|
77
|
-
//
|
|
78
|
-
// No adapter refuses audio at its wire today: by this point the part is already a
|
|
79
|
-
// text marker, so downstream adapters see text and continue. A typed unsupported-
|
|
80
|
-
// modality carrier that survives to final adapter dispatch is a separate, recorded
|
|
81
|
-
// residual — do not describe this branch as a refusal.
|
|
82
69
|
const b = block as { audio_url?: string; format?: string };
|
|
83
|
-
|
|
84
|
-
|
|
70
|
+
// Keep bytes only when they have a Chat-compatible inline form. Unsupported or
|
|
71
|
+
// remote references retain a marker for text-only wires; the final adapter guard
|
|
72
|
+
// refuses them rather than allowing a silent drop.
|
|
73
|
+
const audio = audioContentFromRaw(b.audio_url, b.format);
|
|
74
|
+
if (audio) {
|
|
75
|
+
parts.push(audio);
|
|
76
|
+
} else if (nonEmptyString(b.audio_url)) {
|
|
77
|
+
const format = safeAudioFormat(b.format);
|
|
85
78
|
parts.push({ type: "text", text: format ? `[audio: ${format}]` : "[audio]" });
|
|
86
79
|
}
|
|
87
80
|
} else if (block.type === "input_file") {
|
|
@@ -104,7 +97,7 @@ export function inputContentParts(blocks: unknown): string | OcxContentPart[] {
|
|
|
104
97
|
// fabricating a "[file: ...]" marker for an attachment that was never sent.
|
|
105
98
|
}
|
|
106
99
|
}
|
|
107
|
-
// Collapse to a plain string only for a single TEXT part;
|
|
100
|
+
// Collapse to a plain string only for a single TEXT part; media must stay structured.
|
|
108
101
|
if (parts.length === 1 && parts[0].type === "text") return parts[0].text;
|
|
109
102
|
return parts;
|
|
110
103
|
}
|
|
@@ -129,15 +122,15 @@ export function outputTextOf(blocks: unknown): OcxTextContent[] {
|
|
|
129
122
|
}
|
|
130
123
|
|
|
131
124
|
/**
|
|
132
|
-
* Tool-call output content. Preserves images (e.g. Codex `view_image` returns
|
|
133
|
-
*
|
|
134
|
-
* Never inlines an image_url as text (that would explode the token count).
|
|
125
|
+
* Tool-call output content. Preserves images (e.g. Codex `view_image` returns)
|
|
126
|
+
* and inline audio: returns content parts when either is present, else a plain joined string.
|
|
127
|
+
* Never inlines an image_url or audio payload as text (that would explode the token count).
|
|
135
128
|
*/
|
|
136
129
|
export function outputToToolResultContent(output: string | unknown[] | undefined): string | OcxContentPart[] {
|
|
137
130
|
if (typeof output === "string") return output;
|
|
138
131
|
if (!Array.isArray(output)) return "";
|
|
139
132
|
const parts: OcxContentPart[] = [];
|
|
140
|
-
let
|
|
133
|
+
let hasMedia = false;
|
|
141
134
|
for (const raw of output) {
|
|
142
135
|
if (!isObj(raw)) continue;
|
|
143
136
|
if (raw.type === "output_text" || raw.type === "text" || raw.type === "input_text") {
|
|
@@ -149,15 +142,17 @@ export function outputToToolResultContent(output: string | unknown[] | undefined
|
|
|
149
142
|
const fileId = nonEmptyString(raw.file_id);
|
|
150
143
|
if (imageUrl) {
|
|
151
144
|
parts.push({ type: "image", imageUrl, ...(typeof raw.detail === "string" ? { detail: normalizeImageDetail(raw.detail) } : {}) });
|
|
152
|
-
|
|
145
|
+
hasMedia = true;
|
|
153
146
|
} else if (fileId) {
|
|
154
147
|
parts.push({ type: "text", text: `[image: ${fileId}]` });
|
|
155
148
|
}
|
|
156
149
|
} else if (raw.type === "input_audio") {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
150
|
+
const audio = audioContentFromRaw(raw.audio_url, raw.format);
|
|
151
|
+
if (audio) {
|
|
152
|
+
parts.push(audio);
|
|
153
|
+
hasMedia = true;
|
|
154
|
+
} else if (nonEmptyString(raw.audio_url)) {
|
|
155
|
+
const format = safeAudioFormat(raw.format);
|
|
161
156
|
parts.push({ type: "text", text: format ? `[audio: ${format}]` : "[audio]" });
|
|
162
157
|
}
|
|
163
158
|
} else if (raw.type === "encrypted_content") {
|
|
@@ -165,7 +160,7 @@ export function outputToToolResultContent(output: string | unknown[] | undefined
|
|
|
165
160
|
parts.push({ type: "text", text: "[encrypted content omitted]" });
|
|
166
161
|
}
|
|
167
162
|
}
|
|
168
|
-
if (!
|
|
163
|
+
if (!hasMedia) return parts.map(p => (p.type === "text" ? p.text : "")).join("");
|
|
169
164
|
return parts;
|
|
170
165
|
}
|
|
171
166
|
|
|
@@ -4,14 +4,21 @@
|
|
|
4
4
|
* Provider ciphertext and signatures are never included in the portable retained items.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { Database } from "bun:sqlite";
|
|
7
8
|
import { expandRetainedCompactionInput } from "./retained-compaction";
|
|
8
|
-
import { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
9
|
+
import { chmodSync, closeSync, lstatSync, mkdirSync, openSync, opendirSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
import { getConfigDir } from "../config/paths";
|
|
12
|
+
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
13
|
+
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
10
14
|
import { estimateTokens } from "../lib/token-estimate";
|
|
11
15
|
import { decodeReasoningEnvelope } from "./reasoning-envelope";
|
|
12
16
|
|
|
13
17
|
export const DEFAULT_REASONING_RETENTION_PERCENT = 20;
|
|
14
18
|
export const DEFAULT_MAX_REASONING_TOKENS = 100_000;
|
|
19
|
+
/** Compiled-in aggregate ceiling for the on-disk reasoning archive directory. */
|
|
20
|
+
export const DEFAULT_ARCHIVE_MAX_BYTES = 512 * 1024 * 1024;
|
|
21
|
+
export const DEFAULT_ARCHIVE_MAX_FILES = 256;
|
|
15
22
|
|
|
16
23
|
export interface ReasoningRetentionOptions {
|
|
17
24
|
/** Directory that receives archived reasoning files. */
|
|
@@ -24,6 +31,10 @@ export interface ReasoningRetentionOptions {
|
|
|
24
31
|
maxTokens?: number;
|
|
25
32
|
/** Clock injection for tests. */
|
|
26
33
|
now?: number;
|
|
34
|
+
/** Aggregate archive byte ceiling. Defaults to 512 MiB; smaller values are a test seam. */
|
|
35
|
+
archiveMaxBytes?: number;
|
|
36
|
+
/** Aggregate archive file-count ceiling. Defaults to 256; smaller values are a test seam. */
|
|
37
|
+
archiveMaxFiles?: number;
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
export interface ReasoningArchive {
|
|
@@ -100,6 +111,183 @@ function retentionTokenCap(value: number | undefined): number {
|
|
|
100
111
|
: DEFAULT_MAX_REASONING_TOKENS;
|
|
101
112
|
}
|
|
102
113
|
|
|
114
|
+
/** Aggregate-capacity refusal: the archive directory is at or over its compiled-in ceiling. */
|
|
115
|
+
export class ReasoningArchiveQuotaError extends Error {
|
|
116
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
117
|
+
super(message, options);
|
|
118
|
+
this.name = "ReasoningArchiveQuotaError";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The sibling archive lock is held by another cooperating writer. */
|
|
123
|
+
export class ReasoningArchiveBusyError extends Error {
|
|
124
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
125
|
+
super(message, options);
|
|
126
|
+
this.name = "ReasoningArchiveBusyError";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Unsafe or uninspectable archive state; the attempt is refused without touching anything. */
|
|
131
|
+
export class ReasoningArchiveError extends Error {
|
|
132
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
133
|
+
super(message, options);
|
|
134
|
+
this.name = "ReasoningArchiveError";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const ARCHIVE_SEPARATOR = "\n\n---\n\n";
|
|
139
|
+
const ARCHIVE_SEPARATOR_BYTES = Buffer.byteLength(ARCHIVE_SEPARATOR, "utf8");
|
|
140
|
+
const ARCHIVE_LOCK_SIDECARS = ["-journal", "-wal", "-shm"] as const;
|
|
141
|
+
|
|
142
|
+
function archiveCap(value: number | undefined, fallback: number): number {
|
|
143
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** lstat that treats a vanished path as absent and refuses anything it cannot classify. */
|
|
147
|
+
function archiveLstat(path: string): ReturnType<typeof lstatSync> | undefined {
|
|
148
|
+
try {
|
|
149
|
+
return lstatSync(path);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
152
|
+
throw new ReasoningArchiveError(`reasoning archive path is not inspectable: ${path}`, { cause: error });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function assertRegularArchiveDirectory(archiveDir: string): void {
|
|
157
|
+
const entry = archiveLstat(archiveDir);
|
|
158
|
+
if (!entry?.isDirectory() || entry.isSymbolicLink()) {
|
|
159
|
+
throw new ReasoningArchiveError(`reasoning archive directory is not a regular directory: ${archiveDir}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function assertRegularArchiveLockPath(lockPath: string): void {
|
|
164
|
+
const entry = archiveLstat(lockPath);
|
|
165
|
+
if (entry !== undefined && (!entry.isFile() || entry.isSymbolicLink())) {
|
|
166
|
+
throw new ReasoningArchiveError(`reasoning archive lock path is not a regular file: ${lockPath}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Exact UTF-8 byte total of the would-be archive payload, computed before any joined string exists. */
|
|
171
|
+
function archivePayloadBytes(texts: string[]): number {
|
|
172
|
+
let bytes = texts.length > 0 ? (texts.length - 1) * ARCHIVE_SEPARATOR_BYTES : 0;
|
|
173
|
+
for (const text of texts) {
|
|
174
|
+
const next = bytes + Buffer.byteLength(text, "utf8");
|
|
175
|
+
if (!Number.isSafeInteger(next)) {
|
|
176
|
+
throw new ReasoningArchiveError("reasoning archive payload byte size is not representable");
|
|
177
|
+
}
|
|
178
|
+
bytes = next;
|
|
179
|
+
}
|
|
180
|
+
return bytes;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface ArchiveUsage {
|
|
184
|
+
files: number;
|
|
185
|
+
bytes: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Non-recursive accounting of every committed, remnant and foreign file. Anything that is not a
|
|
190
|
+
* plain regular file refuses closed; an entry vanishing mid-scan is already gone and skipped.
|
|
191
|
+
*/
|
|
192
|
+
function scanArchiveUsage(archiveDir: string, maxBytes: number, maxFiles: number): ArchiveUsage {
|
|
193
|
+
let directory: ReturnType<typeof opendirSync> | undefined;
|
|
194
|
+
let files = 0;
|
|
195
|
+
let bytes = 0;
|
|
196
|
+
let inspected = 0;
|
|
197
|
+
let failure: ReasoningArchiveError | undefined;
|
|
198
|
+
try {
|
|
199
|
+
// One entry per read: a legacy over-capacity directory must not first become an array
|
|
200
|
+
// or an unbounded read-ahead buffer on this synchronous request path.
|
|
201
|
+
directory = opendirSync(archiveDir, { bufferSize: 1 });
|
|
202
|
+
let entry;
|
|
203
|
+
while ((entry = directory.readSync()) !== null) {
|
|
204
|
+
inspected += 1;
|
|
205
|
+
if (!entry.isFile()) {
|
|
206
|
+
throw new ReasoningArchiveError(`reasoning archive entry is not a regular file: ${entry.name}`);
|
|
207
|
+
}
|
|
208
|
+
const stat = archiveLstat(join(archiveDir, entry.name));
|
|
209
|
+
if (stat === undefined) {
|
|
210
|
+
if (inspected >= maxFiles) throw new ReasoningArchiveError("reasoning archive scan changed beyond its inspection limit");
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
214
|
+
throw new ReasoningArchiveError(`reasoning archive entry is not a regular file: ${entry.name}`);
|
|
215
|
+
}
|
|
216
|
+
files += 1;
|
|
217
|
+
bytes += Number(stat.size);
|
|
218
|
+
if (files >= maxFiles || bytes > maxBytes) break; // refusal is already certain
|
|
219
|
+
if (inspected >= maxFiles) throw new ReasoningArchiveError("reasoning archive scan changed beyond its inspection limit");
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
failure = error instanceof ReasoningArchiveError ? error
|
|
223
|
+
: new ReasoningArchiveError("reasoning archive directory cannot be scanned", { cause: error });
|
|
224
|
+
} finally {
|
|
225
|
+
try { directory?.closeSync(); } catch (error) {
|
|
226
|
+
failure ??= new ReasoningArchiveError("reasoning archive directory could not be closed", { cause: error });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (failure !== undefined) throw failure;
|
|
230
|
+
return { files, bytes };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isSqliteBusy(error: unknown): boolean {
|
|
234
|
+
const code = error && typeof error === "object" && "code" in error
|
|
235
|
+
? String((error as { code?: unknown }).code)
|
|
236
|
+
: "";
|
|
237
|
+
const message = error instanceof Error ? error.message : "";
|
|
238
|
+
return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" || /database (?:is|table is) locked/i.test(message);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Serialize usage scan, exclusive create, write and close for one physical archive directory
|
|
243
|
+
* across cooperating processes. The sibling SQLite write transaction is the lock: the OS
|
|
244
|
+
* releases it on process exit, so no stale-owner reclamation is needed. busy_timeout=0 keeps
|
|
245
|
+
* the synchronous request path fail-fast instead of freezing the event loop.
|
|
246
|
+
*/
|
|
247
|
+
function withArchiveQuotaLockSync<T>(archiveDir: string, fn: () => T): T {
|
|
248
|
+
const normalized = archiveDir.replace(/[/\\]+$/, "");
|
|
249
|
+
const base = basename(normalized);
|
|
250
|
+
if (!base || base === "." || base === "..") {
|
|
251
|
+
throw new ReasoningArchiveError(`reasoning archive directory is not a usable path: ${archiveDir}`);
|
|
252
|
+
}
|
|
253
|
+
const parent = dirname(normalized);
|
|
254
|
+
assertNotRealHomeUnderTest(parent);
|
|
255
|
+
const lockPath = `${normalized}.lock`;
|
|
256
|
+
// The lock lives beside the archive inside the config home; foreign parents need no manifest entry.
|
|
257
|
+
if (parent === getConfigDir()) {
|
|
258
|
+
recordOwnedConfigPath(parent, lockPath);
|
|
259
|
+
for (const suffix of ARCHIVE_LOCK_SIDECARS) recordOwnedConfigPath(parent, `${lockPath}${suffix}`);
|
|
260
|
+
}
|
|
261
|
+
assertRegularArchiveLockPath(lockPath);
|
|
262
|
+
let database: Database;
|
|
263
|
+
try {
|
|
264
|
+
database = new Database(lockPath, { create: true });
|
|
265
|
+
} catch (error) {
|
|
266
|
+
throw new ReasoningArchiveError(`reasoning archive lock could not be opened: ${lockPath}`, { cause: error });
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
try { chmodSync(lockPath, 0o600); } catch { /* platform may ignore chmod */ }
|
|
270
|
+
assertRegularArchiveLockPath(lockPath);
|
|
271
|
+
database.exec("PRAGMA locking_mode = NORMAL; PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
|
|
272
|
+
} catch (error) {
|
|
273
|
+
try { database.close(); } catch { /* acquisition already failed */ }
|
|
274
|
+
if (error instanceof ReasoningArchiveError) throw error;
|
|
275
|
+
if (isSqliteBusy(error)) {
|
|
276
|
+
throw new ReasoningArchiveBusyError(
|
|
277
|
+
`reasoning archive lock is held by another writer: ${lockPath}`,
|
|
278
|
+
{ cause: error },
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
throw new ReasoningArchiveError(`reasoning archive lock could not be acquired: ${lockPath}`, { cause: error });
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
return fn();
|
|
285
|
+
} finally {
|
|
286
|
+
try { database.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
|
|
287
|
+
try { database.close(); } catch { /* the OS lock is released with the handle */ }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
103
291
|
/** Separate reasoning locally before dispatch; unknown windows enforce the absolute cap. */
|
|
104
292
|
export function applyReasoningRetention(
|
|
105
293
|
input: unknown,
|
|
@@ -130,17 +318,36 @@ export function applyReasoningRetention(
|
|
|
130
318
|
content: [{ type: "input_text", text }] })),
|
|
131
319
|
};
|
|
132
320
|
|
|
133
|
-
|
|
321
|
+
const archiveBytes = archiveCap(options.archiveMaxBytes, DEFAULT_ARCHIVE_MAX_BYTES);
|
|
322
|
+
const archiveFiles = archiveCap(options.archiveMaxFiles, DEFAULT_ARCHIVE_MAX_FILES);
|
|
323
|
+
const incomingBytes = archivePayloadBytes(texts);
|
|
134
324
|
const stamp = new Date(options.now ?? Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
135
325
|
const path = join(options.archiveDir, "reasoning-" + stamp + "-" + Math.random().toString(36).slice(2, 8) + ".md");
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
326
|
+
try {
|
|
327
|
+
mkdirSync(options.archiveDir, { recursive: true, mode: 0o700 });
|
|
328
|
+
assertRegularArchiveDirectory(options.archiveDir);
|
|
329
|
+
withArchiveQuotaLockSync(options.archiveDir, () => {
|
|
330
|
+
assertRegularArchiveDirectory(options.archiveDir);
|
|
331
|
+
const usage = scanArchiveUsage(options.archiveDir, archiveBytes, archiveFiles);
|
|
332
|
+
if (usage.files >= archiveFiles || incomingBytes > archiveBytes - usage.bytes) {
|
|
333
|
+
throw new ReasoningArchiveQuotaError(
|
|
334
|
+
`reasoning archive is at capacity: ${usage.files} file(s), ${usage.bytes} byte(s) under ${options.archiveDir}; delete older archives to archive more`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
// Only a successful exclusive open grants ownership; an occupied path is never removed.
|
|
338
|
+
const fd = openSync(path, "wx", 0o600);
|
|
339
|
+
let failure: unknown;
|
|
340
|
+
try { writeFileSync(fd, texts.join(ARCHIVE_SEPARATOR), "utf-8"); } catch (error) { failure = error; }
|
|
341
|
+
try { closeSync(fd); } catch (error) { failure ??= error; }
|
|
342
|
+
if (failure !== undefined) {
|
|
343
|
+
try { rmSync(path); } catch { /* preserve the original I/O error; cleanup is best-effort */ }
|
|
344
|
+
throw failure;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (error instanceof ReasoningArchiveQuotaError || error instanceof ReasoningArchiveBusyError
|
|
349
|
+
|| error instanceof ReasoningArchiveError) throw error;
|
|
350
|
+
throw new ReasoningArchiveError("reasoning archive could not be written", { cause: error });
|
|
144
351
|
}
|
|
145
352
|
|
|
146
353
|
const limitDescription = windowKnown
|
package/src/responses/schema.ts
CHANGED
|
@@ -27,10 +27,9 @@ const inputFileBlockSchema = z.object({
|
|
|
27
27
|
file_data: z.string().optional(),
|
|
28
28
|
});
|
|
29
29
|
// codex-rs protocol/src/models.rs sends audio as input_audio with an audio_url, in
|
|
30
|
-
// both user content and tool output.
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// signal reaching final adapter dispatch remains a recorded residual.
|
|
30
|
+
// both user content and tool output. The translated IR preserves supported inline
|
|
31
|
+
// WAV/MP3 bytes and the Chat adapter sends them as input_audio; other wires and
|
|
32
|
+
// unsupported formats are refused at the final translated-adapter boundary.
|
|
34
33
|
const inputAudioBlockSchema = z.object({
|
|
35
34
|
type: z.literal("input_audio"),
|
|
36
35
|
audio_url: z.string().min(1),
|
|
@@ -91,9 +91,18 @@ export function rewriteReplayedWebSearchCells(input: readonly unknown[]): Rewrit
|
|
|
91
91
|
if (!input.some(item => isObj(item) && item.type === "web_search_call")) return undefined;
|
|
92
92
|
// A synthetic call id must not collide with any client call id, nor with another cell's.
|
|
93
93
|
const taken = new Set(input.flatMap(item => isObj(item) && typeof item.call_id === "string" ? [item.call_id] : []));
|
|
94
|
+
// First suffix each base has not settled yet; taken stays the collision check on every candidate,
|
|
95
|
+
// so seeded ids and ids handed out under another base still block the smallest free suffix.
|
|
96
|
+
const nextSuffix = new Map<string, number>();
|
|
94
97
|
const allocate = (base: string): string => {
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
if (!taken.has(base)) {
|
|
99
|
+
taken.add(base);
|
|
100
|
+
return base;
|
|
101
|
+
}
|
|
102
|
+
let n = nextSuffix.get(base) ?? 1;
|
|
103
|
+
let id = `${base}_${n}`;
|
|
104
|
+
while (taken.has(id)) id = `${base}_${++n}`;
|
|
105
|
+
nextSuffix.set(base, n + 1);
|
|
97
106
|
taken.add(id);
|
|
98
107
|
return id;
|
|
99
108
|
};
|