@bitkyc08/opencodex 2.46.0-preview.20260907 → 2.47.0-preview.20260908
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 +22 -0
- package/SPONSORS.md +105 -0
- package/gui/dist/assets/index-B8ZZ0ivI.css +1 -0
- package/gui/dist/assets/{index-NcAVXkST.js → index-CNTWhC5F.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/adapters/cursor/protobuf-request.ts +34 -21
- package/src/adapters/cursor/tool-guidance.ts +2 -2
- package/src/adapters/cursor/tool-result-normalize.ts +22 -2
- package/src/adapters/exec-tool-result-normalize.ts +87 -0
- package/src/adapters/kiro.ts +29 -18
- package/src/adapters/responses-code-mode.ts +14 -4
- package/src/adapters/tool-catalog-nudge.ts +2 -2
- package/src/bridge.ts +40 -24
- package/src/claude/inbound.ts +21 -6
- package/src/claude/outbound.ts +43 -26
- package/src/cli/capabilities.ts +27 -0
- package/src/cli/integrations.ts +1 -1
- package/src/cli/models-runtime-subcommands.ts +2 -0
- package/src/cli/models-runtime.ts +108 -1
- package/src/cli/observe.ts +18 -3
- package/src/cli/usage-report.ts +6 -1
- package/src/clients/config-export.ts +5 -3
- package/src/codex/auth-api.ts +36 -0
- package/src/codex/catalog/provider-fetch.ts +2 -1
- package/src/codex/quota-auto-refresh-state.ts +8 -0
- package/src/codex/quota-auto-refresh.ts +157 -27
- package/src/codex/warmup.ts +5 -0
- package/src/config/provider-validation.ts +70 -1
- package/src/config.ts +73 -1
- package/src/generated/compatibility-version.json +67 -55
- package/src/lib/json-byte-size.ts +61 -0
- package/src/lib/provider-outbound.ts +11 -10
- package/src/oauth/index.ts +42 -8
- package/src/oauth/orcarouter.ts +200 -0
- package/src/providers/key-store.ts +22 -0
- package/src/providers/registry.ts +78 -29
- package/src/responses/citation-markers.ts +56 -24
- package/src/responses/reasoning-envelope.ts +53 -19
- package/src/server/auth-cors.ts +8 -0
- package/src/server/chat-completions.ts +7 -0
- package/src/server/chat-native.ts +65 -3
- package/src/server/claude-messages.ts +30 -10
- package/src/server/effort-policy.ts +183 -1
- package/src/server/management/agent-settings-routes.ts +50 -9
- package/src/server/management/config-routes.ts +2 -0
- package/src/server/management/logs-usage-routes.ts +11 -3
- package/src/server/management/model-routes.ts +65 -1
- package/src/server/management/model-rows.ts +5 -0
- package/src/server/management/provider-routes.ts +99 -6
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -7
- package/src/server/responses/compact.ts +3 -2
- package/src/server/responses/core.ts +16 -2
- package/src/server/startup-health-cache.ts +31 -9
- package/src/types/config.ts +5 -0
- package/src/types/provider.ts +4 -0
- package/src/usage/cost.ts +27 -31
- package/src/usage/summary.ts +52 -9
- package/src/usage/time-range.ts +48 -0
- package/src/usage/user-cost-overlays.ts +35 -3
- package/src/vision/anthropic-describe.ts +46 -2
- package/src/web-search/anthropic-executor.ts +49 -3
- package/src/web-search/parse.ts +1 -1
- package/gui/dist/assets/index-BFgUC17B.css +0 -1
package/src/bridge.ts
CHANGED
|
@@ -493,7 +493,7 @@ export function bridgeToResponsesSSE(
|
|
|
493
493
|
const previousBytes = pendingSignatureBytes
|
|
494
494
|
+ pendingRedacted.reduce((sum, value) => sum + bytesOf(value), 0)
|
|
495
495
|
+ (hiddenText ? hiddenThinkingBytes : 0);
|
|
496
|
-
const encoded = encodeReasoningEnvelope(envelope);
|
|
496
|
+
const encoded = encodeReasoningEnvelope(envelope, budget);
|
|
497
497
|
const reservation = budget?.reserveTransient(bytesOf(encoded), { kind: "reasoning" });
|
|
498
498
|
pendingSignature = undefined;
|
|
499
499
|
pendingSignatureBytes = 0;
|
|
@@ -533,7 +533,7 @@ export function bridgeToResponsesSSE(
|
|
|
533
533
|
if (!hiddenRawReasoningText) return;
|
|
534
534
|
rawReasoningForNextToolCall = hiddenRawReasoningText;
|
|
535
535
|
const previousBytes = hiddenRawReasoningBytes;
|
|
536
|
-
const encrypted = encodeReasoningEnvelope({ txt: hiddenRawReasoningText });
|
|
536
|
+
const encrypted = encodeReasoningEnvelope({ txt: hiddenRawReasoningText }, budget);
|
|
537
537
|
const reservation = budget?.reserveTransient(bytesOf(encrypted), { kind: "reasoning" });
|
|
538
538
|
hiddenRawReasoningText = "";
|
|
539
539
|
hiddenRawReasoningBytes = 0;
|
|
@@ -556,7 +556,7 @@ export function bridgeToResponsesSSE(
|
|
|
556
556
|
const flushKiroRedactedReasoning = () => {
|
|
557
557
|
if (!pendingKiroRedacted) return;
|
|
558
558
|
const previousBytes = pendingKiroRedactedBytes;
|
|
559
|
-
const encrypted = encodeReasoningEnvelope({ krc: pendingKiroRedacted });
|
|
559
|
+
const encrypted = encodeReasoningEnvelope({ krc: pendingKiroRedacted }, budget);
|
|
560
560
|
const reservation = budget?.reserveTransient(bytesOf(encrypted), { kind: "reasoning" });
|
|
561
561
|
pendingKiroRedacted = undefined;
|
|
562
562
|
pendingKiroRedactedBytes = 0;
|
|
@@ -902,6 +902,16 @@ export function bridgeToResponsesSSE(
|
|
|
902
902
|
gated = true;
|
|
903
903
|
stepping = false;
|
|
904
904
|
};
|
|
905
|
+
const attemptTerminationCleanup = (action: () => void): boolean => {
|
|
906
|
+
try {
|
|
907
|
+
action();
|
|
908
|
+
return !terminated && !closed;
|
|
909
|
+
} catch (error) {
|
|
910
|
+
if (!isTranslatorBudgetExceededError(error)) throw error;
|
|
911
|
+
terminateForTranslatorOverflow(error);
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
};
|
|
905
915
|
const step = async () => {
|
|
906
916
|
if (stepping || closed) return;
|
|
907
917
|
stepping = true;
|
|
@@ -1415,10 +1425,12 @@ export function bridgeToResponsesSSE(
|
|
|
1415
1425
|
return;
|
|
1416
1426
|
}
|
|
1417
1427
|
if (!terminated) {
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1428
|
+
if (!attemptTerminationCleanup(() => {
|
|
1429
|
+
flushHiddenRawReasoning();
|
|
1430
|
+
if (currentToolCall) failCurrentToolCall();
|
|
1431
|
+
if (currentWebSearch) closeCurrentWebSearch("failed", []);
|
|
1432
|
+
releasePendingWebSources();
|
|
1433
|
+
})) return;
|
|
1422
1434
|
const failure = responseError(
|
|
1423
1435
|
500,
|
|
1424
1436
|
"proxy_error",
|
|
@@ -1448,13 +1460,15 @@ export function bridgeToResponsesSSE(
|
|
|
1448
1460
|
if (!terminated) {
|
|
1449
1461
|
// The adapter generator ended without an explicit done/error event. Mark as incomplete
|
|
1450
1462
|
// rather than completed so Codex can distinguish a clean finish from a truncated stream.
|
|
1451
|
-
if (
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1463
|
+
if (!attemptTerminationCleanup(() => {
|
|
1464
|
+
if (currentMsg) closeCurrentMessage();
|
|
1465
|
+
if (currentReasoning) closeCurrentReasoning();
|
|
1466
|
+
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
1467
|
+
flushHiddenRawReasoning();
|
|
1468
|
+
if (currentToolCall) failCurrentToolCall();
|
|
1469
|
+
if (currentWebSearch) closeCurrentWebSearch("failed", []);
|
|
1470
|
+
releasePendingWebSources();
|
|
1471
|
+
})) return;
|
|
1458
1472
|
options?.onUsage?.(undefined);
|
|
1459
1473
|
await awaitThoughtSignatureDurability();
|
|
1460
1474
|
emit("response.incomplete", {
|
|
@@ -1493,13 +1507,15 @@ export function bridgeToResponsesSSE(
|
|
|
1493
1507
|
upstreamActivity = false;
|
|
1494
1508
|
stallTicks = 0;
|
|
1495
1509
|
} else if (++stallTicks >= maxStallTicks) {
|
|
1496
|
-
if (
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1510
|
+
if (!attemptTerminationCleanup(() => {
|
|
1511
|
+
if (currentMsg) closeCurrentMessage();
|
|
1512
|
+
if (currentReasoning) closeCurrentReasoning();
|
|
1513
|
+
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
1514
|
+
flushHiddenRawReasoning();
|
|
1515
|
+
if (currentToolCall) failCurrentToolCall();
|
|
1516
|
+
if (currentWebSearch) closeCurrentWebSearch("failed", []);
|
|
1517
|
+
releasePendingWebSources();
|
|
1518
|
+
})) return;
|
|
1503
1519
|
// #1926 gap 2 residual: this beat callback is synchronous, so the durability
|
|
1504
1520
|
// barrier is not awaited on the stall-timeout kill path. The in-memory store is
|
|
1505
1521
|
// already updated; only a crash between here and the queued write loses it,
|
|
@@ -1728,7 +1744,7 @@ function buildResponseJSONWithBudget(
|
|
|
1728
1744
|
if (batchRedacted.length > 0) envelope.red = batchRedacted;
|
|
1729
1745
|
const hidden = options?.hideThinkingSummary === true;
|
|
1730
1746
|
if (hidden && currentSummaryReasoning && (envelope.sig || envelope.red)) envelope.txt = currentSummaryReasoning;
|
|
1731
|
-
const encrypted = envelope.sig || envelope.red || envelope.txt ? encodeReasoningEnvelope(envelope) : undefined;
|
|
1747
|
+
const encrypted = envelope.sig || envelope.red || envelope.txt ? encodeReasoningEnvelope(envelope, budget) : undefined;
|
|
1732
1748
|
const sourceBytes = currentSummaryReasoningBytes + batchSignatureBytes + batchRedactedBytes;
|
|
1733
1749
|
batchSignature = undefined;
|
|
1734
1750
|
batchSignatureBytes = 0;
|
|
@@ -1756,7 +1772,7 @@ function buildResponseJSONWithBudget(
|
|
|
1756
1772
|
// Same contract as the streaming path: no visible reasoning, txt-only envelope round-trip.
|
|
1757
1773
|
pushOutput({
|
|
1758
1774
|
type: "reasoning", id: `rs_${uuid()}`, summary: [],
|
|
1759
|
-
encrypted_content: encodeReasoningEnvelope({ txt: currentRawReasoning }),
|
|
1775
|
+
encrypted_content: encodeReasoningEnvelope({ txt: currentRawReasoning }, budget),
|
|
1760
1776
|
}, currentRawReasoningBytes, "reasoning");
|
|
1761
1777
|
currentRawReasoning = "";
|
|
1762
1778
|
currentRawReasoningBytes = 0;
|
|
@@ -2044,7 +2060,7 @@ function buildResponseJSONWithBudget(
|
|
|
2044
2060
|
// pushOutput reserves the item itself and releases the retained raw blob it replaces.
|
|
2045
2061
|
pushOutput({
|
|
2046
2062
|
type: "reasoning", id: `rs_${uuid()}`, summary: [],
|
|
2047
|
-
encrypted_content: encodeReasoningEnvelope({ krc: batchKiroRedacted }),
|
|
2063
|
+
encrypted_content: encodeReasoningEnvelope({ krc: batchKiroRedacted }, budget),
|
|
2048
2064
|
}, batchKiroRedactedBytes, "reasoning");
|
|
2049
2065
|
batchKiroRedacted = undefined;
|
|
2050
2066
|
batchKiroRedactedBytes = 0;
|
package/src/claude/inbound.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { AnthropicRequestError, isRec, type Rec } from "./inbound-records";
|
|
|
18
18
|
import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, formatFromOutputConfig } from "./inbound-model-options";
|
|
19
19
|
import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options";
|
|
20
20
|
import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
|
|
21
|
+
import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget";
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
|
|
@@ -210,7 +211,7 @@ function userMessageToItems(content: unknown, input: Rec[], elide: SkillElisionC
|
|
|
210
211
|
pushUserMessage(input, pending);
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
function assistantMessageToItems(content: unknown, input: Rec[]): void {
|
|
214
|
+
function assistantMessageToItems(content: unknown, input: Rec[], budget: TranslatorBudget): void {
|
|
214
215
|
if (typeof content === "string") {
|
|
215
216
|
if (content.length > 0) input.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: content }] });
|
|
216
217
|
return;
|
|
@@ -240,11 +241,12 @@ function assistantMessageToItems(content: unknown, input: Rec[]): void {
|
|
|
240
241
|
const thinking = typeof raw.thinking === "string" ? raw.thinking : "";
|
|
241
242
|
const signature = typeof raw.signature === "string" ? raw.signature : "";
|
|
242
243
|
if (signature.startsWith(OCX_REASONING_PREFIX)) {
|
|
243
|
-
const owned = decodeReasoningEnvelope(signature);
|
|
244
|
+
const owned = decodeReasoningEnvelope(signature, budget);
|
|
244
245
|
if (!owned) throw new AnthropicRequestError("malformed ocxr1 reasoning signature");
|
|
245
246
|
if (Object.hasOwn(owned, "sig")) throw new AnthropicRequestError("OpenCodex reasoning continuity cannot be replayed as an Anthropic signature");
|
|
246
247
|
}
|
|
247
|
-
const encrypted = signature.length === 0 ? undefined : signature.startsWith(OCX_REASONING_PREFIX) ? signature : encodeReasoningEnvelope({ sig: signature });
|
|
248
|
+
const encrypted = signature.length === 0 ? undefined : signature.startsWith(OCX_REASONING_PREFIX) ? signature : encodeReasoningEnvelope({ sig: signature }, budget);
|
|
249
|
+
if (encrypted) budget.chargeRetained(2 * encrypted.length, { kind: "reasoning" });
|
|
248
250
|
if (thinking.length === 0 && !encrypted) break;
|
|
249
251
|
input.push({ type: "reasoning", id: `rs_${crypto.randomUUID().replace(/-/g, "")}`, summary: thinking.length > 0 ? [{ type: "summary_text", text: thinking }] : [], ...(encrypted ? { encrypted_content: encrypted } : {}) });
|
|
250
252
|
break;
|
|
@@ -252,7 +254,11 @@ function assistantMessageToItems(content: unknown, input: Rec[]): void {
|
|
|
252
254
|
case "redacted_thinking": {
|
|
253
255
|
flush();
|
|
254
256
|
const data = typeof raw.data === "string" ? raw.data : "";
|
|
255
|
-
if (data.length > 0)
|
|
257
|
+
if (data.length > 0) {
|
|
258
|
+
const encrypted = encodeReasoningEnvelope({ red: [data] }, budget);
|
|
259
|
+
budget.chargeRetained(2 * encrypted.length, { kind: "reasoning" });
|
|
260
|
+
input.push({ type: "reasoning", id: `rs_${crypto.randomUUID().replace(/-/g, "")}`, summary: [], encrypted_content: encrypted });
|
|
261
|
+
}
|
|
256
262
|
break;
|
|
257
263
|
}
|
|
258
264
|
default:
|
|
@@ -294,7 +300,16 @@ export function anthropicToResponsesBody(raw: unknown, cc?: OcxClaudeCodeConfig)
|
|
|
294
300
|
* OUT-OF-BODY tuple (audit 133 R3#1 — an in-body marker would leak upstream through
|
|
295
301
|
* the native Responses forward and 400).
|
|
296
302
|
*/
|
|
297
|
-
export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCodeConfig): ClaudeInboundTranslation {
|
|
303
|
+
export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCodeConfig, budget?: TranslatorBudget): ClaudeInboundTranslation {
|
|
304
|
+
const activeBudget = budget ?? createTranslatorBudget();
|
|
305
|
+
try {
|
|
306
|
+
return translateAnthropicRequest(raw, cc, activeBudget);
|
|
307
|
+
} finally {
|
|
308
|
+
if (!budget) activeBudget.dispose();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undefined, budget: TranslatorBudget): ClaudeInboundTranslation {
|
|
298
313
|
if (!isRec(raw)) throw new AnthropicRequestError("request body must be a JSON object");
|
|
299
314
|
if (typeof raw.model !== "string" || raw.model.length === 0) {
|
|
300
315
|
throw new AnthropicRequestError("model is required");
|
|
@@ -315,7 +330,7 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode
|
|
|
315
330
|
for (const msg of raw.messages) {
|
|
316
331
|
if (!isRec(msg)) throw new AnthropicRequestError("each message must be an object");
|
|
317
332
|
if (msg.role === "user") userMessageToItems(msg.content, input, elide);
|
|
318
|
-
else if (msg.role === "assistant") assistantMessageToItems(msg.content, input);
|
|
333
|
+
else if (msg.role === "assistant") assistantMessageToItems(msg.content, input, budget);
|
|
319
334
|
else if (msg.role === "system") {
|
|
320
335
|
const text = systemMessageText(msg.content);
|
|
321
336
|
if (text.length > 0) systemParts.push(text);
|
package/src/claude/outbound.ts
CHANGED
|
@@ -215,6 +215,8 @@ interface OpenBlock {
|
|
|
215
215
|
callId?: string;
|
|
216
216
|
/** Last fixed-size reasoning identity (item + summary/content index) seen by this block. */
|
|
217
217
|
reasoningPartKey?: string;
|
|
218
|
+
/** Fixed-size item identity; missing IDs only match other missing IDs. */
|
|
219
|
+
reasoningItemKey?: string;
|
|
218
220
|
thinkingBuf?: string;
|
|
219
221
|
thinkingBufBytes?: number;
|
|
220
222
|
reasoningSig?: string;
|
|
@@ -308,7 +310,21 @@ export function responsesSseToAnthropicSse(
|
|
|
308
310
|
open.webSearchArgsEmitted = true;
|
|
309
311
|
}
|
|
310
312
|
if (open.kind === "thinking") {
|
|
311
|
-
|
|
313
|
+
// Delay the index and all thinking frames until closure so a matching
|
|
314
|
+
// done envelope can put its redacted blocks first. The existing buffer
|
|
315
|
+
// remains charged through signature emission, including queued frames.
|
|
316
|
+
open.index = blockIndex++;
|
|
317
|
+
emit("content_block_start", {
|
|
318
|
+
type: "content_block_start", index: open.index,
|
|
319
|
+
content_block: { type: "thinking", thinking: "", signature: "" },
|
|
320
|
+
});
|
|
321
|
+
if (open.thinkingBuf) {
|
|
322
|
+
emit("content_block_delta", {
|
|
323
|
+
type: "content_block_delta", index: open.index,
|
|
324
|
+
delta: { type: "thinking_delta", thinking: open.thinkingBuf },
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const signature = open.reasoningSig ?? encodeReasoningEnvelope({ txt: open.thinkingBuf ?? "" }, translatorBudget);
|
|
312
328
|
emit("content_block_delta", {
|
|
313
329
|
type: "content_block_delta", index: open.index,
|
|
314
330
|
delta: { type: "signature_delta", signature },
|
|
@@ -323,12 +339,13 @@ export function responsesSseToAnthropicSse(
|
|
|
323
339
|
ensureStarted();
|
|
324
340
|
if (open && open.kind === kind) return;
|
|
325
341
|
closeOpenBlock();
|
|
342
|
+
if (kind === "thinking") {
|
|
343
|
+
open = { kind, index: -1, thinkingBuf: "", thinkingBufBytes: 0 };
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
326
346
|
const index = blockIndex++;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
: { type: "thinking", thinking: "", signature: "" };
|
|
330
|
-
emit("content_block_start", { type: "content_block_start", index, content_block: contentBlock });
|
|
331
|
-
open = { kind, index, thinkingBuf: "", thinkingBufBytes: 0 };
|
|
347
|
+
emit("content_block_start", { type: "content_block_start", index, content_block: { type: "text", text: "" } });
|
|
348
|
+
open = { kind, index };
|
|
332
349
|
};
|
|
333
350
|
const finish = (stopReason: string, usage: unknown) => {
|
|
334
351
|
if (terminated) return;
|
|
@@ -405,11 +422,13 @@ export function responsesSseToAnthropicSse(
|
|
|
405
422
|
case "response.reasoning_summary_text.delta":
|
|
406
423
|
case "response.reasoning_text.delta": {
|
|
407
424
|
if (typeof data.delta !== "string" || data.delta.length === 0) break;
|
|
425
|
+
const itemKey = boundedReasoningIdentity(data.item_id);
|
|
426
|
+
if (open?.kind === "thinking" && open.reasoningItemKey !== itemKey) closeOpenBlock();
|
|
408
427
|
ensureBlock("thinking");
|
|
409
428
|
const active = open;
|
|
410
429
|
if (!active || active.kind !== "thinking") break;
|
|
411
430
|
// The JSON path joins reasoning summary/content parts with "\n\n"
|
|
412
|
-
// (responsesJsonToAnthropicMessage); mirror that at part
|
|
431
|
+
// (responsesJsonToAnthropicMessage); mirror that at part boundaries
|
|
413
432
|
// so multi-part summaries do not glue into one run-on paragraph. Frames
|
|
414
433
|
// without part indices produce a constant key and never get a separator.
|
|
415
434
|
const slot = eventName === "response.reasoning_summary_text.delta"
|
|
@@ -418,7 +437,7 @@ export function responsesSseToAnthropicSse(
|
|
|
418
437
|
// Upstream string metadata can be arbitrarily large. Hash strings into fixed-size
|
|
419
438
|
// components while retaining item and part equality, rather than dropping item_id and
|
|
420
439
|
// accidentally joining distinct malformed reasoning items.
|
|
421
|
-
const partKey = `${
|
|
440
|
+
const partKey = `${itemKey}:${slot}`;
|
|
422
441
|
const needsPartSeparator = active.reasoningPartKey !== undefined
|
|
423
442
|
&& active.reasoningPartKey !== partKey;
|
|
424
443
|
const appended = `${needsPartSeparator ? "\n\n" : ""}${data.delta}`;
|
|
@@ -436,17 +455,8 @@ export function responsesSseToAnthropicSse(
|
|
|
436
455
|
reservation.release();
|
|
437
456
|
throw error;
|
|
438
457
|
}
|
|
439
|
-
|
|
440
|
-
emit("content_block_delta", {
|
|
441
|
-
type: "content_block_delta", index: active.index,
|
|
442
|
-
delta: { type: "thinking_delta", thinking: "\n\n" },
|
|
443
|
-
});
|
|
444
|
-
}
|
|
458
|
+
active.reasoningItemKey = itemKey;
|
|
445
459
|
active.reasoningPartKey = partKey;
|
|
446
|
-
emit("content_block_delta", {
|
|
447
|
-
type: "content_block_delta", index: active.index,
|
|
448
|
-
delta: { type: "thinking_delta", thinking: data.delta },
|
|
449
|
-
});
|
|
450
460
|
break;
|
|
451
461
|
}
|
|
452
462
|
case "response.output_item.added": {
|
|
@@ -561,22 +571,29 @@ export function responsesSseToAnthropicSse(
|
|
|
561
571
|
else if (open && open.kind === "text" && item.type === "message") closeOpenBlock();
|
|
562
572
|
else if (item.type === "reasoning") {
|
|
563
573
|
const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : "";
|
|
564
|
-
const env = encrypted ? decodeReasoningEnvelope(encrypted) : null;
|
|
574
|
+
const env = encrypted ? decodeReasoningEnvelope(encrypted, translatorBudget) : null;
|
|
565
575
|
const red = env?.red ?? [];
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
576
|
+
const itemKey = boundedReasoningIdentity(item.id);
|
|
577
|
+
// A late/unrelated done cannot reorder or sign another item's text.
|
|
578
|
+
if (open?.kind === "thinking" && open.reasoningItemKey !== itemKey) {
|
|
569
579
|
closeOpenBlock();
|
|
570
580
|
}
|
|
571
581
|
if (red.length > 0) {
|
|
572
582
|
ensureStarted();
|
|
573
|
-
closeOpenBlock();
|
|
583
|
+
if (open?.kind !== "thinking") closeOpenBlock();
|
|
574
584
|
}
|
|
575
585
|
for (const data of red) {
|
|
576
586
|
const idx = blockIndex++;
|
|
577
587
|
emit("content_block_start", { type: "content_block_start", index: idx, content_block: { type: "redacted_thinking", data } });
|
|
578
588
|
emit("content_block_stop", { type: "content_block_stop", index: idx });
|
|
579
589
|
}
|
|
590
|
+
if (env?.sig && open?.kind !== "thinking") {
|
|
591
|
+
ensureBlock("thinking");
|
|
592
|
+
}
|
|
593
|
+
if (open?.kind === "thinking") {
|
|
594
|
+
if (env?.sig) open.reasoningSig = env.sig;
|
|
595
|
+
closeOpenBlock();
|
|
596
|
+
}
|
|
580
597
|
}
|
|
581
598
|
break;
|
|
582
599
|
}
|
|
@@ -785,7 +802,7 @@ export function responsesSseToAnthropicSse(
|
|
|
785
802
|
}
|
|
786
803
|
|
|
787
804
|
/** Non-streaming: /v1/responses JSON -> Anthropic message JSON. */
|
|
788
|
-
export function responsesJsonToAnthropicMessage(json: unknown, model: string): Rec {
|
|
805
|
+
export function responsesJsonToAnthropicMessage(json: unknown, model: string, translatorBudget?: TranslatorBudget): Rec {
|
|
789
806
|
const body = isRec(json) ? json : {};
|
|
790
807
|
const output = Array.isArray(body.output) ? body.output : [];
|
|
791
808
|
const content: Rec[] = [];
|
|
@@ -817,14 +834,14 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
|
|
|
817
834
|
}
|
|
818
835
|
}
|
|
819
836
|
const encrypted = typeof raw.encrypted_content === "string" ? raw.encrypted_content : "";
|
|
820
|
-
const env = encrypted ? decodeReasoningEnvelope(encrypted) : null;
|
|
837
|
+
const env = encrypted ? decodeReasoningEnvelope(encrypted, translatorBudget) : null;
|
|
821
838
|
// Legacy combined envelopes place redacted blocks before the signed block,
|
|
822
839
|
// matching the Anthropic adapter. New bridge output uses separate items.
|
|
823
840
|
for (const data of env?.red ?? []) content.push({ type: "redacted_thinking", data });
|
|
824
841
|
// env.txt may be locally hidden text. Do not expose it here or manufacture
|
|
825
842
|
// a new signed continuity carrier; hidden-summary replay remains limited.
|
|
826
843
|
if (parts.length > 0 || env?.sig) {
|
|
827
|
-
content.push({ type: "thinking", thinking: parts.join("\n\n"), signature: env?.sig ?? encodeReasoningEnvelope({ txt: parts.join("\n\n") }) });
|
|
844
|
+
content.push({ type: "thinking", thinking: parts.join("\n\n"), signature: env?.sig ?? encodeReasoningEnvelope({ txt: parts.join("\n\n") }, translatorBudget) });
|
|
828
845
|
}
|
|
829
846
|
break;
|
|
830
847
|
}
|
package/src/cli/capabilities.ts
CHANGED
|
@@ -95,6 +95,31 @@ export const HEAD_CAPABILITIES: readonly HeadCapability[] = [
|
|
|
95
95
|
* A capability must not name a route the command does not actually fetch.
|
|
96
96
|
*/
|
|
97
97
|
export const CAPABILITIES: readonly Capability[] = [
|
|
98
|
+
{
|
|
99
|
+
command: ["models", "price"],
|
|
100
|
+
summary: "Read the saved manual price for an exact provider/model selector.",
|
|
101
|
+
routes: [{ method: "GET", path: "/api/providers/{provider}/model-costs" }],
|
|
102
|
+
flags: [{ name: "--json", value: "boolean", summary: "Emit provider, modelId, and cost (null for automatic pricing)." }],
|
|
103
|
+
mutates: false,
|
|
104
|
+
json: "envelope",
|
|
105
|
+
details: ["The provider must be configured; everything after the first slash is the exact upstream model ID."],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
command: ["models", "set-price"],
|
|
109
|
+
summary: "Save four manual USD-per-1M-token rates, or restore automatic pricing for one model.",
|
|
110
|
+
routes: [{ method: "PUT", path: "/api/providers/{provider}/model-costs" }],
|
|
111
|
+
flags: [
|
|
112
|
+
{ name: "--input", value: "number", summary: "Input rate; required unless --auto is used." },
|
|
113
|
+
{ name: "--output", value: "number", summary: "Output rate; required unless --auto is used." },
|
|
114
|
+
{ name: "--cache-read", value: "number", summary: "Cache read rate; defaults to 0." },
|
|
115
|
+
{ name: "--cache-write", value: "number", summary: "Cache write rate; defaults to 0." },
|
|
116
|
+
{ name: "--auto", value: "boolean", summary: "Remove this model's override; cannot be combined with rates." },
|
|
117
|
+
{ name: "--json", value: "boolean", summary: "Emit the saved price or reset result as JSON." },
|
|
118
|
+
],
|
|
119
|
+
mutates: true,
|
|
120
|
+
json: "payload",
|
|
121
|
+
details: ["Uses the exact upstream model ID after the first slash. Omitted cache rates default to zero; sibling model prices are preserved."],
|
|
122
|
+
},
|
|
98
123
|
{
|
|
99
124
|
command: ["status"],
|
|
100
125
|
summary: "Proxy status, injection state, and version skew between this CLI and the running proxy.",
|
|
@@ -200,6 +225,8 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
200
225
|
routes: [{ method: "GET", path: "/api/usage" }],
|
|
201
226
|
flags: [
|
|
202
227
|
{ name: "--range", value: "string", summary: "today | 1d | 7d | 30d | all" },
|
|
228
|
+
{ name: "--since", value: "string", summary: "Inclusive start: epoch milliseconds or full ISO datetime with timezone; requires --until and overrides --range." },
|
|
229
|
+
{ name: "--until", value: "string", summary: "Inclusive end: epoch milliseconds or full ISO datetime with timezone; requires --since." },
|
|
203
230
|
{ name: "--provider", value: "string", summary: "Restrict to one provider." },
|
|
204
231
|
{ name: "--model", value: "string", summary: "Restrict to one model id." },
|
|
205
232
|
{ name: "--json", value: "boolean", summary: "Emit the usage report as JSON." },
|
package/src/cli/integrations.ts
CHANGED
|
@@ -189,7 +189,7 @@ function singleClientStatusLines(result: unknown): string[] {
|
|
|
189
189
|
const rest = Object.fromEntries(Object.entries(result as Record<string, unknown>).filter(([key]) => key !== "raycast"));
|
|
190
190
|
const lines = [...summaryLines(rest), `plan: ${raycast.plan}`];
|
|
191
191
|
if (!raycast.aiDirPresent) {
|
|
192
|
-
lines.push('
|
|
192
|
+
lines.push('On macOS or Windows, open Raycast → Settings → AI → "Reveal Providers Config" once so the ai folder exists.');
|
|
193
193
|
}
|
|
194
194
|
return lines;
|
|
195
195
|
}
|
|
@@ -13,9 +13,18 @@ import {
|
|
|
13
13
|
type RuntimeApiDeps,
|
|
14
14
|
} from "./runtime-api";
|
|
15
15
|
import { isModelsRuntimeSubcommand } from "./models-runtime-subcommands";
|
|
16
|
+
import { isValidProviderName } from "../config/provider-name";
|
|
17
|
+
import { isValidModelDiscoveryModelId } from "../providers/model-discovery-limits";
|
|
18
|
+
import { redactSecretString } from "../lib/redact";
|
|
19
|
+
import type { ProviderCostOverlay } from "../types";
|
|
20
|
+
import { MAX_COST4_RATE } from "../usage/expected-prices";
|
|
21
|
+
import { isValidCost4Rate } from "../usage/user-cost-overlays";
|
|
16
22
|
|
|
17
23
|
const USAGE = `Usage:
|
|
18
24
|
ocx models live [--provider <name>] [--json]
|
|
25
|
+
ocx models price <provider/model> [--json]
|
|
26
|
+
ocx models set-price <provider/model> --input N --output N [--cache-read N] [--cache-write N] [--json]
|
|
27
|
+
ocx models set-price <provider/model> --auto [--json]
|
|
19
28
|
ocx models edit <custom-id> [--model-id <id>] [--display-name <name|->]
|
|
20
29
|
[--context-window <tokens|0>] [--modalities <text,image,audio|->]
|
|
21
30
|
[--reasoning-efforts <none,minimal,low,medium,high,xhigh,max,ultra|->]
|
|
@@ -28,7 +37,10 @@ const USAGE = `Usage:
|
|
|
28
37
|
ocx models new-policy [on|off] [--provider <name>] [--json]
|
|
29
38
|
ocx models new-arrivals [--json]
|
|
30
39
|
ocx models context <status|value <tokens> [--set-all]|provider <name> on [--value <tokens>]|provider <name> off|all <on|off>> [--json]
|
|
31
|
-
ocx models shadow <status|set> [model|-] [--enabled <on|off>] [--json]
|
|
40
|
+
ocx models shadow <status|set> [model|-] [--enabled <on|off>] [--json]
|
|
41
|
+
|
|
42
|
+
Prices are USD per 1M tokens. Omitted cache rates default to 0.
|
|
43
|
+
Price selectors use the exact upstream model ID after the first slash.`;
|
|
32
44
|
|
|
33
45
|
type ModelRow = {
|
|
34
46
|
provider?: string;
|
|
@@ -55,6 +67,99 @@ async function live(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
55
67
|
}));
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
function priceRecord(value: unknown): value is Record<string, unknown> {
|
|
71
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const PRICE_RATE_KEYS = ["input", "output", "cacheRead", "cacheWrite"] as const;
|
|
75
|
+
|
|
76
|
+
function validPriceCost(value: unknown): value is ProviderCostOverlay {
|
|
77
|
+
return priceRecord(value) && Object.keys(value).length === PRICE_RATE_KEYS.length
|
|
78
|
+
&& PRICE_RATE_KEYS.every(key => Object.hasOwn(value, key) && isValidCost4Rate(value[key]));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function price(write: boolean, argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
await priceRequest(write, argv, deps);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
// Duplicated, inline and stray options also reach parser diagnostics.
|
|
86
|
+
// Keep HTTP-specific RuntimeApiError exits while masking usage errors.
|
|
87
|
+
if (error instanceof CliUsageError) {
|
|
88
|
+
throw new CliUsageError(redactSecretString(error.message), error.usage);
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function priceRequest(write: boolean, argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
95
|
+
const args = [...argv];
|
|
96
|
+
const selector = args.shift() ?? "";
|
|
97
|
+
const slash = selector.indexOf("/");
|
|
98
|
+
const provider = selector.slice(0, slash);
|
|
99
|
+
const modelId = selector.slice(slash + 1);
|
|
100
|
+
if (slash < 1 || !isValidProviderName(provider) || !isValidModelDiscoveryModelId(modelId)) {
|
|
101
|
+
throw new CliUsageError("model selector must be provider/model with an exact upstream model id", USAGE);
|
|
102
|
+
}
|
|
103
|
+
if (redactSecretString(modelId) !== modelId) {
|
|
104
|
+
throw new CliUsageError("modelId cannot be displayed safely", USAGE);
|
|
105
|
+
}
|
|
106
|
+
const wantsJson = takeFlag(args, "--json");
|
|
107
|
+
const path = `/api/providers/${encodeURIComponent(provider)}/model-costs`;
|
|
108
|
+
if (!write) {
|
|
109
|
+
rejectArgs(args, USAGE);
|
|
110
|
+
const result = await runtimeRequest<unknown>(path, {}, deps);
|
|
111
|
+
if (!priceRecord(result) || result.provider !== provider || !priceRecord(result.modelCosts)
|
|
112
|
+
|| !Object.values(result.modelCosts).every(validPriceCost)) {
|
|
113
|
+
throw new Error("Invalid model price response");
|
|
114
|
+
}
|
|
115
|
+
let cost: ProviderCostOverlay | null = null;
|
|
116
|
+
if (Object.hasOwn(result.modelCosts, modelId)) {
|
|
117
|
+
const stored = result.modelCosts[modelId];
|
|
118
|
+
if (!validPriceCost(stored)) throw new Error("Invalid model price response");
|
|
119
|
+
cost = { ...stored };
|
|
120
|
+
}
|
|
121
|
+
printData({ provider, modelId, cost }, wantsJson, [
|
|
122
|
+
cost === null ? `${selector}: automatic pricing` : `${selector}: ${JSON.stringify(cost)} USD per 1M tokens`,
|
|
123
|
+
]);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const auto = takeFlag(args, "--auto");
|
|
127
|
+
const input = takeOption(args, "--input");
|
|
128
|
+
const output = takeOption(args, "--output");
|
|
129
|
+
const cacheRead = takeOption(args, "--cache-read");
|
|
130
|
+
const cacheWrite = takeOption(args, "--cache-write");
|
|
131
|
+
rejectArgs(args, USAGE);
|
|
132
|
+
if (auto && [input, output, cacheRead, cacheWrite].some(value => value !== undefined)) {
|
|
133
|
+
throw new CliUsageError("--auto cannot be combined with price rates", USAGE);
|
|
134
|
+
}
|
|
135
|
+
if (!auto && (input === undefined || output === undefined)) {
|
|
136
|
+
throw new CliUsageError("--input and --output are required unless --auto is used", USAGE);
|
|
137
|
+
}
|
|
138
|
+
const rate = (raw: string, flag: string): number => {
|
|
139
|
+
const value = Number(raw);
|
|
140
|
+
if (!raw.trim() || !isValidCost4Rate(value)) {
|
|
141
|
+
throw new CliUsageError(`${flag} must be a finite number between 0 and ${MAX_COST4_RATE}`, USAGE);
|
|
142
|
+
}
|
|
143
|
+
return value;
|
|
144
|
+
};
|
|
145
|
+
const cost: ProviderCostOverlay | null = auto ? null : {
|
|
146
|
+
input: rate(input!, "--input"),
|
|
147
|
+
output: rate(output!, "--output"),
|
|
148
|
+
cacheRead: rate(cacheRead ?? "0", "--cache-read"),
|
|
149
|
+
cacheWrite: rate(cacheWrite ?? "0", "--cache-write"),
|
|
150
|
+
};
|
|
151
|
+
const result = await runtimeRequest(path, { method: "PUT", body: JSON.stringify({ modelId, cost }) }, deps);
|
|
152
|
+
const receivedCost = priceRecord(result) ? result.cost : undefined;
|
|
153
|
+
if (!priceRecord(result) || result.ok !== true || result.provider !== provider || result.modelId !== modelId
|
|
154
|
+
|| (cost === null ? receivedCost !== null : !validPriceCost(receivedCost)
|
|
155
|
+
|| !PRICE_RATE_KEYS.every(key => receivedCost[key] === cost[key]))) {
|
|
156
|
+
throw new Error("Invalid model price persistence receipt");
|
|
157
|
+
}
|
|
158
|
+
// Project the acknowledged fields only; unrelated response fields are not CLI output.
|
|
159
|
+
printData({ ok: true, provider, modelId, cost }, wantsJson,
|
|
160
|
+
[auto ? `${selector}: automatic pricing restored.` : `${selector}: manual pricing saved.`]);
|
|
161
|
+
}
|
|
162
|
+
|
|
58
163
|
async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
59
164
|
const args = [...argv];
|
|
60
165
|
const id = args.shift()?.trim();
|
|
@@ -328,6 +433,8 @@ export async function handleModelsRuntimeCommand(sub: string, argv: string[], de
|
|
|
328
433
|
if (!isModelsRuntimeSubcommand(sub)) return null;
|
|
329
434
|
let action: (() => Promise<void>) | undefined;
|
|
330
435
|
if (sub === "live") action = () => live(argv, deps);
|
|
436
|
+
else if (sub === "price") action = () => price(false, argv, deps);
|
|
437
|
+
else if (sub === "set-price") action = () => price(true, argv, deps);
|
|
331
438
|
else if (sub === "edit") action = () => edit(argv, deps);
|
|
332
439
|
else if (sub === "enable") action = () => visibility(true, argv, deps);
|
|
333
440
|
else if (sub === "disable") action = () => visibility(false, argv, deps);
|
package/src/cli/observe.ts
CHANGED
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
type RuntimeApiDeps,
|
|
12
12
|
} from "./runtime-api";
|
|
13
13
|
import { formatUsageReport } from "./usage-report";
|
|
14
|
-
import { USAGE_RANGES, USAGE_SURFACES } from "../usage/summary";
|
|
14
|
+
import { USAGE_RANGES, USAGE_SURFACES, type UsageSummary } from "../usage/summary";
|
|
15
|
+
import { parseUsageTimeWindow, type UsageTimeWindow } from "../usage/time-range";
|
|
16
|
+
import { redactSecretString } from "../lib/redact";
|
|
15
17
|
|
|
16
18
|
const USAGE = `Usage:
|
|
17
19
|
ocx observe logs [--provider <name>] [--model <id>] [--status <code>]
|
|
@@ -20,6 +22,7 @@ const USAGE = `Usage:
|
|
|
20
22
|
ocx logs rebuild-index
|
|
21
23
|
ocx logs index-status
|
|
22
24
|
ocx observe usage [--range <today|1d|7d|30d|all>] [--surface <all|codex|claude|grok>]
|
|
25
|
+
[--since <epoch-ms|ISO-datetime>] [--until <epoch-ms|ISO-datetime>]
|
|
23
26
|
[--provider <name>] [--model <id>] [--json]
|
|
24
27
|
ocx observe storage [codex-logs [status|protect|unprotect|repair|compact] [--mode <compat|quiet>]] [--json]
|
|
25
28
|
ocx observe memory [--json]
|
|
@@ -146,6 +149,14 @@ async function usage(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
146
149
|
const surface = takeOption(args, "--surface") ?? "all";
|
|
147
150
|
const provider = takeOption(args, "--provider");
|
|
148
151
|
const model = takeOption(args, "--model");
|
|
152
|
+
const since = takeOption(args, "--since");
|
|
153
|
+
const until = takeOption(args, "--until");
|
|
154
|
+
let window: UsageTimeWindow | undefined;
|
|
155
|
+
try {
|
|
156
|
+
window = parseUsageTimeWindow(since, until);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
throw new CliUsageError(error instanceof Error ? error.message : "invalid usage time window", USAGE);
|
|
159
|
+
}
|
|
149
160
|
// `1d` is accepted here as well as server-side so the CLI does not reject an
|
|
150
161
|
// alias the API would have understood.
|
|
151
162
|
const ranges = [...USAGE_RANGES, "1d"];
|
|
@@ -153,8 +164,12 @@ async function usage(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
153
164
|
if (!USAGE_SURFACES.includes(surface as (typeof USAGE_SURFACES)[number])) {
|
|
154
165
|
throw new CliUsageError(`--surface must be one of ${USAGE_SURFACES.join(", ")}`, USAGE);
|
|
155
166
|
}
|
|
156
|
-
rejectArgs(args, USAGE);
|
|
157
|
-
const result = await runtimeRequest(`/api/usage${query({ range, surface, provider, model })}`, {}, deps);
|
|
167
|
+
rejectArgs(args.map(redactSecretString), USAGE);
|
|
168
|
+
const result = await runtimeRequest<UsageSummary>(`/api/usage${query({ range, surface, provider, model, since: window?.since, until: window?.until })}`, {}, deps);
|
|
169
|
+
// Older daemons ignore custom bounds and return successful preset reports.
|
|
170
|
+
if (window && (result?.customWindow !== true || result.since !== window.since || result.until !== window.until)) {
|
|
171
|
+
throw new Error("The server did not confirm the requested custom usage window. Upgrade and restart the proxy, then retry.");
|
|
172
|
+
}
|
|
158
173
|
// Built only when it will be printed: JavaScript evaluates arguments before
|
|
159
174
|
// the call, so passing formatUsageReport(...) inline would run the human
|
|
160
175
|
// renderer during --json and let its assumptions affect a path that is meant
|
package/src/cli/usage-report.ts
CHANGED
|
@@ -24,6 +24,8 @@ interface UsageReportInput {
|
|
|
24
24
|
range?: string;
|
|
25
25
|
surface?: string;
|
|
26
26
|
since?: number | null;
|
|
27
|
+
until?: number;
|
|
28
|
+
customWindow?: boolean;
|
|
27
29
|
summary?: {
|
|
28
30
|
requests?: number;
|
|
29
31
|
totalTokens?: number;
|
|
@@ -90,7 +92,10 @@ function table(header: string[], rows: string[][]): string[] {
|
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
function describeScope(data: UsageReportInput): string {
|
|
93
|
-
const
|
|
95
|
+
const interval = data.customWindow && typeof data.since === "number" && typeof data.until === "number"
|
|
96
|
+
? `custom ${new Date(data.since).toISOString()} to ${new Date(data.until).toISOString()} (inclusive)`
|
|
97
|
+
: data.range ?? "?";
|
|
98
|
+
const parts = [`Usage — ${interval}`];
|
|
94
99
|
if (data.surface && data.surface !== "all") parts.push(`surface=${data.surface}`);
|
|
95
100
|
if (data.filter?.provider) parts.push(`provider=${data.filter.provider}`);
|
|
96
101
|
if (data.filter?.model) parts.push(`model=${data.filter.model}`);
|