@bitkyc08/opencodex 2.89.0 → 2.91.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 (242) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/App-nZ34cR5G.js +58 -0
  3. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
  5. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +2 -2
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/base.ts +4 -0
  10. package/src/adapters/claude-cli/adapter.ts +13 -7
  11. package/src/adapters/claude-cli/usage-admission.ts +28 -2
  12. package/src/adapters/coding-agent/turn.ts +14 -4
  13. package/src/adapters/cursor/protobuf-request.ts +3 -0
  14. package/src/adapters/google-progress-history.ts +19 -1
  15. package/src/adapters/google.ts +28 -9
  16. package/src/adapters/image.ts +5 -3
  17. package/src/adapters/input-media-guard.ts +5 -2
  18. package/src/adapters/kiro-refusal.ts +41 -1
  19. package/src/adapters/ollama-native.ts +1 -0
  20. package/src/adapters/openai-chat/messages.ts +34 -4
  21. package/src/adapters/openai-chat/passthrough.ts +37 -4
  22. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  23. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  24. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  25. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  26. package/src/adapters/openai-chat.ts +7 -6
  27. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  28. package/src/adapters/openai-responses/passthrough.ts +7 -2
  29. package/src/adapters/openai-responses/request-strips.ts +32 -0
  30. package/src/adapters/openai-responses/transport.ts +19 -1
  31. package/src/chat/inbound.ts +20 -2
  32. package/src/chat/outbound.ts +36 -4
  33. package/src/chat/reasoning-details.ts +137 -0
  34. package/src/claude/desktop-3p-library.ts +43 -3
  35. package/src/claude/desktop-3p.ts +20 -10
  36. package/src/claude/desktop-picker-profile.ts +18 -9
  37. package/src/claude/desktop-remote-store-io.ts +2 -2
  38. package/src/cli/capabilities-agents-routing.ts +50 -0
  39. package/src/cli/capabilities-base.ts +15 -2
  40. package/src/cli/capabilities-observe-system.ts +4 -4
  41. package/src/cli/codex-shim-autorestore.ts +5 -0
  42. package/src/cli/dispatch.ts +30 -0
  43. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  44. package/src/cli/fallback.ts +127 -0
  45. package/src/cli/help.ts +1 -0
  46. package/src/cli/index.ts +1 -2
  47. package/src/cli/link-apply-restart.ts +72 -0
  48. package/src/cli/link-child.ts +6 -2
  49. package/src/cli/link-cleanup.ts +95 -0
  50. package/src/cli/link-enrollment.ts +4 -0
  51. package/src/cli/link.ts +84 -9
  52. package/src/cli/provider.ts +44 -2
  53. package/src/cli/registry.ts +13 -1
  54. package/src/cli/restart-failure.ts +4 -0
  55. package/src/cli/runtime-api.ts +1 -1
  56. package/src/cli/status-codex-settings.ts +56 -0
  57. package/src/cli/status-fallback.ts +29 -0
  58. package/src/cli/status-install.ts +52 -0
  59. package/src/cli/status.ts +20 -1
  60. package/src/cli/system-restart-client.ts +32 -1
  61. package/src/cli/update-restart-child.ts +4 -3
  62. package/src/cli/update-restart-home.ts +20 -8
  63. package/src/client/connect.ts +82 -6
  64. package/src/client/home-unreachable.ts +16 -0
  65. package/src/client/hub-relay.ts +8 -2
  66. package/src/client/link-home.ts +119 -6
  67. package/src/client/link-parity-reporter.ts +107 -0
  68. package/src/client/link-relay-transport.ts +8 -1
  69. package/src/client/link-relay.ts +27 -3
  70. package/src/client/machine-listener.ts +83 -1
  71. package/src/client/runtime-restart.ts +127 -0
  72. package/src/client/runtime.ts +137 -25
  73. package/src/codex/account-lifecycle.ts +19 -6
  74. package/src/codex/account-store.ts +27 -8
  75. package/src/codex/account-usability.ts +4 -1
  76. package/src/codex/auth-api/account-list.ts +10 -5
  77. package/src/codex/auth-api/account-pause-group.ts +2 -22
  78. package/src/codex/auth-api/login-collision.ts +59 -0
  79. package/src/codex/auth-api/login-flow.ts +12 -7
  80. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  81. package/src/codex/auth-collision.ts +31 -4
  82. package/src/codex/auth-context.ts +58 -12
  83. package/src/codex/catalog/parsing.ts +4 -0
  84. package/src/codex/cli-update-plan.ts +18 -3
  85. package/src/codex/collision-identity.ts +78 -0
  86. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  87. package/src/codex/main-account-hard-lock.ts +14 -0
  88. package/src/codex/quota-history.ts +7 -0
  89. package/src/codex/quota.ts +10 -0
  90. package/src/codex/routing/cooldown-math.ts +5 -0
  91. package/src/codex/routing/failure-window.ts +61 -9
  92. package/src/codex/routing.ts +4 -3
  93. package/src/codex/shim-diagnostics.ts +2 -1
  94. package/src/codex/shim-fingerprint.ts +11 -2
  95. package/src/codex/shim-templates.ts +16 -5
  96. package/src/codex/shim-windows-refresh.ts +224 -0
  97. package/src/codex/shim.ts +45 -21
  98. package/src/codex/subagent-model-fallback.ts +24 -1
  99. package/src/combos/failover.ts +8 -1
  100. package/src/combos/index.ts +1 -0
  101. package/src/combos/resolve.ts +12 -0
  102. package/src/config/diagnostics.ts +5 -0
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/provider-validation.ts +7 -2
  105. package/src/config/schema/config-schema.ts +3 -0
  106. package/src/config/schema/fallback-policy.ts +40 -0
  107. package/src/generated/compatibility-version.json +360 -192
  108. package/src/generated/model-metadata.ts +1 -1
  109. package/src/images/loop.ts +2 -2
  110. package/src/lib/authorization-scheme.ts +95 -0
  111. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  112. package/src/lib/provider-admission-wait.ts +14 -0
  113. package/src/lib/provider-client-headers.ts +1 -0
  114. package/src/lib/redact.ts +2 -2
  115. package/src/lib/service-secrets.ts +37 -2
  116. package/src/lib/system-restart-contract.ts +24 -0
  117. package/src/lib/test-home-guard.ts +78 -11
  118. package/src/lib/translator-budget.ts +1 -1
  119. package/src/lib/upstream-retry.ts +15 -0
  120. package/src/link/child-parity.ts +124 -0
  121. package/src/link/codex-settings.ts +37 -0
  122. package/src/link/compatibility.ts +119 -0
  123. package/src/link/management-routes.ts +2 -0
  124. package/src/link/peer-report.ts +100 -0
  125. package/src/link/remote-platform.ts +78 -0
  126. package/src/link/ssh-argv.ts +19 -0
  127. package/src/link/status-projection.ts +24 -2
  128. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  129. package/src/oauth/account-import/parser.ts +15 -2
  130. package/src/oauth/account-import/types.ts +7 -0
  131. package/src/oauth/chatgpt-shared.ts +10 -1
  132. package/src/oauth/dsh.ts +45 -2
  133. package/src/oauth/index.ts +1 -1
  134. package/src/oauth/kimi.ts +2 -0
  135. package/src/oauth/kiro.ts +44 -1
  136. package/src/oauth/login-flow-state.ts +11 -2
  137. package/src/oauth/nous.ts +4 -1
  138. package/src/oauth/store.ts +2 -1
  139. package/src/protocols/codecs/chat.ts +3 -3
  140. package/src/protocols/encoders/adapter-events.ts +26 -2
  141. package/src/protocols/encoders/chat.ts +55 -3
  142. package/src/providers/claude-cli-usage.ts +20 -5
  143. package/src/providers/initial-model-selection.ts +2 -2
  144. package/src/providers/key-failover.ts +10 -2
  145. package/src/providers/key-store.ts +8 -3
  146. package/src/providers/mistral-transport.ts +240 -0
  147. package/src/providers/openai-sidecar.ts +5 -0
  148. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  149. package/src/providers/quota/antigravity.ts +8 -10
  150. package/src/providers/quota/failure-codes.ts +18 -0
  151. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  152. package/src/providers/quota-routing-cache.ts +1 -1
  153. package/src/providers/quota.ts +5 -1
  154. package/src/providers/registry/entries-extended.ts +6 -1
  155. package/src/providers/routed-config.ts +1 -1
  156. package/src/providers/xai-transport.ts +2 -1
  157. package/src/quota-pool/assemble.ts +326 -0
  158. package/src/quota-pool/contract.ts +58 -0
  159. package/src/quota-pool/identity.ts +203 -0
  160. package/src/remote/features.ts +74 -0
  161. package/src/remote/protocol.ts +2 -0
  162. package/src/remote/served-by.ts +57 -0
  163. package/src/responses/audio.ts +46 -0
  164. package/src/responses/input-media.ts +37 -15
  165. package/src/responses/parser-content.ts +22 -27
  166. package/src/responses/parser.ts +7 -0
  167. package/src/responses/reasoning-envelope.ts +12 -0
  168. package/src/responses/reasoning-retention.ts +218 -11
  169. package/src/responses/schema.ts +3 -4
  170. package/src/responses/web-search-replay.ts +11 -2
  171. package/src/routing/fallback-policy.ts +290 -0
  172. package/src/routing/fallback-state.ts +179 -0
  173. package/src/server/audio-client.ts +2 -1
  174. package/src/server/audio-dictation.ts +2 -1
  175. package/src/server/audio-upstream.ts +2 -1
  176. package/src/server/auth-cors.ts +6 -5
  177. package/src/server/background-lifecycle.ts +4 -0
  178. package/src/server/chat-completions.ts +8 -1
  179. package/src/server/chat-native.ts +8 -7
  180. package/src/server/gui-session.ts +2 -1
  181. package/src/server/index/link-listener.ts +11 -1
  182. package/src/server/index/link-management.ts +47 -0
  183. package/src/server/index/link-relay-sessions.ts +11 -2
  184. package/src/server/index/optional-listeners.ts +7 -2
  185. package/src/server/index/serve-options.ts +27 -4
  186. package/src/server/index/websocket-handler.ts +10 -0
  187. package/src/server/lifecycle.ts +17 -1
  188. package/src/server/live.ts +5 -0
  189. package/src/server/management/context.ts +1 -1
  190. package/src/server/management/fallback-routes.ts +75 -0
  191. package/src/server/management/link-enrollment.ts +21 -3
  192. package/src/server/management/link-route-state.ts +2 -0
  193. package/src/server/management/link-routes.ts +12 -1
  194. package/src/server/management/oauth-account-routes.ts +2 -1
  195. package/src/server/management/quota-pool-routes.ts +24 -0
  196. package/src/server/management/route-registry.ts +3 -0
  197. package/src/server/management-api.ts +16 -0
  198. package/src/server/management-auth.ts +2 -1
  199. package/src/server/messages-native.ts +11 -0
  200. package/src/server/request-log.ts +16 -7
  201. package/src/server/responses/adapter-continuation.ts +2 -2
  202. package/src/server/responses/adapter-dispatch.ts +2 -2
  203. package/src/server/responses/agent-task-recovery.ts +1 -1
  204. package/src/server/responses/codex-ws-exchange.ts +3 -0
  205. package/src/server/responses/codex-ws-metadata.ts +44 -0
  206. package/src/server/responses/codex-ws-session.ts +21 -1
  207. package/src/server/responses/combo-stream-preflight.ts +7 -2
  208. package/src/server/responses/compact.ts +4 -2
  209. package/src/server/responses/core-auth.ts +31 -1
  210. package/src/server/responses/core-codex-account.ts +21 -14
  211. package/src/server/responses/core-combo.ts +75 -1
  212. package/src/server/responses/core-opaque-recovery.ts +20 -11
  213. package/src/server/responses/deepseek-image-files.ts +121 -0
  214. package/src/server/responses/empty-completion-guard.ts +8 -0
  215. package/src/server/responses/encrypted-payload.ts +3 -3
  216. package/src/server/responses/fetch-helpers.ts +30 -10
  217. package/src/server/responses/input-admission.ts +6 -2
  218. package/src/server/responses/main-hard-lock-routing.ts +85 -8
  219. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  220. package/src/server/responses/passthrough-completion.ts +41 -0
  221. package/src/server/responses/passthrough-delivery.ts +2 -0
  222. package/src/server/responses/passthrough-dispatch.ts +13 -13
  223. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  224. package/src/server/responses/policy-fallback.ts +44 -1
  225. package/src/server/responses/reasoning-archive-error.ts +21 -0
  226. package/src/server/responses/request-prepare.ts +19 -1
  227. package/src/server/responses/request-transport.ts +61 -3
  228. package/src/server/restart-replacement.ts +50 -2
  229. package/src/server/ws-bridge.ts +5 -0
  230. package/src/service/cli.ts +25 -2
  231. package/src/service/diagnostics.ts +20 -6
  232. package/src/service/orchestration.ts +17 -0
  233. package/src/service/status-json.ts +116 -0
  234. package/src/types/config.ts +7 -0
  235. package/src/types/fallback.ts +38 -0
  236. package/src/types/provider.ts +2 -0
  237. package/src/types/request.ts +16 -1
  238. package/src/types.ts +1 -0
  239. package/src/web-search/loop.ts +2 -2
  240. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  241. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  242. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -1,6 +1,7 @@
