@bitkyc08/opencodex 2.48.0 → 2.49.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.
Files changed (97) hide show
  1. package/README.md +8 -5
  2. package/SPONSORS.md +1 -1
  3. package/assets/sponsors/orcarouter.png +0 -0
  4. package/assets/sponsors/packycode.png +0 -0
  5. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  6. package/gui/dist/assets/index-BtyONQrZ.js +115 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/packycode.svg +19 -0
  9. package/gui/dist/provider-icons/qoder.svg +5 -0
  10. package/package.json +5 -3
  11. package/src/adapters/anthropic.ts +31 -16
  12. package/src/adapters/codebuddy/adapter.ts +85 -0
  13. package/src/adapters/codebuddy/profiles.ts +52 -0
  14. package/src/adapters/coding-agent/profile.ts +100 -0
  15. package/src/adapters/coding-agent/protocol.ts +463 -0
  16. package/src/adapters/coding-agent/turn.ts +353 -0
  17. package/src/adapters/google.ts +15 -11
  18. package/src/adapters/mimo-free.ts +3 -0
  19. package/src/adapters/openai-chat.ts +2 -2
  20. package/src/adapters/openai-responses.ts +18 -11
  21. package/src/adapters/qoder/adapter.ts +70 -0
  22. package/src/adapters/qoder/live-models.ts +89 -0
  23. package/src/adapters/qoder/profiles.ts +36 -0
  24. package/src/adapters/registry.ts +12 -0
  25. package/src/adapters/responses-tool-schema.ts +113 -8
  26. package/src/cli/dispatch.ts +2 -0
  27. package/src/cli/doctor.ts +40 -0
  28. package/src/cli/effort.ts +24 -8
  29. package/src/cli/help.ts +2 -0
  30. package/src/cli/index.ts +24 -1
  31. package/src/cli/provider-runtime.ts +2 -1
  32. package/src/cli/registry.ts +2 -2
  33. package/src/cli/system-command.ts +10 -3
  34. package/src/cli/usage-report.ts +9 -5
  35. package/src/codex/account-lifecycle.ts +35 -2
  36. package/src/codex/auth-api.ts +6 -3
  37. package/src/codex/auth-collision.ts +12 -2
  38. package/src/codex/auth-context.ts +75 -7
  39. package/src/codex/catalog/provider-fetch.ts +73 -9
  40. package/src/codex/inject.ts +127 -30
  41. package/src/codex/internal/catalog-writer.ts +3 -0
  42. package/src/codex/journal.ts +61 -12
  43. package/src/codex/model-cache.ts +11 -4
  44. package/src/codex/native-profile-startup.ts +72 -5
  45. package/src/codex/native-profile-store.ts +2 -2
  46. package/src/codex/ocx-compaction-history.ts +226 -0
  47. package/src/codex/project-config-warnings.ts +3 -1
  48. package/src/codex/quota.ts +18 -8
  49. package/src/codex/reserve-availability.ts +21 -5
  50. package/src/codex/runtime.ts +45 -1
  51. package/src/codex/sync.ts +5 -0
  52. package/src/config.ts +1 -0
  53. package/src/generated/compatibility-version.json +126 -74
  54. package/src/images/loop.ts +1 -0
  55. package/src/images/xai-video-client.ts +2 -0
  56. package/src/integrations/registry.ts +1 -0
  57. package/src/lib/process-control.ts +52 -8
  58. package/src/lib/upstream-retry.ts +1 -0
  59. package/src/oauth/chatgpt.ts +83 -0
  60. package/src/oauth/xai.ts +151 -8
  61. package/src/providers/api-key-selection-capture.ts +10 -0
  62. package/src/providers/api-key-selection.ts +2 -7
  63. package/src/providers/caller-authorization.ts +36 -0
  64. package/src/providers/codebuddy-models.ts +184 -0
  65. package/src/providers/derive.ts +5 -0
  66. package/src/providers/free-directory.ts +26 -2
  67. package/src/providers/openai-sidecar.ts +35 -11
  68. package/src/providers/qoder-models.ts +25 -0
  69. package/src/providers/registry.ts +139 -0
  70. package/src/responses/compaction.ts +4 -0
  71. package/src/responses/task-input.ts +21 -1
  72. package/src/router.ts +1 -1
  73. package/src/server/chat-completions.ts +26 -9
  74. package/src/server/claude-messages.ts +12 -2
  75. package/src/server/images.ts +1 -0
  76. package/src/server/management/account-selection-stream.ts +13 -4
  77. package/src/server/management/config-routes.ts +24 -5
  78. package/src/server/management/logs-usage-routes.ts +1 -0
  79. package/src/server/management/native-integration-routes.ts +2 -1
  80. package/src/server/management/provider-routes.ts +24 -0
  81. package/src/server/management-api.ts +14 -0
  82. package/src/server/port-reclaim.ts +11 -26
  83. package/src/server/request-log.ts +6 -0
  84. package/src/server/responses/collaboration.ts +4 -9
  85. package/src/server/responses/core.ts +141 -19
  86. package/src/server/responses/fetch-helpers.ts +18 -7
  87. package/src/server/responses/policy-fallback.ts +12 -0
  88. package/src/service.ts +36 -2
  89. package/src/storage/cleanup.ts +77 -45
  90. package/src/types/config.ts +7 -1
  91. package/src/usage/log.ts +24 -0
  92. package/src/vision/anthropic-describe.ts +1 -0
  93. package/src/web-search/anthropic-executor.ts +1 -0
  94. package/src/web-search/loop.ts +1 -0
  95. package/src/web-search/progress-stream.ts +4 -0
  96. package/gui/dist/assets/index-B5r7LNHN.js +0 -115
  97. package/gui/dist/assets/index-D5SiRo8X.css +0 -1
