@bitkyc08/opencodex 2.75.0 → 2.76.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/README.md +6 -1
- package/bin/ocx.mjs +12 -1
- package/gui/dist/assets/App-CddorHdA.js +51 -0
- package/gui/dist/assets/App-DMudtISW.css +1 -0
- package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
- package/gui/dist/assets/index-Btze1I3T.css +1 -0
- package/gui/dist/assets/index-CvnY7E6i.js +86 -0
- package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/opengateway.svg +12 -0
- package/package.json +1 -1
- package/src/adapters/anthropic/account-metadata.ts +17 -0
- package/src/adapters/anthropic/client-identity.ts +69 -0
- package/src/adapters/anthropic/passthrough.ts +23 -4
- package/src/adapters/anthropic.ts +18 -1
- package/src/adapters/command-code-project-context.ts +7 -11
- package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
- package/src/adapters/kiro-retry.ts +23 -8
- package/src/adapters/openai-chat/messages.ts +1 -1
- package/src/adapters/registry.ts +8 -1
- package/src/adapters/zed.ts +377 -0
- package/src/bridge/sse.ts +4 -2
- package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
- package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
- package/src/chatgpt/app-server-shim/filter.ts +157 -0
- package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
- package/src/chatgpt/app-server-shim/launcher.ts +91 -0
- package/src/claude/desktop-3p-startup.ts +62 -0
- package/src/claude/desktop-3p.ts +5 -0
- package/src/claude/first-party-settings.ts +14 -0
- package/src/claude/intercept/cli-catalog.ts +150 -0
- package/src/claude/intercept/cli-picker.ts +82 -0
- package/src/claude/intercept/listener.ts +21 -6
- package/src/claude/intercept/picker-bootstrap.ts +23 -5
- package/src/claude/intercept/picker-models.ts +57 -13
- package/src/claude/intercept/runtime.ts +30 -2
- package/src/claude/intercept/settings.ts +3 -0
- package/src/cli/agent.ts +172 -0
- package/src/cli/alias.ts +2 -1
- package/src/cli/capabilities-command.ts +3 -2
- package/src/cli/capabilities.ts +87 -0
- package/src/cli/chatgpt-command.ts +140 -0
- package/src/cli/combo.ts +144 -19
- package/src/cli/config-command.ts +3 -1
- package/src/cli/dispatch.ts +10 -1
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +5 -24
- package/src/cli/init.ts +16 -2
- package/src/cli/integrations.ts +13 -0
- package/src/cli/internal-command.ts +12 -5
- package/src/cli/observe.ts +6 -1
- package/src/cli/registry.ts +6 -0
- package/src/cli/route-policy.ts +3 -1
- package/src/cli/runtime-api.ts +5 -2
- package/src/cli/status-oauth.ts +5 -0
- package/src/cli/status.ts +8 -1
- package/src/cli/stop-approval.ts +55 -16
- package/src/cli/stop-report.ts +5 -0
- package/src/clients/lazycodex.ts +48 -0
- package/src/clients/omo-role-models.ts +111 -0
- package/src/codex/account-credit-use.ts +99 -0
- package/src/codex/account-lifecycle.ts +2 -0
- package/src/codex/agent-role-models.ts +171 -0
- package/src/codex/auth-api/account-list.ts +10 -3
- package/src/codex/auth-api/routes.ts +34 -0
- package/src/codex/auth-context.ts +52 -11
- package/src/codex/catalog/discovered-natives.ts +11 -1
- package/src/codex/catalog/gather-capture.ts +6 -1
- package/src/codex/catalog/model-hints.ts +9 -1
- package/src/codex/catalog/provider-models.ts +60 -0
- package/src/codex/catalog/routed-gather.ts +30 -5
- package/src/codex/convergence-types.ts +2 -1
- package/src/codex/main-account-external-usage.ts +62 -0
- package/src/codex/main-account-hard-lock.ts +24 -13
- package/src/codex/native-profile-processes.ts +53 -16
- package/src/codex/prompt-layers/toml-edit.ts +1 -1
- package/src/codex/quota-types.ts +3 -7
- package/src/codex/quota.ts +19 -4
- package/src/codex/role-auto-assign.ts +159 -0
- package/src/codex/role-sizing-limits.ts +2 -0
- package/src/codex/role-sizing.ts +216 -0
- package/src/codex/routing/selection.ts +36 -0
- package/src/codex/routing/thread-affinity.ts +2 -0
- package/src/codex/routing.ts +4 -4
- package/src/codex/subagent-model-fallback.ts +82 -26
- package/src/codex/subagent-selectable-models.ts +32 -0
- package/src/combos/index.ts +18 -0
- package/src/combos/jev-decision-contract.ts +20 -0
- package/src/combos/jev-dispatch.ts +18 -0
- package/src/combos/jev-model-backend.ts +127 -0
- package/src/combos/jev.ts +249 -33
- package/src/combos/types.ts +125 -2
- package/src/config/diagnostics.ts +18 -0
- package/src/config/macos-system-proxy.ts +72 -15
- package/src/config/owner-registry.ts +13 -4
- package/src/config/process-state.ts +17 -8
- package/src/config/proxy-env.ts +35 -5
- package/src/config/schema/config-schema.ts +30 -1
- package/src/config/schema/leaf-validators.ts +4 -0
- package/src/config/serving-runtimes.ts +95 -1
- package/src/generated/compatibility-version.json +336 -156
- package/src/lib/errors.ts +7 -7
- package/src/lib/jsonc.ts +13 -0
- package/src/lib/local-chat-completion.ts +144 -0
- package/src/lib/provider-outbound.ts +87 -1
- package/src/lib/provider-runtime-fetch.ts +23 -0
- package/src/lib/provider-tls-profile.ts +221 -0
- package/src/lib/request-execution-budget.ts +16 -6
- package/src/lib/spend-reservation-ledger.ts +54 -20
- package/src/lib/sse-decoder.ts +20 -17
- package/src/lib/synced-state-location.ts +96 -0
- package/src/lib/winsw.ts +14 -5
- package/src/oauth/anthropic-account-refusal.ts +53 -4
- package/src/oauth/anthropic-model-quota.ts +83 -0
- package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
- package/src/oauth/anthropic-routing.ts +112 -58
- package/src/oauth/health.ts +39 -3
- package/src/oauth/index.ts +14 -0
- package/src/oauth/kiro-account-load.ts +10 -0
- package/src/oauth/zed.ts +134 -0
- package/src/providers/model-discovery.ts +19 -8
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/provider-id-rewrite.ts +9 -2
- package/src/providers/quota/account-cache.ts +29 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
- package/src/providers/quota/anthropic-family-headers.ts +25 -0
- package/src/providers/quota/vendor-probes-oauth.ts +5 -1
- package/src/providers/quota-types.ts +4 -0
- package/src/providers/registry/entries-core.ts +18 -0
- package/src/providers/registry/entries-extended.ts +55 -0
- package/src/providers/registry/types.ts +3 -0
- package/src/providers/xai-fast-model.ts +12 -4
- package/src/providers/zed.ts +598 -0
- package/src/responses/state/snapshot-select.ts +22 -9
- package/src/responses/state.ts +2 -3
- package/src/router.ts +9 -0
- package/src/server/anthropic-rate-limit-headers.ts +7 -0
- package/src/server/auth-cors.ts +7 -0
- package/src/server/chat-completions.ts +9 -4
- package/src/server/claude-messages.ts +59 -21
- package/src/server/index/claude-intercept-lifecycle.ts +71 -25
- package/src/server/index/optional-listeners.ts +4 -0
- package/src/server/index/spend-ledger-lifecycle.ts +4 -0
- package/src/server/management/agent-settings-routes.ts +74 -37
- package/src/server/management/claude-desktop-picker-routes.ts +7 -0
- package/src/server/management/claude-intercept-routes.ts +35 -0
- package/src/server/management/codex-agent-role-routes.ts +122 -0
- package/src/server/management/codex-role-auto-assign.ts +208 -0
- package/src/server/management/combo-routes.ts +62 -5
- package/src/server/management/config-routes.ts +35 -3
- package/src/server/management/context.ts +4 -1
- package/src/server/management/decision-discovery.ts +74 -0
- package/src/server/management/decision-model-validation.ts +80 -0
- package/src/server/management/decision-routes.ts +160 -0
- package/src/server/management/native-integration-routes.ts +16 -0
- package/src/server/management/provider-routes.ts +30 -34
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sibling-guard.ts +1 -0
- package/src/server/management/zed-provider-probe.ts +38 -0
- package/src/server/management-api.ts +12 -1
- package/src/server/messages-native-oauth.ts +7 -2
- package/src/server/messages-native.ts +7 -3
- package/src/server/relay.ts +3 -1
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +18 -3
- package/src/server/responses/compact.ts +14 -1
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-combo.ts +67 -11
- package/src/server/responses/core-normalize.ts +21 -1
- package/src/server/responses/core-options.ts +11 -1
- package/src/server/responses/core.ts +5 -3
- package/src/server/responses/fetch-helpers.ts +6 -3
- package/src/server/responses/jev-model-invoke.ts +190 -0
- package/src/server/responses/passthrough-delivery.ts +16 -0
- package/src/server/responses/policy-fallback.ts +55 -3
- package/src/server/responses/policy-request-scope.ts +80 -0
- package/src/server/responses/request-prepare.ts +70 -18
- package/src/server/responses/request-transport.ts +54 -23
- package/src/server/responses/run-turn-execution.ts +1 -0
- package/src/server/responses/sidecar-execution.ts +9 -3
- package/src/server/responses-hosted-image-display.ts +225 -0
- package/src/service/managing-cli.ts +34 -4
- package/src/service/systemd-env.ts +75 -0
- package/src/service/windows-guarded-manager.ts +60 -19
- package/src/service/windows-scheduler.ts +22 -3
- package/src/service/windows-taskxml.ts +32 -10
- package/src/service-manager-probe.ts +72 -19
- package/src/types/config.ts +31 -1
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +2 -0
- package/src/update/index.ts +39 -6
- package/src/update/job.ts +63 -63
- package/src/update/npm-cache-preflight.d.mts +33 -1
- package/src/update/npm-cache-preflight.mjs +131 -18
- package/src/update/ownership-transaction.ts +19 -1
- package/src/update/restart-ownership.ts +80 -6
- package/src/update/transactional-install.d.mts +2 -0
- package/src/update/transactional-install.mjs +6 -1
- package/src/usage/jev-stats.ts +28 -1
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +5 -3
- package/src/vision/index.ts +1 -0
- package/src/vision/plan.ts +2 -1
- package/src/vision/routed-describe.ts +29 -121
- package/src/web-search/alpha-search.ts +3 -3
- package/src/web-search/anthropic-executor.ts +6 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +3 -3
- package/src/web-search/passthrough-bridge.ts +2 -1
- package/src/web-search/run-turn-loop.ts +1 -1
- package/src/web-search/sidecar-providers.ts +2 -1
- package/gui/dist/assets/App-ia4U0ufY.css +0 -1
- package/gui/dist/assets/App-xm0zGNOe.js +0 -51
- package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
- package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
- package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
package/src/router.ts
CHANGED
|
@@ -89,6 +89,12 @@ export interface RouteResult {
|
|
|
89
89
|
combo?: ComboPick;
|
|
90
90
|
/** Bounded route-decision trace (RI-01); never contains secrets. */
|
|
91
91
|
routeDecision?: RouteDecisionTraceV1;
|
|
92
|
+
/**
|
|
93
|
+
* Full eligible provider/model membership from a policy evaluation, keyed
|
|
94
|
+
* "provider\u0000model". The trace's candidate list is bounded; fallback
|
|
95
|
+
* redirect checks need membership for eligible rows that were truncated out.
|
|
96
|
+
*/
|
|
97
|
+
policyEligibility?: ReadonlySet<string>;
|
|
92
98
|
/**
|
|
93
99
|
* Set when a blocked-model redirect moved the request to a different
|
|
94
100
|
* provider. Caller credentials addressed to the source route must not follow
|
|
@@ -733,6 +739,9 @@ function routeModelInternal(
|
|
|
733
739
|
...routed,
|
|
734
740
|
routeKind: "policy" as const,
|
|
735
741
|
routeReason: routed.routeReason === "blocked-model-redirect" ? "blocked-model-redirect" : "policy-selected",
|
|
742
|
+
policyEligibility: new Set(evaluation.candidates
|
|
743
|
+
.filter(candidate => candidate.eligible)
|
|
744
|
+
.map(candidate => candidate.provider + "\u0000" + candidate.model)),
|
|
736
745
|
routeDecision: {
|
|
737
746
|
...evaluation.trace,
|
|
738
747
|
selected: {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function anthropicRateLimitHeaders(upstream: Headers): Record<string, string> {
|
|
2
|
+
const headers: Record<string, string> = {};
|
|
3
|
+
upstream.forEach((value, name) => {
|
|
4
|
+
if (name.startsWith("anthropic-ratelimit-")) headers[name] = value;
|
|
5
|
+
});
|
|
6
|
+
return headers;
|
|
7
|
+
}
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
} from "../config/provider-validation";
|
|
33
33
|
import { providerDestinationConfigError } from "../lib/destination-policy";
|
|
34
34
|
import { providerEgressConfigError } from "../lib/provider-egress";
|
|
35
|
+
import { providerTlsProfileConfigError } from "../lib/provider-tls-profile";
|
|
35
36
|
import { redactSecretString } from "../lib/redact";
|
|
36
37
|
import { DECLARABLE_HOSTED_TOOL_TYPES } from "../responses/hosted-tool-policy";
|
|
37
38
|
import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry";
|
|
@@ -780,6 +781,11 @@ export function providerManagementConfigError(
|
|
|
780
781
|
return `provider ${name} must not include codexAccountMode`;
|
|
781
782
|
}
|
|
782
783
|
const typed = provider as unknown as OcxProviderConfig;
|
|
784
|
+
// Every write path (POST, PUT, reload, both PATCH passes) funnels through here, so a PATCH
|
|
785
|
+
// that changes authMode/baseUrl/adapter under a retained tlsProfile is refused before it
|
|
786
|
+
// persists a row the config schema would later reject as document-fatal.
|
|
787
|
+
const tlsProfileError = providerTlsProfileConfigError(name, typed);
|
|
788
|
+
if (tlsProfileError) return `provider ${JSON.stringify(redactSecretString(name))} ${tlsProfileError}`;
|
|
783
789
|
const baseUrlError = providerBaseUrlConfigError(typed.baseUrl);
|
|
784
790
|
if (baseUrlError) return `provider ${name} ${baseUrlError}`;
|
|
785
791
|
if (effectiveGoogleMode(name, typed) === "vertex" && typed.location !== undefined) {
|
|
@@ -1110,6 +1116,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
|
|
|
1110
1116
|
desktopExecutor: "redacted",
|
|
1111
1117
|
unsafeAllowNativeLocalExec: "editor",
|
|
1112
1118
|
nativeLocalExec: "editor",
|
|
1119
|
+
tlsProfile: "editor",
|
|
1113
1120
|
} as const satisfies Record<keyof OcxProviderConfig, ProviderConfigFieldPolicy>;
|
|
1114
1121
|
|
|
1115
1122
|
type ProviderFieldWithPolicy<Policy extends ProviderConfigFieldPolicy> = {
|
|
@@ -53,6 +53,7 @@ import { clientWireLogOf, clientWireOf } from "./inference/client-wire";
|
|
|
53
53
|
import { directEncodersApply } from "./inference/client-encoder-delivery";
|
|
54
54
|
import { responseWithDeferredRequestLog } from "./relay";
|
|
55
55
|
import { handleResponses } from "./responses";
|
|
56
|
+
import { previewXaiOauthWireModel } from "./responses/core-normalize";
|
|
56
57
|
import { providerConsumesCallerAuthorization } from "../providers/caller-authorization";
|
|
57
58
|
import { captureExplicitOpenAiCallerAuth } from "../providers/openai-sidecar";
|
|
58
59
|
import { captureCallerDirectAuth } from "../providers/caller-authorization";
|
|
@@ -177,16 +178,20 @@ async function handleChatCompletionsWithBudget(
|
|
|
177
178
|
let nativeDecline: ProtocolReasonCode | undefined = "unknown-model";
|
|
178
179
|
try {
|
|
179
180
|
const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody));
|
|
180
|
-
// The native Chat lane sends without re-entering the Responses path, so it
|
|
181
|
-
// has to apply the key's scope itself. Translated traffic is checked where
|
|
182
|
-
// every rewrite converges instead.
|
|
183
|
-
assertRouteAllowedByScope(resolveAdmissionModelScope(config, logIds?.admission), requestedModel, route);
|
|
184
181
|
// Preserve the routed destination for Go recognition, then settle the wire before
|
|
185
182
|
// deriving protocol-scoped affinity. Recognition must not inspect the flipped adapter.
|
|
186
183
|
const routedProvider = route.provider;
|
|
187
184
|
route.staticPolicy = captureRouteStaticPolicy(
|
|
188
185
|
route.providerName, route.modelId, routedProvider, route.staticPolicy.effectiveAlias, "chat",
|
|
189
186
|
);
|
|
187
|
+
// Native Chat must check its own destination; translated xAI OAuth turns
|
|
188
|
+
// preview the billed Fast lane before their final Responses scope check.
|
|
189
|
+
assertRouteAllowedByScope(resolveAdmissionModelScope(config, logIds?.admission), requestedModel, {
|
|
190
|
+
providerName: route.providerName,
|
|
191
|
+
modelId: previewXaiOauthWireModel({ options: {
|
|
192
|
+
serviceTier: typeof chatBody.service_tier === "string" ? chatBody.service_tier : undefined,
|
|
193
|
+
} }, route, config, "chat"),
|
|
194
|
+
});
|
|
190
195
|
const wireProvider = resolveWireProtocolOverride(route.providerName, route.modelId, routedProvider, "chat", route.staticPolicy);
|
|
191
196
|
route.provider = resolveOpenCodeGoTransport(
|
|
192
197
|
wireProvider,
|
|
@@ -28,6 +28,7 @@ import { recordDesktopRequest } from "../claude/desktop-health";
|
|
|
28
28
|
import { stripOneMillionMarker } from "../claude/context-windows";
|
|
29
29
|
import { captureClaudeInbound } from "../claude/inbound-debug";
|
|
30
30
|
import { claudeCodeForIngress } from "../claude/intercept/model-bindings";
|
|
31
|
+
import { classifyInterceptClient } from "../claude/intercept/client-class";
|
|
31
32
|
import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility";
|
|
32
33
|
import { carriesMessageThread, messageThreadUnsupportedResponse } from "../claude/message-threads";
|
|
33
34
|
import {
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
UPSTREAM_RESET_REPLAY_REFUSED_CODE,
|
|
40
41
|
} from "../lib/upstream-retry";
|
|
41
42
|
import { resolveClientRetryAfter } from "../lib/retry-after";
|
|
43
|
+
import { anthropicRateLimitHeaders } from "./anthropic-rate-limit-headers";
|
|
42
44
|
import {
|
|
43
45
|
anthropicErrorBody,
|
|
44
46
|
anthropicErrorResponse,
|
|
@@ -72,6 +74,7 @@ import { clientWireOf } from "./inference/client-wire";
|
|
|
72
74
|
import { directEncodersApply } from "./inference/client-encoder-delivery";
|
|
73
75
|
import type { ClientEncoderOption } from "./responses/core-options";
|
|
74
76
|
import { handleResponses } from "./responses";
|
|
77
|
+
import { previewXaiOauthWireModel } from "./responses/core-normalize";
|
|
75
78
|
import { upstreamWireForAdapter } from "../protocols/contract";
|
|
76
79
|
import { createProtocolEnvelope, type ProtocolEnvelope } from "../protocols/envelope";
|
|
77
80
|
import { featuresFromMessagesBody, type ProtocolFeature } from "../protocols/features";
|
|
@@ -82,6 +85,7 @@ import { requestPathForLane } from "../protocols/path";
|
|
|
82
85
|
import { resolveApiSurfaceSettings, resolveProtocolSettings } from "../protocols/settings";
|
|
83
86
|
import { markProtocolBlocked, markProtocolEntry } from "../protocols/trace";
|
|
84
87
|
import { recordProtocolShadowPlan } from "../protocols/shadow-plan";
|
|
88
|
+
import { captureAnthropicClientIdentity } from "../adapters/anthropic/client-identity";
|
|
85
89
|
import { nativeMessagesDeclineReason, type NativeMessagesSelector } from "./messages-native-eligibility";
|
|
86
90
|
import {
|
|
87
91
|
isApiAuthRequired,
|
|
@@ -296,7 +300,7 @@ export interface PassthroughBodyGuard {
|
|
|
296
300
|
}
|
|
297
301
|
|
|
298
302
|
type PassthroughCloseReason = "terminal" | "client_cancel" | "body_stall" | "body_overflow";
|
|
299
|
-
type PassthroughFinalizeMeta = { closeReason: PassthroughCloseReason; terminalStatus?: "failed" };
|
|
303
|
+
type PassthroughFinalizeMeta = { closeReason: PassthroughCloseReason; terminalStatus?: "failed" | "incomplete" };
|
|
300
304
|
|
|
301
305
|
/**
|
|
302
306
|
* Tap an Anthropic-vocabulary SSE stream for the request log (usage + terminal),
|
|
@@ -340,6 +344,11 @@ export function tapAnthropicSseForLog(
|
|
|
340
344
|
};
|
|
341
345
|
const inspect = (chunk: Uint8Array) => {
|
|
342
346
|
buffer += decoder.decode(chunk, { stream: true });
|
|
347
|
+
// SSE lines may end in CRLF, LF or CR. Normalize the inspection copy to LF (the forwarded
|
|
348
|
+
// bytes are untouched), holding a trailing CR until the next chunk shows whether an LF
|
|
349
|
+
// follows it, so a CRLF split across chunks stays one line ending.
|
|
350
|
+
const heldCr = buffer.endsWith("\r");
|
|
351
|
+
buffer = (heldCr ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n") + (heldCr ? "\r" : "");
|
|
343
352
|
let sep: number;
|
|
344
353
|
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
345
354
|
const frame = buffer.slice(0, sep);
|
|
@@ -347,6 +356,17 @@ export function tapAnthropicSseForLog(
|
|
|
347
356
|
inspectFrame(frame);
|
|
348
357
|
}
|
|
349
358
|
};
|
|
359
|
+
// The last block can sit in the buffer without its blank line: an upstream that stopped after
|
|
360
|
+
// it, or a held trailing CR. Count it before deciding how the turn ended (the Responses relay
|
|
361
|
+
// flushes the same candidate). Returns true when this flush found the terminal in a block the
|
|
362
|
+
// client has not seen a blank line after, so the caller restores one.
|
|
363
|
+
const flushTail = (): boolean => {
|
|
364
|
+
const terminalBefore = terminalSeen;
|
|
365
|
+
const tail = (buffer + decoder.decode()).replace(/\r\n?/g, "\n");
|
|
366
|
+
buffer = "";
|
|
367
|
+
if (tail) inspectFrame(tail);
|
|
368
|
+
return terminalSeen && !terminalBefore && !tail.endsWith("\n\n");
|
|
369
|
+
};
|
|
350
370
|
const reader = upstream.getReader();
|
|
351
371
|
let settled = false;
|
|
352
372
|
let bodyBytes = 0;
|
|
@@ -369,9 +389,25 @@ export function tapAnthropicSseForLog(
|
|
|
369
389
|
settled = true;
|
|
370
390
|
idle.cancel();
|
|
371
391
|
detachAbort();
|
|
392
|
+
const terminalInTail = flushTail();
|
|
372
393
|
recordUsage();
|
|
373
|
-
|
|
374
|
-
|
|
394
|
+
if (terminalSeen) {
|
|
395
|
+
// The turn already ended (message_stop or an upstream error event): an upstream that then
|
|
396
|
+
// idles or keeps sending did not cut it short. Same rule as the read-error branch,
|
|
397
|
+
// including the restored blank line for a terminal found only in the tail.
|
|
398
|
+
finalize(200, { closeReason: "terminal" });
|
|
399
|
+
try {
|
|
400
|
+
if (terminalInTail) tapController?.enqueue(encoder.encode("\n\n"));
|
|
401
|
+
tapController?.close();
|
|
402
|
+
} catch { /* client already torn down */ }
|
|
403
|
+
} else {
|
|
404
|
+
// A cut-short turn, logged as the Responses relay logs a stall-timeout incomplete
|
|
405
|
+
// (httpStatusForRequestLogTerminal: only a max_output_tokens incomplete is a 200). A 200
|
|
406
|
+
// row with no terminalStatus also lost its failure diagnostics in usage.jsonl.
|
|
407
|
+
logCtx.upstreamError = message.slice(0, 500);
|
|
408
|
+
finalize(502, { terminalStatus: "incomplete", closeReason });
|
|
409
|
+
closeWithErrorFrame(errType, message);
|
|
410
|
+
}
|
|
375
411
|
reader.cancel(new DOMException(message, closeReason === "body_stall" ? "TimeoutError" : "QuotaExceededError")).catch(() => {});
|
|
376
412
|
};
|
|
377
413
|
const idle = idleDeadline(guard?.stallMs ?? 0, () => {
|
|
@@ -450,12 +486,7 @@ export function tapAnthropicSseForLog(
|
|
|
450
486
|
settled = true;
|
|
451
487
|
idle.cancel();
|
|
452
488
|
detachAbort();
|
|
453
|
-
|
|
454
|
-
// that block before deciding how the turn ended, as the Responses relay does.
|
|
455
|
-
const terminalBeforeTail = terminalSeen;
|
|
456
|
-
const tail = buffer + decoder.decode();
|
|
457
|
-
buffer = "";
|
|
458
|
-
if (tail) inspectFrame(tail);
|
|
489
|
+
const terminalInTail = flushTail();
|
|
459
490
|
recordUsage();
|
|
460
491
|
if (isTranslatorBudgetExceededError(err)) {
|
|
461
492
|
// A local cap, not an upstream failure: the non-streaming native Messages fold
|
|
@@ -471,7 +502,7 @@ export function tapAnthropicSseForLog(
|
|
|
471
502
|
try {
|
|
472
503
|
// An SSE parser drops an event that EOF cuts off before its blank line, so restore
|
|
473
504
|
// the delimiter when the terminal was only found in that unterminated tail.
|
|
474
|
-
if (
|
|
505
|
+
if (terminalInTail) controller.enqueue(encoder.encode("\n\n"));
|
|
475
506
|
controller.close();
|
|
476
507
|
} catch { /* torn down */ }
|
|
477
508
|
reader.cancel(err).catch(() => {});
|
|
@@ -598,11 +629,13 @@ async function anthropicNativePassthrough(
|
|
|
598
629
|
const upstream = result.upstream;
|
|
599
630
|
|
|
600
631
|
const contentType = upstream.headers.get("content-type") ?? "application/json";
|
|
632
|
+
const rateLimitHeaders = anthropicRateLimitHeaders(upstream.headers);
|
|
601
633
|
const bodyGuard = resolvePassthroughBodyGuard(config, req.signal);
|
|
602
634
|
if (upstream.ok && contentType.includes("text/event-stream") && upstream.body) {
|
|
603
635
|
return new Response(tapAnthropicSseForLog(upstream.body, logCtx, finalize, bodyGuard), {
|
|
604
636
|
status: upstream.status,
|
|
605
637
|
headers: {
|
|
638
|
+
...rateLimitHeaders,
|
|
606
639
|
"Content-Type": contentType,
|
|
607
640
|
"Cache-Control": "no-cache",
|
|
608
641
|
"Connection": "keep-alive",
|
|
@@ -647,7 +680,7 @@ async function anthropicNativePassthrough(
|
|
|
647
680
|
const retryAfter = upstream.headers.get("retry-after");
|
|
648
681
|
return new Response(text, {
|
|
649
682
|
status: upstream.status,
|
|
650
|
-
headers: { "Content-Type": contentType, ...(retryAfter ? { "Retry-After": retryAfter } : {}) },
|
|
683
|
+
headers: { ...rateLimitHeaders, "Content-Type": contentType, ...(retryAfter ? { "Retry-After": retryAfter } : {}) },
|
|
651
684
|
});
|
|
652
685
|
}
|
|
653
686
|
|
|
@@ -891,8 +924,10 @@ async function handleClaudeMessagesWithBudget(
|
|
|
891
924
|
req.headers.get("anthropic-beta") ?? undefined,
|
|
892
925
|
);
|
|
893
926
|
// Client surface discrimination: Desktop 3P aliases resolve through the
|
|
894
|
-
// desktop registry; Code uses readable aliases or direct model names.
|
|
895
|
-
|
|
927
|
+
// desktop registry; Code uses readable aliases or direct model names. The CLI's first-party
|
|
928
|
+
// picker also offers registry aliases, so a CLI-classified User-Agent stays the Code surface.
|
|
929
|
+
if (isRec(anthropicBody) && typeof anthropicBody.model === "string" && resolveDesktop3pAlias(anthropicBody.model)
|
|
930
|
+
&& classifyInterceptClient(req.headers.get("user-agent")) !== "cli") {
|
|
896
931
|
logCtx.surface = "claude-desktop";
|
|
897
932
|
recordDesktopRequest();
|
|
898
933
|
}
|
|
@@ -1046,18 +1081,20 @@ async function handleClaudeMessagesWithBudget(
|
|
|
1046
1081
|
};
|
|
1047
1082
|
try {
|
|
1048
1083
|
const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody));
|
|
1049
|
-
// Same reason as the native Chat lane: this route can be sent from here, so
|
|
1050
|
-
// the key's scope is applied before the wire is settled.
|
|
1051
|
-
assertRouteAllowedByScope(
|
|
1052
|
-
resolveAdmissionModelScope(config, logIds?.admission),
|
|
1053
|
-
String(internalBody.model ?? ""),
|
|
1054
|
-
route,
|
|
1055
|
-
);
|
|
1056
1084
|
// Settle the wire once so the sampling decision below reads the effective
|
|
1057
1085
|
// adapter rather than the provider-wide default (#404).
|
|
1058
1086
|
route.staticPolicy = captureRouteStaticPolicy(
|
|
1059
1087
|
route.providerName, route.modelId, route.provider, route.staticPolicy.effectiveAlias, "anthropic",
|
|
1060
1088
|
);
|
|
1089
|
+
// Keep native dispatch scoped while translated xAI OAuth requests preview
|
|
1090
|
+
// the same billed Fast lane as their final Responses scope check.
|
|
1091
|
+
assertRouteAllowedByScope(
|
|
1092
|
+
resolveAdmissionModelScope(config, logIds?.admission),
|
|
1093
|
+
String(internalBody.model ?? ""),
|
|
1094
|
+
{ providerName: route.providerName, modelId: previewXaiOauthWireModel({ options: {
|
|
1095
|
+
serviceTier: typeof internalBody.service_tier === "string" ? internalBody.service_tier : undefined,
|
|
1096
|
+
} }, route, config, "anthropic") },
|
|
1097
|
+
);
|
|
1061
1098
|
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic", route.staticPolicy);
|
|
1062
1099
|
logCtx.routeDecision = route.routeDecision;
|
|
1063
1100
|
settledRoute = route;
|
|
@@ -1166,7 +1203,8 @@ async function handleClaudeMessagesWithBudget(
|
|
|
1166
1203
|
return await handleNativeMessages({
|
|
1167
1204
|
req, config, logCtx, ...(logIds ? { logIds } : {}),
|
|
1168
1205
|
route: nativeMessagesRoute, body: nativeBody, requestedModel, translatorBudget, selector: nativeSelector,
|
|
1169
|
-
//
|
|
1206
|
+
// Compatibility identity is an opaque request-local handle, separate from credentials.
|
|
1207
|
+
clientIdentity: captureAnthropicClientIdentity(req.headers),
|
|
1170
1208
|
callerAnthropicBeta: req.headers.get("anthropic-beta"),
|
|
1171
1209
|
});
|
|
1172
1210
|
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
startClaudeIntercept,
|
|
9
9
|
type ClaudeInterceptHandle,
|
|
10
10
|
type StartClaudeInterceptOptions,
|
|
11
|
+
type ClaudeInterceptOutcome,
|
|
12
|
+
ClaudeInterceptProxyBindError,
|
|
11
13
|
} from "../../claude/intercept/runtime";
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -20,6 +22,8 @@ export interface ClaudeInterceptLifecycle<T> {
|
|
|
20
22
|
/** True once the TLS listener has bound and `requestServer` is it. */
|
|
21
23
|
ownsListener(requestServer: Server<T>): boolean;
|
|
22
24
|
start(options: StartClaudeInterceptOptions<T>): void;
|
|
25
|
+
ensure(): Promise<ClaudeInterceptOutcome>;
|
|
26
|
+
lastOutcome(): ClaudeInterceptOutcome | null;
|
|
23
27
|
stop(): Promise<void>;
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -53,34 +57,76 @@ export async function loadPickerRoutesFromCatalog(): Promise<PickerRouteInput> {
|
|
|
53
57
|
|
|
54
58
|
export function createClaudeInterceptLifecycle<T>(): ClaudeInterceptLifecycle<T> {
|
|
55
59
|
let listener: Server<T> | null = null;
|
|
56
|
-
let
|
|
60
|
+
let options: StartClaudeInterceptOptions<T> | undefined;
|
|
61
|
+
let handle: ClaudeInterceptHandle<T> | null = null;
|
|
62
|
+
let inflight: Promise<ClaudeInterceptOutcome> | undefined;
|
|
63
|
+
let stopped = false;
|
|
64
|
+
let startupPending = false;
|
|
65
|
+
let retryAfterStartup = false;
|
|
66
|
+
let observed: ClaudeDesktopModeObservation | undefined;
|
|
67
|
+
const stateOf = (bound: ClaudeInterceptHandle<T>) => ({ proxyPort: bound.proxyPort, caCertPath: bound.caCertPath, pickerProxyPort: bound.pickerProxyPort, pickerReason: bound.pickerReason ?? null, pickerFailurePort: bound.pickerFailurePort });
|
|
68
|
+
let outcome: ClaudeInterceptOutcome | null = null;
|
|
69
|
+
const ensure = (): Promise<ClaudeInterceptOutcome> => {
|
|
70
|
+
if (stopped) return Promise.resolve(outcome = { ok: false, reason: "stopped" });
|
|
71
|
+
if (inflight) {
|
|
72
|
+
if (startupPending) retryAfterStartup = true;
|
|
73
|
+
return inflight;
|
|
74
|
+
}
|
|
75
|
+
if (handle) {
|
|
76
|
+
if (options) observed = observeClaudeDesktopMode(options.config);
|
|
77
|
+
return Promise.resolve(outcome = { ok: true, state: stateOf(handle) });
|
|
78
|
+
}
|
|
79
|
+
if (!options) return Promise.resolve(outcome = { ok: false, reason: "failed" });
|
|
80
|
+
const opts = options;
|
|
81
|
+
const run = async (): Promise<ClaudeInterceptOutcome> => {
|
|
82
|
+
if (opts.config.runtimeRole === "client") return { ok: false, reason: "client_role" };
|
|
83
|
+
if (!claudeInterceptEnabled(opts.config)) return { ok: false, reason: "disabled" };
|
|
84
|
+
if (opts.requestedPort === 0 && typeof opts.config.claudeCode?.intercept?.port !== "number")
|
|
85
|
+
return { ok: false, reason: "ephemeral_port" };
|
|
86
|
+
try {
|
|
87
|
+
observed = observeClaudeDesktopMode(opts.config);
|
|
88
|
+
handle = await startClaudeIntercept<T>({
|
|
89
|
+
...opts,
|
|
90
|
+
desiredClients: () => buildInterceptDesiredClients(opts.config, observed!)(),
|
|
91
|
+
loadPickerRoutes: opts.loadPickerRoutes ?? loadPickerRoutesFromCatalog,
|
|
92
|
+
dispatch: (req, requestServer) => {
|
|
93
|
+
listener ??= requestServer;
|
|
94
|
+
return opts.dispatch(req, requestServer);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
if (!handle) return { ok: false, reason: "disabled" };
|
|
98
|
+
listener = handle.listener;
|
|
99
|
+
console.log(`🔐 Claude intercept proxy active on http://127.0.0.1:${handle.proxyPort} (CONNECT api.anthropic.com → local TLS)`);
|
|
100
|
+
return { ok: true, state: stateOf(handle) };
|
|
101
|
+
} catch (error) {
|
|
102
|
+
listener = null;
|
|
103
|
+
console.warn(`⚠ Claude intercept proxy could not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
104
|
+
return error instanceof ClaudeInterceptProxyBindError && error.code === "EADDRINUSE"
|
|
105
|
+
? { ok: false, reason: "port_in_use", port: error.port }
|
|
106
|
+
: { ok: false, reason: "failed" };
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
inflight = run().then(async result => {
|
|
110
|
+
// An ensure arriving during startup first joins that attempt, then retries its null outcome.
|
|
111
|
+
if (!result.ok && retryAfterStartup && !stopped) return run();
|
|
112
|
+
return result;
|
|
113
|
+
}).then(result => outcome = result).finally(() => {
|
|
114
|
+
inflight = undefined; startupPending = false; retryAfterStartup = false;
|
|
115
|
+
});
|
|
116
|
+
return inflight;
|
|
117
|
+
};
|
|
57
118
|
return {
|
|
58
119
|
ownsListener: requestServer => listener !== null && requestServer === listener,
|
|
59
|
-
start(options)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
pending = startClaudeIntercept<T>({
|
|
63
|
-
...options,
|
|
64
|
-
// A disabled Claude surface relays everything while the bound listener lives until restart.
|
|
65
|
-
desiredClients: buildInterceptDesiredClients(options.config, observed),
|
|
66
|
-
loadPickerRoutes: options.loadPickerRoutes ?? loadPickerRoutesFromCatalog,
|
|
67
|
-
dispatch: (req, requestServer) => {
|
|
68
|
-
listener ??= requestServer;
|
|
69
|
-
return dispatch(req, requestServer);
|
|
70
|
-
},
|
|
71
|
-
}).then(handle => {
|
|
72
|
-
if (handle) {
|
|
73
|
-
listener = handle.listener;
|
|
74
|
-
console.log(`🔐 Claude intercept proxy active on http://127.0.0.1:${handle.proxyPort} (CONNECT api.anthropic.com → local TLS)`);
|
|
75
|
-
}
|
|
76
|
-
return handle;
|
|
77
|
-
}).catch((error: unknown) => {
|
|
78
|
-
console.warn(`⚠ Claude intercept proxy could not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
79
|
-
return null;
|
|
80
|
-
});
|
|
81
|
-
},
|
|
120
|
+
start(opts) { options = opts; startupPending = true; void ensure(); },
|
|
121
|
+
ensure,
|
|
122
|
+
lastOutcome: () => outcome,
|
|
82
123
|
async stop() {
|
|
83
|
-
|
|
124
|
+
stopped = true;
|
|
125
|
+
await inflight;
|
|
126
|
+
await handle?.stop();
|
|
127
|
+
handle = null;
|
|
128
|
+
listener = null;
|
|
129
|
+
outcome = { ok: false, reason: "stopped" };
|
|
84
130
|
},
|
|
85
131
|
};
|
|
86
132
|
}
|
|
@@ -28,6 +28,8 @@ export interface OptionalListenerStartContext<T> {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export interface OptionalListenerSet<T> {
|
|
31
|
+
ensureClaudeIntercept(): ReturnType<ClaudeInterceptLifecycle<T>["ensure"]>;
|
|
32
|
+
claudeInterceptOutcome(): ReturnType<ClaudeInterceptLifecycle<T>["lastOutcome"]>;
|
|
31
33
|
ingressOf(server: Server<T>): ServerIngress | undefined;
|
|
32
34
|
linkRouteAllowed(url: URL, req: Request): boolean;
|
|
33
35
|
linkAdmissionKeyIds(): ReadonlySet<string>;
|
|
@@ -68,6 +70,8 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
|
|
|
68
70
|
let supervisorStop: (() => Promise<void>) | undefined;
|
|
69
71
|
|
|
70
72
|
return {
|
|
73
|
+
ensureClaudeIntercept: () => claudeIntercept.ensure(),
|
|
74
|
+
claudeInterceptOutcome: () => claudeIntercept.lastOutcome(),
|
|
71
75
|
ingressOf(server) {
|
|
72
76
|
if (linkListener.ownsListener(server)) return "hub-link";
|
|
73
77
|
if (claudeIntercept.ownsListener(server)) return "claude-intercept";
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
configureSharedSpendLedger,
|
|
8
8
|
spendPolicyFromConfig,
|
|
9
9
|
} from "../../lib/spend-reservation-ledger";
|
|
10
|
+
import { warnIfSyncedStateDirectory } from "../../lib/synced-state-location";
|
|
10
11
|
|
|
11
12
|
const failedStartRollbacks = new WeakMap<object, Promise<void>>();
|
|
12
13
|
|
|
@@ -34,6 +35,9 @@ export interface SpendLedgerServerLifecycle {
|
|
|
34
35
|
/** Acquire before config loading so every later startup failure has one rollback owner. */
|
|
35
36
|
export function acquireSpendLedgerServerLifecycle(configDir: string): SpendLedgerServerLifecycle {
|
|
36
37
|
const owner: SpendLedgerOwnerLease = acquireSpendLedgerOwner(configDir);
|
|
38
|
+
// Advisory: a synced state directory makes the journal's hard-link guard refuse intermittently
|
|
39
|
+
// (#6314). Said once at startup instead of being discovered from a 502.
|
|
40
|
+
warnIfSyncedStateDirectory(configDir);
|
|
37
41
|
// Each entry returns whatever the listener's own stop returned. Typed as void-or-promise
|
|
38
42
|
// because the rollback below has to WAIT on it: declaring it `() => void` let the call site
|
|
39
43
|
// compile while statically erasing the promise it needs to await.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ensureManagementClaudeIntercept, interceptStartRefusal, interceptStatus } from "./claude-intercept-routes";
|
|
1
2
|
import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state";
|
|
2
3
|
import { captureDesktopAppliedMarker, commitDesktopAppliedMarker } from "../../claude/desktop-applied-marker";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -206,6 +207,20 @@ export function setGrokApplyFlightTestHooks(
|
|
|
206
207
|
}
|
|
207
208
|
import type { ManagementContext } from "./context";
|
|
208
209
|
|
|
210
|
+
async function injectionModelOptions(config: OcxConfig, models: readonly CatalogModel[]) {
|
|
211
|
+
const disabled = new Set(config.disabledModels ?? []);
|
|
212
|
+
const { listCatalogNativeSlugs } = await import("../../codex/catalog");
|
|
213
|
+
const nativeModels = listCatalogNativeSlugs()
|
|
214
|
+
.filter(slug => !disabled.has(slug))
|
|
215
|
+
.map(slug => ({ provider: "openai", model: slug, namespaced: slug }));
|
|
216
|
+
const routedModels = uniqueCatalogModelsForPublicList([...models])
|
|
217
|
+
.map(m => ({ provider: m.provider, model: m.id, namespaced: catalogModelSlug(m) }))
|
|
218
|
+
.filter(m => ![...disabled].some(stored => (
|
|
219
|
+
stored === m.namespaced || slugEquals(stored, m.provider, m.model)
|
|
220
|
+
)));
|
|
221
|
+
return [...nativeModels, ...routedModels];
|
|
222
|
+
}
|
|
223
|
+
|
|
209
224
|
export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
210
225
|
const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
|
|
211
226
|
|
|
@@ -547,18 +562,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
547
562
|
// effort the prompt tells the agent to pass to spawn_agent. GET returns the current
|
|
548
563
|
// picks + available models/efforts; PUT sets or clears them.
|
|
549
564
|
if (url.pathname === "/api/injection-model" && req.method === "GET") {
|
|
550
|
-
const models = await fetchAllModels(config);
|
|
551
|
-
const disabled = new Set(config.disabledModels ?? []);
|
|
552
|
-
const { listCatalogNativeSlugs } = await import("../../codex/catalog");
|
|
553
565
|
const { CODEX_REASONING_LEVELS } = await import("../../reasoning-effort");
|
|
554
|
-
const nativeModels = listCatalogNativeSlugs()
|
|
555
|
-
.filter(slug => !disabled.has(slug))
|
|
556
|
-
.map(slug => ({ provider: "openai", model: slug, namespaced: slug }));
|
|
557
|
-
const routedModels = uniqueCatalogModelsForPublicList(models)
|
|
558
|
-
.map(m => ({ provider: m.provider, model: m.id, namespaced: catalogModelSlug(m) }))
|
|
559
|
-
.filter(m => ![...disabled].some(stored => (
|
|
560
|
-
stored === m.namespaced || slugEquals(stored, m.provider, m.model)
|
|
561
|
-
)));
|
|
562
566
|
return jsonResponse({
|
|
563
567
|
multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
|
|
564
568
|
syncCodexSubagentDefaults: subagentDefaultSyncEffective(config),
|
|
@@ -566,9 +570,41 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
566
570
|
effort: config.injectionEffort ?? null,
|
|
567
571
|
prompt: config.injectionPrompt ?? null,
|
|
568
572
|
efforts: CODEX_REASONING_LEVELS.map(l => l.effort),
|
|
569
|
-
available:
|
|
573
|
+
available: await injectionModelOptions(config, await fetchAllModels(config)),
|
|
570
574
|
});
|
|
571
575
|
}
|
|
576
|
+
// Read-only: sizes the described delegated work and proposes a model and effort from the
|
|
577
|
+
// same options GET offers. The page applies an accepted proposal through the PUT below.
|
|
578
|
+
if (url.pathname === "/api/injection-model/suggest" && req.method === "POST") {
|
|
579
|
+
let body: unknown;
|
|
580
|
+
try { body = await readManagementJsonBody(req); } catch (error) {
|
|
581
|
+
rethrowManagementBodyTooLarge(error);
|
|
582
|
+
return jsonResponse({ error: "invalid JSON body" }, 400);
|
|
583
|
+
}
|
|
584
|
+
const { work, model } = (body ?? {}) as { work?: unknown; model?: unknown };
|
|
585
|
+
const { ROLE_INSTRUCTIONS_EXCERPT_CHARS } = await import("../../codex/role-sizing");
|
|
586
|
+
if (typeof work !== "string" || work.trim() === "" || work.length > ROLE_INSTRUCTIONS_EXCERPT_CHARS) {
|
|
587
|
+
return jsonResponse({ error: `work must be a nonblank string of at most ${ROLE_INSTRUCTIONS_EXCERPT_CHARS} characters`, code: "invalid_work" }, 400);
|
|
588
|
+
}
|
|
589
|
+
if (model !== undefined && typeof model !== "string") {
|
|
590
|
+
return jsonResponse({ error: "model must be a string", code: "invalid_model" }, 400);
|
|
591
|
+
}
|
|
592
|
+
const { proposeDelegationModel, NoSizingModelError } = await import("./codex-role-auto-assign");
|
|
593
|
+
const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
|
|
594
|
+
try {
|
|
595
|
+
return jsonResponse(await proposeDelegationModel({
|
|
596
|
+
config,
|
|
597
|
+
work: work.trim(),
|
|
598
|
+
offered: (await injectionModelOptions(config, models)).map(option => option.namespaced),
|
|
599
|
+
...(typeof model === "string" ? { sizingModel: model } : {}),
|
|
600
|
+
models,
|
|
601
|
+
...(deps.completeCodexRoleSizing ? { completeRoleSizing: deps.completeCodexRoleSizing } : {}),
|
|
602
|
+
}));
|
|
603
|
+
} catch (error) {
|
|
604
|
+
if (error instanceof NoSizingModelError) return jsonResponse({ error: error.message, code: "no_sizing_model" }, 409);
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
572
608
|
if (url.pathname === "/api/injection-model" && req.method === "PUT") {
|
|
573
609
|
let parsedBody: unknown;
|
|
574
610
|
try { parsedBody = await readManagementJsonBody(req); } catch (error) {
|
|
@@ -729,20 +765,13 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
729
765
|
// the first five eligible visible rows by display priority; OCX guidance uses natural ranks.
|
|
730
766
|
if (url.pathname === "/api/subagent-models" && req.method === "GET") {
|
|
731
767
|
const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
|
|
732
|
-
const disabled = new Set(config.disabledModels ?? []);
|
|
733
768
|
// Native gpt (passthrough) are also valid subagent picks — they're picker-visible models in the
|
|
734
769
|
// catalog, just buried by priority. List them first so the user can feature them over routed.
|
|
735
|
-
const { listCatalogNativeSlugs } = await
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
))
|
|
740
|
-
.map(catalogModelSlug))];
|
|
770
|
+
const [{ listCatalogNativeSlugs }, { subagentSelectableModels }] = await Promise.all([
|
|
771
|
+
import("../../codex/catalog"),
|
|
772
|
+
import("../../codex/subagent-selectable-models"),
|
|
773
|
+
]);
|
|
741
774
|
const chosen = config.subagentModels ?? [];
|
|
742
|
-
const selectable = [
|
|
743
|
-
...listCatalogNativeSlugs().filter(ns => !disabled.has(ns)),
|
|
744
|
-
...visibleRouted,
|
|
745
|
-
];
|
|
746
775
|
// A saved roster slot must stay representable even after its model is disabled
|
|
747
776
|
// elsewhere (Models page, provider allowlist, a provider row going away). The
|
|
748
777
|
// dashboard treats `available` as the set of rows it can render, so a chosen id
|
|
@@ -751,11 +780,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
751
780
|
// deliberate 5-model roster to an unrelated visibility toggle is data loss, not a
|
|
752
781
|
// filter. Same reasoning as `fetchGrokCandidateModels`, which deliberately lists a
|
|
753
782
|
// model the user already excluded so its switch remains reachable.
|
|
754
|
-
const
|
|
755
|
-
const available = [
|
|
756
|
-
...selectable,
|
|
757
|
-
...[...new Set(chosen)].filter(model => !selectableSet.has(model)),
|
|
758
|
-
];
|
|
783
|
+
const available = subagentSelectableModels(config, models, listCatalogNativeSlugs());
|
|
759
784
|
// #857: let CLI/GUI show when a running Codex app-server keeps an older
|
|
760
785
|
// in-memory catalog than the one on disk. Bounded request-path read: the synchronous
|
|
761
786
|
// collector blocked the event loop for the whole Windows CIM walk (4-7s measured).
|
|
@@ -1114,6 +1139,13 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1114
1139
|
}
|
|
1115
1140
|
}
|
|
1116
1141
|
if (desktopMode === "first-party") {
|
|
1142
|
+
const refusal = interceptStartRefusal(ctx);
|
|
1143
|
+
if (refusal) return refusal;
|
|
1144
|
+
const started = await ensureManagementClaudeIntercept(ctx);
|
|
1145
|
+
if (!started.ok) return jsonResponse({ ...started, code: started.reason }, 409);
|
|
1146
|
+
const { claudeInterceptProxyPort } = await import("../../claude/intercept/runtime");
|
|
1147
|
+
const configured = claudeInterceptProxyPort(config, config.port ?? 10100);
|
|
1148
|
+
if (started.state.proxyPort !== configured) return jsonResponse({ ok: false, code: "port_mismatch", bound: started.state.proxyPort, configured }, 409);
|
|
1117
1149
|
const { setIntegrationEnabled } = await import("../../codex/desired-state");
|
|
1118
1150
|
const desired = setIntegrationEnabled("claude-desktop", true);
|
|
1119
1151
|
if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500);
|
|
@@ -1299,6 +1331,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1299
1331
|
stale: firstPartySeen.stale,
|
|
1300
1332
|
interceptEnabled: firstPartySeen.interceptEnabled,
|
|
1301
1333
|
interceptRunning: intercept !== null,
|
|
1334
|
+
...interceptStatus(ctx),
|
|
1302
1335
|
proxyPort: intercept?.proxyPort ?? firstPartySeen.proxyPort,
|
|
1303
1336
|
caCertPath: firstPartySeen.caCertPath,
|
|
1304
1337
|
// What the running proxy routes with right now (live config), not the file on disk.
|
|
@@ -1471,6 +1504,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1471
1504
|
cliFirstParty: config.claudeCode?.cliFirstParty === true,
|
|
1472
1505
|
cliFirstPartyApplied: config.claudeCode?.cliFirstParty === true && sharedProxy === "live",
|
|
1473
1506
|
desktopFirstParty: desired.desktop,
|
|
1507
|
+
...interceptStatus(ctx),
|
|
1474
1508
|
interceptEligible: eligible,
|
|
1475
1509
|
interceptRunning: bound !== null && eligible,
|
|
1476
1510
|
sharedProxy,
|
|
@@ -1540,14 +1574,17 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1540
1574
|
const { captureDesktopFirstPartyRollback, inspectDesktopFirstParty, observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../../claude/desktop-first-party");
|
|
1541
1575
|
const { firstPartyDesired, readFirstPartyProxyStatus, reconcileClaudeFirstPartySettings } = await import("../../claude/first-party-settings");
|
|
1542
1576
|
const { commitClaudeCodeBlock } = await import("../../claude/claude-code-block");
|
|
1543
|
-
|
|
1577
|
+
let bound = (deps.getClaudeInterceptState ?? getClaudeInterceptState)();
|
|
1544
1578
|
if (body.cliFirstParty) {
|
|
1545
|
-
|
|
1546
|
-
if (
|
|
1579
|
+
const startRefusal = interceptStartRefusal(ctx);
|
|
1580
|
+
if (startRefusal) return startRefusal;
|
|
1581
|
+
const started = await ensureManagementClaudeIntercept(ctx);
|
|
1582
|
+
if (!started.ok) return jsonResponse({ ...started, code: started.reason }, 409);
|
|
1583
|
+
bound = started.state;
|
|
1547
1584
|
const targetPort = claudeInterceptProxyPort(config, config.port ?? 10100);
|
|
1548
1585
|
if (targetPort !== bound.proxyPort) return jsonResponse({
|
|
1549
|
-
error: `Claude intercept port mismatch (configured ${targetPort}, bound ${bound.proxyPort})
|
|
1550
|
-
code: "
|
|
1586
|
+
error: `Claude intercept port mismatch (configured ${targetPort}, bound ${bound.proxyPort})`,
|
|
1587
|
+
code: "port_mismatch", bound: bound.proxyPort, configured: targetPort,
|
|
1551
1588
|
}, 409);
|
|
1552
1589
|
let inspection: ReturnType<typeof inspectDesktopFirstParty>["settings"];
|
|
1553
1590
|
try { inspection = inspectDesktopFirstParty(config).settings; }
|
|
@@ -1559,7 +1596,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1559
1596
|
try { if (body.cliFirstParty) restoreSettings = captureDesktopFirstPartyRollback(config); }
|
|
1560
1597
|
catch { return jsonResponse({ error: "Claude settings are unreadable", code: "unreadable" }, 500); }
|
|
1561
1598
|
type FirstPartyMutation =
|
|
1562
|
-
| { refusal: { error: string; code: "
|
|
1599
|
+
| { refusal: { error: string; code: "disabled" | "port_mismatch"; bound?: number; configured?: number } }
|
|
1563
1600
|
| { claudeCode: OcxConfig["claudeCode"]; previous: { present: boolean; value: boolean };
|
|
1564
1601
|
pinnedMode: "first-party" | "gateway" | undefined; retainedAmbiguous: boolean };
|
|
1565
1602
|
let outcome: ReturnType<typeof mutatePersistedConfig<FirstPartyMutation>>;
|
|
@@ -1567,11 +1604,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1567
1604
|
outcome = mutatePersistedConfig<FirstPartyMutation>(persisted => {
|
|
1568
1605
|
if (body.cliFirstParty) {
|
|
1569
1606
|
if (!claudeInterceptEnabled(persisted)) return { changed: false, value: {
|
|
1570
|
-
refusal: { error: "Claude intercept is disabled", code: "
|
|
1607
|
+
refusal: { error: "Claude intercept is disabled", code: "disabled" as const } } };
|
|
1571
1608
|
const persistedPort = claudeInterceptProxyPort(persisted, persisted.port ?? 10100);
|
|
1572
1609
|
if (bound === null || persistedPort !== bound.proxyPort) return { changed: false, value: {
|
|
1573
|
-
refusal: { error: `Claude intercept port mismatch (configured ${persistedPort}, bound ${bound?.proxyPort ?? "none"})
|
|
1574
|
-
code: "
|
|
1610
|
+
refusal: { error: `Claude intercept port mismatch (configured ${persistedPort}, bound ${bound?.proxyPort ?? "none"})`,
|
|
1611
|
+
code: "port_mismatch" as const, bound: bound?.proxyPort, configured: persistedPort } } };
|
|
1575
1612
|
}
|
|
1576
1613
|
const before = structuredClone(persisted);
|
|
1577
1614
|
const previous = { present: Object.hasOwn(persisted.claudeCode ?? {}, "cliFirstParty"),
|