@bitkyc08/opencodex 2.7.39 → 2.7.40-preview.20260725
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 +4 -4
- package/gui/dist/assets/index-BxQ8N_K5.js +52 -0
- package/gui/dist/assets/index-CMip1DzF.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/arg-normalize.ts +23 -7
- package/src/adapters/cursor/live-transport.ts +26 -14
- package/src/adapters/cursor/native-exec-fs.ts +1 -1
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +1 -1
- package/src/adapters/cursor/protobuf-events.ts +72 -13
- package/src/adapters/cursor/protobuf-request.ts +82 -11
- package/src/adapters/cursor/request-builder.ts +35 -11
- package/src/adapters/cursor/tool-definitions.ts +175 -30
- package/src/adapters/openai-chat.ts +28 -7
- package/src/adapters/openai-responses.ts +150 -4
- package/src/bridge.ts +20 -1
- package/src/claude/outbound.ts +91 -6
- package/src/codex/auth-api.ts +12 -25
- package/src/codex/auth-context.ts +48 -3
- package/src/codex/catalog/provider-fetch.ts +56 -24
- package/src/codex/model-cache.ts +23 -0
- package/src/codex/quota.ts +120 -0
- package/src/codex/routing.ts +178 -9
- package/src/config.ts +56 -1
- package/src/providers/openai-sidecar.ts +8 -1
- package/src/providers/openai-tiers.ts +18 -0
- package/src/server/adapter-resolve.ts +24 -10
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-completions.ts +4 -0
- package/src/server/claude-messages.ts +4 -0
- package/src/server/index.ts +3 -1
- package/src/server/live.ts +56 -0
- package/src/server/memory-watchdog.ts +1 -1
- package/src/server/responses/compact.ts +40 -10
- package/src/server/responses/core.ts +180 -26
- package/src/server/responses/terminal-guard.ts +230 -0
- package/src/service.ts +113 -30
- package/src/types.ts +52 -0
- package/src/usage/expected-prices.ts +12 -0
- package/src/web-search/anthropic-executor.ts +3 -1
- package/src/web-search/index.ts +7 -1
- package/src/web-search/loop.ts +17 -3
- package/README.ja.md +0 -445
- package/README.ko.md +0 -435
- package/README.ru.md +0 -486
- package/README.zh-CN.md +0 -411
- package/gui/dist/assets/index-B-cheu55.js +0 -52
- package/gui/dist/assets/index-oOZcqVmj.css +0 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { IncomingMeta, ProviderAdapter } from "./base";
|
|
3
|
-
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types";
|
|
3
|
+
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types";
|
|
4
4
|
import { catalogModelSupportsReasoningSummaries } from "../codex/catalog";
|
|
5
|
-
import { decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
|
|
5
|
+
import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
|
|
6
|
+
import { decodeServerSentEvents } from "../lib/sse-decoder";
|
|
7
|
+
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
6
8
|
import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
|
|
7
9
|
import { modelRecordValue } from "../reasoning-effort";
|
|
8
10
|
|
|
@@ -518,6 +520,81 @@ function stripUnsupportedHostedTools(body: unknown): unknown {
|
|
|
518
520
|
return tools.length === body.tools.length ? body : { ...body, tools };
|
|
519
521
|
}
|
|
520
522
|
|
|
523
|
+
/** Replace every `input_image` part under a routed-compaction body with a short marker. */
|
|
524
|
+
function stripInputImagesDeep(value: unknown): unknown {
|
|
525
|
+
if (Array.isArray(value)) return value.map(stripInputImagesDeep);
|
|
526
|
+
if (!isPlainObject(value)) return value;
|
|
527
|
+
if (value.type === "input_image") {
|
|
528
|
+
return { type: "input_text", text: "[image omitted for compaction]" };
|
|
529
|
+
}
|
|
530
|
+
const out: Record<string, unknown> = {};
|
|
531
|
+
for (const [key, entry] of Object.entries(value)) out[key] = stripInputImagesDeep(entry);
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Rewrite a compaction turn for an upstream that does not speak Codex's private
|
|
537
|
+
* `compaction_trigger` item: drop the trigger and the whole tool surface, and ask
|
|
538
|
+
* for the handoff summary in plain terms instead (#422).
|
|
539
|
+
*
|
|
540
|
+
* The adapter builds from `parsed._rawBody`, so the summarizer prompt that
|
|
541
|
+
* handleResponses() pushed onto `parsed.context` never reaches the wire — it has to
|
|
542
|
+
* be applied here. Images go too: a summary needs no pixels, and a text-only
|
|
543
|
+
* gateway would reject them.
|
|
544
|
+
*/
|
|
545
|
+
function buildRoutedCompactionBody(body: unknown): unknown {
|
|
546
|
+
if (!isPlainObject(body)) return body;
|
|
547
|
+
const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, ...rest } = body;
|
|
548
|
+
const input = Array.isArray(body.input) ? body.input : [];
|
|
549
|
+
const kept = input.filter(item => !isPlainObject(item)
|
|
550
|
+
// `additional_tools` is how Codex Desktop's responses-lite shape carries tools;
|
|
551
|
+
// leaving it in would break the no-tools invariant even with `tools` removed.
|
|
552
|
+
|| (item.type !== "compaction_trigger" && item.type !== "additional_tools"));
|
|
553
|
+
return {
|
|
554
|
+
...rest,
|
|
555
|
+
input: [
|
|
556
|
+
...(stripInputImagesDeep(kept) as unknown[]),
|
|
557
|
+
{ type: "message", role: "user", content: [{ type: "input_text", text: COMPACT_PROMPT }] },
|
|
558
|
+
],
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Read the Responses `usage` block, if the gateway sent one. */
|
|
563
|
+
function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
|
|
564
|
+
if (!isPlainObject(payload) || !isPlainObject(payload.usage)) return undefined;
|
|
565
|
+
const usage = payload.usage;
|
|
566
|
+
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
|
|
567
|
+
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
|
|
568
|
+
if (inputTokens === 0 && outputTokens === 0) return undefined;
|
|
569
|
+
return {
|
|
570
|
+
inputTokens,
|
|
571
|
+
outputTokens,
|
|
572
|
+
...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function responsesPayloadText(response: unknown): string {
|
|
577
|
+
if (!isPlainObject(response) || !Array.isArray(response.output)) return "";
|
|
578
|
+
return response.output
|
|
579
|
+
.filter(item => isPlainObject(item) && item.type === "message")
|
|
580
|
+
.flatMap(item => (Array.isArray((item as Record<string, unknown>).content)
|
|
581
|
+
? (item as { content: unknown[] }).content
|
|
582
|
+
: []))
|
|
583
|
+
.filter(part => isPlainObject(part) && part.type === "output_text")
|
|
584
|
+
.map(part => String((part as { text?: unknown }).text ?? ""))
|
|
585
|
+
.join("");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function responsesErrorMessage(payload: unknown): string {
|
|
589
|
+
if (!isPlainObject(payload)) return "upstream compaction failed";
|
|
590
|
+
const err = payload.error;
|
|
591
|
+
if (typeof err === "string") return err;
|
|
592
|
+
if (isPlainObject(err) && typeof err.message === "string") return err.message;
|
|
593
|
+
const incomplete = payload.incomplete_details;
|
|
594
|
+
if (isPlainObject(incomplete) && typeof incomplete.reason === "string") return incomplete.reason;
|
|
595
|
+
return "upstream compaction failed";
|
|
596
|
+
}
|
|
597
|
+
|
|
521
598
|
export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } {
|
|
522
599
|
return {
|
|
523
600
|
name: "openai-responses",
|
|
@@ -573,6 +650,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
573
650
|
outBody = repairOversizedReplayCallIds(outBody);
|
|
574
651
|
}
|
|
575
652
|
outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId);
|
|
653
|
+
// Same predicate as the routedCompaction gate in handleResponses(): an
|
|
654
|
+
// authMode check would let a noncanonical custom forward provider skip this
|
|
655
|
+
// rewrite while the server still routes it as a summarizer turn (#422).
|
|
656
|
+
if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
|
|
657
|
+
outBody = buildRoutedCompactionBody(outBody);
|
|
658
|
+
}
|
|
576
659
|
return {
|
|
577
660
|
url,
|
|
578
661
|
method: "POST",
|
|
@@ -585,8 +668,71 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
585
668
|
};
|
|
586
669
|
},
|
|
587
670
|
|
|
588
|
-
|
|
589
|
-
|
|
671
|
+
// The passthrough normally relays the upstream stream verbatim and never parses.
|
|
672
|
+
// The exception is a routed compaction turn: the server drives this adapter like
|
|
673
|
+
// an ordinary one so the bridge can build the single compaction item (#422).
|
|
674
|
+
async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
|
|
675
|
+
if (!response.body) {
|
|
676
|
+
yield { type: "error", message: "passthrough adapter received no response body" };
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
let deltas = "";
|
|
680
|
+
let doneText = "";
|
|
681
|
+
let snapshot = "";
|
|
682
|
+
let usage: OcxUsage | undefined;
|
|
683
|
+
for await (const event of decodeServerSentEvents(response.body)) {
|
|
684
|
+
let payload: unknown;
|
|
685
|
+
try { payload = JSON.parse(event.data); } catch { continue; }
|
|
686
|
+
if (!isPlainObject(payload)) continue;
|
|
687
|
+
switch (payload.type) {
|
|
688
|
+
case "response.output_text.delta":
|
|
689
|
+
if (typeof payload.delta === "string") deltas += payload.delta;
|
|
690
|
+
break;
|
|
691
|
+
case "response.output_text.done":
|
|
692
|
+
if (typeof payload.text === "string") doneText += payload.text;
|
|
693
|
+
break;
|
|
694
|
+
case "response.failed":
|
|
695
|
+
case "error":
|
|
696
|
+
yield { type: "error", message: responsesErrorMessage(payload.response ?? payload) };
|
|
697
|
+
return;
|
|
698
|
+
case "response.incomplete":
|
|
699
|
+
yield { type: "incomplete", reason: responsesErrorMessage(payload.response ?? payload) };
|
|
700
|
+
return;
|
|
701
|
+
case "response.completed":
|
|
702
|
+
snapshot = responsesPayloadText(payload.response);
|
|
703
|
+
usage = usageFromResponsesPayload(payload.response);
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// Gateways differ in which of these they emit; prefer the authoritative
|
|
708
|
+
// completed snapshot so text is never double-counted.
|
|
709
|
+
const text = snapshot || doneText || deltas;
|
|
710
|
+
if (text) yield { type: "text_delta", text };
|
|
711
|
+
yield { type: "done", ...(usage ? { usage } : {}) };
|
|
712
|
+
},
|
|
713
|
+
|
|
714
|
+
async parseResponse(response: Response): Promise<AdapterEvent[]> {
|
|
715
|
+
let payload: unknown;
|
|
716
|
+
try { payload = await response.json(); } catch {
|
|
717
|
+
return [{ type: "error", message: "malformed upstream compaction response" }];
|
|
718
|
+
}
|
|
719
|
+
if (!isPlainObject(payload)) {
|
|
720
|
+
return [{ type: "error", message: "malformed upstream compaction response" }];
|
|
721
|
+
}
|
|
722
|
+
if (payload.error || payload.status === "failed") {
|
|
723
|
+
return [{ type: "error", message: responsesErrorMessage(payload) }];
|
|
724
|
+
}
|
|
725
|
+
if (payload.status === "incomplete") {
|
|
726
|
+
return [{ type: "incomplete", reason: responsesErrorMessage(payload) }];
|
|
727
|
+
}
|
|
728
|
+
const text = responsesPayloadText(payload);
|
|
729
|
+
if (!text) {
|
|
730
|
+
// A completed turn with no usable text cannot become a summary; saying so is
|
|
731
|
+
// better than installing an empty compaction as replacement history.
|
|
732
|
+
return [{ type: "error", message: "upstream compaction returned no summary text" }];
|
|
733
|
+
}
|
|
734
|
+
const usage = usageFromResponsesPayload(payload);
|
|
735
|
+
return [{ type: "text_delta", text }, { type: "done", ...(usage ? { usage } : {}) }];
|
|
590
736
|
},
|
|
591
737
|
};
|
|
592
738
|
}
|
package/src/bridge.ts
CHANGED
|
@@ -456,6 +456,17 @@ export function bridgeToResponsesSSE(
|
|
|
456
456
|
if (event.type !== "done" && event.type !== "incomplete" && event.type !== "error") continue;
|
|
457
457
|
}
|
|
458
458
|
switch (event.type) {
|
|
459
|
+
case "assistant_boundary": {
|
|
460
|
+
// A guarded continuation starts a fresh assistant output item while keeping the
|
|
461
|
+
// intermediate, suspicious text in the same Responses turn.
|
|
462
|
+
if (currentMsg) closeCurrentMessage();
|
|
463
|
+
if (currentReasoning) closeCurrentReasoning();
|
|
464
|
+
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
465
|
+
flushHiddenRawReasoning();
|
|
466
|
+
if (currentToolCall) closeCurrentToolCall();
|
|
467
|
+
flushHiddenReasoningEnvelope();
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
459
470
|
case "text_delta": {
|
|
460
471
|
if (currentReasoning) closeCurrentReasoning();
|
|
461
472
|
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
@@ -971,6 +982,12 @@ export function buildResponseJSON(
|
|
|
971
982
|
|
|
972
983
|
for (const e of events) {
|
|
973
984
|
switch (e.type) {
|
|
985
|
+
case "assistant_boundary":
|
|
986
|
+
flushText();
|
|
987
|
+
flushSummaryReasoning();
|
|
988
|
+
flushRawReasoning();
|
|
989
|
+
flushToolCall();
|
|
990
|
+
break;
|
|
974
991
|
case "text_delta":
|
|
975
992
|
if (currentText && currentTextPhase !== e.phase) flushText();
|
|
976
993
|
if (currentSummaryReasoning) flushSummaryReasoning();
|
|
@@ -1062,7 +1079,9 @@ export function buildResponseJSON(
|
|
|
1062
1079
|
flushSummaryReasoning();
|
|
1063
1080
|
flushRawReasoning();
|
|
1064
1081
|
flushToolCall();
|
|
1065
|
-
|
|
1082
|
+
// A truncated turn must never be installed as replacement history: emit the
|
|
1083
|
+
// compaction item only when the turn actually completed (#422).
|
|
1084
|
+
if (options?.compaction && !errorEvent && !incompleteEvent && stopReason !== "max_tokens") {
|
|
1066
1085
|
output.push({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) });
|
|
1067
1086
|
}
|
|
1068
1087
|
|
package/src/claude/outbound.ts
CHANGED
|
@@ -76,6 +76,43 @@ function sseFrame(name: string, data: Rec): string {
|
|
|
76
76
|
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Claude Code / Anthropic WebSearch domain filters are optional and mutually exclusive.
|
|
81
|
+
* Empty arrays are rejected ("ambiguous"); both fields together are rejected. Routed models
|
|
82
|
+
* often emit both shapes — sanitize before Claude Code sees the tool_use / server_tool_use
|
|
83
|
+
* input (issue #381).
|
|
84
|
+
*/
|
|
85
|
+
export function isClaudeWebSearchToolName(name: string): boolean {
|
|
86
|
+
const trimmed = name.trim();
|
|
87
|
+
return trimmed === "WebSearch" || /^web_search/i.test(trimmed);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeWebSearchDomainList(value: unknown): string[] | undefined {
|
|
91
|
+
if (!Array.isArray(value)) return undefined;
|
|
92
|
+
const domains = value
|
|
93
|
+
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
94
|
+
.map(entry => entry.trim());
|
|
95
|
+
return domains.length > 0 ? domains : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Strip empty domain filters; if both remain, keep `allowed_domains` and drop `blocked_domains`. */
|
|
99
|
+
export function sanitizeWebSearchInput(input: unknown): Rec {
|
|
100
|
+
const src: Rec = isRec(input) ? { ...input } : {};
|
|
101
|
+
const allowed = normalizeWebSearchDomainList(src.allowed_domains);
|
|
102
|
+
const blocked = normalizeWebSearchDomainList(src.blocked_domains);
|
|
103
|
+
delete src.allowed_domains;
|
|
104
|
+
delete src.blocked_domains;
|
|
105
|
+
if (allowed && blocked) {
|
|
106
|
+
// Prefer allow-list (restrict-to) when a routed model sets both non-empty fields.
|
|
107
|
+
src.allowed_domains = allowed;
|
|
108
|
+
} else if (allowed) {
|
|
109
|
+
src.allowed_domains = allowed;
|
|
110
|
+
} else if (blocked) {
|
|
111
|
+
src.blocked_domains = blocked;
|
|
112
|
+
}
|
|
113
|
+
return src;
|
|
114
|
+
}
|
|
115
|
+
|
|
79
116
|
/**
|
|
80
117
|
* Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use
|
|
81
118
|
* input (query/queries) and the web_search_tool_result content (hits, or the error
|
|
@@ -87,7 +124,9 @@ function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultConte
|
|
|
87
124
|
? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0)
|
|
88
125
|
: [];
|
|
89
126
|
const query = typeof action.query === "string" ? action.query : "";
|
|
90
|
-
const input
|
|
127
|
+
const input = sanitizeWebSearchInput(
|
|
128
|
+
queries.length > 1 ? { queries } : { query: queries[0] ?? query },
|
|
129
|
+
);
|
|
91
130
|
const completed = item.status !== "failed";
|
|
92
131
|
let resultContent: unknown;
|
|
93
132
|
if (completed) {
|
|
@@ -125,6 +164,10 @@ interface OpenBlock {
|
|
|
125
164
|
index: number;
|
|
126
165
|
/** Responses item_id (tool calls) so output_item.done can match. */
|
|
127
166
|
itemId?: string;
|
|
167
|
+
/** Buffer WebSearch args and emit one sanitized input_json_delta on close (#381). */
|
|
168
|
+
bufferWebSearchArgs?: boolean;
|
|
169
|
+
argsBuf?: string;
|
|
170
|
+
webSearchArgsEmitted?: boolean;
|
|
128
171
|
}
|
|
129
172
|
|
|
130
173
|
/** Streaming: Responses SSE bytes -> Anthropic Messages SSE bytes. */
|
|
@@ -170,6 +213,19 @@ export function responsesSseToAnthropicSse(
|
|
|
170
213
|
}
|
|
171
214
|
const closeOpenBlock = () => {
|
|
172
215
|
if (!open) return;
|
|
216
|
+
if (open.kind === "tool_use" && open.bufferWebSearchArgs && !open.webSearchArgsEmitted) {
|
|
217
|
+
// Emit sanitized args even if output_item.done never supplied a full arguments field
|
|
218
|
+
// (finish/fail paths, or deltas-only streams).
|
|
219
|
+
let parsed: unknown = {};
|
|
220
|
+
const rawArgs = open.argsBuf ?? "";
|
|
221
|
+
try { parsed = rawArgs.length > 0 ? JSON.parse(rawArgs) : {}; } catch { parsed = {}; }
|
|
222
|
+
emit("content_block_delta", {
|
|
223
|
+
type: "content_block_delta",
|
|
224
|
+
index: open.index,
|
|
225
|
+
delta: { type: "input_json_delta", partial_json: JSON.stringify(sanitizeWebSearchInput(parsed)) },
|
|
226
|
+
});
|
|
227
|
+
open.webSearchArgsEmitted = true;
|
|
228
|
+
}
|
|
173
229
|
if (open.kind === "thinking") {
|
|
174
230
|
// Synthetic signature: Claude Code accepts it (003 E6); inbound drops replays anyway.
|
|
175
231
|
emit("content_block_delta", {
|
|
@@ -253,21 +309,34 @@ export function responsesSseToAnthropicSse(
|
|
|
253
309
|
closeOpenBlock();
|
|
254
310
|
sawToolUse = true;
|
|
255
311
|
const index = blockIndex++;
|
|
312
|
+
const name = typeof item.name === "string" ? item.name : "";
|
|
313
|
+
const bufferWebSearchArgs = isClaudeWebSearchToolName(name);
|
|
256
314
|
emit("content_block_start", {
|
|
257
315
|
type: "content_block_start", index,
|
|
258
316
|
content_block: {
|
|
259
317
|
type: "tool_use",
|
|
260
318
|
id: typeof item.call_id === "string" ? item.call_id : `toolu_${uuid()}`,
|
|
261
|
-
name
|
|
319
|
+
name,
|
|
262
320
|
input: {},
|
|
263
321
|
},
|
|
264
322
|
});
|
|
265
|
-
open = {
|
|
323
|
+
open = {
|
|
324
|
+
kind: "tool_use",
|
|
325
|
+
index,
|
|
326
|
+
itemId: typeof item.id === "string" ? item.id : undefined,
|
|
327
|
+
bufferWebSearchArgs,
|
|
328
|
+
argsBuf: "",
|
|
329
|
+
webSearchArgsEmitted: false,
|
|
330
|
+
};
|
|
266
331
|
break;
|
|
267
332
|
}
|
|
268
333
|
case "response.function_call_arguments.delta": {
|
|
269
334
|
if (typeof data.delta !== "string" || data.delta.length === 0) break;
|
|
270
335
|
if (!open || open.kind !== "tool_use") break;
|
|
336
|
+
if (open.bufferWebSearchArgs) {
|
|
337
|
+
open.argsBuf = `${open.argsBuf ?? ""}${data.delta}`;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
271
340
|
emit("content_block_delta", {
|
|
272
341
|
type: "content_block_delta", index: open.index,
|
|
273
342
|
delta: { type: "input_json_delta", partial_json: data.delta },
|
|
@@ -307,7 +376,22 @@ export function responsesSseToAnthropicSse(
|
|
|
307
376
|
if (!open) break;
|
|
308
377
|
// Close the matching open block (message/reasoning items close implicitly on
|
|
309
378
|
// the next block; function_call items must close here so tool input parses).
|
|
310
|
-
if (open.kind === "tool_use" && item.type === "function_call")
|
|
379
|
+
if (open.kind === "tool_use" && item.type === "function_call") {
|
|
380
|
+
if (open.bufferWebSearchArgs && !open.webSearchArgsEmitted) {
|
|
381
|
+
const rawArgs = typeof item.arguments === "string" && item.arguments.length > 0
|
|
382
|
+
? item.arguments
|
|
383
|
+
: (open.argsBuf ?? "");
|
|
384
|
+
let parsed: unknown = {};
|
|
385
|
+
try { parsed = rawArgs.length > 0 ? JSON.parse(rawArgs) : {}; } catch { parsed = {}; }
|
|
386
|
+
emit("content_block_delta", {
|
|
387
|
+
type: "content_block_delta",
|
|
388
|
+
index: open.index,
|
|
389
|
+
delta: { type: "input_json_delta", partial_json: JSON.stringify(sanitizeWebSearchInput(parsed)) },
|
|
390
|
+
});
|
|
391
|
+
open.webSearchArgsEmitted = true;
|
|
392
|
+
}
|
|
393
|
+
closeOpenBlock();
|
|
394
|
+
}
|
|
311
395
|
else if (open.kind === "text" && item.type === "message") closeOpenBlock();
|
|
312
396
|
else if (open.kind === "thinking" && item.type === "reasoning") closeOpenBlock();
|
|
313
397
|
break;
|
|
@@ -442,11 +526,12 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
|
|
|
442
526
|
if (typeof raw.arguments === "string" && raw.arguments.length > 0) {
|
|
443
527
|
try { input = JSON.parse(raw.arguments); } catch { input = {}; }
|
|
444
528
|
}
|
|
529
|
+
const name = typeof raw.name === "string" ? raw.name : "";
|
|
445
530
|
content.push({
|
|
446
531
|
type: "tool_use",
|
|
447
532
|
id: typeof raw.call_id === "string" ? raw.call_id : `toolu_${uuid()}`,
|
|
448
|
-
name
|
|
449
|
-
input,
|
|
533
|
+
name,
|
|
534
|
+
input: isClaudeWebSearchToolName(name) ? sanitizeWebSearchInput(input) : input,
|
|
450
535
|
});
|
|
451
536
|
break;
|
|
452
537
|
}
|
package/src/codex/auth-api.ts
CHANGED
|
@@ -19,11 +19,19 @@ import {
|
|
|
19
19
|
getAccountQuota,
|
|
20
20
|
listAccountQuotas,
|
|
21
21
|
parseUsageQuota,
|
|
22
|
+
setAccountQuotaFromParsed,
|
|
22
23
|
updateAccountQuota,
|
|
23
24
|
type StoredAccountQuota,
|
|
24
25
|
type WhamUsageResponse,
|
|
25
26
|
} from "./quota";
|
|
26
|
-
export {
|
|
27
|
+
export {
|
|
28
|
+
applyAccountQuotaFromUpstreamHeaders,
|
|
29
|
+
clearAccountQuota,
|
|
30
|
+
getAccountQuota,
|
|
31
|
+
parseUsageQuota,
|
|
32
|
+
setAccountQuotaFromParsed,
|
|
33
|
+
updateAccountQuota,
|
|
34
|
+
} from "./quota";
|
|
27
35
|
import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
|
|
28
36
|
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
|
|
29
37
|
import { maskEmail } from "../lib/privacy";
|
|
@@ -280,14 +288,7 @@ export async function fetchMainAccountInfo(forceRefresh = false): Promise<{ emai
|
|
|
280
288
|
// score and auto-switch the main account exactly like a pool account (Option A).
|
|
281
289
|
setMainAccountPlan(result.plan);
|
|
282
290
|
if (result.quota) {
|
|
283
|
-
|
|
284
|
-
MAIN_CODEX_ACCOUNT_ID,
|
|
285
|
-
result.quota.weeklyPercent,
|
|
286
|
-
result.quota.weeklyResetAt,
|
|
287
|
-
result.quota.monthlyPercent,
|
|
288
|
-
result.quota.monthlyResetAt,
|
|
289
|
-
result.quota.resetCredits,
|
|
290
|
-
);
|
|
291
|
+
setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota);
|
|
291
292
|
}
|
|
292
293
|
return result;
|
|
293
294
|
} catch {
|
|
@@ -327,14 +328,7 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
|
|
|
327
328
|
const data = (await resp.json()) as WhamUsageResponse;
|
|
328
329
|
const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan });
|
|
329
330
|
if (!quota) return { quota: existing ?? null, needsReauth: false };
|
|
330
|
-
|
|
331
|
-
accountId,
|
|
332
|
-
quota.weeklyPercent,
|
|
333
|
-
quota.weeklyResetAt,
|
|
334
|
-
quota.monthlyPercent,
|
|
335
|
-
quota.monthlyResetAt,
|
|
336
|
-
quota.resetCredits,
|
|
337
|
-
);
|
|
331
|
+
setAccountQuotaFromParsed(accountId, quota);
|
|
338
332
|
return { quota: getAccountQuota(accountId), needsReauth: false };
|
|
339
333
|
} catch (e) {
|
|
340
334
|
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError) return { quota: existing ?? null, needsReauth: false };
|
|
@@ -767,14 +761,7 @@ export async function handleCodexAuthAPI(
|
|
|
767
761
|
markCodexAccountValidated(accountId, warmup.validatedAt);
|
|
768
762
|
clearAccountNeedsReauth(accountId);
|
|
769
763
|
if (quota) {
|
|
770
|
-
|
|
771
|
-
accountId,
|
|
772
|
-
quota.weeklyPercent,
|
|
773
|
-
quota.weeklyResetAt,
|
|
774
|
-
quota.monthlyPercent,
|
|
775
|
-
quota.monthlyResetAt,
|
|
776
|
-
quota.resetCredits,
|
|
777
|
-
);
|
|
764
|
+
setAccountQuotaFromParsed(accountId, quota);
|
|
778
765
|
}
|
|
779
766
|
|
|
780
767
|
const latestConfig = getRuntimeConfig(config);
|
|
@@ -9,6 +9,8 @@ import { isCodexAccountUsable } from "./account-usability";
|
|
|
9
9
|
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
|
|
10
10
|
import {
|
|
11
11
|
getCodexAccountCooldownUntil,
|
|
12
|
+
releaseCodexQuotaProbeLease,
|
|
13
|
+
tryAcquireCodexQuotaProbeLease,
|
|
12
14
|
pickLowestUsageCodexAccount,
|
|
13
15
|
resolveCodexAccountForThreadDetailed,
|
|
14
16
|
} from "./routing";
|
|
@@ -24,6 +26,12 @@ export type CodexAuthContext =
|
|
|
24
26
|
generation: number;
|
|
25
27
|
accessToken: string;
|
|
26
28
|
chatgptAccountId: string;
|
|
29
|
+
/**
|
|
30
|
+
* Set when this request was admitted through an active quota cooldown as
|
|
31
|
+
* the account's single probe. Must be echoed into the upstream outcome so
|
|
32
|
+
* only this request can clear the cooldown (#433).
|
|
33
|
+
*/
|
|
34
|
+
probeLeaseId?: string;
|
|
27
35
|
}
|
|
28
36
|
| {
|
|
29
37
|
// Main Codex account participating in rotation: token injected from ~/.codex/auth.json
|
|
@@ -32,8 +40,24 @@ export type CodexAuthContext =
|
|
|
32
40
|
accountId: string;
|
|
33
41
|
accessToken: string;
|
|
34
42
|
chatgptAccountId: string;
|
|
43
|
+
/** See `pool.probeLeaseId`. */
|
|
44
|
+
probeLeaseId?: string;
|
|
35
45
|
};
|
|
36
46
|
|
|
47
|
+
/** Probe lease carried by this context, when it holds one. */
|
|
48
|
+
export function codexProbeLeaseId(ctx: CodexAuthContext | undefined): string | undefined {
|
|
49
|
+
return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeLeaseId : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Hand back a probe lease for a request that will not reach upstream. Safe to
|
|
54
|
+
* call with a context that holds no lease.
|
|
55
|
+
*/
|
|
56
|
+
export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void {
|
|
57
|
+
const leaseId = codexProbeLeaseId(ctx);
|
|
58
|
+
if (ctx && leaseId) releaseCodexQuotaProbeLease(ctx.accountId!, leaseId);
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
export type OcxRuntimeProviderConfig = OcxProviderConfig & {
|
|
38
62
|
_codexAccountOverride?: { accessToken: string; chatgptAccountId: string };
|
|
39
63
|
_codexAccountRequired?: boolean;
|
|
@@ -130,13 +154,30 @@ export async function resolveCodexAuthContext(
|
|
|
130
154
|
.catch(() => {});
|
|
131
155
|
}
|
|
132
156
|
const cooldownUntil = getCodexAccountCooldownUntil(accountId);
|
|
133
|
-
|
|
157
|
+
// A cooled-down account never sends traffic, so upstream recovery can never be
|
|
158
|
+
// observed and the cooldown outlives the real limit. Admit one probe per
|
|
159
|
+
// interval; its outcome decides whether the cooldown ends (#433).
|
|
160
|
+
let probeLeaseId: string | undefined;
|
|
161
|
+
if (cooldownUntil) {
|
|
162
|
+
probeLeaseId = tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
|
|
163
|
+
if (!probeLeaseId) throw new CodexAccountCooldownError(accountId, cooldownUntil);
|
|
164
|
+
}
|
|
134
165
|
|
|
135
166
|
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
136
167
|
// Main account in rotation: inject the read-only auth.json token and fail closed if it vanished.
|
|
137
168
|
const token = getMainAccountToken();
|
|
138
|
-
if (!token)
|
|
139
|
-
|
|
169
|
+
if (!token) {
|
|
170
|
+
// Nothing will reach upstream, so give the probe back instead of burning it.
|
|
171
|
+
if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
172
|
+
throw new CodexPoolAuthenticationError();
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
kind: "main-pool",
|
|
176
|
+
accountId,
|
|
177
|
+
accessToken: token.accessToken,
|
|
178
|
+
chatgptAccountId: token.chatgptAccountId,
|
|
179
|
+
...(probeLeaseId ? { probeLeaseId } : {}),
|
|
180
|
+
};
|
|
140
181
|
}
|
|
141
182
|
|
|
142
183
|
try {
|
|
@@ -147,8 +188,10 @@ export async function resolveCodexAuthContext(
|
|
|
147
188
|
generation: token.generation,
|
|
148
189
|
accessToken: token.accessToken,
|
|
149
190
|
chatgptAccountId: token.chatgptAccountId,
|
|
191
|
+
...(probeLeaseId ? { probeLeaseId } : {}),
|
|
150
192
|
};
|
|
151
193
|
} catch (cause) {
|
|
194
|
+
if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
152
195
|
if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
|
|
153
196
|
markAccountNeedsReauth(accountId);
|
|
154
197
|
}
|
|
@@ -158,6 +201,8 @@ export async function resolveCodexAuthContext(
|
|
|
158
201
|
|
|
159
202
|
export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefined): void {
|
|
160
203
|
if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return;
|
|
204
|
+
// A context holding the probe lease was deliberately admitted through the cooldown.
|
|
205
|
+
if (ctx.probeLeaseId) return;
|
|
161
206
|
const cooldownUntil = getCodexAccountCooldownUntil(ctx.accountId);
|
|
162
207
|
if (cooldownUntil) throw new CodexAccountCooldownError(ctx.accountId, cooldownUntil);
|
|
163
208
|
}
|