@bitkyc08/opencodex 2.60.0 → 2.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-CH6C5H7x.js +50 -0
- package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/index-wpTOyepx.js +86 -0
- package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +10 -0
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-listener.ts +2 -5
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/schema.ts +3 -0
- package/src/router.ts +17 -2
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape.
|
|
8
8
|
*/
|
|
9
9
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
10
|
+
import {
|
|
11
|
+
admissionModelDeniedResponse,
|
|
12
|
+
AdmissionModelDeniedError,
|
|
13
|
+
assertRouteAllowedByScope,
|
|
14
|
+
resolveAdmissionModelScope,
|
|
15
|
+
} from "./admission-model-scope";
|
|
10
16
|
import { jsonUtf8Bytes } from "../lib/json-byte-size";
|
|
11
17
|
import { sseFieldValue } from "../lib/sse-decoder";
|
|
12
18
|
import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard";
|
|
@@ -18,7 +24,14 @@ import { recordDesktopRequest } from "../claude/desktop-health";
|
|
|
18
24
|
import { stripOneMillionMarker } from "../claude/context-windows";
|
|
19
25
|
import { captureClaudeInbound } from "../claude/inbound-debug";
|
|
20
26
|
import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility";
|
|
21
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
applyReplayRefusalClientHeaders,
|
|
29
|
+
carryReplayRefusal,
|
|
30
|
+
isReplayRefusalResponse,
|
|
31
|
+
isTransientUpstreamStatus,
|
|
32
|
+
REPLAY_REFUSED_STATUS,
|
|
33
|
+
UPSTREAM_RESET_REPLAY_REFUSED_CODE,
|
|
34
|
+
} from "../lib/upstream-retry";
|
|
22
35
|
import { resolveClientRetryAfter } from "../lib/retry-after";
|
|
23
36
|
import {
|
|
24
37
|
anthropicErrorBody,
|
|
@@ -815,6 +828,13 @@ async function handleClaudeMessagesWithBudget(
|
|
|
815
828
|
// verified live 2026-07-11). Strip them for that route; routed providers keep them.
|
|
816
829
|
try {
|
|
817
830
|
const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody));
|
|
831
|
+
// Same reason as the native Chat lane: this route can be sent from here, so
|
|
832
|
+
// the key's scope is applied before the wire is settled.
|
|
833
|
+
assertRouteAllowedByScope(
|
|
834
|
+
resolveAdmissionModelScope(config, logIds?.admission),
|
|
835
|
+
String(internalBody.model ?? ""),
|
|
836
|
+
route,
|
|
837
|
+
);
|
|
818
838
|
// Settle the wire once so the sampling decision below reads the effective
|
|
819
839
|
// adapter rather than the provider-wide default (#404).
|
|
820
840
|
route.staticPolicy = captureRouteStaticPolicy(
|
|
@@ -847,6 +867,11 @@ async function handleClaudeMessagesWithBudget(
|
|
|
847
867
|
if (ladder !== undefined && ladder.length === 0) delete internalBody.reasoning;
|
|
848
868
|
}
|
|
849
869
|
} catch (err) {
|
|
870
|
+
if (err instanceof AdmissionModelDeniedError) {
|
|
871
|
+
logCtx.requestedModel = requestedModel;
|
|
872
|
+
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 403, { closeReason: "non_stream" });
|
|
873
|
+
return admissionModelDeniedResponse(err);
|
|
874
|
+
}
|
|
850
875
|
if (err instanceof UnknownRoutingPolicyError) {
|
|
851
876
|
logCtx.requestedModel = requestedModel;
|
|
852
877
|
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" });
|
|
@@ -961,6 +986,9 @@ async function handleClaudeMessagesWithBudget(
|
|
|
961
986
|
const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream;
|
|
962
987
|
|
|
963
988
|
if (!response.ok) {
|
|
989
|
+
// Read the shared provenance verdict before consuming and re-wrapping the body. A refusal
|
|
990
|
+
// and an ordinary provider rate limit are both 429, so the status cannot distinguish them.
|
|
991
|
+
const replayRefusal = isReplayRefusalResponse(response);
|
|
964
992
|
// Re-shape the OpenAI-style error envelope into the Anthropic one, preserving status.
|
|
965
993
|
let message = `upstream error (${response.status})`;
|
|
966
994
|
try {
|
|
@@ -975,14 +1003,16 @@ async function handleClaudeMessagesWithBudget(
|
|
|
975
1003
|
}
|
|
976
1004
|
} catch { /* keep fallback message */ }
|
|
977
1005
|
const upstreamRetryAfter = response.headers.get("retry-after");
|
|
978
|
-
const retryAfter =
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1006
|
+
const retryAfter = replayRefusal
|
|
1007
|
+
? undefined
|
|
1008
|
+
: resolveClientRetryAfter({
|
|
1009
|
+
status: response.status,
|
|
1010
|
+
message,
|
|
1011
|
+
upstreamRetryAfter,
|
|
1012
|
+
})
|
|
1013
|
+
// Instant-retry "0" is a valid client directive but rejected by cooldown parsers.
|
|
1014
|
+
// Preserve it so it still wins over the transient "2" fallback (claude-529 mapping).
|
|
1015
|
+
?? (upstreamRetryAfter?.trim() === "0" ? "0" : undefined);
|
|
986
1016
|
// Transient upstream 5xx (already retried pre-stream, 010): reclassify as Anthropic
|
|
987
1017
|
// 529 overloaded_error so the Claude Code client applies its built-in backoff retry
|
|
988
1018
|
// instead of dying on a fatal api_error (260716 sol-builder incident). The request
|
|
@@ -993,16 +1023,24 @@ async function handleClaudeMessagesWithBudget(
|
|
|
993
1023
|
const nativeMainFence = response.status === 503
|
|
994
1024
|
&& upstreamRetryAfter?.trim() === "1"
|
|
995
1025
|
&& message === CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE;
|
|
996
|
-
const transient = !nativeMainFence && isTransientUpstreamStatus(response.status);
|
|
997
|
-
const outStatus =
|
|
998
|
-
|
|
1026
|
+
const transient = !replayRefusal && !nativeMainFence && isTransientUpstreamStatus(response.status);
|
|
1027
|
+
const outStatus = replayRefusal
|
|
1028
|
+
? REPLAY_REFUSED_STATUS
|
|
1029
|
+
: nativeMainFence ? 503 : transient ? 529 : response.status;
|
|
1030
|
+
const outHeaders = new Headers({ "Content-Type": "application/json" });
|
|
1031
|
+
if (retryAfter) outHeaders.set("Retry-After", retryAfter);
|
|
1032
|
+
else if (transient) outHeaders.set("Retry-After", "2");
|
|
1033
|
+
if (replayRefusal) applyReplayRefusalClientHeaders(outHeaders);
|
|
1034
|
+
const out = new Response(JSON.stringify(anthropicErrorBody(
|
|
1035
|
+
outStatus,
|
|
1036
|
+
message,
|
|
1037
|
+
undefined,
|
|
1038
|
+
replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : undefined,
|
|
1039
|
+
)), {
|
|
999
1040
|
status: outStatus,
|
|
1000
|
-
headers:
|
|
1001
|
-
"Content-Type": "application/json",
|
|
1002
|
-
...(retryAfter ? { "Retry-After": retryAfter } : (transient ? { "Retry-After": "2" } : {})),
|
|
1003
|
-
},
|
|
1041
|
+
headers: outHeaders,
|
|
1004
1042
|
});
|
|
1005
|
-
return out;
|
|
1043
|
+
return carryReplayRefusal(response, out);
|
|
1006
1044
|
}
|
|
1007
1045
|
|
|
1008
1046
|
const contentType = response.headers.get("content-type") ?? "";
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/** Strict terminal reconstruction selected by the Grok compatibility marker. */
|
|
2
2
|
import type { TranslatorBudget } from "../lib/translator-budget";
|
|
3
3
|
import { MAX_COMPLETED_OUTPUT_ITEMS, MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES } from "./relay";
|
|
4
|
-
import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
|
|
4
|
+
import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
|
|
5
5
|
import { isPlainObject, jsonBlock, type RetainedOutputItem } from "./responses-snapshot-codec";
|
|
6
|
+
import {
|
|
7
|
+
requestToolScope,
|
|
8
|
+
type RequestToolScope,
|
|
9
|
+
type RequestToolScopeCorrespondence,
|
|
10
|
+
} from "./responses-request-tool-scope";
|
|
6
11
|
|
|
7
12
|
type SparseTerminalOpenItem = {
|
|
8
13
|
type: string;
|
|
@@ -16,6 +21,20 @@ type SparseTerminalCompletedItem = RetainedOutputItem & {
|
|
|
16
21
|
|
|
17
22
|
const MAX_GROK_OPEN_ITEM_IDENTITY_BYTES = MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES;
|
|
18
23
|
|
|
24
|
+
/** Terminal event this repair publishes when it refused to reconstruct a call faithfully. */
|
|
25
|
+
export const GROK_REFUSED_TERMINAL_EVENT_TYPE = "response.incomplete";
|
|
26
|
+
|
|
27
|
+
/** `incomplete_details.reason` carried by that terminal. */
|
|
28
|
+
export const GROK_FORBIDDEN_TOOL_CALL_REASON = "forbidden_tool_call";
|
|
29
|
+
|
|
30
|
+
/** An upstream-supplied name reaches the terminal message; keep it bounded. */
|
|
31
|
+
const MAX_REPORTED_TOOL_NAME_CHARS = 100;
|
|
32
|
+
|
|
33
|
+
export function forbiddenToolCallMessage(name: string): string {
|
|
34
|
+
return `routed provider called "${name.slice(0, MAX_REPORTED_TOOL_NAME_CHARS)}", `
|
|
35
|
+
+ "which this request's tool selection excludes; the reconstructed output omits that call";
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
const GROK_TERMINAL_OUTPUT_ITEM_TYPES = new Set([
|
|
20
39
|
"message",
|
|
21
40
|
"reasoning",
|
|
@@ -167,6 +186,45 @@ function plausibleGrokOpenItem(
|
|
|
167
186
|
};
|
|
168
187
|
}
|
|
169
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Publish the refusal on the terminal itself rather than as a silent omission.
|
|
191
|
+
*
|
|
192
|
+
* The ordinary reconstruction replaces only the data payload's `output`, so its event name still
|
|
193
|
+
* describes the payload. A refusal does not: the turn no longer completed the way the upstream
|
|
194
|
+
* said it did, so the event line moves with the status instead of leaving a client to read a
|
|
195
|
+
* clean finish off an unchanged `event: response.completed`.
|
|
196
|
+
*/
|
|
197
|
+
function refusedTerminalBlock(
|
|
198
|
+
block: string,
|
|
199
|
+
parsed: Record<string, unknown>,
|
|
200
|
+
response: Record<string, unknown>,
|
|
201
|
+
output: readonly Record<string, unknown>[],
|
|
202
|
+
refusedName: string | undefined,
|
|
203
|
+
): string {
|
|
204
|
+
const payload = JSON.stringify({
|
|
205
|
+
...parsed,
|
|
206
|
+
type: GROK_REFUSED_TERMINAL_EVENT_TYPE,
|
|
207
|
+
response: {
|
|
208
|
+
...response,
|
|
209
|
+
status: "incomplete",
|
|
210
|
+
output,
|
|
211
|
+
incomplete_details: {
|
|
212
|
+
reason: GROK_FORBIDDEN_TOOL_CALL_REASON,
|
|
213
|
+
...(refusedName === undefined ? {} : { message: forbiddenToolCallMessage(refusedName) }),
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
const rewritten = replaceSseDataPayload(block, payload);
|
|
218
|
+
const newline = block.includes("\r\n") ? "\r\n" : "\n";
|
|
219
|
+
let eventRewritten = false;
|
|
220
|
+
const lines = rewritten.split(/\r?\n/).map(line => {
|
|
221
|
+
if (eventRewritten || !line.startsWith("event:")) return line;
|
|
222
|
+
eventRewritten = true;
|
|
223
|
+
return `event: ${GROK_REFUSED_TERMINAL_EVENT_TYPE}`;
|
|
224
|
+
});
|
|
225
|
+
return lines.join(newline);
|
|
226
|
+
}
|
|
227
|
+
|
|
170
228
|
/**
|
|
171
229
|
* Narrow client repair for grok-build's Responses consumer.
|
|
172
230
|
*
|
|
@@ -176,12 +234,26 @@ function plausibleGrokOpenItem(
|
|
|
176
234
|
* empty output. Reconstruct only from real, unique, contiguous, bounded done
|
|
177
235
|
* events whose raw semantics are already valid. Any ambiguity stays byte-level
|
|
178
236
|
* fail-closed; the provider-opt-in snapshot repair above is unchanged.
|
|
237
|
+
*
|
|
238
|
+
* The terminal this publishes is one the upstream never sent, so it carries only what the final
|
|
239
|
+
* outbound request still authorized. A client call outside that request's tool selection is left
|
|
240
|
+
* out and the terminal says so explicitly. The declaration guard downstream answers the other
|
|
241
|
+
* half of the question — whether a name was declared at all — and keeps policing the raw stream,
|
|
242
|
+
* which this rewrite never edits.
|
|
179
243
|
*/
|
|
180
244
|
export function createGrokResponsesSparseTerminalBlockRewrite(
|
|
181
245
|
budget?: TranslatorBudget,
|
|
246
|
+
outboundRequestBody?: unknown,
|
|
247
|
+
toolIdentityCorrespondence?: RequestToolScopeCorrespondence,
|
|
182
248
|
): SseBlockRewrite {
|
|
249
|
+
const toolScope: RequestToolScope | undefined = requestToolScope(
|
|
250
|
+
outboundRequestBody,
|
|
251
|
+
toolIdentityCorrespondence,
|
|
252
|
+
);
|
|
183
253
|
const openItems = new Map<number, SparseTerminalOpenItem>();
|
|
184
254
|
const completedItems = new Map<number, SparseTerminalCompletedItem>();
|
|
255
|
+
const withheldIndices = new Set<number>();
|
|
256
|
+
let withheldToolName: string | undefined;
|
|
185
257
|
let aggregateItemBytes = 0;
|
|
186
258
|
let aggregateOpenItemBytes = 0;
|
|
187
259
|
let tainted = false;
|
|
@@ -194,6 +266,8 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
|
|
|
194
266
|
}
|
|
195
267
|
openItems.clear();
|
|
196
268
|
completedItems.clear();
|
|
269
|
+
withheldIndices.clear();
|
|
270
|
+
withheldToolName = undefined;
|
|
197
271
|
aggregateItemBytes = 0;
|
|
198
272
|
aggregateOpenItemBytes = 0;
|
|
199
273
|
hasVisibleOutput = false;
|
|
@@ -217,7 +291,7 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
|
|
|
217
291
|
if (tainted) return;
|
|
218
292
|
const sourceBytes = Buffer.byteLength(JSON.stringify(item), "utf8");
|
|
219
293
|
if (sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES
|
|
220
|
-
|| completedItems.size >= MAX_COMPLETED_OUTPUT_ITEMS
|
|
294
|
+
|| completedItems.size + withheldIndices.size >= MAX_COMPLETED_OUTPUT_ITEMS
|
|
221
295
|
|| aggregateItemBytes + sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES) {
|
|
222
296
|
taintAndRelease();
|
|
223
297
|
return;
|
|
@@ -228,6 +302,24 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
|
|
|
228
302
|
hasVisibleOutput = hasVisibleOutput || visibleToGrok;
|
|
229
303
|
};
|
|
230
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Record the position of a call this request forbade without retaining the item.
|
|
307
|
+
*
|
|
308
|
+
* Only the offending item is dropped. Tainting here instead would discard the assistant text
|
|
309
|
+
* that arrived in the same turn and leave the client the empty terminal this repair exists to
|
|
310
|
+
* fix, which punishes the caller for the provider's overreach. The index is kept so the
|
|
311
|
+
* contiguity proof below still covers the whole output.
|
|
312
|
+
*/
|
|
313
|
+
const withholdForbiddenCall = (index: number, name: string): void => {
|
|
314
|
+
if (tainted) return;
|
|
315
|
+
if (completedItems.size + withheldIndices.size >= MAX_COMPLETED_OUTPUT_ITEMS) {
|
|
316
|
+
taintAndRelease();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
withheldIndices.add(index);
|
|
320
|
+
withheldToolName ??= name.slice(0, MAX_REPORTED_TOOL_NAME_CHARS);
|
|
321
|
+
};
|
|
322
|
+
|
|
231
323
|
const closeOpenItem = (index: number): void => {
|
|
232
324
|
const open = openItems.get(index);
|
|
233
325
|
if (!open) return;
|
|
@@ -265,6 +357,7 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
|
|
|
265
357
|
const open = isPlainObject(parsed.item) ? plausibleGrokOpenItem(parsed.item) : null;
|
|
266
358
|
if (outputIndex === undefined || !open
|
|
267
359
|
|| openItems.has(outputIndex) || completedItems.has(outputIndex)
|
|
360
|
+
|| withheldIndices.has(outputIndex)
|
|
268
361
|
|| openItems.size >= MAX_COMPLETED_OUTPUT_ITEMS) {
|
|
269
362
|
taintAndRelease();
|
|
270
363
|
} else if (!tainted) {
|
|
@@ -284,7 +377,8 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
|
|
|
284
377
|
if (type === "response.output_item.done") {
|
|
285
378
|
const item = isPlainObject(parsed.item) ? parsed.item : null;
|
|
286
379
|
const proof = item ? trustedGrokCompletedItem(item) : null;
|
|
287
|
-
if (outputIndex === undefined || !proof
|
|
380
|
+
if (outputIndex === undefined || !proof
|
|
381
|
+
|| completedItems.has(outputIndex) || withheldIndices.has(outputIndex)) {
|
|
288
382
|
taintAndRelease();
|
|
289
383
|
return [block];
|
|
290
384
|
}
|
|
@@ -295,7 +389,12 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
|
|
|
295
389
|
return [block];
|
|
296
390
|
}
|
|
297
391
|
closeOpenItem(outputIndex);
|
|
298
|
-
|
|
392
|
+
const forbidden = toolScope?.forbiddenClientToolCallName(item!);
|
|
393
|
+
if (forbidden === undefined) {
|
|
394
|
+
retainCompletedItem(outputIndex, item!, proof.visibleToGrok);
|
|
395
|
+
} else {
|
|
396
|
+
withholdForbiddenCall(outputIndex, forbidden);
|
|
397
|
+
}
|
|
299
398
|
return [block];
|
|
300
399
|
}
|
|
301
400
|
|
|
@@ -312,14 +411,18 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
|
|
|
312
411
|
const outputIsAuthoritative = Array.isArray(output) && output.length > 0;
|
|
313
412
|
const outputIsSparse = !("output" in response)
|
|
314
413
|
|| (Array.isArray(output) && output.length === 0);
|
|
414
|
+
// A withheld call is a reason to publish on its own: the refusal has to reach the client
|
|
415
|
+
// even when nothing visible survived it, or the turn ends as an ordinary empty finish.
|
|
416
|
+
const refused = withheldIndices.size > 0;
|
|
315
417
|
if (!outputIsAuthoritative && outputIsSparse && terminalStatusConsistent
|
|
316
|
-
&&
|
|
418
|
+
&& openItems.size === 0 && (hasVisibleOutput || refused)) {
|
|
317
419
|
const ordered = [...completedItems.entries()].sort(([left], [right]) => left - right);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
420
|
+
const positions = [...completedItems.keys(), ...withheldIndices].sort((left, right) => left - right);
|
|
421
|
+
if (positions.length > 0 && positions.every((index, position) => index === position)) {
|
|
422
|
+
const rebuilt = ordered.map(([, retained]) => retained.item);
|
|
423
|
+
out = refused
|
|
424
|
+
? refusedTerminalBlock(block, parsed, response, rebuilt, withheldToolName)
|
|
425
|
+
: jsonBlock({ ...parsed, response: { ...response, output: rebuilt } });
|
|
323
426
|
}
|
|
324
427
|
}
|
|
325
428
|
}
|
|
@@ -330,4 +433,3 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
|
|
|
330
433
|
rewrite.dispose = reset;
|
|
331
434
|
return rewrite;
|
|
332
435
|
}
|
|
333
|
-
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether the served dashboard bundle predates the sources it was built from.
|
|
6
|
+
*
|
|
7
|
+
* The dashboard is a build artifact: `AGENTS.md` records that `gui/` is served from `gui/dist`,
|
|
8
|
+
* so a checkout that moves forward without `bun run build:gui` keeps serving the previous bundle.
|
|
9
|
+
* Nothing fails when that happens. The proxy answers, the page loads, and every feature added since
|
|
10
|
+
* the last build is simply absent — which reads as the feature being broken rather than unbuilt.
|
|
11
|
+
* A five-day-old bundle hid the entire menu-bar and widget section of the Usage page this way.
|
|
12
|
+
*
|
|
13
|
+
* This reports; it never rebuilds. A proxy that compiled a frontend while starting would trade a
|
|
14
|
+
* silent staleness for a slow, surprising start, and the rebuild belongs to whoever moved the
|
|
15
|
+
* checkout.
|
|
16
|
+
*/
|
|
17
|
+
export interface GuiBundleFreshness {
|
|
18
|
+
/** Absolute path of the served bundle, or null when no bundle was found. */
|
|
19
|
+
bundlePath: string | null;
|
|
20
|
+
/** Newest mtime under the bundle, in epoch milliseconds. */
|
|
21
|
+
bundleModifiedMs: number | null;
|
|
22
|
+
/** Newest mtime under the GUI sources, in epoch milliseconds. */
|
|
23
|
+
sourceModifiedMs: number | null;
|
|
24
|
+
/** True only when both sides are known and the sources are strictly newer. */
|
|
25
|
+
stale: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Directory entries that never carry meaningful build input or output timestamps. */
|
|
29
|
+
const IGNORED_DIRECTORIES = new Set(["node_modules", ".git", ".vite", ".cache"]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Newest mtime beneath `root`, or null when the tree is missing or empty.
|
|
33
|
+
*
|
|
34
|
+
* Walking is bounded by `maxEntries` because this runs on a status path: a pathological tree must
|
|
35
|
+
* cost a predictable amount rather than stalling the command that reports on it. Hitting the bound
|
|
36
|
+
* yields the newest value seen so far, which can only make the comparison more conservative.
|
|
37
|
+
*/
|
|
38
|
+
export function newestModifiedMs(root: string, maxEntries = 20_000): number | null {
|
|
39
|
+
if (!existsSync(root)) return null;
|
|
40
|
+
let newest: number | null = null;
|
|
41
|
+
let seen = 0;
|
|
42
|
+
const queue: string[] = [root];
|
|
43
|
+
while (queue.length > 0) {
|
|
44
|
+
const current = queue.pop()!;
|
|
45
|
+
let entries;
|
|
46
|
+
try {
|
|
47
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (seen >= maxEntries) return newest;
|
|
53
|
+
if (entry.isSymbolicLink()) continue;
|
|
54
|
+
const full = join(current, entry.name);
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
if (IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
57
|
+
queue.push(full);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!entry.isFile()) continue;
|
|
61
|
+
seen += 1;
|
|
62
|
+
try {
|
|
63
|
+
const mtime = statSync(full).mtimeMs;
|
|
64
|
+
if (newest === null || mtime > newest) newest = mtime;
|
|
65
|
+
} catch {
|
|
66
|
+
// A file that vanished mid-walk cannot make the bundle look fresher than it is.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return newest;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Compare the built bundle against its sources.
|
|
75
|
+
*
|
|
76
|
+
* Unknown is not stale. A packaged install has no `gui/src` beside it, and a missing bundle is a
|
|
77
|
+
* different condition with its own message elsewhere; neither should produce a rebuild warning.
|
|
78
|
+
*/
|
|
79
|
+
export function inspectGuiBundleFreshness(input: {
|
|
80
|
+
bundlePath: string | null;
|
|
81
|
+
sourcePath: string;
|
|
82
|
+
}): GuiBundleFreshness {
|
|
83
|
+
const bundleModifiedMs = input.bundlePath === null ? null : newestModifiedMs(input.bundlePath);
|
|
84
|
+
const sourceModifiedMs = newestModifiedMs(input.sourcePath);
|
|
85
|
+
const stale = bundleModifiedMs !== null
|
|
86
|
+
&& sourceModifiedMs !== null
|
|
87
|
+
&& sourceModifiedMs > bundleModifiedMs;
|
|
88
|
+
return {
|
|
89
|
+
bundlePath: input.bundlePath,
|
|
90
|
+
bundleModifiedMs,
|
|
91
|
+
sourceModifiedMs,
|
|
92
|
+
stale,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Operator-facing lines for a stale bundle; empty when the bundle is current or unknown. */
|
|
97
|
+
export function staleGuiBundleLines(freshness: GuiBundleFreshness): string[] {
|
|
98
|
+
if (!freshness.stale) return [];
|
|
99
|
+
return [
|
|
100
|
+
"Dashboard bundle is older than the GUI sources, so the page is missing everything built since.",
|
|
101
|
+
"Rebuild it with: bun run build:gui",
|
|
102
|
+
];
|
|
103
|
+
}
|
package/src/server/gui-static.ts
CHANGED
|
@@ -2,15 +2,11 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
2
2
|
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { browserSecurityHeaders } from "./auth-cors";
|
|
4
4
|
import type { GuiSessionBootstrap } from "./gui-session";
|
|
5
|
+
import { packageVersion } from "../lib/package-version";
|
|
6
|
+
import { isStandaloneBinary, standaloneRoot } from "../lib/standalone";
|
|
5
7
|
|
|
6
8
|
/** opencodex version, read from the packaged package.json (same source as the server bootstrap). */
|
|
7
|
-
const VERSION = (
|
|
8
|
-
try {
|
|
9
|
-
return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version as string;
|
|
10
|
-
} catch {
|
|
11
|
-
return "0.0.0";
|
|
12
|
-
}
|
|
13
|
-
})();
|
|
9
|
+
const VERSION = packageVersion("0.0.0");
|
|
14
10
|
|
|
15
11
|
const MIME_TYPES: Record<string, string> = {
|
|
16
12
|
".html": "text/html", ".js": "application/javascript", ".css": "text/css",
|
|
@@ -24,11 +20,13 @@ const MIME_TYPES: Record<string, string> = {
|
|
|
24
20
|
*/
|
|
25
21
|
const HASHED_ASSET_PATTERN = /-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9]+$/;
|
|
26
22
|
|
|
27
|
-
function findGuiDist(): string | null {
|
|
23
|
+
export function findGuiDist(): string | null {
|
|
28
24
|
const candidates = [
|
|
25
|
+
process.env.OPENCODEX_GUI_DIST,
|
|
26
|
+
...(isStandaloneBinary() ? [join(standaloneRoot(), "gui", "dist")] : []),
|
|
29
27
|
join(import.meta.dir, "..", "..", "gui", "dist"),
|
|
30
28
|
join(import.meta.dir, "..", "..", "..", "gui", "dist"),
|
|
31
|
-
];
|
|
29
|
+
].filter((candidate): candidate is string => Boolean(candidate));
|
|
32
30
|
for (const c of candidates) {
|
|
33
31
|
if (existsSync(join(c, "index.html"))) return c;
|
|
34
32
|
}
|
package/src/server/images.ts
CHANGED
|
@@ -33,6 +33,8 @@ import { selectProactiveApiKeyTransport } from "../providers/key-failover";
|
|
|
33
33
|
import { getProviderRegistryEntry } from "../providers/registry";
|
|
34
34
|
import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
|
|
35
35
|
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
|
|
36
|
+
import type { DataPlaneAdmission } from "./auth-cors";
|
|
37
|
+
import { admissionScopeDenial } from "./admission-model-scope";
|
|
36
38
|
import type { RequestLogContext } from "./request-log";
|
|
37
39
|
import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
|
|
38
40
|
import { getValidAccessToken, getOAuthCredentialProjectId } from "../oauth/index";
|
|
@@ -115,6 +117,18 @@ export async function readImageResponseBytes(
|
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
const CCA_IMAGE_MODEL = "gemini-3.1-flash-image";
|
|
120
|
+
const XAI_IMAGE_BRIDGE_MODEL = "grok-imagine-image-quality";
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The model this request names, or undefined when it names none.
|
|
124
|
+
*
|
|
125
|
+
* An absent `model` is relayed as an absent `model`: the upstream picks, so
|
|
126
|
+
* there is no destination to echo and none a model list can allow.
|
|
127
|
+
*/
|
|
128
|
+
function requestedImageSelector(body: unknown): string | undefined {
|
|
129
|
+
const model = (body as { model?: unknown } | null)?.model;
|
|
130
|
+
return typeof model === "string" && model.trim() ? model : undefined;
|
|
131
|
+
}
|
|
118
132
|
|
|
119
133
|
/**
|
|
120
134
|
* Google Gemini finishReasons that indicate a permanent content/safety block.
|
|
@@ -207,11 +221,21 @@ async function tryCcaImageGeneration(
|
|
|
207
221
|
logCtx: RequestLogContext,
|
|
208
222
|
signal: AbortSignal,
|
|
209
223
|
endpoint: ImagesEndpoint,
|
|
224
|
+
admission: DataPlaneAdmission | undefined,
|
|
210
225
|
): Promise<Response | undefined> {
|
|
211
226
|
if (endpoint !== "generations") return undefined;
|
|
212
227
|
const provider = config.providers?.["google-antigravity"];
|
|
213
228
|
if (!provider || provider.disabled) return undefined;
|
|
214
229
|
|
|
230
|
+
// The destination is decided here, not by the caller: this branch always
|
|
231
|
+
// bills Antigravity for CCA_IMAGE_MODEL whatever the body named. That is the
|
|
232
|
+
// pair a scoped key is held to.
|
|
233
|
+
const denial = admissionScopeDenial(config, admission, requestedImageSelector(body), {
|
|
234
|
+
providerName: "google-antigravity",
|
|
235
|
+
modelId: CCA_IMAGE_MODEL,
|
|
236
|
+
});
|
|
237
|
+
if (denial) return denial;
|
|
238
|
+
|
|
215
239
|
const prompt = (body as { prompt?: unknown })?.prompt;
|
|
216
240
|
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
217
241
|
return formatErrorResponse(400, "invalid_request_error", "prompt is required and must not be empty");
|
|
@@ -446,10 +470,20 @@ async function tryXaiImageRelay(
|
|
|
446
470
|
logCtx: RequestLogContext,
|
|
447
471
|
signal: AbortSignal | undefined,
|
|
448
472
|
endpoint: ImagesEndpoint,
|
|
473
|
+
admission: DataPlaneAdmission | undefined,
|
|
449
474
|
): Promise<Response | undefined> {
|
|
450
475
|
if (config.images?.bridgeEnabled !== true) return undefined;
|
|
451
476
|
const found = findXaiProvider(config);
|
|
452
477
|
if (!found) return undefined;
|
|
478
|
+
const bridgeModel = config.images?.bridgeModel ?? XAI_IMAGE_BRIDGE_MODEL;
|
|
479
|
+
// The bridge sends this request to the configured xAI provider and the bridge
|
|
480
|
+
// model regardless of the selector in the body, so that is what the key is
|
|
481
|
+
// checked against.
|
|
482
|
+
const denial = admissionScopeDenial(config, admission, requestedImageSelector(body), {
|
|
483
|
+
providerName: found.name,
|
|
484
|
+
modelId: bridgeModel,
|
|
485
|
+
});
|
|
486
|
+
if (denial) return denial;
|
|
453
487
|
const obj = body && typeof body === "object" && !Array.isArray(body)
|
|
454
488
|
? body as Record<string, unknown>
|
|
455
489
|
: {};
|
|
@@ -496,11 +530,11 @@ async function tryXaiImageRelay(
|
|
|
496
530
|
}
|
|
497
531
|
if (!token) return xaiImageAuthMissing();
|
|
498
532
|
logCtx.provider = "xai";
|
|
499
|
-
logCtx.model =
|
|
533
|
+
logCtx.model = bridgeModel;
|
|
500
534
|
const result = await callXaiImages(
|
|
501
535
|
{
|
|
502
536
|
prompt,
|
|
503
|
-
model:
|
|
537
|
+
model: bridgeModel,
|
|
504
538
|
n,
|
|
505
539
|
size,
|
|
506
540
|
quality,
|
|
@@ -602,6 +636,7 @@ export async function handleImages(
|
|
|
602
636
|
endpoint: ImagesEndpoint,
|
|
603
637
|
logCtx: RequestLogContext,
|
|
604
638
|
turnAdmissionLease?: AdmissionLease,
|
|
639
|
+
admission?: DataPlaneAdmission,
|
|
605
640
|
): Promise<Response> {
|
|
606
641
|
let body: unknown;
|
|
607
642
|
try {
|
|
@@ -616,7 +651,7 @@ export async function handleImages(
|
|
|
616
651
|
// Explicit images.provider owns the route, including its validation errors.
|
|
617
652
|
// Do not divert that selection to the xAI Imagine relay.
|
|
618
653
|
if (config.images?.provider === undefined) {
|
|
619
|
-
const xaiRelay = await tryXaiImageRelay(body, config, logCtx, req.signal, endpoint);
|
|
654
|
+
const xaiRelay = await tryXaiImageRelay(body, config, logCtx, req.signal, endpoint, admission);
|
|
620
655
|
if (xaiRelay) return xaiRelay;
|
|
621
656
|
}
|
|
622
657
|
if (candidates.error) {
|
|
@@ -641,7 +676,7 @@ export async function handleImages(
|
|
|
641
676
|
const canUseOpenAiForward = !skipOpenAiForwardForAdmissionBearer && candidates.forwardCandidates.length > 0;
|
|
642
677
|
|
|
643
678
|
if (!canUseOpenAiForward && !candidates.keyed) {
|
|
644
|
-
const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint);
|
|
679
|
+
const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint, admission);
|
|
645
680
|
if (ccaResponse) return ccaResponse;
|
|
646
681
|
// 400, not 5xx: codex retries every 5xx up to 5 total attempts, and this is a permanent
|
|
647
682
|
// configuration state that must surface on the first attempt.
|
|
@@ -688,7 +723,20 @@ export async function handleImages(
|
|
|
688
723
|
|
|
689
724
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
690
725
|
let url: string;
|
|
726
|
+
// Both relay branches copy the body upstream, so the destination is the
|
|
727
|
+
// provider chosen in that branch and the model the caller named. Each branch
|
|
728
|
+
// is checked as it is entered, before it resolves a credential or commits a
|
|
729
|
+
// key rotation, so a refused request spends nothing.
|
|
730
|
+
const relaySelector = requestedImageSelector(body);
|
|
691
731
|
if (forward) {
|
|
732
|
+
const denial = admissionScopeDenial(config, admission, relaySelector, {
|
|
733
|
+
providerName: forward.providerName,
|
|
734
|
+
modelId: relaySelector,
|
|
735
|
+
});
|
|
736
|
+
if (denial) {
|
|
737
|
+
forward.releaseProbeLease?.();
|
|
738
|
+
return denial;
|
|
739
|
+
}
|
|
692
740
|
const { provider } = forward;
|
|
693
741
|
if (provider.headers) Object.assign(headers, provider.headers);
|
|
694
742
|
for (const [name, value] of forward.headers) headers[name] = value;
|
|
@@ -697,13 +745,18 @@ export async function handleImages(
|
|
|
697
745
|
} else if (forwardAuthError) {
|
|
698
746
|
// Before surfacing the OpenAI auth failure, try CCA — the user may have a
|
|
699
747
|
// valid Google Antigravity login even though their OpenAI pool is broken.
|
|
700
|
-
const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint);
|
|
748
|
+
const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint, admission);
|
|
701
749
|
if (ccaResponse) return ccaResponse;
|
|
702
750
|
// No CCA either: a configured OpenAI pool mode owns its authentication failure.
|
|
703
751
|
// Do not hide a broken/expired pool behind separately billed API-key image generation.
|
|
704
752
|
return forwardAuthError;
|
|
705
753
|
} else if (candidates.keyed) {
|
|
706
754
|
const { providerName } = candidates.keyed;
|
|
755
|
+
const denial = admissionScopeDenial(config, admission, relaySelector, {
|
|
756
|
+
providerName,
|
|
757
|
+
modelId: relaySelector,
|
|
758
|
+
});
|
|
759
|
+
if (denial) return denial;
|
|
707
760
|
// The keyed image path builds its own URL and Authorization header and never enters
|
|
708
761
|
// handleResponses, so the pre-dispatch key pick happens here.
|
|
709
762
|
//
|
|
@@ -737,7 +790,7 @@ export async function handleImages(
|
|
|
737
790
|
url = `${provider.baseUrl.replace(/\/v1\/?$/, "")}/v1/images/${endpoint}`;
|
|
738
791
|
} else {
|
|
739
792
|
// No usable OpenAI credential — try CCA before giving up.
|
|
740
|
-
const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint);
|
|
793
|
+
const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint, admission);
|
|
741
794
|
if (ccaResponse) return ccaResponse;
|
|
742
795
|
return formatErrorResponse(
|
|
743
796
|
401,
|