@yansigit/opencodex 2.36.1-dev.20260829.48 → 2.36.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/{ApiKeys-LOOFiZfv.js → ApiKeys-CsmNyf4I.js} +1 -1
- package/gui/dist/assets/{Claude-BwKzpXe3.js → Claude-B4sHsGTD.js} +1 -1
- package/gui/dist/assets/{CodexSet-B5qi9KeE.js → CodexSet-BuAQ4YQn.js} +1 -1
- package/gui/dist/assets/{FileIntegrationPage-B0sUef6W.js → FileIntegrationPage-BC65sEaS.js} +1 -1
- package/gui/dist/assets/{Grok-bsb4n-f4.js → Grok-DNJWlISf.js} +1 -1
- package/gui/dist/assets/{Integrations-CSMwFDvM.js → Integrations-vGJshFZK.js} +2 -2
- package/gui/dist/assets/{IntegrationsOverview-CTORdKJA.js → IntegrationsOverview-DYMkDFHE.js} +1 -1
- package/gui/dist/assets/{Logs-BWYbfELf.js → Logs-Coc7_RPP.js} +1 -1
- package/gui/dist/assets/{Models-CexEtdT1.js → Models-CKVT8Meb.js} +1 -1
- package/gui/dist/assets/{NumberStepper-BapSFQnW.js → NumberStepper-Dug4wR8n.js} +1 -1
- package/gui/dist/assets/{Providers-BwUFeAgA.js → Providers-DyaCHRWL.js} +1 -1
- package/gui/dist/assets/{RestoreDialog-CD8piq90.js → RestoreDialog-BpuvVU2w.js} +1 -1
- package/gui/dist/assets/{Startup-BgX731C2.js → Startup-DAUQqNEN.js} +1 -1
- package/gui/dist/assets/{Storage-Bgq7HphP.js → Storage-DfP95SlJ.js} +1 -1
- package/gui/dist/assets/{Subagents-ChxItbeF.js → Subagents-CMNsZLfk.js} +1 -1
- package/gui/dist/assets/{Usage-BkylzP50.js → Usage-ESFRfBMO.js} +1 -1
- package/gui/dist/assets/{codex-stale-banner-D4gvSWnO.js → codex-stale-banner-W4FhikN7.js} +1 -1
- package/gui/dist/assets/{data-surface-BtO3lwam.js → data-surface-B8PX8rjm.js} +1 -1
- package/gui/dist/assets/{data-surface-pCXChiBf.js → data-surface-CETFXqiA.js} +1 -1
- package/gui/dist/assets/{index-CNopOid3.js → index-DHQHBRhb.js} +3 -3
- package/gui/dist/assets/{model-display-CiUpg8T9.js → model-display-OBTJhQwT.js} +1 -1
- package/gui/dist/assets/{provider-payload-b2jlS-On.js → provider-payload-CJRJUn7w.js} +1 -1
- package/gui/dist/assets/{section-tabs-BP7gEPK6.js → section-tabs-B8sLMiz6.js} +1 -1
- package/gui/dist/assets/shared-DI18-uF7.js +69 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/adapters/base.ts +26 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +34 -41
- package/src/adapters/cursor/envelope-echo.ts +128 -0
- package/src/adapters/cursor/request-builder.ts +19 -12
- package/src/adapters/cursor/tool-definitions.ts +2 -1
- package/src/adapters/cursor/tool-result-normalize.ts +23 -31
- package/src/adapters/cursor.ts +21 -2
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +71 -2
- package/src/adapters/google.ts +19 -4
- package/src/adapters/kiro-constants.ts +12 -0
- package/src/adapters/kiro.ts +128 -11
- package/src/adapters/openai-chat.ts +16 -2
- package/src/adapters/openai-responses.ts +15 -2
- package/src/adapters/tool-catalog-nudge.ts +2 -1
- package/src/adapters/xai-web-search.ts +10 -14
- package/src/claude/outbound.ts +14 -3
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +93 -16
- package/src/cli/account-extended.ts +262 -36
- package/src/cli/account-main.ts +12 -12
- package/src/cli/account.ts +40 -10
- package/src/cli/agent.ts +8 -1
- package/src/cli/capabilities-command.ts +94 -0
- package/src/cli/capabilities.ts +535 -0
- package/src/cli/claude-desktop.ts +31 -11
- package/src/cli/dispatch.ts +195 -27
- package/src/cli/doctor.ts +100 -1
- package/src/cli/help.ts +11 -2
- package/src/cli/index.ts +19 -3
- package/src/cli/inspect.ts +230 -0
- package/src/cli/observe.ts +11 -3
- package/src/cli/registry.ts +34 -2
- package/src/cli/runtime-api.ts +51 -7
- package/src/cli/status.ts +16 -0
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/usage-report.ts +52 -2
- package/src/cli/version-skew.ts +46 -0
- package/src/codex/account-label.ts +21 -0
- package/src/codex/catalog/provider-fetch.ts +4 -0
- package/src/codex/transition-state.ts +12 -3
- package/src/compatibility/openai-responses.ts +9 -1
- package/src/generated/compatibility-version.json +94 -58
- package/src/integrations/ownership-policy.ts +24 -5
- package/src/integrations/ownership.ts +36 -2
- package/src/integrations/state.ts +40 -7
- package/src/integrations/writer.ts +21 -3
- package/src/lib/admin-secrets.ts +24 -0
- package/src/lib/errors.ts +25 -1
- package/src/lib/service-secrets.ts +15 -0
- package/src/oauth/store.ts +16 -6
- package/src/providers/label.ts +34 -1
- package/src/responses/turn-termination.ts +107 -0
- package/src/server/management/logs-usage-routes.ts +5 -27
- package/src/server/management/route-registry.ts +317 -0
- package/src/server/proxy-liveness.ts +27 -4
- package/src/server/request-log.ts +37 -17
- package/src/server/responses/core.ts +80 -4
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/service.ts +34 -0
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +79 -33
- package/src/usage/log.ts +32 -6
- package/src/usage/summary.ts +22 -34
- package/gui/dist/assets/shared-BdYGV-yJ.js +0 -69
|
@@ -513,10 +513,10 @@ export function antigravityReplaySessionKeysForTests(): string[] {
|
|
|
513
513
|
|
|
514
514
|
function extractSignature(part: Record<string, unknown>): string | undefined {
|
|
515
515
|
const direct = part.thoughtSignature ?? part.thought_signature;
|
|
516
|
-
if (typeof direct === "string" && direct.length >= MIN_SIGNATURE_LEN && direct !==
|
|
516
|
+
if (typeof direct === "string" && direct.length >= MIN_SIGNATURE_LEN && direct !== THOUGHT_SIGNATURE_BYPASS) return direct;
|
|
517
517
|
const extra = part.extra_content as { google?: { thought_signature?: unknown } } | undefined;
|
|
518
518
|
const nested = extra?.google?.thought_signature;
|
|
519
|
-
if (typeof nested === "string" && nested.length >= MIN_SIGNATURE_LEN && nested !==
|
|
519
|
+
if (typeof nested === "string" && nested.length >= MIN_SIGNATURE_LEN && nested !== THOUGHT_SIGNATURE_BYPASS) return nested;
|
|
520
520
|
return undefined;
|
|
521
521
|
}
|
|
522
522
|
|
|
@@ -625,6 +625,75 @@ export function antigravityUsesReplayCache(model: string): boolean {
|
|
|
625
625
|
return !/claude/i.test(model);
|
|
626
626
|
}
|
|
627
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Gemini 3 rejects a turn whose FIRST functionCall part carries no thought signature. When
|
|
630
|
+
* neither the wire metadata nor the replay cache can supply a real one, this is the official
|
|
631
|
+
* validator-bypass token.
|
|
632
|
+
*/
|
|
633
|
+
const THOUGHT_SIGNATURE_BYPASS = "skip_thought_signature_validator";
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* True when the model speaks the Gemini wire dialect that requires a thought signature on the
|
|
637
|
+
* first functionCall of a turn — and therefore accepts the validator-bypass sentinel.
|
|
638
|
+
*
|
|
639
|
+
* Deliberately NOT `antigravityUsesReplayCache`. That predicate is broad on purpose (every
|
|
640
|
+
* non-Claude model participates in signature replay), and reusing it for the sentinel is how a
|
|
641
|
+
* Gemini-only control token was observed being injected into `gpt-oss-120b-medium`. Replaying a
|
|
642
|
+
* signature upstream gave us is harmless for any model; *fabricating* a Gemini token is not.
|
|
643
|
+
*
|
|
644
|
+
* The identity must be REDUCED to its model component before matching, not scanned whole. The
|
|
645
|
+
* Vertex replay key is built in `src/adapters/google.ts` as
|
|
646
|
+
* `vertex:<project>:<location>:<modelId>`, and the project id is operator-chosen: a project
|
|
647
|
+
* named `gemini-prod` made a whole-string scan return true for
|
|
648
|
+
* `vertex:gemini-prod:global:gpt-oss-120b`, arming the Gemini-only sentinel for a non-Gemini
|
|
649
|
+
* model — the exact class of defect this predicate exists to prevent, reintroduced one layer up.
|
|
650
|
+
*
|
|
651
|
+
* So: take the last `:` segment for a Vertex identity, then the last `/` segment for a
|
|
652
|
+
* namespaced id (`google/gemini-3-pro`), and match only that. The trailing `[-.\d]` keeps
|
|
653
|
+
* `geminibot` and `my-gemini-clone` out. A model outside this set that genuinely needs the
|
|
654
|
+
* sentinel must arrive with a captured accepted CCA contract, not by widening this predicate.
|
|
655
|
+
*/
|
|
656
|
+
export function antigravitySupportsThoughtSignatureSentinel(model: string): boolean {
|
|
657
|
+
const afterTransport = model.slice(model.lastIndexOf(":") + 1);
|
|
658
|
+
const wireModel = afterTransport.slice(afterTransport.lastIndexOf("/") + 1);
|
|
659
|
+
return /^gemini[-.\d]/i.test(wireModel);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Ensure every model turn's FIRST functionCall carries a thought signature, injecting the
|
|
664
|
+
* validator-bypass sentinel only where one is genuinely absent.
|
|
665
|
+
*
|
|
666
|
+
* Split out of `applyAntigravityReplay` on purpose. Replay answers "what did upstream already
|
|
667
|
+
* tell us about this call", and its absence of a signature is meaningful — 18 assertions in the
|
|
668
|
+
* suite read `thoughtSignature === undefined` as "the cache did not match", covering eviction,
|
|
669
|
+
* TTL expiry, oversize refusal and clear-on-invalid. Folding a fabricated token into that
|
|
670
|
+
* function would overwrite the very signal those tests read. Keeping the sentinel as its own
|
|
671
|
+
* pass means a cache miss still looks like a cache miss.
|
|
672
|
+
*
|
|
673
|
+
* Three properties this must hold, each of which a naive presence-check gets wrong:
|
|
674
|
+
* - it decides from `extractSignature`, so a valid NESTED
|
|
675
|
+
* `extra_content.google.thought_signature` counts as signed (no competing sentinel) and a
|
|
676
|
+
* present-but-too-short value does not (the fallback still fires);
|
|
677
|
+
* - it looks at the FIRST functionCall only, so a later sibling receiving a cached signature
|
|
678
|
+
* cannot vote away the sentinel the first call requires;
|
|
679
|
+
* - it is gated on the Gemini wire dialect, not on replay-cache participation.
|
|
680
|
+
*/
|
|
681
|
+
export function applyAntigravityThoughtSignatureFallback(model: string, contents: unknown[]): unknown[] {
|
|
682
|
+
if (!antigravitySupportsThoughtSignatureSentinel(model) || !Array.isArray(contents)) return contents;
|
|
683
|
+
for (const rawContent of contents as { role?: string; parts?: unknown[] }[]) {
|
|
684
|
+
if (!rawContent || typeof rawContent !== "object" || rawContent.role !== "model") continue;
|
|
685
|
+
if (!Array.isArray(rawContent.parts)) continue;
|
|
686
|
+
for (const rawPart of rawContent.parts) {
|
|
687
|
+
if (!rawPart || typeof rawPart !== "object") continue;
|
|
688
|
+
const part = rawPart as Record<string, unknown>;
|
|
689
|
+
if (!part.functionCall) continue;
|
|
690
|
+
if (!extractSignature(part)) part.thoughtSignature = THOUGHT_SIGNATURE_BYPASS;
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return contents;
|
|
695
|
+
}
|
|
696
|
+
|
|
628
697
|
/**
|
|
629
698
|
* Observe a parsed CCA chunk's `candidates[0].content.parts` and record thought signatures keyed by
|
|
630
699
|
* the functionCall identity (name + args). Accumulates across the whole session so a sequential
|
package/src/adapters/google.ts
CHANGED
|
@@ -28,7 +28,13 @@ import { canonicalAntigravityHttpsHost, isAntigravityHttpsHost } from "./google-
|
|
|
28
28
|
import { compileGoogleWireBody } from "./google-wire-compiler";
|
|
29
29
|
import { sanitizeGeminiToolParameters } from "./google-tool-schema";
|
|
30
30
|
import { identifyRoutedModel } from "./identity";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
antigravityUsesReplayCache,
|
|
33
|
+
applyAntigravityReplay,
|
|
34
|
+
applyAntigravityThoughtSignatureFallback,
|
|
35
|
+
clearAntigravityReplay,
|
|
36
|
+
observeAntigravityReplay,
|
|
37
|
+
} from "./google-antigravity-replay";
|
|
32
38
|
import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
|
|
33
39
|
import {
|
|
34
40
|
extractCcaGroundingSources,
|
|
@@ -996,6 +1002,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
996
1002
|
} else {
|
|
997
1003
|
sanitizeAntigravityClaudeSignatures(contents);
|
|
998
1004
|
}
|
|
1005
|
+
// After replay, not instead of it: a real signature always wins, and the sentinel only
|
|
1006
|
+
// fills a first functionCall that replay could not sign. Outside the cache branch too,
|
|
1007
|
+
// because the turn still needs a signature when no session was ever recorded.
|
|
1008
|
+
applyAntigravityThoughtSignatureFallback(wireModelId, contents);
|
|
999
1009
|
// Claude-on-Antigravity rejects assistant-tail (model-tail in Gemini terms) histories
|
|
1000
1010
|
// as prefill: "This model does not support assistant message prefill. The conversation
|
|
1001
1011
|
// must end with a user message." Context compaction, previous_response_id expansion,
|
|
@@ -1045,6 +1055,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
1045
1055
|
vertexReplaySession,
|
|
1046
1056
|
(compiled.body as { contents: unknown[] }).contents,
|
|
1047
1057
|
);
|
|
1058
|
+
applyAntigravityThoughtSignatureFallback(
|
|
1059
|
+
vertexReplayModel,
|
|
1060
|
+
(compiled.body as { contents: unknown[] }).contents,
|
|
1061
|
+
);
|
|
1048
1062
|
}
|
|
1049
1063
|
// Vertex AI: project/location endpoint with GCP ADC, or x-goog-api-key fast path.
|
|
1050
1064
|
const apiKey = resolveVertexApiKey(provider.apiKey);
|
|
@@ -1678,9 +1692,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
1678
1692
|
if (invalidFunctionCall) return finish([invalidGoogleFunctionCallEvent(invalidFunctionCall)]);
|
|
1679
1693
|
// Non-streaming Google-family response: observe thought signatures for the next turn,
|
|
1680
1694
|
// using the same transport-scoped namespace as the streaming path.
|
|
1681
|
-
const replayModel = vertexReplayModel;
|
|
1682
|
-
const replaySession = vertexReplaySession;
|
|
1683
|
-
if (provider.googleMode === "
|
|
1695
|
+
const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
|
|
1696
|
+
const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession;
|
|
1697
|
+
if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex")
|
|
1698
|
+
&& replayModel && replaySession) {
|
|
1684
1699
|
observeAntigravityReplay(replayModel, replaySession, parts as unknown[]);
|
|
1685
1700
|
}
|
|
1686
1701
|
let pendingThoughtSig: string | undefined;
|
|
@@ -22,6 +22,18 @@ export const KIRO_COMPLETION_RETRY_MESSAGE =
|
|
|
22
22
|
export const KIRO_TOOL_RESULT_CARRIER_MESSAGE = "The requested tool result is attached.";
|
|
23
23
|
export const KIRO_EMPTY_TOOL_RESULT_MESSAGE = "The tool completed without textual output.";
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Placeholder for the user turn Kiro requires after an assistant turn that ALREADY delivered its
|
|
27
|
+
* final answer.
|
|
28
|
+
*
|
|
29
|
+
* The protocol needs a trailing user turn, but the usual continuation/retry text instructs the
|
|
30
|
+
* model to keep working, which reopens a finished task and reads as a still-open goal. This states
|
|
31
|
+
* the delivered state and explicitly withholds a new request, so the turn stays structurally valid
|
|
32
|
+
* without asking for more work.
|
|
33
|
+
*/
|
|
34
|
+
export const KIRO_ANSWER_DELIVERED_MESSAGE =
|
|
35
|
+
"The previous final answer was delivered to the user and that task is closed. No new request has been made yet. Do not repeat, revise, or continue that work; wait for the user's next instruction.";
|
|
36
|
+
|
|
25
37
|
export const KIRO_COMPLETION_INSTRUCTIONS =
|
|
26
38
|
`When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Continue using tools after progress updates. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text.`;
|
|
27
39
|
|
package/src/adapters/kiro.ts
CHANGED
|
@@ -36,15 +36,18 @@ import type {
|
|
|
36
36
|
OcxTool,
|
|
37
37
|
OcxUsage,
|
|
38
38
|
} from "../types";
|
|
39
|
+
import { hasRecordedTrailingDeliveredFinalAnswer } from "../responses/turn-termination";
|
|
39
40
|
import type { ProviderAdapter } from "./base";
|
|
40
41
|
import type { AdapterFetchContext, AdapterRequest } from "./base";
|
|
41
42
|
import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-images";
|
|
42
43
|
import { sniffImageDimensions } from "./anthropic-image-guard";
|
|
43
44
|
import { fetchKiroWithRetry, noteKiroTransientThrottle } from "./kiro-retry";
|
|
44
45
|
import { convertKiroToolContext } from "./kiro-tools";
|
|
46
|
+
import { normalizeEmptyExecToolResultText } from "./exec-tool-result-normalize";
|
|
45
47
|
import { identifyRoutedModel } from "./identity";
|
|
46
48
|
import { buildNonOpenAIToolCatalogNudgeFromNames, isBareShellBridgeTool, isCodexCodeModeExecTool } from "./tool-catalog-nudge";
|
|
47
49
|
import {
|
|
50
|
+
KIRO_ANSWER_DELIVERED_MESSAGE,
|
|
48
51
|
KIRO_COMPLETION_INSTRUCTIONS,
|
|
49
52
|
KIRO_COMPLETION_RETRY_MESSAGE,
|
|
50
53
|
KIRO_COMPLETION_TOOL_NAME,
|
|
@@ -343,8 +346,54 @@ function validateKiroCapabilities(parsed: OcxParsedRequest): void {
|
|
|
343
346
|
}
|
|
344
347
|
|
|
345
348
|
type KiroTurn =
|
|
346
|
-
| {
|
|
347
|
-
|
|
349
|
+
| {
|
|
350
|
+
kind: "user";
|
|
351
|
+
content: string;
|
|
352
|
+
images: KiroImage[];
|
|
353
|
+
toolResults: KiroToolResult[];
|
|
354
|
+
/**
|
|
355
|
+
* True only for the proxy-generated acknowledgement that follows a delivered final answer.
|
|
356
|
+
* A flag rather than a content comparison: a real user message may legitimately quote the
|
|
357
|
+
* same sentence, and treating that as internal state would strip its thinking tags and
|
|
358
|
+
* completion retry.
|
|
359
|
+
*/
|
|
360
|
+
answerDeliveredAck?: boolean;
|
|
361
|
+
}
|
|
362
|
+
| {
|
|
363
|
+
kind: "assistant";
|
|
364
|
+
content: string;
|
|
365
|
+
toolUses: KiroToolUse[];
|
|
366
|
+
redactedReasoning?: string;
|
|
367
|
+
/**
|
|
368
|
+
* True when this assistant turn was the DELIVERED final answer (Responses
|
|
369
|
+
* `phase: "final_answer"`). A trailing assistant turn normally means the model stopped
|
|
370
|
+
* mid-task and needs a continuation prompt, but a delivered final answer already ended its
|
|
371
|
+
* turn — prompting it again restarts finished work as if a goal were still open.
|
|
372
|
+
*/
|
|
373
|
+
finalAnswer?: boolean;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* True when the LAST content-bearing message is an assistant final answer that closed its turn.
|
|
378
|
+
*
|
|
379
|
+
* Mirrors the turn-merge rule: a tool call in that message, or any later user/tool-result message,
|
|
380
|
+
* means work continued, so the turn is no longer terminal. Empty assistant messages are skipped
|
|
381
|
+
* rather than treated as continuation, since they carry no visible turn.
|
|
382
|
+
*/
|
|
383
|
+
function hasTrailingDeliveredFinalAnswer(messages: readonly OcxMessage[], parsed?: OcxParsedRequest): boolean {
|
|
384
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
385
|
+
const msg = messages[i];
|
|
386
|
+
if (msg.role !== "assistant") return false;
|
|
387
|
+
const aMsg = msg as OcxAssistantMessage;
|
|
388
|
+
const hasToolCall = (aMsg.content ?? []).some(part => part.type === "toolCall");
|
|
389
|
+
if (hasToolCall) return false;
|
|
390
|
+
const hasText = (aMsg.content ?? []).some(part => part.type === "text" && part.text.trim());
|
|
391
|
+
if (!hasText) continue;
|
|
392
|
+
return aMsg.phase === "final_answer"
|
|
393
|
+
|| (parsed !== undefined && hasRecordedTrailingDeliveredFinalAnswer(parsed, messages));
|
|
394
|
+
}
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
348
397
|
|
|
349
398
|
function appendTurnText(target: string, next: string): string {
|
|
350
399
|
if (!next) return target;
|
|
@@ -453,8 +502,19 @@ export function buildKiroPayload(
|
|
|
453
502
|
const registry = createKiroToolNameRegistry();
|
|
454
503
|
const toolContext = convertKiroToolContext(parsed, registry);
|
|
455
504
|
const ordinaryTools = toolContext.tools;
|
|
505
|
+
// A turn whose history already ENDS with a delivered final answer has nothing to complete.
|
|
506
|
+
// Leaving completion "required" here would keep advertising codex_kiro_final_answer with its
|
|
507
|
+
// instructions, so the model answers again, or replies with ordinary text and trips the
|
|
508
|
+
// `needsFallback` retry, which ends its payload with KIRO_COMPLETION_RETRY_MESSAGE and reopens
|
|
509
|
+
// the finished task. Suppressing the mode is what actually closes that loop; the neutral
|
|
510
|
+
// acknowledgement below only stops the resume wording.
|
|
511
|
+
//
|
|
512
|
+
// Read from parsed messages because `completionMode` is needed to build the tool catalog, which
|
|
513
|
+
// happens before the turn list exists. `forcedCompletionMode` still wins: the fallback retry
|
|
514
|
+
// passes "text_fallback" explicitly and must not be silently downgraded.
|
|
515
|
+
const trailingDeliveredAnswer = hasTrailingDeliveredFinalAnswer(kiroPayloadMessages(parsed), parsed);
|
|
456
516
|
const completionMode: KiroCompletionMode = forcedCompletionMode
|
|
457
|
-
?? (ordinaryTools.length > 0 ? "required" : "disabled");
|
|
517
|
+
?? (ordinaryTools.length > 0 && !trailingDeliveredAnswer ? "required" : "disabled");
|
|
458
518
|
const kiroTools = completionMode === "disabled"
|
|
459
519
|
? ordinaryTools
|
|
460
520
|
: [...ordinaryTools, kiroCompletionTool()];
|
|
@@ -521,15 +581,24 @@ export function buildKiroPayload(
|
|
|
521
581
|
turns.push({ kind: "user", content, images: [...images], toolResults: [...toolResults] });
|
|
522
582
|
}
|
|
523
583
|
};
|
|
524
|
-
const pushAssistant = (content: string, toolUses: KiroToolUse[], redactedReasoning?: string): void => {
|
|
584
|
+
const pushAssistant = (content: string, toolUses: KiroToolUse[], redactedReasoning?: string, finalAnswer?: boolean): void => {
|
|
525
585
|
const last = turns.at(-1);
|
|
526
586
|
if (last?.kind === "assistant") {
|
|
527
587
|
last.content = appendTurnText(last.content, content);
|
|
528
588
|
last.toolUses.push(...toolUses);
|
|
529
589
|
// Merged turns keep the newest blob: it covers the reasoning up to the merged turn's end.
|
|
530
590
|
if (redactedReasoning) last.redactedReasoning = redactedReasoning;
|
|
591
|
+
// A merged turn is final only if its LAST component was: commentary appended after a final
|
|
592
|
+
// answer means the model kept working, so the turn is no longer terminal.
|
|
593
|
+
last.finalAnswer = finalAnswer === true;
|
|
531
594
|
} else {
|
|
532
|
-
turns.push({
|
|
595
|
+
turns.push({
|
|
596
|
+
kind: "assistant",
|
|
597
|
+
content,
|
|
598
|
+
toolUses: [...toolUses],
|
|
599
|
+
...(redactedReasoning ? { redactedReasoning } : {}),
|
|
600
|
+
...(finalAnswer ? { finalAnswer: true } : {}),
|
|
601
|
+
});
|
|
533
602
|
}
|
|
534
603
|
};
|
|
535
604
|
|
|
@@ -559,14 +628,24 @@ export function buildKiroPayload(
|
|
|
559
628
|
const hasReasoning = aMsg.content.some(part => part.type === "thinking" && part.thinking.trim());
|
|
560
629
|
if (hasReasoning) continue;
|
|
561
630
|
}
|
|
562
|
-
|
|
631
|
+
// `phase` survives the Responses round trip (parser.ts assistant branch), so a replayed
|
|
632
|
+
// final answer is identifiable here rather than guessed from turn position.
|
|
633
|
+
pushAssistant(text, toolUses, aMsg.kiroRedactedReasoning, aMsg.phase === "final_answer" && toolUses.length === 0);
|
|
563
634
|
} else if (msg.role === "toolResult") {
|
|
564
635
|
const tr = msg as OcxToolResultMessage;
|
|
565
636
|
if (tr.containsEncryptedContent) {
|
|
566
637
|
throw new Error(`Kiro cannot translate encrypted output for tool call ${JSON.stringify(tr.toolCallId)}`);
|
|
567
638
|
}
|
|
568
639
|
const text = userContentText(tr.content);
|
|
569
|
-
|
|
640
|
+
// An empty code-mode exec result needs the SPECIFIC reason, not the generic fallback: the
|
|
641
|
+
// model otherwise reads a blank result, concludes its earlier context was lost, and restarts
|
|
642
|
+
// the task instead of calling text()/notify(). Checked before `text.trim()` because the
|
|
643
|
+
// wrapper form ("Script completed\nWall time ...\nOutput:\n") is non-blank and would
|
|
644
|
+
// otherwise pass through as if it were real output.
|
|
645
|
+
const resultText = normalizeEmptyExecToolResultText(text, {
|
|
646
|
+
toolName: tr.toolName,
|
|
647
|
+
toolNamespace: tr.toolNamespace,
|
|
648
|
+
}) ?? (text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE);
|
|
570
649
|
const images = extractKiroImages(tr.content);
|
|
571
650
|
const toolUseId = normalizeToolId(tr.toolCallId);
|
|
572
651
|
if (!priorCalls.has(toolUseId)) {
|
|
@@ -587,12 +666,23 @@ export function buildKiroPayload(
|
|
|
587
666
|
if (turns.length === 0 || turns[0].kind === "assistant") {
|
|
588
667
|
turns.unshift({ kind: "user", content: KIRO_CONTINUATION_MESSAGE, images: [], toolResults: [] });
|
|
589
668
|
}
|
|
590
|
-
|
|
669
|
+
// Kiro requires the request to end with a user turn, so a trailing assistant turn always gets
|
|
670
|
+
// one appended (the pop below throws otherwise). What that turn SAYS is the load-bearing part.
|
|
671
|
+
//
|
|
672
|
+
// Normally a trailing assistant turn means the model stopped mid-task, and a continuation/retry
|
|
673
|
+
// prompt is correct. A DELIVERED final answer is the exception: the turn already ended, and
|
|
674
|
+
// telling that model to "continue" or to call the completion tool again reopens finished work —
|
|
675
|
+
// the completed-task-behaves-like-an-open-goal loop. It gets a neutral acknowledgement instead:
|
|
676
|
+
// structurally valid, but carrying no instruction to resume.
|
|
677
|
+
const trailing = turns.at(-1);
|
|
678
|
+
if (trailing?.kind === "assistant") {
|
|
679
|
+
const resumeText = completionMode === "text_fallback" ? KIRO_COMPLETION_RETRY_MESSAGE : KIRO_CONTINUATION_MESSAGE;
|
|
591
680
|
turns.push({
|
|
592
681
|
kind: "user",
|
|
593
|
-
content:
|
|
682
|
+
content: trailing.finalAnswer ? KIRO_ANSWER_DELIVERED_MESSAGE : resumeText,
|
|
594
683
|
images: [],
|
|
595
684
|
toolResults: [],
|
|
685
|
+
...(trailing.finalAnswer ? { answerDeliveredAck: true } : {}),
|
|
596
686
|
});
|
|
597
687
|
}
|
|
598
688
|
|
|
@@ -608,6 +698,8 @@ export function buildKiroPayload(
|
|
|
608
698
|
|
|
609
699
|
const currentTurn = turns.pop();
|
|
610
700
|
if (!currentTurn || currentTurn.kind !== "user") throw new Error("Kiro request must end with a user turn");
|
|
701
|
+
// Survives the pop as state, so the checks below never infer intent from user-supplied text.
|
|
702
|
+
const answerDeliveredAck = currentTurn.answerDeliveredAck === true;
|
|
611
703
|
const toEntry = (turn: KiroTurn): KiroHistoryEntry => turn.kind === "assistant"
|
|
612
704
|
? {
|
|
613
705
|
assistantResponseMessage: {
|
|
@@ -638,10 +730,17 @@ export function buildKiroPayload(
|
|
|
638
730
|
currentUim.userInputMessageContext = { ...(currentUim.userInputMessageContext ?? {}), tools: kiroTools };
|
|
639
731
|
}
|
|
640
732
|
if (completionMode === "text_fallback") {
|
|
641
|
-
|
|
733
|
+
// Never append the retry instruction onto the answer-delivered acknowledgement: it exists
|
|
734
|
+
// precisely to avoid asking a finished turn for another completion call, and appending here
|
|
735
|
+
// would reinstate the loop it prevents.
|
|
736
|
+
if (currentUim.content !== KIRO_COMPLETION_RETRY_MESSAGE && !answerDeliveredAck) {
|
|
642
737
|
currentUim.content = appendTurnText(currentUim.content, KIRO_COMPLETION_RETRY_MESSAGE);
|
|
643
738
|
}
|
|
644
|
-
} else if (
|
|
739
|
+
} else if (
|
|
740
|
+
!currentUim.userInputMessageContext?.toolResults
|
|
741
|
+
&& currentUim.content !== KIRO_CONTINUATION_MESSAGE
|
|
742
|
+
&& !answerDeliveredAck
|
|
743
|
+
) {
|
|
645
744
|
currentUim.content = injectKiroThinkingTags(currentUim.content, parsed);
|
|
646
745
|
}
|
|
647
746
|
|
|
@@ -1905,6 +2004,24 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
|
|
|
1905
2004
|
|
|
1906
2005
|
return {
|
|
1907
2006
|
name: "kiro",
|
|
2007
|
+
// A replayed history that already ENDS with a delivered final answer has nothing to ask Kiro.
|
|
2008
|
+
// Before this hook the adapter still appended a trailing user turn — a neutral acknowledgement,
|
|
2009
|
+
// but structurally still a prompt — and performed a real inference, so the model answered the
|
|
2010
|
+
// closed task again and the finished turn behaved like a still-open goal.
|
|
2011
|
+
//
|
|
2012
|
+
// Suppressing the completion contract (above) removed the instruction to complete; it could not
|
|
2013
|
+
// remove the inference. This is the boundary: no request is built, nothing is sent, and no token
|
|
2014
|
+
// estimate is recorded.
|
|
2015
|
+
//
|
|
2016
|
+
// The forced-fallback build is deliberately NOT consulted here: this hook runs on the inbound
|
|
2017
|
+
// turn only, and the adapter-owned bounded retry passes "text_fallback" through `build`
|
|
2018
|
+
// directly, never through this path.
|
|
2019
|
+
localTerminal(parsed: OcxParsedRequest) {
|
|
2020
|
+
return hasTrailingDeliveredFinalAnswer(kiroPayloadMessages(parsed), parsed)
|
|
2021
|
+
? { reason: "kiro_final_answer_already_delivered" }
|
|
2022
|
+
: undefined;
|
|
2023
|
+
},
|
|
2024
|
+
|
|
1908
2025
|
async buildRequest(parsed: OcxParsedRequest, incoming) {
|
|
1909
2026
|
const built = await build(parsed);
|
|
1910
2027
|
modelId = parsed.modelId;
|
|
@@ -1113,7 +1113,8 @@ function intersectBound(target: unknown, sibling: unknown, direction: "max" | "m
|
|
|
1113
1113
|
* Compose two `properties` maps. A property named in BOTH the referenced target and the
|
|
1114
1114
|
* node is the same conjunction problem `required` had: letting the sibling win discards
|
|
1115
1115
|
* the target's constraints for that member. Merge the two member schemas so neither side
|
|
1116
|
-
* loses its keywords
|
|
1116
|
+
* loses its keywords. Shared member bounds are the same conjunction one level down,
|
|
1117
|
+
* and nested object members recurse through this helper instead of replacing the target.
|
|
1117
1118
|
*/
|
|
1118
1119
|
function composeProperties(
|
|
1119
1120
|
target: Record<string, unknown>,
|
|
@@ -1127,7 +1128,20 @@ function composeProperties(
|
|
|
1127
1128
|
const member: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
1128
1129
|
for (const [k, v] of Object.entries(existing)) member[k] = v;
|
|
1129
1130
|
for (const [k, v] of Object.entries(sub)) {
|
|
1130
|
-
|
|
1131
|
+
if (k === "required") {
|
|
1132
|
+
member[k] = unionRequired(member[k], v);
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
if (k === "properties" && isXaiObjectSchema(member[k]) && isXaiObjectSchema(v)) {
|
|
1136
|
+
member[k] = composeProperties(member[k] as Record<string, unknown>, v);
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
const boundDirection = MOONSHOT_BOUND_KEYWORDS[k];
|
|
1140
|
+
if (boundDirection && k in member) {
|
|
1141
|
+
member[k] = intersectBound(member[k], v, boundDirection);
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
member[k] = v;
|
|
1131
1145
|
}
|
|
1132
1146
|
combined[name] = member;
|
|
1133
1147
|
continue;
|
|
@@ -319,8 +319,8 @@ function stripUnsupportedReasoningParams(body: unknown): unknown {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
/**
|
|
322
|
-
* GPT-5.6
|
|
323
|
-
*
|
|
322
|
+
* GPT-5.6 retired the legacy 24-hour retention field, and the ChatGPT backend 400s the whole
|
|
323
|
+
* request when that field is present (issue #2092).
|
|
324
324
|
*
|
|
325
325
|
* The retired field is NOT translated to the replacement: 5.6 carries a different TTL contract,
|
|
326
326
|
* and implicit caching still applies when the caller sent no replacement options. Inventing a
|
|
@@ -340,6 +340,18 @@ function stripDeprecatedPromptCacheRetention(body: unknown, modelId: unknown): u
|
|
|
340
340
|
return rest;
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Public Responses clients can send `prompt_cache_options`, but the canonical ChatGPT Codex
|
|
345
|
+
* backend rejects the top-level field before inference (issue #2765). Custom forward gateways and
|
|
346
|
+
* API-key Responses providers own different wire contracts, so the caller applies this only after
|
|
347
|
+
* the canonical destination predicate succeeds.
|
|
348
|
+
*/
|
|
349
|
+
function stripCanonicalForwardPromptCacheOptions(body: unknown): unknown {
|
|
350
|
+
if (!isPlainObject(body) || !Object.hasOwn(body, "prompt_cache_options")) return body;
|
|
351
|
+
const { prompt_cache_options: _options, ...rest } = body;
|
|
352
|
+
return rest;
|
|
353
|
+
}
|
|
354
|
+
|
|
343
355
|
/**
|
|
344
356
|
* A false model capability prevents Codex from emitting summary fields after the catalog refresh.
|
|
345
357
|
* Strip them here as well so an already-running client with a stale catalog cannot keep sending an
|
|
@@ -2004,6 +2016,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
2004
2016
|
// third-party forward gateway may still accept it, so this must not be widened.
|
|
2005
2017
|
if (isCanonicalOpenAiForwardProvider(provider)) {
|
|
2006
2018
|
outBody = stripDeprecatedPromptCacheRetention(outBody, parsed.modelId);
|
|
2019
|
+
outBody = stripCanonicalForwardPromptCacheOptions(outBody);
|
|
2007
2020
|
outBody = normalizeCanonicalForwardPromptEnvelope(outBody);
|
|
2008
2021
|
outBody = normalizeCanonicalForwardContinuationEnvelope(outBody);
|
|
2009
2022
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type OcxTool,
|
|
6
6
|
type OcxProviderConfig,
|
|
7
7
|
} from "../types";
|
|
8
|
+
import { CODE_MODE_RESULT_ECHO_SENTENCE } from "./exec-tool-result-normalize";
|
|
8
9
|
|
|
9
10
|
// Tool names that exist only in OTHER agent harnesses (Claude Code and friends). Naming one
|
|
10
11
|
// here tells a routed model not to call it unless this turn's catalog really lists it.
|
|
@@ -120,7 +121,7 @@ export function buildNonOpenAIToolCatalogNudgeFromNames(
|
|
|
120
121
|
"Call only listed names with their listed argument keys; do not invent, translate, or rename tools.",
|
|
121
122
|
"Names mentioned only in instructions, tool descriptions, argument descriptions, or nested helper APIs are not additional top-level tools.",
|
|
122
123
|
verifiedCodeModeExecName
|
|
123
|
-
? "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.<name>(...)`, for example `await tools.exec_command({cmd: \"ls\"})` or `await tools.codex_app__list_threads({})`. Absence from the top-level catalog or from `" + verifiedCodeModeExecName + "`'s description is not absence: deferred helpers stay callable on `tools.<name>`. Discover them from the isolate global `ALL_TOOLS`, not `tools.ALL_TOOLS`. Do not skip an available nested helper because it is omitted from the listed top-level names. Nested `tools.apply_patch(input)` is host-executed: the string must begin exactly with `*** Begin Patch` and end with `*** End Patch` (no trailing `***` on those lines). OpenCodex does not rewrite JavaScript inside exec, so a decorated `*** Begin Patch ***` envelope is rejected by Codex before the file is touched."
|
|
124
|
+
? "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.<name>(...)`, for example `await tools.exec_command({cmd: \"ls\"})` or `await tools.codex_app__list_threads({})`. Absence from the top-level catalog or from `" + verifiedCodeModeExecName + "`'s description is not absence: deferred helpers stay callable on `tools.<name>`. Discover them from the isolate global `ALL_TOOLS`, not `tools.ALL_TOOLS`. Do not skip an available nested helper because it is omitted from the listed top-level names. " + CODE_MODE_RESULT_ECHO_SENTENCE + " Nested `tools.apply_patch(input)` is host-executed: the string must begin exactly with `*** Begin Patch` and end with `*** End Patch` (no trailing `***` on those lines). OpenCodex does not rewrite JavaScript inside exec, so a decorated `*** Begin Patch ***` envelope is rejected by Codex before the file is touched."
|
|
124
125
|
: "If a listed tool exposes nested helpers such as a tools.* API, call the listed parent tool and use those helpers only inside that tool's input.",
|
|
125
126
|
unavailableNeighborNames.length > 0
|
|
126
127
|
? "Do not use neighboring-agent tool names " + quoteNames(unavailableNeighborNames) + " unless this turn's catalog lists those exact names."
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { OcxProviderConfig } from "../types";
|
|
2
|
+
import { isXaiResponsesDestination } from "../providers/xai-transport";
|
|
2
3
|
|
|
3
4
|
const CODEX_WEB_SEARCH_TOOL = "web_search";
|
|
4
5
|
const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview";
|
|
5
|
-
const XAI_API_HOST = "api.x.ai";
|
|
6
6
|
|
|
7
7
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
8
8
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -12,18 +12,6 @@ function isCodexWebSearchToolType(value: unknown): boolean {
|
|
|
12
12
|
return value === CODEX_WEB_SEARCH_TOOL || value === CODEX_WEB_SEARCH_PREVIEW_TOOL;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/** Match only xAI's documented public API, not arbitrary Responses-compatible gateways. */
|
|
16
|
-
function isXaiPublicApi(provider: Pick<OcxProviderConfig, "baseUrl">): boolean {
|
|
17
|
-
try {
|
|
18
|
-
const url = new URL(provider.baseUrl);
|
|
19
|
-
return url.protocol === "https:"
|
|
20
|
-
&& url.hostname.toLowerCase() === XAI_API_HOST
|
|
21
|
-
&& (url.port === "" || url.port === "443");
|
|
22
|
-
} catch {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
15
|
type ToolGroupRewrite = {
|
|
28
16
|
tools: unknown[];
|
|
29
17
|
changed: boolean;
|
|
@@ -150,12 +138,20 @@ function normalizeToolChoice(body: Record<string, unknown>): Record<string, unkn
|
|
|
150
138
|
/**
|
|
151
139
|
* Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other
|
|
152
140
|
* providers or mutating the caller-owned request body.
|
|
141
|
+
*
|
|
142
|
+
* Scoped to BOTH xAI Responses hosts, not just the public API. The 2026-08-22 probe recorded in
|
|
143
|
+
* `normalizeToolGroup` and in `isXaiResponsesDestination` already found the two hosts to be one
|
|
144
|
+
* dialect, but this gate stayed on `api.x.ai` alone, so the Grok CLI proxy — the OAuth lane — was
|
|
145
|
+
* left unnormalized. Re-probed 2026-08-27 against `cli-chat-proxy.grok.com`:
|
|
146
|
+
* `web_search_preview` -> 422 `unknown variant`, `external_web_access` -> 400 on every value,
|
|
147
|
+
* `search_context_size` -> 400, while `user_location` and `search_content_types` -> 200. Identical
|
|
148
|
+
* to the public API, which is what makes one shared gate correct.
|
|
153
149
|
*/
|
|
154
150
|
export function normalizeXaiResponsesWebSearch(
|
|
155
151
|
body: unknown,
|
|
156
152
|
provider: Pick<OcxProviderConfig, "baseUrl">,
|
|
157
153
|
): unknown {
|
|
158
|
-
if (!
|
|
154
|
+
if (!isXaiResponsesDestination(provider) || !isPlainObject(body)) return body;
|
|
159
155
|
|
|
160
156
|
let next: Record<string, unknown> = body;
|
|
161
157
|
if (Array.isArray(body.tools)) {
|
package/src/claude/outbound.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* - errors: {type:"error", error:{type,message}}; may arrive mid-stream after HTTP 200.
|
|
12
12
|
*/
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
|
+
import { httpStatusFromTerminalError } from "../lib/errors";
|
|
14
15
|
import { isTransientUpstreamStatus } from "../lib/upstream-retry";
|
|
15
16
|
import {
|
|
16
17
|
isTranslatorBudgetExceededError,
|
|
@@ -555,9 +556,19 @@ export function responsesSseToAnthropicSse(
|
|
|
555
556
|
}
|
|
556
557
|
const status = code === "translation_buffer_limit"
|
|
557
558
|
? 413
|
|
558
|
-
: typeof error.status === "number"
|
|
559
|
-
|
|
560
|
-
|
|
559
|
+
: typeof error.status === "number"
|
|
560
|
+
? error.status
|
|
561
|
+
// Internal response.failed envelopes carry the classified {type, code, message}
|
|
562
|
+
// but no numeric status. Derive it with the same mapping /api/logs uses so a
|
|
563
|
+
// classified 429/401/400 reaches Claude Code as its real Anthropic error type
|
|
564
|
+
// instead of being masked as retryable overload.
|
|
565
|
+
: httpStatusFromTerminalError({
|
|
566
|
+
type: typeof error.type === "string" ? error.type : undefined,
|
|
567
|
+
code: typeof error.code === "string" ? error.code : null,
|
|
568
|
+
message,
|
|
569
|
+
});
|
|
570
|
+
// Unclassified status-absent response.failed (relaySseWithFailedTail synthetic
|
|
571
|
+
// tail) still lands on a transient 5xx here — the mid-stream reset shape maps to
|
|
561
572
|
// overloaded_error by design.
|
|
562
573
|
fail(
|
|
563
574
|
status,
|
package/src/cli/access.ts
CHANGED
|
@@ -17,6 +17,51 @@ const USAGE = `Usage:
|
|
|
17
17
|
ocx access models [--json]
|
|
18
18
|
ocx access test <model> [--protocol <chat|responses|messages>] [--json]`;
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Render the key table with the usage fields the API already returns (#2705).
|
|
22
|
+
*
|
|
23
|
+
* `usage` is a DISCRIMINATED UNION server-side (`api-key-usage.ts`): the `{ambiguous:true}`
|
|
24
|
+
* variant carries no numbers at all, because when two config entries share an id there IS no
|
|
25
|
+
* per-key total. The union exists specifically so a consumer cannot print a number beside an
|
|
26
|
+
* ambiguity marker, so this renders the word `ambiguous` across the numeric columns rather
|
|
27
|
+
* than a fabricated 0 -- reporting 0 requests for a key that may be in heavy use is the
|
|
28
|
+
* dangerous answer to hand someone deciding what to delete.
|
|
29
|
+
*
|
|
30
|
+
* `attributionSince` and `historyTruncated` describe the DATA SET, not a key, so they print
|
|
31
|
+
* once as a footer. Without `attributionSince`, an absent `lastUsedAt` is unreadable: it
|
|
32
|
+
* could mean "never used" or "nothing is attributable yet".
|
|
33
|
+
*/
|
|
34
|
+
function formatKeyRows(payload: Record<string, unknown>, keys: Array<Record<string, unknown>>): string[] {
|
|
35
|
+
const cells: string[][] = [["ID", "NAME", "PREFIX", "REQ 7D", "TOTAL", "LAST USED"]];
|
|
36
|
+
for (const entry of keys) {
|
|
37
|
+
const usage = (entry.usage ?? {}) as Record<string, unknown>;
|
|
38
|
+
const ambiguous = usage.ambiguous === true;
|
|
39
|
+
const num = (value: unknown): string => (typeof value === "number" ? value.toLocaleString("en-US") : "-");
|
|
40
|
+
cells.push([
|
|
41
|
+
String(entry.id ?? ""),
|
|
42
|
+
String(entry.name ?? ""),
|
|
43
|
+
String(entry.prefix ?? ""),
|
|
44
|
+
// One marker spanning both numeric columns: the union guarantees neither exists.
|
|
45
|
+
ambiguous ? "ambiguous" : num(usage.requests7d),
|
|
46
|
+
ambiguous ? "" : num(usage.totalRequests),
|
|
47
|
+
ambiguous ? "" : (typeof usage.lastUsedAt === "string" ? usage.lastUsedAt : "never"),
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
const widths = cells[0]!.map((_, column) => Math.max(...cells.map(row => (row[column] ?? "").length)));
|
|
51
|
+
const lines = cells.map(row => row.map((cell, i) => (cell ?? "").padEnd(widths[i]!)).join(" ").trimEnd());
|
|
52
|
+
const footer: string[] = [];
|
|
53
|
+
if (typeof payload.attributionSince === "string") {
|
|
54
|
+
footer.push(`attribution since ${payload.attributionSince}`);
|
|
55
|
+
}
|
|
56
|
+
if (payload.historyTruncated === true) {
|
|
57
|
+
footer.push("older history truncated");
|
|
58
|
+
}
|
|
59
|
+
if (keys.some(entry => (entry.usage as Record<string, unknown> | undefined)?.ambiguous === true)) {
|
|
60
|
+
footer.push("ambiguous: two configured keys share an id, so per-key totals do not exist");
|
|
61
|
+
}
|
|
62
|
+
return footer.length > 0 ? [...lines, "", ...footer] : lines;
|
|
63
|
+
}
|
|
64
|
+
|
|
20
65
|
async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
21
66
|
const args = [...argv];
|
|
22
67
|
const action = (args.shift() ?? "list").toLowerCase();
|
|
@@ -25,9 +70,7 @@ async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
25
70
|
rejectArgs(args, USAGE);
|
|
26
71
|
const result = await runtimeRequest<Record<string, unknown>>("/api/keys", {}, deps);
|
|
27
72
|
const keys = Array.isArray(result.keys) ? result.keys as Array<Record<string, unknown>> : [];
|
|
28
|
-
printData(result, wantsJson, keys.length
|
|
29
|
-
? keys.map(entry => `${String(entry.id)} ${String(entry.name)} ${String(entry.prefix ?? "")}`)
|
|
30
|
-
: ["No API access keys configured."]);
|
|
73
|
+
printData(result, wantsJson, keys.length ? formatKeyRows(result, keys) : ["No API access keys configured."]);
|
|
31
74
|
return;
|
|
32
75
|
}
|
|
33
76
|
if (action === "create") {
|