@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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const OPEN_TAG = "<tool_call>";
|
|
2
|
+
const FUNCTION_TAG = "<function=";
|
|
3
|
+
// MiMo gateways append an opaque hexadecimal identity to both delimiters.
|
|
4
|
+
export const TAG_SUFFIX = "(?:_[a-fA-F0-9]{1,64})?";
|
|
5
|
+
export const MAX_TAG_LENGTH = "</tool_call_".length + 64 + 1;
|
|
6
|
+
const OPENING = new RegExp(`^<tool_call${TAG_SUFFIX}>`);
|
|
7
|
+
const OPEN_PREFIX = /^<tool_call(?:_[a-fA-F0-9]{0,64})?$/;
|
|
8
|
+
|
|
9
|
+
export interface TextContext {
|
|
10
|
+
fence: string | null;
|
|
11
|
+
lineStart: boolean;
|
|
12
|
+
inlineTicks?: number;
|
|
13
|
+
literalLine?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Splits safe visible text from a possible control block while carrying Markdown context across chunks. */
|
|
17
|
+
export function splitAtPossibleSerializedToolCall(
|
|
18
|
+
text: string,
|
|
19
|
+
initialContext: TextContext = { fence: null, lineStart: true },
|
|
20
|
+
final = false,
|
|
21
|
+
allowMidLineBare = false,
|
|
22
|
+
): {
|
|
23
|
+
emit: string;
|
|
24
|
+
defer: string;
|
|
25
|
+
hasOpenTag: boolean;
|
|
26
|
+
context: TextContext;
|
|
27
|
+
} {
|
|
28
|
+
const context = { ...initialContext };
|
|
29
|
+
const split = (at: number, hasOpenTag = false) => ({
|
|
30
|
+
emit: text.slice(0, at), defer: text.slice(at), hasOpenTag, context,
|
|
31
|
+
});
|
|
32
|
+
for (let index = 0; index < text.length; index++) {
|
|
33
|
+
if (context.lineStart) {
|
|
34
|
+
const rest = text.slice(index);
|
|
35
|
+
const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)/.exec(rest);
|
|
36
|
+
if (!context.inlineTicks && fence && (!context.fence || (fence[1]![0] === context.fence[0]
|
|
37
|
+
&& fence[1]!.length >= context.fence.length && /^[ \t\r]*$/.test(fence[2]!)))) {
|
|
38
|
+
if (!final && !rest.includes("\n")) return split(index);
|
|
39
|
+
context.fence = context.fence ? null : fence[1]!;
|
|
40
|
+
index += fence[0].length - 1;
|
|
41
|
+
context.lineStart = false;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!final && /^ {0,3}(`*|~*)$/.test(rest)) return split(index);
|
|
45
|
+
context.literalLine = /[ \t>]/.test(text[index]!);
|
|
46
|
+
}
|
|
47
|
+
// Plain wrappers retain their bare-line rule. Nonced wrappers also occur after prose;
|
|
48
|
+
// quotes, indentation and Markdown code remain literal, even beside a matching call.
|
|
49
|
+
if (!context.fence && !context.inlineTicks && !context.literalLine && text[index] === "<") {
|
|
50
|
+
const rest = text.slice(index);
|
|
51
|
+
const opening = OPENING.exec(rest)?.[0];
|
|
52
|
+
if (opening && (context.lineStart || opening !== OPEN_TAG || allowMidLineBare)) {
|
|
53
|
+
const header = rest.slice(opening.length).trimStart();
|
|
54
|
+
if (/^<function=[^>\r\n]+>/.test(header)) return split(index, true);
|
|
55
|
+
if (!final && (FUNCTION_TAG.startsWith(header)
|
|
56
|
+
|| (header.startsWith(FUNCTION_TAG) && !/[>\r\n]/.test(header.slice(FUNCTION_TAG.length))))) {
|
|
57
|
+
return split(index);
|
|
58
|
+
}
|
|
59
|
+
} else if (!final && (OPEN_TAG.startsWith(rest) || OPEN_PREFIX.test(rest))) return split(index);
|
|
60
|
+
}
|
|
61
|
+
if (!context.fence && text[index] === "`") {
|
|
62
|
+
let end = index + 1;
|
|
63
|
+
while (text[end] === "`") end++;
|
|
64
|
+
if (!final && end === text.length) return split(index);
|
|
65
|
+
const ticks = end - index;
|
|
66
|
+
if (!context.inlineTicks) context.inlineTicks = ticks;
|
|
67
|
+
else if (context.inlineTicks === ticks) context.inlineTicks = undefined;
|
|
68
|
+
index = end - 1;
|
|
69
|
+
}
|
|
70
|
+
context.lineStart = text[index] === "\n";
|
|
71
|
+
}
|
|
72
|
+
return split(text.length);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The line, fence and inline-code state after `text`. Serialized blocks are neutralised first so
|
|
77
|
+
* the scan runs through the whole text instead of stopping at the first opening tag; a block ends
|
|
78
|
+
* mid-line, which is exactly what the neutral spelling reports too.
|
|
79
|
+
*/
|
|
80
|
+
export function contextAfter(text: string, context: TextContext): TextContext {
|
|
81
|
+
if (text.length === 0) return context;
|
|
82
|
+
return splitAtPossibleSerializedToolCall(text.replace(new RegExp(`<tool_call${TAG_SUFFIX}>`, "g"), "<tool-call>"), context, true).context;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A deferred header/fence can itself be long. While its unresolved portion only grows, a
|
|
87
|
+
* chunk-local check suffices; the first resolving character goes back through the full splitter.
|
|
88
|
+
* Each unbounded phase is scanned once on entry and once on exit, preserving its Markdown rules.
|
|
89
|
+
*/
|
|
90
|
+
export function deferredContinuationBreak(text: string, context: TextContext): RegExp | undefined {
|
|
91
|
+
const opening = OPENING.exec(text)?.[0];
|
|
92
|
+
if (opening) {
|
|
93
|
+
const header = text.slice(opening.length);
|
|
94
|
+
if (/^\s*$/.test(header)) return /\S/;
|
|
95
|
+
if (/^\s*<function=[^>\r\n]*$/.test(header)) return /[>\r\n]/;
|
|
96
|
+
}
|
|
97
|
+
if (!context.inlineTicks && context.lineStart) {
|
|
98
|
+
const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)$/.exec(text);
|
|
99
|
+
if (fence) {
|
|
100
|
+
if (!context.fence) return /\n/;
|
|
101
|
+
// Closing-fence ticks may grow only before its optional whitespace suffix.
|
|
102
|
+
if (!fence[2]) return fence[1]![0] === "`" ? /[^`]/ : /[^~]/;
|
|
103
|
+
return /[^ \t\r]/;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (/^`+$/.test(text)) return /[^`]/;
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { TRANSLATOR_MAX_CALL_ARGUMENT_BYTES, type TranslatorBudget } from "../../lib/translator-budget";
|
|
2
|
+
import { jsonUtf8Bytes } from "../../lib/json-byte-size";
|
|
3
|
+
import type { AdapterEvent } from "../../types";
|
|
4
|
+
import {
|
|
5
|
+
hasNestedSerializedToolCallHeader, MAX_STANDALONE_CANDIDATES, recoverStandaloneBareBlocks, reconcileSerializedToolCallEvents, scanSerializedToolCalls, SerializedToolCallContentBuffer,
|
|
6
|
+
type FreeformToolIdentity, type StructuredToolCallReference,
|
|
7
|
+
} from "./serialized-tool-call-content";
|
|
8
|
+
|
|
9
|
+
const MAX_CANDIDATE_BYTES = 4 * 1024 * 1024;
|
|
10
|
+
type BareCall = Pick<ReturnType<typeof recoverStandaloneBareBlocks>[number], "name" | "input">;
|
|
11
|
+
const isMiMo = (model: string | undefined) => /(?:^|[/-])mimo-v2(?:\.|$)/i.test(model ?? "");
|
|
12
|
+
const argumentsFor = (call: BareCall) => JSON.stringify({ input: call.input });
|
|
13
|
+
function* callEvents(call: BareCall, id: string, restore: (name: string) => string): Generator<AdapterEvent> {
|
|
14
|
+
yield { type: "tool_call_start", id, name: restore(call.name) };
|
|
15
|
+
yield { type: "tool_call_delta", arguments: argumentsFor(call) };
|
|
16
|
+
yield { type: "tool_call_end" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** An explicitly separate policy for bare blocks with NO structured calls, never identified wrappers. */
|
|
20
|
+
export class StandaloneBareToolCallBuffer extends SerializedToolCallContentBuffer {
|
|
21
|
+
private candidates: BareCall[] = [];
|
|
22
|
+
private candidateBytes = 0;
|
|
23
|
+
private disabled = false;
|
|
24
|
+
private readonly eligible: boolean;
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly recoveryBudget: TranslatorBudget,
|
|
28
|
+
model: string | undefined,
|
|
29
|
+
private readonly freeformTools: ReadonlyMap<string, FreeformToolIdentity>,
|
|
30
|
+
) {
|
|
31
|
+
super(recoveryBudget, isMiMo(model) && freeformTools.size > 0);
|
|
32
|
+
this.eligible = isMiMo(model) && freeformTools.size > 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Retain candidate inputs only, with aggregate and per-call limits; overflow disables the batch. */
|
|
36
|
+
private remember(calls: BareCall[]): void {
|
|
37
|
+
if (this.disabled || calls.length === 0) return;
|
|
38
|
+
const sizes = calls.map(call => jsonUtf8Bytes({ name: call.name, input: call.input }));
|
|
39
|
+
const bytes = sizes.reduce((sum, size) => sum + size, 0);
|
|
40
|
+
if (sizes.some(size => size > TRANSLATOR_MAX_CALL_ARGUMENT_BYTES)
|
|
41
|
+
|| this.candidateBytes + bytes > MAX_CANDIDATE_BYTES || this.candidates.length + calls.length > MAX_STANDALONE_CANDIDATES) {
|
|
42
|
+
this.clearCandidates();
|
|
43
|
+
this.disabled = true;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const retained = this.recoveryBudget.reserveTransient(bytes, { kind: "live_transient" });
|
|
47
|
+
try {
|
|
48
|
+
const temporary = this.recoveryBudget.reserveTransient(bytes, { kind: "live_transient" });
|
|
49
|
+
try {
|
|
50
|
+
// Detach small inputs from a large released answer's backing string.
|
|
51
|
+
const copies = calls.map(({ name, input }) => JSON.parse(JSON.stringify({ name, input })) as BareCall);
|
|
52
|
+
this.candidates.push(...copies);
|
|
53
|
+
this.candidateBytes += bytes;
|
|
54
|
+
retained.commitRetained();
|
|
55
|
+
} finally { temporary.release(); }
|
|
56
|
+
} catch (error) {
|
|
57
|
+
retained.release();
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
protected override releaseBounded(): AdapterEvent[] {
|
|
63
|
+
if (!this.eligible || this.disabled) return super.releaseBounded();
|
|
64
|
+
const text = this.current();
|
|
65
|
+
const scan = scanSerializedToolCalls(text, this.context, true);
|
|
66
|
+
if (scan.overflow || scan.incomplete || scan.calls.some(call => hasNestedSerializedToolCallHeader(call.body))) {
|
|
67
|
+
// We cannot safely recognize a later inner block after losing its unresolved outer body.
|
|
68
|
+
this.clearCandidates();
|
|
69
|
+
this.disabled = true;
|
|
70
|
+
return super.releaseBounded();
|
|
71
|
+
}
|
|
72
|
+
this.remember(recoverStandaloneBareBlocks(text, this.freeformTools, this.context));
|
|
73
|
+
return super.releaseBounded();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
*recoverStandalone(noStructuredCalls: boolean, nextId: () => string, restore: (name: string) => string): Generator<AdapterEvent> {
|
|
77
|
+
if (!noStructuredCalls) {
|
|
78
|
+
this.clearCandidates();
|
|
79
|
+
this.disabled = true;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!this.eligible || this.disabled) return;
|
|
83
|
+
if (scanSerializedToolCalls(this.current(), this.context, true).overflow) {
|
|
84
|
+
this.clearCandidates();
|
|
85
|
+
this.disabled = true;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const held = recoverStandaloneBareBlocks(this.current(), this.freeformTools, this.context);
|
|
89
|
+
this.remember(held);
|
|
90
|
+
// The first terminal flush is the only recovery decision; content after it cannot authorize a call.
|
|
91
|
+
if (this.disabled || this.candidates.length === 0) { this.disabled = true; return; }
|
|
92
|
+
yield* this.drainRanges(held);
|
|
93
|
+
for (const call of this.candidates) yield* callEvents(call, nextId(), restore);
|
|
94
|
+
this.clearCandidates();
|
|
95
|
+
this.disabled = true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private clearCandidates(): void {
|
|
99
|
+
this.recoveryBudget.releaseRetained(this.candidateBytes, { kind: "live_transient" });
|
|
100
|
+
this.candidateBytes = 0;
|
|
101
|
+
this.candidates = [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
override dispose(): void {
|
|
105
|
+
this.clearCandidates();
|
|
106
|
+
super.dispose();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Buffered counterpart; the existing structured-call/identified-wrapper reconciler owns its path. */
|
|
111
|
+
export function reconcileStandaloneBareToolCallEvents(
|
|
112
|
+
events: AdapterEvent[], start: number, end: number,
|
|
113
|
+
structuredCalls: readonly StructuredToolCallReference[], budget: TranslatorBudget,
|
|
114
|
+
model: string | undefined, freeformTools: ReadonlyMap<string, FreeformToolIdentity>, restore: (name: string) => string,
|
|
115
|
+
): void {
|
|
116
|
+
if (structuredCalls.length > 0 || !isMiMo(model) || freeformTools.size === 0) {
|
|
117
|
+
reconcileSerializedToolCallEvents(events, start, end, structuredCalls, budget);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const buffer = new StandaloneBareToolCallBuffer(budget, model, freeformTools);
|
|
121
|
+
const reconciled: AdapterEvent[] = [];
|
|
122
|
+
try {
|
|
123
|
+
for (const event of events.slice(start, end)) {
|
|
124
|
+
if (event.type !== "text_delta") reconciled.push(...buffer.hold(event).filter(held => held.type !== "heartbeat"));
|
|
125
|
+
else {
|
|
126
|
+
const text = buffer.ingest(event.text);
|
|
127
|
+
if (text) reconciled.push({ type: "text_delta", text });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let sequence = 0;
|
|
131
|
+
reconciled.push(...buffer.recoverStandalone(true, () => `call_${++sequence}`, restore), ...buffer.drain([]));
|
|
132
|
+
} finally {
|
|
133
|
+
buffer.dispose();
|
|
134
|
+
}
|
|
135
|
+
events.splice(start, end - start, ...reconciled);
|
|
136
|
+
}
|
|
@@ -47,7 +47,8 @@ import { messagesToChatFormat } from "./openai-chat/messages";
|
|
|
47
47
|
import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry";
|
|
48
48
|
import { openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire";
|
|
49
49
|
import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema";
|
|
50
|
-
import { freeformToolsByWireName, type FreeformToolIdentity,
|
|
50
|
+
import { freeformToolsByWireName, type FreeformToolIdentity, reconcileStructuredToolCall, reconcileStructuredToolCalls } from "./openai-chat/serialized-tool-call-content";
|
|
51
|
+
import { StandaloneBareToolCallBuffer, reconcileStandaloneBareToolCallEvents } from "./openai-chat/standalone-bare-tool-calls";
|
|
51
52
|
|
|
52
53
|
export { stripBracketedModelSuffix } from "./openai-chat/wire";
|
|
53
54
|
export { buildOpenAIChatPassthroughRequest } from "./openai-chat/passthrough";
|
|
@@ -300,7 +301,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
300
301
|
sawArgumentsString: boolean;
|
|
301
302
|
}
|
|
302
303
|
const pendingToolCalls: PendingToolCall[] = [];
|
|
303
|
-
const toolCallContent = new
|
|
304
|
+
const toolCallContent = new StandaloneBareToolCallBuffer(budget, lastRequestedModelId, freeformTools);
|
|
304
305
|
const heldText = (): AdapterEvent[] => toolCallContent.drain([]);
|
|
305
306
|
let toolCallSeq = 0;
|
|
306
307
|
const closeToolCalls = (): PendingToolCall[] => {
|
|
@@ -322,7 +323,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
322
323
|
// Returns "terminate" when a pending call cannot be dispatched, so every flush site
|
|
323
324
|
// stops the turn instead of emitting an unusable call. `closeToolCalls()` runs first,
|
|
324
325
|
// so budget reservations are released for every pending call even on the early return.
|
|
325
|
-
const flushToolCalls = function* (): Generator<AdapterEvent, "continue" | "terminate"> {
|
|
326
|
+
const flushToolCalls = function* (terminal = true): Generator<AdapterEvent, "continue" | "terminate"> {
|
|
326
327
|
const calls = closeToolCalls();
|
|
327
328
|
for (const call of calls) {
|
|
328
329
|
// Ingest already proved `name` is a string; the typeof guard keeps this branch
|
|
@@ -335,9 +336,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
335
336
|
return yield* terminateWithError(unnamedToolCallEvent(pendingUsage));
|
|
336
337
|
}
|
|
337
338
|
}
|
|
338
|
-
// Held markup is released only now, as one batch per response: the doubled-input repair needs every call.
|
|
339
339
|
const references = reconcileStructuredToolCalls(calls.map(call => ({ wireName: call.name, restoredName: toolNames.restore(call.name), argumentsText: call.args, freeformTool: freeformTools.get(call.name) })), toolCallContent.current(), !toolCallContent.releasedAnswerText && /(?:^|[/-])mimo-v2(?:\.|$)/i.test(lastRequestedModelId ?? ""));
|
|
340
340
|
calls.forEach((call, index) => { call.args = references[index]!.argumentsText; });
|
|
341
|
+
yield* toolCallContent.recoverStandalone(calls.length === 0 && terminal, () => `call_${++toolCallSeq}`, name => toolNames.restore(name));
|
|
341
342
|
yield* toolCallContent.drain(references);
|
|
342
343
|
for (const call of calls) {
|
|
343
344
|
if (!call.id) call.id = `call_${++toolCallSeq}`;
|
|
@@ -657,7 +658,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
657
658
|
yield* terminateWithError({ type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" });
|
|
658
659
|
return;
|
|
659
660
|
}
|
|
660
|
-
if ((yield* flushToolCalls()) === "terminate") return;
|
|
661
|
+
if ((yield* flushToolCalls(sawFinish)) === "terminate") return; // A cut stream never synthesizes a bare call.
|
|
661
662
|
const stopReason = stopReasonFor(finishReason);
|
|
662
663
|
yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
|
|
663
664
|
} catch (error) {
|
|
@@ -804,7 +805,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
804
805
|
}
|
|
805
806
|
const references = reconcileStructuredToolCalls(structuredCalls, answerText, /(?:^|[/-])mimo-v2(?:\.|$)/i.test(lastRequestedModelId ?? ""));
|
|
806
807
|
structuredCalls.forEach((call, index) => { call.delta.arguments = references[index]!.argumentsText; });
|
|
807
|
-
|
|
808
|
+
reconcileStandaloneBareToolCallEvents(events, contentStart, contentEnd, references, budget, lastRequestedModelId, freeformTools, name => toolNames.restore(name));
|
|
808
809
|
const stopReason = stopReasonFor(choice.finish_reason);
|
|
809
810
|
events.push({
|
|
810
811
|
type: "done",
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { isPlainObject } from "./internal";
|
|
2
|
+
import { normalizeResponsesToolResultAdjacency } from "./tool-output-recovery";
|
|
3
|
+
|
|
4
|
+
export class OpenGatewayInputCompatibilityError extends Error {
|
|
5
|
+
constructor() {
|
|
6
|
+
super("OpenGateway cannot preserve this tool result; use text or valid input_image parts with a call_id.");
|
|
7
|
+
this.name = "OpenGatewayInputCompatibilityError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const TOOL_ROUND_TYPES = new Set([
|
|
12
|
+
"function_call",
|
|
13
|
+
"function_call_output",
|
|
14
|
+
"custom_tool_call",
|
|
15
|
+
"custom_tool_call_output",
|
|
16
|
+
"local_shell_call",
|
|
17
|
+
"local_shell_call_output",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function normalizeImageDetail(part: unknown): unknown {
|
|
21
|
+
return isPlainObject(part) && part.type === "input_image" && part.detail === "original"
|
|
22
|
+
? { ...part, detail: "high" }
|
|
23
|
+
: part;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isToolRound(item: unknown): boolean {
|
|
27
|
+
return isPlainObject(item) && typeof item.type === "string" && TOOL_ROUND_TYPES.has(item.type);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pairedCallId(item: Record<string, unknown>): string | undefined {
|
|
31
|
+
return typeof item.call_id === "string" && item.call_id.length > 0 ? item.call_id : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type ProjectedInput = { readonly changed: boolean; readonly input: unknown[] };
|
|
35
|
+
|
|
36
|
+
function projectOpenGatewayInput(input: readonly unknown[]): ProjectedInput {
|
|
37
|
+
let changed = false;
|
|
38
|
+
const projected: unknown[] = [];
|
|
39
|
+
const pendingImages: unknown[] = [];
|
|
40
|
+
const flushImages = (): void => {
|
|
41
|
+
if (pendingImages.length === 0) return;
|
|
42
|
+
projected.push(...pendingImages);
|
|
43
|
+
pendingImages.length = 0;
|
|
44
|
+
};
|
|
45
|
+
for (const item of input) {
|
|
46
|
+
if (!isToolRound(item)) flushImages();
|
|
47
|
+
if (!isPlainObject(item)) {
|
|
48
|
+
projected.push(item);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (item.type === "function_call_output" && Array.isArray(item.output)) {
|
|
52
|
+
const callId = pairedCallId(item);
|
|
53
|
+
const content: Record<string, unknown>[] = [];
|
|
54
|
+
const text: string[] = [];
|
|
55
|
+
let supported = callId !== undefined;
|
|
56
|
+
let hasImage = false;
|
|
57
|
+
if (supported) {
|
|
58
|
+
for (const part of item.output) {
|
|
59
|
+
if (!isPlainObject(part)) {
|
|
60
|
+
supported = false;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
switch (part.type) {
|
|
64
|
+
case "text":
|
|
65
|
+
case "input_text":
|
|
66
|
+
case "output_text":
|
|
67
|
+
if (typeof part.text !== "string") {
|
|
68
|
+
supported = false;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
text.push(part.text);
|
|
72
|
+
content.push({ type: "input_text", text: part.text });
|
|
73
|
+
break;
|
|
74
|
+
case "input_image":
|
|
75
|
+
if (!((typeof part.image_url === "string" && part.image_url.length > 0)
|
|
76
|
+
|| (typeof part.file_id === "string" && part.file_id.length > 0))
|
|
77
|
+
|| (part.image_url !== undefined && typeof part.image_url !== "string")
|
|
78
|
+
|| (part.file_id !== undefined && typeof part.file_id !== "string")
|
|
79
|
+
|| (part.detail !== undefined && (typeof part.detail !== "string"
|
|
80
|
+
|| !["auto", "low", "high", "original"].includes(part.detail)))) {
|
|
81
|
+
supported = false;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
content.push({ ...part, ...(part.detail === "original" ? { detail: "high" } : {}) });
|
|
85
|
+
hasImage = true;
|
|
86
|
+
break;
|
|
87
|
+
default:
|
|
88
|
+
supported = false;
|
|
89
|
+
}
|
|
90
|
+
if (!supported) break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (supported && callId !== undefined) {
|
|
94
|
+
changed = true;
|
|
95
|
+
const marker = `[tool output for ${callId}]`;
|
|
96
|
+
projected.push({
|
|
97
|
+
...item,
|
|
98
|
+
output: text.length > 0 ? text.join("\n") : hasImage ? marker : "",
|
|
99
|
+
});
|
|
100
|
+
if (hasImage) {
|
|
101
|
+
pendingImages.push({
|
|
102
|
+
type: "message",
|
|
103
|
+
role: "user",
|
|
104
|
+
content: [{ type: "input_text", text: marker }, ...content],
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
throw new OpenGatewayInputCompatibilityError();
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(item.content)) {
|
|
112
|
+
const originalContent = item.content;
|
|
113
|
+
const content = originalContent.map(normalizeImageDetail);
|
|
114
|
+
if (content.some((part, index) => part !== originalContent[index])) {
|
|
115
|
+
changed = true;
|
|
116
|
+
projected.push({ ...item, content });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
projected.push(item);
|
|
121
|
+
}
|
|
122
|
+
flushImages();
|
|
123
|
+
return { changed, input: projected };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** OpenGateway requires string tool results and auto/low/high image detail. */
|
|
127
|
+
export function normalizeOpenGatewayResponsesInput(body: unknown, url: string): unknown {
|
|
128
|
+
// Same exact-URL boundary as completion recovery; a query-routed variant is a different destination.
|
|
129
|
+
if (url !== "https://apis.opengateway.ai/v1/responses" || !isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
130
|
+
|
|
131
|
+
const direct = projectOpenGatewayInput(body.input);
|
|
132
|
+
if (!direct.changed) return body;
|
|
133
|
+
const aligned = normalizeResponsesToolResultAdjacency(body);
|
|
134
|
+
if (!isPlainObject(aligned) || !Array.isArray(aligned.input) || aligned === body) {
|
|
135
|
+
return { ...body, input: direct.input };
|
|
136
|
+
}
|
|
137
|
+
const paired = projectOpenGatewayInput(aligned.input);
|
|
138
|
+
return { ...aligned, input: paired.changed ? paired.input : direct.input };
|
|
139
|
+
}
|
|
@@ -55,6 +55,7 @@ import { observeOutbound } from "../../usage/cache-diagnostic";
|
|
|
55
55
|
import { normalizeMuseToolChoice } from "./muse-tool-choice";
|
|
56
56
|
import { declaresHostedWebSearch, projectHostedSearchHistory, renderCompactionSearchHistoryItems } from "./compaction-search-history";
|
|
57
57
|
import { stripKimiEmptyAssistantMessages } from "./empty-assistant";
|
|
58
|
+
import { normalizeOpenGatewayResponsesInput } from "./opengateway-input";
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
61
|
* Identifies DeepSeek's strict Responses replay contract: tool-bearing continuations need
|
|
@@ -508,11 +509,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
508
509
|
|| !declaresHostedWebSearch(finalBody))) {
|
|
509
510
|
finalBody = projectHostedSearchHistory(finalBody);
|
|
510
511
|
}
|
|
512
|
+
finalBody = normalizeOpenGatewayResponsesInput(finalBody, url);
|
|
511
513
|
observeOutbound(parsed._rawBody, finalBody, headers);
|
|
512
514
|
if (!isCanonicalOpenAiForwardProvider(provider)) {
|
|
513
515
|
validateFinalCustomToolCompatibility(finalBody, provider.supportsResponsesCustomTools);
|
|
514
516
|
}
|
|
515
|
-
const
|
|
517
|
+
const wireBody = applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName);
|
|
518
|
+
const body = JSON.stringify(wireBody);
|
|
516
519
|
const releaseBodyObservation = translatorBudget.observeExternallyCapped(
|
|
517
520
|
"passthrough_serialization",
|
|
518
521
|
Buffer.byteLength(body, "utf8"),
|
|
@@ -522,6 +525,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
522
525
|
method: "POST",
|
|
523
526
|
headers,
|
|
524
527
|
body,
|
|
528
|
+
...(isPlainObject(wireBody) && typeof wireBody.model === "string" ? { outboundModelId: wireBody.model } : {}),
|
|
525
529
|
releaseBodyObservation,
|
|
526
530
|
...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}),
|
|
527
531
|
...(routedCustomToolRepairNames ? { routedCustomToolRepairNames } : {}),
|
|
@@ -23,6 +23,18 @@ export function copyResponsesClientMetadata(target: Headers, incoming: Headers,
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Strip trailing slashes with a linear scan rather than a regex.
|
|
28
|
+
*
|
|
29
|
+
* `/\/+$/` retries candidate end positions when a long slash run is not at the end of the
|
|
30
|
+
* string; the scan walks each character once and produces the same result on every input.
|
|
31
|
+
*/
|
|
32
|
+
function stripTrailingSlashes(value: string): string {
|
|
33
|
+
let end = value.length;
|
|
34
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
|
|
35
|
+
return end === value.length ? value : value.slice(0, end);
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
/** Shared Responses URL/auth/header construction; capabilities never grant auth authority. */
|
|
27
39
|
export function buildResponsesTransport(provider: OcxProviderConfig, incoming: Pick<IncomingMeta, "headers">): {
|
|
28
40
|
url: string; headers: Record<string, string>;
|
|
@@ -31,7 +43,7 @@ export function buildResponsesTransport(provider: OcxProviderConfig, incoming: P
|
|
|
31
43
|
let url: string;
|
|
32
44
|
if (provider.authMode === "forward") {
|
|
33
45
|
const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider);
|
|
34
|
-
const baseUrl = mayForwardCallerCredentials ? CODEX_FORWARD_BASE_URL : provider.baseUrl
|
|
46
|
+
const baseUrl = mayForwardCallerCredentials ? CODEX_FORWARD_BASE_URL : stripTrailingSlashes(provider.baseUrl);
|
|
35
47
|
url = `${baseUrl}/responses`;
|
|
36
48
|
if (provider.headers) Object.assign(headers, provider.headers);
|
|
37
49
|
const runtimeProvider = provider as {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, constants, fstatSync, lstatSync, openSync,
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths";
|
|
@@ -44,6 +44,20 @@ export interface Desktop3pMetadata {
|
|
|
44
44
|
|
|
45
45
|
export const DESKTOP_PICKER_ENTRY_NAME = "opencodex-picker";
|
|
46
46
|
|
|
47
|
+
export const MAX_DESKTOP_PROFILE_FILE_BYTES = 4 * 1024 * 1024;
|
|
48
|
+
|
|
49
|
+
export function assertDesktopLibraryTextSize(text: string): void {
|
|
50
|
+
if (Buffer.byteLength(text, "utf8") > MAX_DESKTOP_PROFILE_FILE_BYTES) {
|
|
51
|
+
throw new Error("desktop_config_too_large");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function serializeDesktopLibraryJson(value: unknown): string {
|
|
56
|
+
const text = JSON.stringify(value, null, 2) + "\n";
|
|
57
|
+
assertDesktopLibraryTextSize(text);
|
|
58
|
+
return text;
|
|
59
|
+
}
|
|
60
|
+
|
|
47
61
|
/** A path that no longer names the observed file was swapped; a same-path failure keeps its own errno. */
|
|
48
62
|
function pathStillNamesExpectedFile(path: string, expected: BigIntStats): boolean {
|
|
49
63
|
try {
|
|
@@ -54,7 +68,13 @@ function pathStillNamesExpectedFile(path: string, expected: BigIntStats): boolea
|
|
|
54
68
|
}
|
|
55
69
|
|
|
56
70
|
/** Read regular config files only; a path swap must not turn an observation into a FIFO wait. */
|
|
57
|
-
export function readDesktopLibraryBytes(
|
|
71
|
+
export function readDesktopLibraryBytes(
|
|
72
|
+
path: string,
|
|
73
|
+
maxBytes: number = MAX_DESKTOP_PROFILE_FILE_BYTES,
|
|
74
|
+
): Buffer {
|
|
75
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_DESKTOP_PROFILE_FILE_BYTES) {
|
|
76
|
+
throw new RangeError("invalid Desktop library read limit");
|
|
77
|
+
}
|
|
58
78
|
const expected = lstatSync(path, { bigint: true });
|
|
59
79
|
if (!expected.isFile()) throw new Error("desktop_config_not_regular");
|
|
60
80
|
// POSIX open of a FIFO can itself block, before fstat is possible. Windows has no POSIX
|
|
@@ -73,7 +93,27 @@ export function readDesktopLibraryBytes(path: string): Buffer {
|
|
|
73
93
|
const opened = fstatSync(fd, { bigint: true });
|
|
74
94
|
if (!opened.isFile() || opened.dev !== expected.dev || opened.ino !== expected.ino
|
|
75
95
|
|| opened.ctimeNs !== expected.ctimeNs) throw new Error("desktop_config_not_regular");
|
|
76
|
-
|
|
96
|
+
if (opened.size > BigInt(maxBytes)) throw new Error("desktop_config_too_large");
|
|
97
|
+
// Allocate for the observed size plus one sentinel byte, not the full cap: callers scan many
|
|
98
|
+
// small profiles, and the cap would zero-fill megabytes per read. A file that fills the
|
|
99
|
+
// buffer grew after validation — keep reading within the bound so it classifies as
|
|
100
|
+
// desktop_config_too_large rather than silently truncating.
|
|
101
|
+
let bytes = Buffer.alloc(Number(opened.size) + 1);
|
|
102
|
+
let length = 0;
|
|
103
|
+
for (;;) {
|
|
104
|
+
const count = readSync(fd, bytes, length, Math.min(64 * 1024, bytes.length - length), null);
|
|
105
|
+
if (count === 0) break;
|
|
106
|
+
length += count;
|
|
107
|
+
if (length > maxBytes) throw new Error("desktop_config_too_large");
|
|
108
|
+
if (length === bytes.length && bytes.length <= maxBytes) {
|
|
109
|
+
const grown = Buffer.alloc(Math.min(maxBytes + 1, bytes.length * 2));
|
|
110
|
+
bytes.copy(grown, 0, 0, length);
|
|
111
|
+
bytes = grown;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (length > maxBytes) throw new Error("desktop_config_too_large");
|
|
115
|
+
if (!pathStillNamesExpectedFile(path, opened)) throw new Error("desktop_config_not_regular");
|
|
116
|
+
return bytes.subarray(0, length);
|
|
77
117
|
} finally {
|
|
78
118
|
closeSync(fd);
|
|
79
119
|
}
|
package/src/claude/desktop-3p.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { canonicalDirectory } from "./desktop-remote-store-io";
|
|
|
6
6
|
import {
|
|
7
7
|
resolveDesktop3pConfigLibraryPath, parseMetadata, SAFE_DESKTOP_PROFILE_ID, isRecord,
|
|
8
8
|
isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileSnapshot, readDesktopLibraryText, readDesktopLibraryBytes,
|
|
9
|
-
readOptionalDesktopLibraryBytes,
|
|
9
|
+
readOptionalDesktopLibraryBytes, assertDesktopLibraryTextSize, serializeDesktopLibraryJson,
|
|
10
10
|
type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry,
|
|
11
11
|
} from "./desktop-3p-library";
|
|
12
12
|
export { resolveDesktop3pConfigLibraryPath, type Desktop3pConfigLibraryOptions } from "./desktop-3p-library";
|
|
@@ -642,13 +642,24 @@ function removeDesktop3pStandardPivotLocal(
|
|
|
642
642
|
if (targetIds.length === 0) return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath };
|
|
643
643
|
|
|
644
644
|
let metadataAfterPivot = metadata;
|
|
645
|
+
let standardPath: string | undefined;
|
|
646
|
+
let pivotMetadataJson: string | undefined;
|
|
645
647
|
if (selectedOwnedGatewayActive) {
|
|
646
648
|
const standardId = randomUUID();
|
|
647
|
-
|
|
648
|
-
atomicWriteFile(standardPath, "{}\n");
|
|
649
|
+
standardPath = profilePath(inspected.libraryPath, standardId);
|
|
649
650
|
const standardEntry: Desktop3pMetadataEntry = { id: standardId, name: "opencodex-standard" };
|
|
650
651
|
metadataAfterPivot = { ...metadata, appliedId: standardId, entries: [...metadata.entries, standardEntry] };
|
|
651
|
-
|
|
652
|
+
pivotMetadataJson = serializeDesktopLibraryJson(metadataAfterPivot);
|
|
653
|
+
}
|
|
654
|
+
// Bound the final metadata before any unlink so a size refusal cannot strand
|
|
655
|
+
// a row pointing at an already-deleted profile.
|
|
656
|
+
const removalMetadataJson = serializeDesktopLibraryJson({
|
|
657
|
+
...metadataAfterPivot,
|
|
658
|
+
entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)),
|
|
659
|
+
});
|
|
660
|
+
if (standardPath !== undefined && pivotMetadataJson !== undefined) {
|
|
661
|
+
atomicWriteFile(standardPath, "{}\n");
|
|
662
|
+
atomicWriteFile(metadataPath, pivotMetadataJson);
|
|
652
663
|
}
|
|
653
664
|
|
|
654
665
|
const residualPaths: string[] = [];
|
|
@@ -674,10 +685,7 @@ function removeDesktop3pStandardPivotLocal(
|
|
|
674
685
|
}
|
|
675
686
|
// Do not leave a metadata row pointing at a deleted profile. For a foreign
|
|
676
687
|
// selection this only removes proven opencodex residues; appliedId is kept.
|
|
677
|
-
atomicWriteFile(
|
|
678
|
-
metadataPath,
|
|
679
|
-
JSON.stringify({ ...metadataAfterPivot, entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)) }, null, 2) + "\n",
|
|
680
|
-
);
|
|
688
|
+
atomicWriteFile(metadataPath, removalMetadataJson);
|
|
681
689
|
return { ok: true, changed: true, kind: "removed", libraryPath: inspected.libraryPath };
|
|
682
690
|
} catch (error) {
|
|
683
691
|
return { ok: false, changed: false, kind: "write_failed", libraryPath: inspected.libraryPath, reason: desktopMutationFailureReason(error) };
|
|
@@ -831,7 +839,7 @@ function writeDesktop3pConfigWithGenerator(
|
|
|
831
839
|
|
|
832
840
|
const generated = generate();
|
|
833
841
|
const previous = readDesktopProfileSnapshot(configPath);
|
|
834
|
-
const configJson =
|
|
842
|
+
const configJson = serializeDesktopLibraryJson({ ...previous.foreign, ...generated });
|
|
835
843
|
const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16);
|
|
836
844
|
if (refreshOnly) {
|
|
837
845
|
atomicWriteFileNoFollow(configPath, configJson, undefined, {
|
|
@@ -849,9 +857,10 @@ function writeDesktop3pConfigWithGenerator(
|
|
|
849
857
|
});
|
|
850
858
|
return { written: true, path: configPath, fingerprint };
|
|
851
859
|
}
|
|
860
|
+
const metadataJson = serializeDesktopLibraryJson({ ...metadata, appliedId: id, entries });
|
|
852
861
|
atomicReplaceDesktopConfig(configPath, configJson, previous.bytes);
|
|
853
862
|
try {
|
|
854
|
-
atomicWriteFile(metadataPath,
|
|
863
|
+
atomicWriteFile(metadataPath, metadataJson);
|
|
855
864
|
} catch (metaError) {
|
|
856
865
|
// Roll back from the verified bytes in memory: reopening the backup could meet a swapped
|
|
857
866
|
// entry and leave the new profile beside the old metadata. A profile this call created is
|
|
@@ -895,6 +904,7 @@ export function atomicReplaceDesktopConfig(
|
|
|
895
904
|
previous: Buffer | undefined,
|
|
896
905
|
writer: typeof atomicWriteFileNoFollow = atomicWriteFileNoFollow,
|
|
897
906
|
): { backupPath?: string } {
|
|
907
|
+
assertDesktopLibraryTextSize(content);
|
|
898
908
|
const validateBeforeRename = () => assertDesktopProfileUnchanged(path, previous);
|
|
899
909
|
validateBeforeRename();
|
|
900
910
|
const backupPath = `${path}.bak`;
|