@@ -210,11 +210,14 @@ import {
210
210
  import {
211
211
  ForwardAdmissionCredentialError,
212
212
  hasForwardableCodexBearer,
213
+ isProxyAdmissionSecret,
213
214
  validateForwardAdmissionCredential,
214
215
  } from "../auth-cors";
215
216
  import type { DataPlaneAdmission } from "../auth-cors";
216
217
  import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget";
217
- import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
218
+ import { captureExplicitOpenAiCallerAuth, listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ExplicitOpenAiCallerAuth, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
219
+ import { inspectChatGptDomainClaim } from "../../oauth/chatgpt";
220
+ import { captureCallerDirectAuth, providerConsumesCallerAuthorization, type CallerDirectAuth } from "../../providers/caller-authorization";
218
221
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
219
222
  import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported } from "../../codex/loopback-target";
220
223
  import { providerContextCap } from "../../providers/context-cap";
@@ -1121,7 +1124,8 @@ export async function shouldRetryCodexPoolAccountQuota(
1121
1124
  }
1122
1125
 
1123
1126
  interface CodexPoolAccountRetryArgs {
1124
- req: Request;
1127
+ /** Sanitized caller input, before any selected Pool credential was materialized. */
1128
+ callerAuthHeaders: Headers;
1125
1129
  config: OcxConfig;
1126
1130
  route: { providerName: string; modelId: string; provider: OcxProviderConfig };
1127
1131
  parsed: OcxParsedRequest;
@@ -1287,7 +1291,7 @@ async function retryCodexPoolOnAlternateAccount(
1287
1291
  args: CodexPoolAccountRetryArgs,
1288
1292
  ): Promise<CodexPoolAccountRetryResult> {
1289
1293
  const {
1290
- req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse,
1294
+ callerAuthHeaders, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse,
1291
1295
  outcomeStatus, upstream, connectMs, passthroughEstimate, stream,
1292
1296
  } = args;
1293
1297
  const inboundWire = options.inboundWire ?? "responses";
@@ -1321,7 +1325,7 @@ async function retryCodexPoolOnAlternateAccount(
1321
1325
  }
1322
1326
  try {
1323
1327
  retryAuthCtx ??= await resolveCodexAuthContext(
1324
- req.headers,
1328
+ callerAuthHeaders,
1325
1329
  config,
1326
1330
  "pool",
1327
1331
  {
@@ -1329,7 +1333,7 @@ async function retryCodexPoolOnAlternateAccount(
1329
1333
  admission: options.admission,
1330
1334
  codexAuthPolicy: options.codexAuthPolicy,
1331
1335
  modelId: route.modelId,
1332
- requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config),
1336
+ requestScopedMainCredential: hasForwardableCodexBearer(callerAuthHeaders, config),
1333
1337
  beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
1334
1338
  resolveCodexModelEntitlements: entitlementResolver,
1335
1339
  },
@@ -1399,7 +1403,7 @@ async function retryCodexPoolOnAlternateAccount(
1399
1403
  // Only a combo reset-derived outcome is deferred. Retry-After, defaults, and
1400
1404
  // ordinary requests must block the first account before the alternate send.
1401
1405
  if (!deferFirstOutcome) recordFirstOutcome();
1402
- const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission);
1406
+ const retryHeaders = headersForCodexAuthContext(callerAuthHeaders, retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission);
1403
1407
  const retryProvider = applyCodexAuthContextToProvider(
1404
1408
  stripCodexRuntimeProviderFields(route.provider),
1405
1409
  retryAuthCtx,
@@ -1703,9 +1707,17 @@ export interface HandleResponsesOptions {
1703
1707
  /**
1704
1708
  * Claude replay may add native-main auth so OpenAI sidecars remain available.
1705
1709
  * Strip only that internal credential when the final route is a noncanonical
1706
- * forward destination; final routing can differ from Claude's preflight route.
1710
+ * forward/caller-auth destination; final routing can differ from Claude's preflight route.
1707
1711
  */
1708
1712
  stripClaudeMainAuthForNoncanonicalForward?: boolean;
1713
+ /** In-memory credential proven by Claude's native-main turn claim; never persist or log. */
1714
+ trustedClaudeMainAuth?: { authorization: string; chatgptAccountId?: string };
1715
+ /** Sidecar-only auth captured before route changes; null means no usable original pair. */
1716
+ openAiSidecarAuth?: ExplicitOpenAiCallerAuth | null;
1717
+ /** Original caller-owned native pair; separate from any claimed sidecar enrichment. */
1718
+ nativeCallerAuth?: ExplicitOpenAiCallerAuth | null;
1719
+ /** Caller Direct credential under Direct\'s own predicate; restored only for the canonical OpenAI final route. */
1720
+ callerDirectAuth?: CallerDirectAuth | null;
1709
1721
  /** Internal recursion guard; callers outside this module must not set it. */
1710
1722
  comboAttempt?: boolean;
1711
1723
  /** Internal combo handoff for one parent-validated continuation snapshot. */
@@ -1925,6 +1937,9 @@ export function createChildPassthroughCallbackGate(options: HandleResponsesOptio
1925
1937
 
1926
1938
  export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers {
1927
1939
  const childHeaders = new Headers(parentHeaders);
1940
+ // A provisional caller credential is not authoritative for a Combo child.
1941
+ childHeaders.delete("authorization");
1942
+ childHeaders.delete("chatgpt-account-id");
1928
1943
  // Combo children re-serialize already-decoded JSON. Keeping transport metadata from
1929
1944
  // the parent would make the child decoder treat plain JSON as compressed bytes.
1930
1945
  childHeaders.delete("content-length");
@@ -2009,7 +2024,7 @@ function canPassThroughEncryptedV2AgentTask(
2009
2024
  }
2010
2025
 
2011
2026
  type ResponsesAuthResolution =
2012
- | { ok: true; authCtx: CodexAuthContext; headers: Headers; substituteMainCredential: boolean }
2027
+ | { ok: true; authCtx: CodexAuthContext; headers: Headers; callerAuthHeaders: Headers; substituteMainCredential: boolean }
2013
2028
  | { ok: false; response: Response };
2014
2029
 
2015
2030
  /**
@@ -2021,8 +2036,75 @@ async function resolveResponsesCodexAuth(
2021
2036
  config: OcxConfig,
2022
2037
  route: RouteResult,
2023
2038
  options: HandleResponsesOptions,
2039
+ credentialDomainWasRewritten = false,
2024
2040
  ): Promise<ResponsesAuthResolution> {
2025
2041
  try {
2042
+ const routeMayChangeCredentialDomain = options.comboAttempt === true
2043
+ || route.routeKind === "policy"
2044
+ || credentialDomainWasRewritten;
2045
+ const trustedClaudeMainForFinalRoute = options.stripClaudeMainAuthForNoncanonicalForward === true
2046
+ && isCanonicalOpenAiForwardProvider(route.provider)
2047
+ ? options.trustedClaudeMainAuth : undefined;
2048
+ let authInputHeaders = req.headers;
2049
+ // Route-changing recursion retains typed admission, never an unscoped raw
2050
+ // caller credential. Bearer admission is substituted or stripped below.
2051
+ if (routeMayChangeCredentialDomain && options.admission?.source !== "bearer"
2052
+ && !trustedClaudeMainForFinalRoute) {
2053
+ authInputHeaders = new Headers(req.headers);
2054
+ authInputHeaders.delete("authorization");
2055
+ authInputHeaders.delete("chatgpt-account-id");
2056
+ }
2057
+ if (trustedClaudeMainForFinalRoute) {
2058
+ authInputHeaders = new Headers(authInputHeaders);
2059
+ authInputHeaders.set("authorization", trustedClaudeMainForFinalRoute.authorization);
2060
+ if (trustedClaudeMainForFinalRoute.chatgptAccountId) {
2061
+ authInputHeaders.set("chatgpt-account-id", trustedClaudeMainForFinalRoute.chatgptAccountId);
2062
+ } else {
2063
+ authInputHeaders.delete("chatgpt-account-id");
2064
+ }
2065
+ }
2066
+ // A caller-auth transport that is not canonical OpenAI (keyless Cursor) consumes the
2067
+ // caller's Authorization as its own upstream token. Keep that contract only for a clean
2068
+ // single bearer with NO ChatGPT-domain marker. A bearer marked for the ChatGPT domain —
2069
+ // whether its marker is valid or malformed/conflicting — a combined/malformed value, or
2070
+ // the captured explicit OpenAI pair is never a Cursor token; a foreign JWT carrying only
2071
+ // a generic organizations claim is not ChatGPT-marked and keeps the legacy contract.
2072
+ // chatgpt-account-id has no meaning outside the ChatGPT domain.
2073
+ if (!isCanonicalOpenAiForwardProvider(route.provider)
2074
+ && providerConsumesCallerAuthorization(route.provider)) {
2075
+ const rawAuth = authInputHeaders.get("authorization")?.trim();
2076
+ const singleBearer = /^Bearer[\t ]+([^\s,]+)$/i.exec(rawAuth ?? "")?.[1];
2077
+ const domainClaim = singleBearer ? inspectChatGptDomainClaim(singleBearer) : { kind: "absent" as const };
2078
+ const dropBearer = options.nativeCallerAuth != null || domainClaim.kind !== "absent"
2079
+ || (rawAuth !== undefined && singleBearer === undefined);
2080
+ if (dropBearer || authInputHeaders.has("chatgpt-account-id")) {
2081
+ const scoped = new Headers(authInputHeaders);
2082
+ if (dropBearer) scoped.delete("authorization");
2083
+ scoped.delete("chatgpt-account-id");
2084
+ authInputHeaders = scoped;
2085
+ }
2086
+ }
2087
+ // The caller's own Direct credential may cross an internal route change only to the
2088
+ // canonical OpenAI transport, under a predicate deliberately STRICTER than plain
2089
+ // unchanged-route Direct forwarding: a clean non-proxy bearer whose ChatGPT-domain
2090
+ // marker is valid, with any explicit account header matching that marker. Unchanged
2091
+ // routes keep their legacy rules; sidecar enrichment grants no primary authority.
2092
+ if (options.callerDirectAuth && isCanonicalOpenAiForwardProvider(route.provider)) {
2093
+ const directHeaders = new Headers({
2094
+ authorization: options.callerDirectAuth.authorization,
2095
+ ...(options.callerDirectAuth.chatgptAccountId
2096
+ ? { "chatgpt-account-id": options.callerDirectAuth.chatgptAccountId } : {}),
2097
+ });
2098
+ if (captureCallerDirectAuth(directHeaders, config)) {
2099
+ authInputHeaders = new Headers(authInputHeaders);
2100
+ authInputHeaders.set("authorization", options.callerDirectAuth.authorization);
2101
+ if (options.callerDirectAuth.chatgptAccountId) {
2102
+ authInputHeaders.set("chatgpt-account-id", options.callerDirectAuth.chatgptAccountId);
2103
+ } else {
2104
+ authInputHeaders.delete("chatgpt-account-id");
2105
+ }
2106
+ }
2107
+ }
2026
2108
  // #1686: a caller that proved admission with a BEARER presented one of our own secrets.
2027
2109
  // Refusing it here is what made the codex-cli `env_key` contract unusable against Direct.
2028
2110
  // Admitting it is only safe because the stored main credential is substituted below, so
@@ -2046,15 +2128,16 @@ async function resolveResponsesCodexAuth(
2046
2128
  // no-ChatGPT-login install keeps working.
2047
2129
  const substituteMainCredential = options.admission?.source === "bearer"
2048
2130
  && (route.codexAccountMode !== undefined || isCanonicalOpenAiForwardProvider(route.provider));
2131
+ const stripAuthorization = options.admission?.source === "bearer" && !substituteMainCredential;
2049
2132
  const requestScopedMainCredential = route.codexAccountMode !== undefined
2050
2133
  && !substituteMainCredential
2051
- && hasForwardableCodexBearer(req.headers, config);
2134
+ && hasForwardableCodexBearer(authInputHeaders, config);
2052
2135
  if (route.codexAccountMode === "direct" && !substituteMainCredential) {
2053
- validateForwardAdmissionCredential(req.headers, config);
2136
+ validateForwardAdmissionCredential(authInputHeaders, config);
2054
2137
  }
2055
2138
  let authCtx: CodexAuthContext;
2056
2139
  if (route.codexAccountMode) {
2057
- authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, {
2140
+ authCtx = await resolveCodexAuthContext(authInputHeaders, config, route.codexAccountMode, {
2058
2141
  admission: options.admission,
2059
2142
  codexAuthPolicy: options.codexAuthPolicy,
2060
2143
  accountId: route.codexAccountId,
@@ -2090,7 +2173,7 @@ async function resolveResponsesCodexAuth(
2090
2173
  // (custom-named canonical-forward providers must retain the same protection).
2091
2174
  const mainPolicyConfig = isCanonicalOpenAiForwardProvider(route.provider)
2092
2175
  ? options.codexAuthPolicy ?? config : undefined;
2093
- const headers = await materializeCodexUpstreamAuthAsync(req.headers, authCtx, {
2176
+ const headers = await materializeCodexUpstreamAuthAsync(authInputHeaders, authCtx, {
2094
2177
  admission: options.admission,
2095
2178
  config: mainPolicyConfig,
2096
2179
  modelId: route.modelId,
@@ -2109,10 +2192,27 @@ async function resolveResponsesCodexAuth(
2109
2192
  response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
2110
2193
  };
2111
2194
  }
2195
+ if (stripAuthorization) {
2196
+ headers.delete("authorization");
2197
+ headers.delete("chatgpt-account-id");
2198
+ }
2199
+ if (providerConsumesCallerAuthorization(route.provider) && options.admission?.source !== undefined
2200
+ && options.admission.source !== "loopback") {
2201
+ validateForwardAdmissionCredential(headers, config);
2202
+ } else {
2203
+ // Even adapters that ignore caller auth must not retain a proxy secret for
2204
+ // a later internal hop or a future transport change.
2205
+ const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
2206
+ if (bearer && isProxyAdmissionSecret(bearer, config)) {
2207
+ headers.delete("authorization");
2208
+ headers.delete("chatgpt-account-id");
2209
+ }
2210
+ }
2112
2211
  return {
2113
2212
  ok: true,
2114
2213
  authCtx,
2115
2214
  headers,
2215
+ callerAuthHeaders: new Headers(authInputHeaders),
2116
2216
  substituteMainCredential,
2117
2217
  };
2118
2218
  } catch (err) {
@@ -3084,6 +3184,12 @@ export async function handleResponses(
3084
3184
  try {
3085
3185
  const response = await handleResponsesInner(req, config, logCtx, {
3086
3186
  ...options,
3187
+ openAiSidecarAuth: options.openAiSidecarAuth === undefined
3188
+ ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth,
3189
+ nativeCallerAuth: options.nativeCallerAuth === undefined
3190
+ ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.nativeCallerAuth,
3191
+ callerDirectAuth: options.callerDirectAuth === undefined
3192
+ ? captureCallerDirectAuth(req.headers, config) : options.callerDirectAuth,
3087
3193
  // Capture before combo replay rebuilds the Request headers; children carry options.
3088
3194
  visionDescribeTerminal: options.visionDescribeTerminal === true
3089
3195
  || req.headers.get("x-opencodex-vision-describe") === "1",
@@ -3332,6 +3438,7 @@ async function handleResponsesInner(
3332
3438
  logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
3333
3439
 
3334
3440
  let route: RouteResult;
3441
+ let credentialDomainWasRewritten = false;
3335
3442
  try {
3336
3443
  // A `compaction_trigger` turn may name a bare native model the operator has
3337
3444
  // no canonical OpenAI route for (#2901). Only the initial compaction route
@@ -3353,6 +3460,7 @@ async function handleResponsesInner(
3353
3460
  } catch { /* Native Codex helper calls remain OpenAI-owned without an enabled OpenAI route. */ }
3354
3461
  const targetRoute = resolveRoute(_sci.model);
3355
3462
  if (shouldInterceptShadowCall(parsed.modelId, _sci.sourceModels, sourceIdentity, targetRoute)) {
3463
+ credentialDomainWasRewritten = true;
3356
3464
  const _sciOriginal = parsed.modelId;
3357
3465
  parsed.modelId = _sci.model;
3358
3466
  if (parsed._rawBody && typeof parsed._rawBody === "object") {
@@ -3484,6 +3592,7 @@ async function handleResponsesInner(
3484
3592
  if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
3485
3593
  try {
3486
3594
  route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody));
3595
+ credentialDomainWasRewritten = true;
3487
3596
  logCtx.routeDecision = route.routeDecision;
3488
3597
  } catch (err) {
3489
3598
  if (err instanceof NoAvailableComboTargetsError) {
@@ -3620,6 +3729,7 @@ async function handleResponsesInner(
3620
3729
  if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
3621
3730
  try {
3622
3731
  route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody));
3732
+ credentialDomainWasRewritten = true;
3623
3733
  logCtx.routeDecision = route.routeDecision;
3624
3734
  } catch (err) {
3625
3735
  if (err instanceof NoAvailableComboTargetsError) {
@@ -3742,11 +3852,13 @@ async function handleResponsesInner(
3742
3852
  }
3743
3853
 
3744
3854
  let substituteMainCredential = false;
3855
+ let callerAuthHeaders: Headers;
3745
3856
  {
3746
- const finalAuth = await resolveResponsesCodexAuth(req, config, route, options);
3857
+ const finalAuth = await resolveResponsesCodexAuth(req, config, route, options, credentialDomainWasRewritten);
3747
3858
  if (!finalAuth.ok) return finalAuth.response;
3748
3859
  authCtx = finalAuth.authCtx;
3749
3860
  selectedForwardHeaders = finalAuth.headers;
3861
+ callerAuthHeaders = finalAuth.callerAuthHeaders;
3750
3862
  substituteMainCredential = finalAuth.substituteMainCredential;
3751
3863
  }
3752
3864
 
@@ -4036,7 +4148,8 @@ async function handleResponsesInner(
4036
4148
  const ownsBearer = snapshot !== undefined
4037
4149
  && sentHeaders?.get("authorization") === `Bearer ${snapshot.accessToken}`
4038
4150
  && !sentHeaders?.has("x-api-key");
4039
- const response = await fetchImpl(destination, dispatchInit);
4151
+ // Reselection can choose a provider override instead of the supplied executor.
4152
+ const response = await fetchImpl(destination, { ...dispatchInit, redirect: "manual" });
4040
4153
  // Observe each physical response before retries replace it. The binding belongs to
4041
4154
  // this dispatch, so a manual switch cannot file A's headers against B. Header
4042
4155
  // overrides and credential replacement make ownership unprovable: skip those writes.
@@ -4223,9 +4336,9 @@ async function handleResponsesInner(
4223
4336
  );
4224
4337
  let adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
4225
4338
  const stripClaudeMainAuth = options.stripClaudeMainAuthForNoncanonicalForward === true
4226
- && adapterProvider.adapter === "openai-responses"
4227
- && adapterProvider.authMode === "forward"
4228
- && !isCanonicalOpenAiForwardProvider(adapterProvider);
4339
+ && !isCanonicalOpenAiForwardProvider(adapterProvider)
4340
+ && ((adapterProvider.adapter === "openai-responses" && adapterProvider.authMode === "forward")
4341
+ || providerConsumesCallerAuthorization(adapterProvider));
4229
4342
  if (stripClaudeMainAuth) {
4230
4343
  releaseCodexAuthContextProbeLease(authCtx);
4231
4344
  authCtx = { kind: "main", accountId: null };
@@ -4313,9 +4426,18 @@ async function handleResponsesInner(
4313
4426
  const needsOpenAiSearch = shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough);
4314
4427
  if (needsOpenAiVision || needsOpenAiSearch) {
4315
4428
  try {
4429
+ // Preserve explicit OpenAI helper auth across route changes without returning it to
4430
+ // primary-provider headers or alternate-main retry. The resolver revalidates scope.
4431
+ const sidecarHeaders = new Headers(req.headers);
4432
+ sidecarHeaders.delete("authorization");
4433
+ sidecarHeaders.delete("chatgpt-account-id");
4434
+ if (options.openAiSidecarAuth) {
4435
+ sidecarHeaders.set("authorization", options.openAiSidecarAuth.authorization);
4436
+ sidecarHeaders.set("chatgpt-account-id", options.openAiSidecarAuth.chatgptAccountId);
4437
+ }
4316
4438
  openAiSidecar = await resolveFirstUsableOpenAiSidecar(
4317
4439
  listOpenAiForwardSidecarCandidates(config),
4318
- req.headers,
4440
+ sidecarHeaders,
4319
4441
  config,
4320
4442
  {
4321
4443
  admission: options.admission,
@@ -5400,7 +5522,7 @@ async function handleResponsesInner(
5400
5522
  // justify because this flag already produced the identical result.
5401
5523
  const storedReplaySpent = codex401ReplayKind === "stored";
5402
5524
  const retry = await retryCodexPoolOnAlternateAccount({
5403
- req,
5525
+ callerAuthHeaders,
5404
5526
  config,
5405
5527
  route,
5406
5528
  parsed,
@@ -74,13 +74,20 @@ export function providerFetch(
74
74
  const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
75
75
  base.preconnect?.(...args);
76
76
  };
77
+ // Rebuilt dispatches must use the same physical-send boundary as ordinary HTTP sends.
78
+ // Return the original 3xx so the response owner retains its retry/health/relay contract.
79
+ const dispatch = Object.assign(
80
+ (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
81
+ base(input, { ...init, redirect: "manual" }),
82
+ { preconnect },
83
+ ) as typeof globalThis.fetch;
77
84
  const httpFetch = Object.assign(
78
85
  async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
79
86
  options.beforeDispatch?.(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)));
80
87
  const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 };
81
88
  return options.dispatchOverride
82
- ? options.dispatchOverride(input, dispatchInit, base)
83
- : base(input, dispatchInit);
89
+ ? options.dispatchOverride(input, dispatchInit, dispatch)
90
+ : dispatch(input, dispatchInit);
84
91
  },
85
92
  { preconnect },
86
93
  ) as typeof globalThis.fetch;
@@ -163,6 +170,10 @@ export function storedPoolReplayDispatchNotifier(
163
170
  }) as ProviderFetch;
164
171
  }
165
172
 
173
+ /**
174
+ * Fetch through the header deadline with redirects always manual.
175
+ * @param _manualRedirect Ignored; retained for call compatibility. Even false uses manual.
176
+ */
166
177
  export async function fetchWithHeaderTimeout(
167
178
  url: string,
168
179
  init: Omit<RequestInit, "signal">,
@@ -170,7 +181,8 @@ export async function fetchWithHeaderTimeout(
170
181
  timeoutMs: number,
171
182
  preferIdentityEncoding = false,
172
183
  executor: typeof globalThis.fetch = globalThis.fetch,
173
- manualRedirect = false,
184
+ // Retained for existing callers; credential-bearing transport no longer opts out.
185
+ _manualRedirect = false,
174
186
  ): Promise<Response> {
175
187
  const pacing = executor as ProviderFetch;
176
188
  await pacing.waitForPacing?.(abortSignal);
@@ -189,10 +201,9 @@ export async function fetchWithHeaderTimeout(
189
201
  return await fetchExecutor(url, {
190
202
  ...init,
191
203
  headers,
192
- // Credential-bearing sends opt into manual redirects so a 3xx is relayed
193
- // as a Response instead of being followed into a rejection that is
194
- // indistinguishable from a pre-connection failure (#914).
195
- ...(manualRedirect ? { redirect: "manual" as const } : {}),
204
+ // Never replay provider credentials or request bodies to a redirect destination.
205
+ // Preserve the 3xx for the owner's existing response/health policy (#914, #1471).
206
+ redirect: "manual",
196
207
  signal: AbortSignal.any([abortSignal, timeout.signal]),
197
208
  timeout: 0,
198
209
  });
@@ -6,6 +6,8 @@ import type { OcxConfig } from "../../types";
6
6
  import type { RouteCandidateTrace, RouteDecisionTraceV1 } from "../../routing/trace";
7
7
  import { handleResponses as handleResponsesCore } from "./core";
8
8
  import { requestPacingOverloadResponse } from "./pacing-overload";
9
+ import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar";
10
+ import { captureCallerDirectAuth } from "../../providers/caller-authorization";
9
11
 
10
12
  type CoreHandler = typeof handleResponsesCore;
11
13
  type CoreOptions = Parameters<CoreHandler>[3];
@@ -47,6 +49,10 @@ function requestWithCandidate(
47
49
  candidate: Pick<RouteCandidateTrace, "provider" | "model">,
48
50
  ): Request {
49
51
  const headers = new Headers(req.headers);
52
+ // The next candidate owns a different physical credential domain. Typed
53
+ // admission and any claimed Claude snapshot stay in caller-owned CoreOptions.
54
+ headers.delete("authorization");
55
+ headers.delete("chatgpt-account-id");
50
56
  headers.delete("content-encoding");
51
57
  headers.delete("content-length");
52
58
  headers.set("content-type", "application/json");
@@ -119,6 +125,12 @@ export async function handleResponsesWithPolicyFallback(
119
125
  let storedPool401ReplayDispatched = false;
120
126
  const coreOptions: CoreOptions = {
121
127
  ...options,
128
+ openAiSidecarAuth: options.openAiSidecarAuth === undefined
129
+ ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth,
130
+ nativeCallerAuth: options.nativeCallerAuth === undefined
131
+ ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.nativeCallerAuth,
132
+ callerDirectAuth: options.callerDirectAuth === undefined
133
+ ? captureCallerDirectAuth(req.headers, config) : options.callerDirectAuth,
122
134
  ...(options.onRequestBodyRead ? {
123
135
  onRequestBodyRead: () => {
124
136
  if (requestBodyReadNotified) return;
package/src/service.ts CHANGED
@@ -16,6 +16,13 @@ import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject";
16
16
  import { stripGrokConfig } from "./grok/inject";
17
17
  import { isWslRuntime, resolveCodexHomeDir, type CodexHomeDeps } from "./codex/home";
18
18
  import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./lib/bun-runtime";
19
+
20
+ /**
21
+ * Written only by the launchd plist and the systemd unit. `OCX_SERVICE=1` cannot stand in
22
+ * for it: `ocx claude` and `ocx opencode` set that on the proxies they spawn to borrow its
23
+ * routing-preservation meaning, so a proxy carrying it is not necessarily the managed job.
24
+ */
25
+ export const SERVICE_MANAGED_ENV = "OCX_SERVICE_MANAGED";
19
26
  import type { BunRuntimeSource, DurableBunRuntime } from "./lib/bun-runtime";
20
27
  import { isProcessAlive, stopProxy } from "./lib/process-control";
21
28
  import { serviceApiTokenFilePath } from "./lib/service-secrets";
@@ -508,6 +515,11 @@ export function buildPlist(
508
515
  const opencodexHome = process.env.OPENCODEX_HOME?.trim();
509
516
  const envLines = [
510
517
  ` <key>OCX_SERVICE</key><string>1</string>`,
518
+ // OCX_SERVICE alone cannot identify the managed job: `ocx claude` and `ocx opencode`
519
+ // also set it on the proxies they spawn, to borrow its routing-preservation meaning
520
+ // (src/cli/index.ts preserveRouting). Only the wrapper writes this second marker, so
521
+ // the dashboard-stop refusal below can tell a real launchd job from an ordinary child.
522
+ ` <key>${SERVICE_MANAGED_ENV}</key><string>1</string>`,
511
523
  ...(launcher ? [] : [
512
524
  ` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntimeSource}</string>`,
513
525
  ` <key>${BUN_RUNTIME_PATH_ENV}</key><string>${plistString(bun)}</string>`,
@@ -3328,6 +3340,7 @@ export function buildUnit(
3328
3340
  const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
3329
3341
  const envLines = [
3330
3342
  systemdEnvironmentAssignment("OCX_SERVICE", "1"),
3343
+ systemdEnvironmentAssignment(SERVICE_MANAGED_ENV, "1"),
3331
3344
  ...(launcher ? [] : [
3332
3345
  systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
3333
3346
  systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun),
@@ -3860,10 +3873,31 @@ export async function installFreshWindowsSchedulerSafely(
3860
3873
  export function installedServiceRespawnRisk(
3861
3874
  probe: () => WindowsSchedulerTaskProbe = probeWindowsSchedulerTask,
3862
3875
  platform: NodeJS.Platform = process.platform,
3863
- ): "none" | "respawnable" | "unknown" {
3876
+ io: { env?: NodeJS.ProcessEnv; exists?: (path: string) => boolean } = {},
3877
+ ): "none" | "respawnable" | "unknown" | "self-unload" {
3864
3878
  // launchd, systemd and WinSW are down when they report stopped; only the Task Scheduler
3865
3879
  // wrapper survives its task ending (#764).
3866
- if (platform !== "win32") return "none";
3880
+ //
3881
+ // "Down when they report stopped" answers the RESPAWN question but not the SELF-UNLOAD
3882
+ // one (#4023). When the proxy is itself the managed job, `launchctl unload` /
3883
+ // `systemctl stop` terminate this very process, so the manager stop can kill the request
3884
+ // handler before the shared teardown restores the native Codex config keys — leaving
3885
+ // `openai_base_url`, `experimental_realtime_ws_base_url` and `model_catalog_json`
3886
+ // pointed at a proxy that is gone. Reordering teardown ahead of the manager stop is not
3887
+ // available here: the #3008 contract requires the manager to be proven stopped first.
3888
+ // So refuse, exactly as Windows does, and send the operator to `ocx stop`, which stops
3889
+ // the proxy from the outside and owns the teardown through its receipt.
3890
+ if (platform !== "win32") {
3891
+ const env = io.env ?? process.env;
3892
+ // Discriminate on the wrapper-only marker, not on OCX_SERVICE: `ocx claude` and
3893
+ // `ocx opencode` set OCX_SERVICE=1 on the proxies they spawn (for preserveRouting),
3894
+ // and refusing their dashboard stop would break a proxy that no manager supervises.
3895
+ if (env[SERVICE_MANAGED_ENV] !== "1") return "none";
3896
+ const exists = io.exists ?? existsSync;
3897
+ if (platform === "darwin") return exists(plistPath()) ? "self-unload" : "none";
3898
+ if (platform === "linux") return exists(unitPath()) ? "self-unload" : "none";
3899
+ return "none";
3900
+ }
3867
3901
  try {
3868
3902
  // `probeWindowsSchedulerTask` returns "unknown" as an ordinary value when its queries
3869
3903
  // fail — it does not throw — so testing for "present" let an unanswerable probe