1
1
  import { validateAntigravityImportCredential } from "../google-antigravity";
2
2
  import { upsertCredentialByIdentity } from "../store";
3
3
  import type { OAuthCredentials } from "../types";
4
+ import { probeAntigravityUsageQuota } from "../../providers/quota/antigravity";
4
5
  import {
5
6
  ACCOUNT_IMPORT_FORMAT,
6
7
  ACCOUNT_IMPORT_MAX_EMAIL_LENGTH,
@@ -14,6 +15,26 @@ import {
14
15
  export interface AntigravityImportAdapterDeps {
15
16
  validate(refreshToken: string, signal?: AbortSignal): Promise<ValidatedAntigravityCredential>;
16
17
  upsert(credential: OAuthCredentials): Promise<"inserted" | "updated">;
18
+ /**
19
+ * Prove that the freshly validated account token can use an operator-supplied project.
20
+ * Without this owner a fallback project is never admitted.
21
+ */
22
+ verifyProject?(accessToken: string, projectId: string, signal?: AbortSignal): Promise<boolean>;
23
+ }
24
+
25
+ /**
26
+ * The account-bound admission probe for a fallback project: the same Cloud Code Assist quota
27
+ * probe the account pool runs, authenticated with this account's own access token against the
28
+ * named project. Only a parsed quota answer admits it; denial, rate limiting, transport failure
29
+ * or an unusable body all keep the record out.
30
+ */
31
+ export async function verifyAntigravityImportProject(accessToken: string, projectId: string, signal?: AbortSignal): Promise<boolean> {
32
+ if (signal?.aborted) return false;
33
+ try {
34
+ return (await probeAntigravityUsageQuota(accessToken, projectId, signal)).kind === "available" && !signal?.aborted;
35
+ } catch {
36
+ return false;
37
+ }
17
38
  }
18
39
 
19
40
  function safeProviderEmail(value: unknown): string | null {
@@ -32,6 +53,7 @@ export function createAntigravityAccountImportAdapter(
32
53
  deps: AntigravityImportAdapterDeps = {
33
54
  validate: validateAntigravityImportCredential,
34
55
  upsert: credential => upsertCredentialByIdentity(ACCOUNT_IMPORT_PROVIDER, credential),
56
+ verifyProject: verifyAntigravityImportProject,
35
57
  },
36
58
  ): AccountImportAdapter {
37
59
  return {
@@ -52,15 +74,29 @@ export function createAntigravityAccountImportAdapter(
52
74
  const providerEmail = safeProviderEmail(credential.email);
53
75
  if (!providerEmail) return { status: "failed", code: "credential_rejected" };
54
76
  if (providerEmail !== record.email) return { status: "failed", code: "identity_mismatch" };
55
- if (typeof credential.projectId !== "string" || credential.projectId.length === 0) {
56
- return { status: "failed", code: "missing_project" };
77
+ // Automatic discovery wins: a discovered project is used as-is and the record's project_id
78
+ // is ignored. The record's project is a fallback only, admitted after an account-bound probe.
79
+ let projectId = typeof credential.projectId === "string" && credential.projectId.length > 0
80
+ ? credential.projectId
81
+ : undefined;
82
+ if (!projectId && record.projectId && deps.verifyProject) {
83
+ let admitted = false;
84
+ try {
85
+ admitted = await deps.verifyProject(credential.access, record.projectId, signal);
86
+ } catch {
87
+ admitted = false;
88
+ }
89
+ throwIfAccountImportAborted(signal);
90
+ if (admitted) projectId = record.projectId;
57
91
  }
92
+ if (!projectId) return { status: "failed", code: "missing_project" };
58
93
 
59
94
  throwIfAccountImportAborted(signal);
60
95
  try {
61
96
  const disposition = await deps.upsert({
62
97
  ...credential,
63
98
  email: providerEmail,
99
+ projectId,
64
100
  source: "credential-file",
65
101
  });
66
102
  return disposition === "updated"
@@ -1,13 +1,17 @@
1
1
  import {
2
2
  ACCOUNT_IMPORT_MAX_BYTES,
3
3
  ACCOUNT_IMPORT_MAX_EMAIL_LENGTH,
4
+ ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH,
4
5
  ACCOUNT_IMPORT_MAX_RECORDS,
5
6
  ACCOUNT_IMPORT_MAX_REFRESH_TOKEN_BYTES,
6
7
  type ParsedCockpitRecord,
7
8
  } from "./types";
8
9
 
9
10
  const encoder = new TextEncoder();
10
- const ALLOWED_RECORD_KEYS = new Set(["email", "refresh_token", "tags", "notes"]);
11
+ const ALLOWED_RECORD_KEYS = new Set(["email", "refresh_token", "project_id", "tags", "notes"]);
12
+ // Google Cloud project id grammar: a lowercase letter, then lowercase letters, digits or hyphens,
13
+ // not ending in a hyphen. Exact match only — no trimming, so whitespace or controls are rejected.
14
+ const PROJECT_ID_SHAPE = /^[a-z][a-z0-9-]*[a-z0-9]$/;
11
15
 
12
16
  export type CockpitDocumentParseResult =
13
17
  | { ok: true; records: ParsedCockpitRecord[] }
@@ -50,10 +54,18 @@ function validMetadata(record: Record<string, unknown>): boolean {
50
54
  return record.notes === undefined || typeof record.notes === "string";
51
55
  }
52
56
 
57
+ function validProjectId(value: unknown): value is string | undefined {
58
+ if (value === undefined) return true;
59
+ return typeof value === "string"
60
+ && value.length >= 6
61
+ && value.length <= ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH
62
+ && PROJECT_ID_SHAPE.test(value);
63
+ }
64
+
53
65
  function parseRecord(value: unknown, index: number): ParsedCockpitRecord {
54
66
  if (!isPlainRecord(value)) return { index, code: "invalid_record" };
55
67
  if (Object.keys(value).some(key => !ALLOWED_RECORD_KEYS.has(key))) return { index, code: "invalid_record" };
56
- if (!validEmail(value.email) || !validRefreshToken(value.refresh_token) || !validMetadata(value)) {
68
+ if (!validEmail(value.email) || !validRefreshToken(value.refresh_token) || !validProjectId(value.project_id) || !validMetadata(value)) {
57
69
  return { index, code: "invalid_record" };
58
70
  }
59
71
  return {
@@ -61,6 +73,7 @@ function parseRecord(value: unknown, index: number): ParsedCockpitRecord {
61
73
  record: {
62
74
  email: value.email.toLowerCase(),
63
75
  refreshToken: value.refresh_token,
76
+ ...(typeof value.project_id === "string" ? { projectId: value.project_id } : {}),
64
77
  },
65
78
  };
66
79
  }
@@ -11,6 +11,8 @@ export const ACCOUNT_IMPORT_MAX_REQUEST_BYTES = ACCOUNT_IMPORT_MAX_BYTES + 1024;
11
11
  export const ACCOUNT_IMPORT_DEADLINE_MS = 600_000;
12
12
  export const ACCOUNT_IMPORT_MAX_RECORDS = 25;
13
13
  export const ACCOUNT_IMPORT_MAX_EMAIL_LENGTH = 254;
14
+ /** Google Cloud project ids are 6–30 characters; anything longer cannot name a project. */
15
+ export const ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH = 30;
14
16
  export const ACCOUNT_IMPORT_MAX_REFRESH_TOKEN_BYTES = 16 * 1024;
15
17
 
16
18
  export type AccountImportStatus = "imported" | "updated" | "failed" | "unsupported";
@@ -65,6 +67,11 @@ export function throwIfAccountImportAborted(signal?: AbortSignal): void {
65
67
  export interface CockpitAccountRecord {
66
68
  email: string;
67
69
  refreshToken: string;
70
+ /**
71
+ * Operator-supplied Cloud Code Assist project, used only when automatic discovery finds none
72
+ * and only after the account's own token proves it can use that project.
73
+ */
74
+ projectId?: string;
68
75
  }
69
76
 
70
77
  export type ParsedCockpitRecord =
@@ -46,7 +46,16 @@ export function extractEmail(idToken?: string, accessToken?: string): string | u
46
46
  if (!token) continue;
47
47
  const payload = decodeJwtPayload(token);
48
48
  if (!payload) continue;
49
- if (typeof payload.email === "string") return payload.email.toLowerCase();
49
+ if (typeof payload.email === "string" && payload.email.trim()) {
50
+ return payload.email.trim().toLowerCase();
51
+ }
52
+ const profile = payload["https://api.openai.com/profile"];
53
+ if (profile && typeof profile === "object" && !Array.isArray(profile)) {
54
+ const email = (profile as Record<string, unknown>).email;
55
+ if (typeof email === "string" && email.trim()) {
56
+ return email.trim().toLowerCase();
57
+ }
58
+ }
50
59
  }
51
60
  return undefined;
52
61
  }
package/src/oauth/dsh.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { OAuthController, OAuthCredentials } from "./types";
12
+ import { readBoundedResponseBody } from "../lib/bounded-body";
12
13
  import { homedir } from "node:os";
13
14
  import { join } from "node:path";
14
15
  import { existsSync, readFileSync } from "node:fs";
@@ -16,6 +17,20 @@ import { existsSync, readFileSync } from "node:fs";
16
17
  export const DSH_PLATFORM_ORIGIN = "https://platform.deepseek.com";
17
18
  export const DSH_INFERENCE_ORIGIN = "https://api.deepseek.com/anthropic";
18
19
 
20
+ /**
21
+ * Byte ceiling for the profile document returned by /auth-api/v0/users/current.
22
+ *
23
+ * A legitimate profile is a small fixed-shape JSON object (code, data.biz_code,
24
+ * biz_data.id/email/id_profile{name,picture}) well under a few KiB; 64 KiB matches
25
+ * the shared bounded-body ceiling and still leaves orders of magnitude of
26
+ * headroom. Anything larger is rejected before parsing so a hostile or broken
27
+ * upstream cannot turn an import validation into unbounded memory work.
28
+ */
29
+ export const DSH_PROFILE_MAX_BYTES = 64 * 1024;
30
+
31
+ /** Wall-clock budget for the profile request and its body read, in milliseconds. */
32
+ const DSH_PROFILE_FETCH_TIMEOUT_MS = 10_000;
33
+
19
34
  export interface DshAccountDetectionResult {
20
35
  detected: boolean;
21
36
  token?: string;
@@ -80,7 +95,7 @@ export async function validateDshAccountToken(
80
95
  signal?: AbortSignal,
81
96
  ): Promise<DshValidatedAccountIdentity> {
82
97
  try {
83
- const timeoutSignal = AbortSignal.timeout(10_000);
98
+ const timeoutSignal = AbortSignal.timeout(DSH_PROFILE_FETCH_TIMEOUT_MS);
84
99
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
85
100
  const response = await fetch(`${DSH_PLATFORM_ORIGIN}/auth-api/v0/users/current`, {
86
101
  method: "GET",
@@ -99,7 +114,35 @@ export async function validateDshAccountToken(
99
114
  return { valid: false, error: `Platform returned HTTP ${response.status}` };
100
115
  }
101
116
 
102
- const body = (await response.json()) as {
117
+ // A declared length past the cap is rejected without touching the stream.
118
+ const declaredLength = Number(response.headers.get("content-length"));
119
+ if (Number.isFinite(declaredLength) && declaredLength > DSH_PROFILE_MAX_BYTES) {
120
+ try {
121
+ void response.body?.cancel(
122
+ new DOMException("DSH profile response is too large", "QuotaExceededError"),
123
+ ).catch(() => undefined);
124
+ } catch {
125
+ // Best-effort cancellation only.
126
+ }
127
+ return { valid: false, error: "PROFILE_VALIDATION_FAILED" };
128
+ }
129
+
130
+ // Missing or understated Content-Length still meets the cap on the decoded
131
+ // stream itself: once an over-limit byte is observed the retained buffer is
132
+ // discarded and cancellation is requested on the body. Malformed UTF-8 fails
133
+ // closed so a replacement character can never alter the authoritative ID.
134
+ const bounded = await readBoundedResponseBody(response, {
135
+ signal: combinedSignal,
136
+ maxBytes: DSH_PROFILE_MAX_BYTES,
137
+ totalTimeoutMs: DSH_PROFILE_FETCH_TIMEOUT_MS,
138
+ inactivityTimeoutMs: DSH_PROFILE_FETCH_TIMEOUT_MS,
139
+ fatalUtf8: true,
140
+ });
141
+ if (bounded.oversized || bounded.truncated || !bounded.displaySafe) {
142
+ return { valid: false, error: "PROFILE_VALIDATION_FAILED" };
143
+ }
144
+
145
+ const body = JSON.parse(bounded.text) as {
103
146
  code?: number;
104
147
  data?: {
105
148
  biz_code?: number;
@@ -1672,7 +1672,7 @@ export async function runLogin(
1672
1672
  : existing.accountId && cred.accountId
1673
1673
  ? existing.accountId === cred.accountId
1674
1674
  : existing.email && cred.email
1675
- ? existing.email.toLowerCase() === cred.email.toLowerCase()
1675
+ ? existing.email.trim().toLowerCase() === cred.email.trim().toLowerCase()
1676
1676
  : false;
1677
1677
  if (!identityMatches) {
1678
1678
  throw new OAuthReauthIdentityMismatchError();
package/src/oauth/kimi.ts CHANGED
@@ -4,6 +4,7 @@ import * as os from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { recordOwnedConfigPath } from "../lib/config-ownership";
7
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
7
8
  import { getConfigDir } from "../config";
8
9
  import type { OAuthController, OAuthCredentials } from "./types";
9
10
 
@@ -107,6 +108,7 @@ function getDeviceId(): string {
107
108
  if ((e as { code?: string })?.code !== "ENOENT") throw e;
108
109
  }
109
110
  const id = randomUUID().replace(/-/g, "");
111
+ assertNotRealHomeUnderTest(getConfigDir());
110
112
  recordOwnedConfigPath(getConfigDir(), p);
111
113
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
112
114
  writeFileSync(p, id + "\n", { mode: 0o600 });
package/src/oauth/kiro.ts CHANGED
@@ -205,11 +205,54 @@ function profileArnFromWhoami(parsed: Record<string, unknown>): string | undefin
205
205
  : undefined);
206
206
  }
207
207
 
208
+ /**
209
+ * `kiro-cli whoami --format json` prints its identity as one JSON object, but once an IAM Identity
210
+ * Center profile is selected kiro-cli 2.32 follows that object with a human-readable `Profile:`
211
+ * block on stdout. Parsing the whole stream then failed, which silently dropped the user email that
212
+ * tells organization users apart (#6641). Only the leading top-level object is identity evidence;
213
+ * it must start the output and end its line, so text on later lines is ignored while a second
214
+ * value on the same line makes the identity unreadable.
215
+ */
216
+ function parseWhoamiObject(stdout: string): Record<string, unknown> | undefined {
217
+ const start = stdout.search(/\S/);
218
+ if (start < 0 || stdout[start] !== "{") return undefined;
219
+ let depth = 0;
220
+ let inString = false;
221
+ let escaped = false;
222
+ for (let index = start; index < stdout.length; index++) {
223
+ const char = stdout[index];
224
+ if (inString) {
225
+ if (escaped) escaped = false;
226
+ else if (char === "\\") escaped = true;
227
+ else if (char === "\"") inString = false;
228
+ continue;
229
+ }
230
+ if (char === "\"") inString = true;
231
+ else if (char === "{" || char === "[") depth++;
232
+ else if (char === "}" || char === "]") {
233
+ depth--;
234
+ if (depth > 0) continue;
235
+ if (!/^[ \t]*(?:\r?\n|$)/.test(stdout.slice(index + 1))) return undefined;
236
+ const parsed: unknown = JSON.parse(stdout.slice(start, index + 1));
237
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
238
+ ? parsed as Record<string, unknown>
239
+ : undefined;
240
+ }
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ /**
246
+ * Same-session identity reported by `kiro-cli whoami --format json`: the lower-cased user email
247
+ * and a structurally valid profile ARN. Any failure (non-zero exit, unreadable output, a thrown
248
+ * runner) yields an empty identity, so a login falls back to the SQLite import instead of failing.
249
+ */
208
250
  async function readKiroCliIdentity(runner: KiroCliRunner, signal?: AbortSignal): Promise<{ email?: string; profileArn?: string }> {
209
251
  try {
210
252
  const result = await runner(["whoami", "--format", "json"], signal);
211
253
  if (result.exitCode !== 0) return {};
212
- const parsed = JSON.parse(result.stdout) as Record<string, unknown>;
254
+ const parsed = parseWhoamiObject(result.stdout);
255
+ if (!parsed) return {};
213
256
  const email = typeof parsed.email === "string" ? parsed.email.trim().toLowerCase() : "";
214
257
  const profileArn = profileArnFromWhoami(parsed);
215
258
  return {
@@ -297,14 +297,23 @@ export function clearLoginState(provider: string): void {
297
297
  /**
298
298
  * Cancel one login. `flowId` names it exactly; without one the provider's newest in-flight flow
299
299
  * is cancelled, which is every single-flow caller (GUI, CLI) unchanged. A flow id belonging to a
300
- * different provider is refused rather than cancelled.
300
+ * different provider is refused rather than cancelled. Only the public OAuth cancellation
301
+ * endpoint opts into failed-outcome dismissal; callers using this boolean as proof of an
302
+ * aborted controller (Codex flow ownership) retain their original contract.
301
303
  */
302
- export function cancelLoginFlow(provider: string, flowId?: string): boolean {
304
+ export function cancelLoginFlow(provider: string, flowId?: string, options?: { dismissSettledFailure?: boolean }): boolean {
303
305
  const flowKey = flowId ?? latestLoginFlowKey(provider);
304
306
  if (flowKey === undefined) return false;
305
307
  const active = loginAbort.get(flowKey);
306
308
  const existing = loginState.get(flowKey);
307
309
  if ((active?.provider ?? loginFlowProvider(flowKey)) !== provider) return false;
310
+ if (options?.dismissSettledFailure && !active && existing?.done && existing.error) {
311
+ // A failed add-account attempt does not invalidate a previously signed-in account. Let
312
+ // its owner dismiss the stale UI outcome without erasing credentials or another flow.
313
+ clearManualCodeSlot(flowKey);
314
+ dropLoginFlow(flowKey);
315
+ return true;
316
+ }
308
317
  if (!active && (!existing || existing.done)) return false;
309
318
  abandonLoginFlow(flowKey, { error: "Login cancelled", errorCode: "no_pending_login" });
310
319
  return true;
package/src/oauth/nous.ts CHANGED
@@ -35,10 +35,11 @@
35
35
  */
36
36
  import { createHash } from "node:crypto";
37
37
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
38
- import { join } from "node:path";
38
+ import { dirname, join } from "node:path";
39
39
  import type { OAuthController, OAuthCredentials } from "./types";
40
40
  import { getAuthStorePath } from "./store";
41
41
  import { atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config";
42
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
42
43
  import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body";
43
44
 
44
45
  export const NOUS_PORTAL_BASE_URL = "https://portal.nousresearch.com";
@@ -220,6 +221,8 @@ function readRefreshIntent(refreshToken: string): RefreshIntent | undefined {
220
221
  }
221
222
 
222
223
  function writeRefreshIntent(refreshToken: string, status: RefreshIntentStatus): void {
224
+ // Before the mkdir/chmod below, which would otherwise touch the real home first.
225
+ assertNotRealHomeUnderTest(dirname(getAuthStorePath()));
223
226
  const dir = refreshIntentDir();
224
227
  // Hardened, owner-only directory + atomic (temp+rename) write. Throws on
225
228
  // failure so the caller can fail closed instead of refreshing blind.
@@ -203,6 +203,7 @@ export function peekOAuthRefreshIntent(provider: string, accountId: string): OAu
203
203
  }
204
204
  export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now(), flightId?: string): OAuthRefreshIntent {
205
205
  const dir = getConfigDir();
206
+ assertNotRealHomeUnderTest(dir);
206
207
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
207
208
  hardenConfigDir();
208
209
  const intent: OAuthRefreshIntent = {
@@ -1152,7 +1153,7 @@ export async function upsertCredentialByIdentity(
1152
1153
  return Boolean(
1153
1154
  safe.email
1154
1155
  && account.credential.email
1155
- && account.credential.email.toLowerCase() === safe.email.toLowerCase(),
1156
+ && account.credential.email.trim().toLowerCase() === safe.email.trim().toLowerCase(),
1156
1157
  );
1157
1158
  };
1158
1159
  const existing = safe.accountId
@@ -6,12 +6,12 @@
6
6
  * (`src/chat/inbound.ts`), including its validation and the fields it drops, which
7
7
  * `src/protocols/features.ts` declares.
8
8
  */
9
- import { chatCompletionsToResponsesBody, chatSettingsToResponsesBody } from "../../chat/inbound";
9
+ import { chatCompletionsToResponsesBody, chatSettingsToResponsesBody, type ChatResponsesProjectionOptions } from "../../chat/inbound";
10
10
  import { featuresFromChatBody } from "../features";
11
11
 
12
12
  /** Project a Chat Completions body onto the internal Responses bridge body. */
13
- export function chatToResponsesBody(body: unknown): Record<string, unknown> {
14
- return chatCompletionsToResponsesBody(body);
13
+ export function chatToResponsesBody(body: unknown, options?: ChatResponsesProjectionOptions): Record<string, unknown> {
14
+ return chatCompletionsToResponsesBody(body, options);
15
15
  }
16
16
 
17
17
  /**
@@ -109,7 +109,13 @@ export interface ClientWireWriter {
109
109
  text(delta: string): void;
110
110
  messageDone(): void;
111
111
  reasoning(delta: string, itemId: string, channel: "summary" | "raw"): void;
112
- reasoningDone(item: { itemId: string; signature?: string; redacted?: string[] }): void;
112
+ /** `hidden`: the signature covers thinking text the client was not shown (the bridge's `txt`). */
113
+ reasoningDone(item: { itemId: string; signature?: string; redacted?: string[]; hidden?: true }): void;
114
+ /**
115
+ * An Anthropic progress update: `text` is shown as content and is also the signed thinking
116
+ * block's text. Optional — a writer without it renders the update as ordinary text.
117
+ */
118
+ progressThinking?(text: string, signature: string): void;
113
119
  toolStart(call: ClientToolCall): void;
114
120
  toolArgsDelta(itemId: string, delta: string): void;
115
121
  toolArgsDone(itemId: string, args: string): void;
@@ -331,6 +337,8 @@ export function encodeAdapterEventStream(
331
337
  let currentRawReasoning: { itemId: string } | null = null;
332
338
  let pendingSignature: string | undefined;
333
339
  let pendingRedacted: string[] = [];
340
+ // The bridge's hiddenThinking: non-empty thinking text suppressed by hideThinkingSummary.
341
+ let hiddenThinkingSeen = false;
334
342
  let hiddenRawBytes = 0;
335
343
  let pendingKiroRedacted: string | undefined;
336
344
  let currentToolCall: OpenToolCall | null = null;
@@ -367,9 +375,11 @@ export function encodeAdapterEventStream(
367
375
  };
368
376
  // hideThinkingSummary: a signed or redacted block still round-trips as an envelope-only item.
369
377
  const flushHiddenReasoningEnvelope = () => {
378
+ const hidden = hiddenThinkingSeen;
379
+ hiddenThinkingSeen = false;
370
380
  const envelope = takeReasoningEnvelope();
371
381
  if (!envelope) return;
372
- writer.reasoningDone({ itemId: `rs_${uuid()}`, ...envelope });
382
+ writer.reasoningDone({ itemId: `rs_${uuid()}`, ...envelope, ...(hidden ? { hidden: true as const } : {}) });
373
383
  };
374
384
  const flushHiddenRawReasoning = () => {
375
385
  if (hiddenRawBytes === 0) return;
@@ -570,6 +580,19 @@ export function encodeAdapterEventStream(
570
580
  break;
571
581
  }
572
582
  case "text_delta": {
583
+ if (event.anthropicThinkingSignature && writer.progressThinking) {
584
+ // The bridge's progress items: close everything, the carrier, then the visible copy.
585
+ if (currentMsg) closeCurrentMessage();
586
+ if (currentReasoning) closeCurrentReasoning();
587
+ if (currentRawReasoning) closeCurrentRawReasoning();
588
+ flushHiddenRawReasoning();
589
+ flushHiddenReasoningEnvelope();
590
+ if (currentToolCall) closeCurrentToolCall();
591
+ writer.progressThinking(event.text, event.anthropicThinkingSignature);
592
+ writer.text(event.text);
593
+ writer.messageDone();
594
+ break;
595
+ }
573
596
  if (currentReasoning) closeCurrentReasoning();
574
597
  if (currentRawReasoning) closeCurrentRawReasoning();
575
598
  flushHiddenRawReasoning();
@@ -585,6 +608,7 @@ export function encodeAdapterEventStream(
585
608
  }
586
609
  case "thinking_delta": {
587
610
  if (options.hideThinkingSummary) {
611
+ if (event.thinking.length > 0) hiddenThinkingSeen = true;
588
612
  flushHiddenRawReasoning();
589
613
  break;
590
614
  }
@@ -13,6 +13,7 @@ import type { AdapterEvent } from "../../types";
13
13
  import type { TranslatorBudget } from "../../lib/translator-budget";
14
14
  import { responsesUsage } from "../../bridge/internal";
15
15
  import {
16
+ appendedUtf8Bytes,
16
17
  chatCompletionsErrorResponse,
17
18
  chatCompletionsFailedResponse,
18
19
  chatCompletionsIncompleteOutcome,
@@ -32,6 +33,7 @@ import {
32
33
  type ClientWireWriter,
33
34
  } from "./adapter-events";
34
35
  import type { RelayedEventObservation } from "../../usage/attempt-delivery";
36
+ import { reasoningDetailEntries } from "../../chat/reasoning-details";
35
37
 
36
38
  type Rec = Record<string, unknown>;
37
39
 
@@ -54,6 +56,41 @@ function createChatCompletionWriter(sink: ClientFrameSink, model: string): Clien
54
56
  const emittedToolIndexes = new Set<number>();
55
57
  let nextToolIndex = 0;
56
58
  let staged: { text: string; observation?: RelayedEventObservation }[] | undefined;
59
+ // #7429: the shown thinking text of the open reasoning item, which its signature pairs with.
60
+ const budget = sink.budget;
61
+ let reasoningItemId: string | undefined;
62
+ let reasoningText = "";
63
+ let reasoningTextBytes = 0;
64
+ let nextReasoningDetailIndex = 0;
65
+ const releaseReasoningText = () => {
66
+ budget.releaseRetained(reasoningTextBytes, { kind: "reasoning" });
67
+ reasoningItemId = undefined;
68
+ reasoningText = "";
69
+ reasoningTextBytes = 0;
70
+ };
71
+ const retainReasoningText = (itemId: string, delta: string) => {
72
+ if (reasoningItemId !== itemId) releaseReasoningText();
73
+ reasoningItemId = itemId;
74
+ const nextBytes = appendedUtf8Bytes(reasoningText, reasoningTextBytes, delta);
75
+ const reservation = budget.reserveTransient(nextBytes, { kind: "reasoning" });
76
+ try {
77
+ reasoningText += delta;
78
+ reservation.commitRetained();
79
+ budget.releaseRetained(reasoningTextBytes, { kind: "reasoning" });
80
+ reasoningTextBytes = nextBytes;
81
+ } catch (error) {
82
+ reservation.release();
83
+ throw error;
84
+ }
85
+ };
86
+ const emitReasoningDetails = (entries: Rec[]) => {
87
+ if (entries.length === 0) return;
88
+ nextReasoningDetailIndex += entries.length;
89
+ ensureRole();
90
+ const frame = chunkBase(id, model, created);
91
+ frame.choices = [{ index: 0, delta: { reasoning_details: entries }, finish_reason: null }];
92
+ emit(frame);
93
+ };
57
94
 
58
95
  const emit = (payload: Rec | "[DONE]", observation?: RelayedEventObservation) => {
59
96
  if (failed) return;
@@ -138,15 +175,30 @@ function createChatCompletionWriter(sink: ClientFrameSink, model: string): Clien
138
175
  emit(frame, { semanticBytes: Buffer.byteLength(delta) });
139
176
  },
140
177
  messageDone() { /* the converter reads nothing from a finished message */ },
141
- reasoning(delta) {
178
+ reasoning(delta, itemId, channel) {
142
179
  if (!delta) return;
143
180
  ensureRole();
144
181
  // Many OpenAI-compatible clients accept reasoning_content; harmless if ignored.
145
182
  const frame = chunkBase(id, model, created);
146
183
  frame.choices = [{ index: 0, delta: { reasoning_content: delta }, finish_reason: null }];
147
184
  emit(frame, { semanticBytes: Buffer.byteLength(delta) });
185
+ // Only summary-channel thinking can carry a signature; raw reasoning never does.
186
+ if (channel === "summary") retainReasoningText(itemId, delta);
187
+ },
188
+ reasoningDone({ itemId, signature, redacted, hidden }) {
189
+ // The bridge converter's output_item.done for a reasoning item (#7429), entry for entry.
190
+ const entries = reasoningDetailEntries({
191
+ text: reasoningItemId === itemId ? reasoningText : "",
192
+ ...(signature ? { signature } : {}),
193
+ ...(redacted ? { redacted } : {}),
194
+ ...(hidden ? { hidden } : {}),
195
+ }, nextReasoningDetailIndex);
196
+ releaseReasoningText();
197
+ emitReasoningDetails(entries);
198
+ },
199
+ progressThinking(text, signature) {
200
+ emitReasoningDetails(reasoningDetailEntries({ text, signature }, nextReasoningDetailIndex));
148
201
  },
149
- reasoningDone() { /* reasoning items carry nothing a Chat client reads */ },
150
202
  toolStart(call) {
151
203
  // Custom and tool-search calls have no Chat representation; the converter skips them.
152
204
  if (call.kind !== "function") return;
@@ -198,7 +250,7 @@ function createChatCompletionWriter(sink: ClientFrameSink, model: string): Clien
198
250
  type: "upstream_error",
199
251
  });
200
252
  },
201
- dispose() { /* nothing retained beyond the driver's frames */ },
253
+ dispose() { releaseReasoningText(); },
202
254
  };
203
255
  }
204
256
 
@@ -4,7 +4,8 @@ import { bindRawDecisionWindows } from "./quota-decision-publication";
4
4
  import { decisionQuotaSinkActive } from "../lib/decision-quota-sink";
5
5
  import { CLAUDE_CLI_USER_AGENT } from "./claude-cli-identity";
6
6
  import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS } from "./quota-wire";
7
- import type { ProviderQuota, ProviderQuotaWindow } from "./quota-types";
7
+ import type { ProviderQuota, ProviderQuotaWindow, QuotaFailureCode } from "./quota-types";
8
+ import { httpQuotaFailure } from "./quota/failure-codes";
8
9
  const hasQuotaRows = (q: ProviderQuota) => q.fiveHourPercent !== undefined || q.weeklyPercent !== undefined || Boolean(q.customWindows?.length);
9
10
 
10
11
  function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number } | null {
@@ -46,6 +47,16 @@ function parseClaudeLimit(value: unknown): ProviderQuotaWindow | null {
46
47
 
47
48
  /** Read the Anthropic OAuth usage endpoint for an access token and return the parsed quota, or null on a non-OK or malformed response. */
48
49
  export async function readAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
50
+ return (await probeAnthropicUsageQuota(accessToken)).quota;
51
+ }
52
+
53
+ /** A usage read plus, when it yields no quota, the closed reason why. A thrown fetch still throws. */
54
+ export interface AnthropicUsageProbe {
55
+ quota: ProviderQuota | null;
56
+ failure?: QuotaFailureCode;
57
+ }
58
+
59
+ export async function probeAnthropicUsageQuota(accessToken: string): Promise<AnthropicUsageProbe> {
49
60
  const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
50
61
  headers: {
51
62
  Accept: "application/json, text/plain, */*",
@@ -56,9 +67,12 @@ export async function readAnthropicUsageQuota(accessToken: string): Promise<Prov
56
67
  },
57
68
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
58
69
  });
59
- if (!response.ok) return null;
70
+ if (!response.ok) {
71
+ try { await response.body?.cancel(); } catch { /* Best-effort release; the status is the diagnosis. */ }
72
+ return { quota: null, failure: httpQuotaFailure(response.status) };
73
+ }
60
74
  const body = asRecord(await readQuotaJson(response));
61
- if (!body) return null;
75
+ if (!body) return { quota: null, failure: "response_unusable" };
62
76
  const fiveHour = parseClaudeBucket(body.five_hour);
63
77
  const sevenDay = parseClaudeBucket(body.seven_day);
64
78
  const fable = parseClaudeBucket(body.seven_day_fable);
@@ -93,9 +107,10 @@ export async function readAnthropicUsageQuota(accessToken: string): Promise<Prov
93
107
  };
94
108
  if (decisionQuotaSinkActive()) bindRawDecisionWindows(quota, rawAnthropicUsageWindows(body, quota.updatedAt));
95
109
  // Empty / schema-changed payloads must not cache as "success with no bars".
96
- return hasQuotaRows(quota) ? markAnthropicFamilyEnumeration(quota, Array.isArray(body.limits) && body.limits.every(raw => {
110
+ if (!hasQuotaRows(quota)) return { quota: null, failure: "response_unusable" };
111
+ return { quota: markAnthropicFamilyEnumeration(quota, Array.isArray(body.limits) && body.limits.every(raw => {
97
112
  const kind = asRecord(raw)?.kind;
98
113
  return kind === "session" || kind === "weekly_all" || kind === "weekly_scoped" && parseClaudeLimit(raw) !== null;
99
- })) : null;
114
+ })) };
100
115
  }
101
116
 
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry";
4
4
  import { routedSlug, slugEquivalenceKey } from "./slug-codec";
5
5
  import { comboDisabledModelSelectors } from "../combos/types";
6
- import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store";
6
+ import { providerUsesKeyAuthOverride } from "./key-store";
7
7
 
8
8
  export const INITIAL_MODEL_SELECTION_THRESHOLD = 20;
9
9
  type Selection = NonNullable<OcxProviderConfig["initialModelSelection"]>;
@@ -31,7 +31,7 @@ function loginConnection(name: string, provider: OcxProviderConfig): boolean {
31
31
  if (entry && providerMatchesRegistryTransport(name, provider)) {
32
32
  if (entry.authKind === "forward") return true;
33
33
  if (entry.authKind === "oauth") {
34
- return !providerUsesKeyAuthOverride(entry, provider, resolveProviderApiKey(provider.apiKey));
34
+ return !providerUsesKeyAuthOverride(entry, provider);
35
35
  }
36
36
  }
37
37
  return provider.authMode === "oauth" || provider.authMode === "forward";