@bitkyc08/opencodex 2.40.0 → 2.42.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 +4 -0
  2. package/gui/dist/assets/index-BU1tE0sr.js +112 -0
  3. package/gui/dist/assets/index-DL9-iS6J.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/meta.svg +1 -0
  6. package/package.json +4 -3
  7. package/src/adapters/cursor/catalog.ts +71 -29
  8. package/src/adapters/cursor/claude-id.ts +76 -0
  9. package/src/adapters/cursor/discovery.ts +16 -3
  10. package/src/adapters/cursor/effort-map.ts +27 -12
  11. package/src/adapters/cursor/protobuf-request.ts +41 -21
  12. package/src/adapters/google.ts +39 -2
  13. package/src/adapters/identity.ts +8 -2
  14. package/src/adapters/openai-responses.ts +57 -4
  15. package/src/bridge.ts +25 -3
  16. package/src/cli/account-auth.ts +28 -3
  17. package/src/cli/account-extended.ts +7 -1
  18. package/src/cli/capabilities.ts +2 -2
  19. package/src/cli/claude.ts +11 -2
  20. package/src/cli/connect.ts +7 -1
  21. package/src/cli/observe.ts +3 -1
  22. package/src/cli/registry.ts +1 -1
  23. package/src/cli/status.ts +19 -4
  24. package/src/client/connect.ts +5 -1
  25. package/src/client/hub-client.ts +29 -5
  26. package/src/clients/config-export.ts +12 -2
  27. package/src/codex/auth-api.ts +102 -9
  28. package/src/codex/catalog/aggregation.ts +8 -0
  29. package/src/codex/catalog/effort.ts +15 -2
  30. package/src/codex/catalog/metadata.ts +119 -9
  31. package/src/codex/catalog/native-models.ts +71 -0
  32. package/src/codex/catalog/parsing.ts +5 -3
  33. package/src/codex/catalog/provider-fetch.ts +166 -28
  34. package/src/codex/catalog.ts +1 -1
  35. package/src/codex/convergence-types.ts +1 -0
  36. package/src/codex/data/upstream-models.json +169 -0
  37. package/src/codex/desired-state.ts +18 -11
  38. package/src/codex/inject.ts +96 -6
  39. package/src/codex/injected-marker.ts +30 -4
  40. package/src/codex/journal.ts +14 -0
  41. package/src/combos/failover.ts +185 -6
  42. package/src/combos/index.ts +6 -0
  43. package/src/combos/resolve.ts +43 -6
  44. package/src/config.ts +5 -1
  45. package/src/generated/compatibility-version.json +115 -83
  46. package/src/generated/model-metadata.ts +1 -1
  47. package/src/grok/sync.ts +10 -2
  48. package/src/integrations/cursor-effort-table.ts +143 -0
  49. package/src/integrations/state.ts +1 -1
  50. package/src/integrations/writer.ts +2 -2
  51. package/src/lib/app-owned-memory-stores.ts +27 -8
  52. package/src/lib/bounded-body.ts +16 -1
  53. package/src/oauth/account-quota-rank.ts +40 -1
  54. package/src/oauth/chatgpt-device.ts +187 -0
  55. package/src/oauth/chatgpt.ts +31 -4
  56. package/src/oauth/generic-account-failover.ts +2 -2
  57. package/src/oauth/index.ts +24 -3
  58. package/src/oauth/log.ts +3 -0
  59. package/src/oauth/meta-muse.ts +235 -0
  60. package/src/providers/antigravity-models.ts +71 -13
  61. package/src/providers/command-code-efforts.ts +15 -0
  62. package/src/providers/free-directory.ts +4 -1
  63. package/src/providers/muse-subscription-usage.ts +95 -0
  64. package/src/providers/quota.ts +96 -0
  65. package/src/providers/registry.ts +116 -8
  66. package/src/responses/code-mode-helper-compat.ts +4 -1
  67. package/src/responses/state.ts +5 -4
  68. package/src/server/auth-cors.ts +241 -56
  69. package/src/server/chat-completions.ts +11 -2
  70. package/src/server/chat-native.ts +30 -4
  71. package/src/server/claude-messages.ts +17 -3
  72. package/src/server/effort-row.ts +131 -0
  73. package/src/server/index.ts +82 -45
  74. package/src/server/live.ts +18 -4
  75. package/src/server/management/api-key-rotation.ts +2 -1
  76. package/src/server/management/api-key-usage.ts +97 -43
  77. package/src/server/management/context.ts +3 -0
  78. package/src/server/management/cursor-integration-routes.ts +36 -7
  79. package/src/server/management/logs-usage-routes.ts +64 -87
  80. package/src/server/management/oauth-account-routes.ts +10 -3
  81. package/src/server/management/provider-routes.ts +218 -1
  82. package/src/server/management/route-registry.ts +1 -0
  83. package/src/server/management/usage-aggregate-cache.ts +464 -0
  84. package/src/server/management/usage-summary-cache.ts +4 -0
  85. package/src/server/models-capabilities.ts +60 -5
  86. package/src/server/responses/core.ts +95 -7
  87. package/src/server/responses/empty-completion-guard.ts +4 -0
  88. package/src/types/config.ts +10 -1
  89. package/src/types/request.ts +8 -0
  90. package/src/types/tools.ts +12 -9
  91. package/src/usage/expected-prices.ts +43 -7
  92. package/src/usage/ledger-scanner.ts +448 -0
  93. package/src/usage/log.ts +1 -1
  94. package/src/usage/summary.ts +915 -655
  95. package/src/web-search/index.ts +1 -1
  96. package/gui/dist/assets/index-BHe2rl_C.js +0 -112
  97. package/gui/dist/assets/index-CJSb3HPe.css +0 -1
@@ -42,7 +42,7 @@ const DATA: Record<string, readonly Row[]> = {
42
42
  "azure-openai": [["gpt-4.1",1047576,32768,"text,image",0,null,2,8,0.5,0],["gpt-4o",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-mini",128000,16384,"text,image",0,null,0.15,0.6,0.075,0],["o3",200000,100000,"text,image",1,null,2,8,0.5,0],["o3-mini",200000,100000,"text",1,null,1.1,4.4,0.55,0]],
43
43
  "cerebras": [["gemma-4-31b",131072,40960,"text,image",1,null,0.99,1.49,0,0],["gpt-oss-120b",131072,40960,"text",1,null,0.35,0.75,0,0],["llama3.1-8b",32000,8000,"text",0,null,0.1,0.1,0,0],["qwen-3-235b-a22b-instruct-2507",131000,32000,"text",0,null,0.6,1.2,0,0],["qwen-3-coder-480b",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.6",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.7",131072,40960,"text",1,null,2.25,2.75,2.25,0]],
44
44
  "deepseek": [["deepseek-v4-flash",1048576,384000,"text",1,null,0.14,0.28,0.0028,0],["deepseek-v4-pro",1048576,384000,"text",1,null,0.435,0.87,0.003625,0]],
45
- "google": [["deep-research-max-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["deep-research-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["gemini-1.5-flash",1000000,8192,"text,image",0,null,0.075,0.3,0.01875,0],["gemini-1.5-flash-8b",1000000,8192,"text,image",0,null,0.0375,0.15,0.01,0],["gemini-1.5-pro",1000000,8192,"text,image",0,null,1.25,5,0.3125,0],["gemini-2.0-flash",1048576,8192,"text,image",0,null,0.1,0.4,0.025,0],["gemini-2.0-flash-lite",1048576,8192,"text,image",0,null,0.075,0.3,0,0],["gemini-2.5-computer-use-preview-10-2025",131072,65536,"text,image",1,null,1.25,10,0,0],["gemini-2.5-flash",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-2.5-flash-lite",1048576,65536,"text,image",1,null,0.1,0.4,0.01,0],["gemini-2.5-flash-lite-preview-06-17",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-lite-preview-09-2025",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-preview-04-17",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-05-20",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-09-2025",1048576,65536,"text,image",1,null,0.3,2.5,0.075,0],["gemini-2.5-pro",1048576,65536,"text,image",1,null,1.25,10,0.125,0],["gemini-2.5-pro-preview-05-06",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-2.5-pro-preview-06-05",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-3-flash-preview",1048576,65536,"text,image",1,null,0.5,3,0.05,0],["gemini-3-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-flash-lite",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-lite-image",65536,65536,"text,image",1,null,0.25,30,0,0],["gemini-3.1-flash-lite-preview",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-live-preview",131072,65536,"text,image",1,null,0.75,4.5,0,0],["gemini-3.1-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-pro-preview-customtools",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.5-flash",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-3.5-flash-lite",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-3.6-flash",1048576,65536,"text,image",1,null,1.5,7.5,0.15,0],["gemini-3.7-flash",1048576,65536,"text,image",1],["gemini-flash-latest",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-flash-lite-latest",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-live-2.5-flash",128000,8000,"text,image",1,null,0.5,2,0,0],["gemini-live-2.5-flash-preview-native-audio",131072,65536,"text",1,null,0.5,2,0,0],["gemini-robotics-er-1.6-preview",131072,65536,"text,image",1,null,1,5,0,0],["gemma-3-27b-it",131072,8192,"text,image",0,null,0,0,0,0],["gemma-4-26b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-26b-a4b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-26b-it",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-E2B-it",131072,8192,"text,image",1,null,0,0,0,0],["gemma-4-E4B-it",131072,8192,"text,image",1,null,0,0,0,0]],
45
+ "google": [["deep-research-max-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["deep-research-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["gemini-1.5-flash",1000000,8192,"text,image",0,null,0.075,0.3,0.01875,0],["gemini-1.5-flash-8b",1000000,8192,"text,image",0,null,0.0375,0.15,0.01,0],["gemini-1.5-pro",1000000,8192,"text,image",0,null,1.25,5,0.3125,0],["gemini-2.0-flash",1048576,8192,"text,image",0,null,0.1,0.4,0.025,0],["gemini-2.0-flash-lite",1048576,8192,"text,image",0,null,0.075,0.3,0,0],["gemini-2.5-computer-use-preview-10-2025",131072,65536,"text,image",1,null,1.25,10,0,0],["gemini-2.5-flash",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-2.5-flash-lite",1048576,65536,"text,image",1,null,0.1,0.4,0.01,0],["gemini-2.5-flash-lite-preview-06-17",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-lite-preview-09-2025",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-preview-04-17",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-05-20",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-09-2025",1048576,65536,"text,image",1,null,0.3,2.5,0.075,0],["gemini-2.5-pro",1048576,65536,"text,image",1,null,1.25,10,0.125,0],["gemini-2.5-pro-preview-05-06",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-2.5-pro-preview-06-05",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-3-flash-preview",1048576,65536,"text,image",1,null,0.5,3,0.05,0],["gemini-3-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-flash-lite",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-lite-image",65536,65536,"text,image",1,null,0.25,30,0,0],["gemini-3.1-flash-lite-preview",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-live-preview",131072,65536,"text,image",1,null,0.75,4.5,0,0],["gemini-3.1-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-pro-preview-customtools",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.5-flash",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-3.5-flash-lite",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-3.6-flash",1048576,65536,"text,image",1,null,1.5,7.5,0.15,0],["gemini-3.7-flash",1048576,65536,"text,image",1],["gemini-3.8-flash",1048576,65536,"text,image",1],["gemini-flash-latest",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-flash-lite-latest",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-live-2.5-flash",128000,8000,"text,image",1,null,0.5,2,0,0],["gemini-live-2.5-flash-preview-native-audio",131072,65536,"text",1,null,0.5,2,0,0],["gemini-robotics-er-1.6-preview",131072,65536,"text,image",1,null,1,5,0,0],["gemma-3-27b-it",131072,8192,"text,image",0,null,0,0,0,0],["gemma-4-26b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-26b-a4b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-26b-it",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-E2B-it",131072,8192,"text,image",1,null,0,0,0,0],["gemma-4-E4B-it",131072,8192,"text,image",1,null,0,0,0,0]],
46
46
  "minimax": [["MiniMax-M2",196608,128000,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.1",204800,131072,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.5",204800,131072,"text",1,null,0.3,1.2,0.03,0.375],["MiniMax-M2.5-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["MiniMax-M2.5-lightning",204800,32000,"text",1,null,0.3,2.4,0,0],["MiniMax-M2.7",204800,131072,"text",1,null,0.3,1.2,0.06,0.375],["MiniMax-M2.7-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["minimax-m3",512000,128000,"text,image",1,null,0.6,2.4,0.12,0],["MiniMax-M3",1000000,128000,"text,image,video",1,null,0.3,1.2,0.06,0]],
47
47
  "mistral": [["codestral-latest",256000,4096,"text",0,null,0.3,0.9,0,0],["devstral-2512",262144,262144,"text",0,null,0.4,2,0,0],["devstral-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-medium-2507",128000,128000,"text",0,null,0.4,2,0,0],["devstral-medium-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-small-2505",128000,128000,"text",0,null,0.1,0.3,0,0],["devstral-small-2507",128000,128000,"text",0,null,0.1,0.3,0,0],["labs-devstral-small-2512",256000,256000,"text,image",0,null,0,0,0,0],["magistral-medium-latest",128000,16384,"text",1,null,2,5,0,0],["magistral-small",128000,128000,"text",1,null,0.5,1.5,0,0],["ministral-3b-latest",128000,128000,"text",0,null,0.04,0.04,0,0],["ministral-8b-latest",128000,128000,"text",0,null,0.1,0.1,0,0],["mistral-large-2411",131072,16384,"text",0,null,2,6,0,0],["mistral-large-2512",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-large-latest",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-medium-2505",131072,131072,"text,image",0,null,0.4,2,0,0],["mistral-medium-2508",262144,262144,"text,image",0,null,0.4,2,0,0],["mistral-medium-2604",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-medium-latest",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["mistral-small-2506",128000,16384,"text,image",0,null,0.1,0.3,0,0],["mistral-small-2603",256000,256000,"text,image",1,null,0.15,0.6,0,0],["mistral-small-latest",256000,256000,"text,image",1,null,0.15,0.6,0,0],["open-mistral-7b",8000,8000,"text",0,null,0.25,0.25,0,0],["open-mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["open-mixtral-8x22b",64000,64000,"text",0,null,2,6,0,0],["open-mixtral-8x7b",32000,32000,"text",0,null,0.7,0.7,0,0],["pixtral-12b",128000,128000,"text,image",0,null,0.15,0.15,0,0],["pixtral-large-latest",128000,128000,"text,image",0,null,2,6,0,0]],
48
48
  "moonshot": [["kimi-k2.5",262144,65536,"text,image",1,null,0,0,0,0]],
package/src/grok/sync.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * Deps are injectable (mirrors src/codex/sync.ts) so tests can run without a live proxy.
8
8
  */
9
9
  import type { CatalogModel } from "../codex/catalog";
10
+ import { standaloneCodexRoutingTarget } from "../codex/inject";
10
11
  import type { OcxConfig } from "../types";
11
12
  import { projectGrokCatalog } from "./catalog";
12
13
  import { injectGrokConfig, type GrokInjectResult } from "./inject";
@@ -47,8 +48,15 @@ export async function syncGrokConfig(
47
48
  // Pass the FULL list plus the exclusion set: the writer allocates aliases over
48
49
  // everything and emits only what is switched on, so a model's alias never depends on
49
50
  // its neighbours' switches. Absent/empty selection keeps today's behaviour exactly.
50
- return deps.injectGrokConfig(port, projection.models, {
51
- ...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}),
51
+ const target = standaloneCodexRoutingTarget(port, {
52
+ hostname: opts.hostname ?? config.hostname,
53
+ unauthenticatedLoopbackListener: config.unauthenticatedLoopbackListener,
54
+ });
55
+ const targetUrl = new URL(target.baseUrl);
56
+ return deps.injectGrokConfig(Number(targetUrl.port), projection.models, {
57
+ hostname: target.requiresAdmissionToken
58
+ ? (opts.hostname ?? config.hostname)
59
+ : targetUrl.hostname,
52
60
  ...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}),
53
61
  excluded: new Set(config.grokExcludedModels ?? []),
54
62
  catalogModelIds: projection.catalogModelIds,
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Cursor's local-agent effort table, read from the installed bundle.
3
+ *
4
+ * Cursor Private Inference decides which model rows get a Reasoning control from a table
5
+ * compiled into extensions/cursor-agent-exec/dist/main.js, not from the gateway's
6
+ * reasoning_effort list (devlog 260902_cursor_bundle_effort_table/000). Reading that table
7
+ * from the install the dashboard already detects lets the prediction follow a Cursor update
8
+ * instead of a hand-copied mirror. Read-only, size-bounded, cached by (path, mtime, size);
9
+ * any parse failure yields null so the caller falls back to the static mirror.
10
+ */
11
+ import { readFileSync, statSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import type { CursorInstall } from "./cursor-detect";
14
+
15
+ export interface CursorEffortFamily {
16
+ id: string;
17
+ pattern: RegExp;
18
+ /** [] = family matched but Cursor shows no control. */
19
+ ladder: readonly string[];
20
+ param?: "reasoning_effort" | "output_config.effort";
21
+ defaultValue?: string;
22
+ outputCap?: number;
23
+ requiresReasoningCapability: boolean;
24
+ }
25
+
26
+ export interface CursorBareGpt5Rule {
27
+ pattern: RegExp;
28
+ ladder: readonly string[];
29
+ defaultValue: string;
30
+ }
31
+
32
+ export interface CursorEffortTable {
33
+ families: readonly CursorEffortFamily[];
34
+ /** The bare gpt-5 / gpt-5.x rule that runs when no family matched. */
35
+ bareGpt5: CursorBareGpt5Rule | null;
36
+ version: string | null;
37
+ bundlePath: string;
38
+ }
39
+
40
+ const BUNDLE_MAX_BYTES = 32 * 1024 * 1024;
41
+
42
+ /** Bundle path under the install root cursor-detect reports. */
43
+ export function cursorAgentBundlePath(install: Pick<CursorInstall, "path">, platform: string = process.platform): string {
44
+ const tail = ["extensions", "cursor-agent-exec", "dist", "main.js"];
45
+ return platform === "darwin"
46
+ ? join(install.path, "Contents", "Resources", "app", ...tail)
47
+ : join(install.path, "resources", "app", ...tail);
48
+ }
49
+
50
+ /**
51
+ * Parse the family table out of the minified source:
52
+ * const w={param:"reasoning_effort",values:[...],defaultValue:"medium"};
53
+ * ...const T={...},k={...},S={...},b=[{id:"...",matches:e=>/.../u.test(e),effort:k,outputCap:128e3},...];
54
+ * Identifier names are minifier-assigned, so binding is by structure: every
55
+ * <ident>={param:"...",values:[...],defaultValue:"..."} is an effort constant, and a family's
56
+ * effort: is either such an identifier or an inline object.
57
+ */
58
+ export function parseCursorEffortTable(source: string): Omit<CursorEffortTable, "version" | "bundlePath"> | null {
59
+ const constants = new Map<string, { param: string; values: string[]; defaultValue: string }>();
60
+ const constRe = /(?:const |,)([A-Za-z_$][\w$]*)=\{param:"(reasoning_effort|output_config\.effort)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/gu;
61
+ for (const m of source.matchAll(constRe)) {
62
+ constants.set(m[1]!, { param: m[2]!, values: splitStrings(m[3]!), defaultValue: m[4]! });
63
+ }
64
+ const tableStart = source.indexOf('=[{id:"anthropic-');
65
+ if (tableStart === -1) return null;
66
+ const tableEnd = source.indexOf("];", tableStart);
67
+ if (tableEnd === -1) return null;
68
+ const body = source.slice(tableStart + 2, tableEnd + 1);
69
+ const entryRe = /\{id:"([^"]+)",matches:e=>\/((?:\\\/|[^/])+)\/([a-z]*)\.test\(e\)((?:,(?:effort:(?:[A-Za-z_$][\w$]*|\{[^}]*\})|outputCap:[\de.]+|effortRequiresReasoningCapability:!0))*)\}/gu;
70
+ const families: CursorEffortFamily[] = [];
71
+ // Every "{id:" opener in the window must be consumed by entryRe. A build that adds a
72
+ // property to one family would otherwise drop that family silently and the caller would
73
+ // report a bundle-sourced "no control" for it instead of falling back to the mirror.
74
+ const openers = body.split('{id:"').length - 1;
75
+ for (const m of body.matchAll(entryRe)) {
76
+ let pattern: RegExp;
77
+ try { pattern = new RegExp(m[2]!, m[3]!); } catch { return null; }
78
+ const tail = m[4]!;
79
+ const effortRef = /effort:([A-Za-z_$][\w$]*)(?:,|$)/u.exec(tail)?.[1];
80
+ const inline = /effort:\{param:"([^"]+)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/u.exec(tail);
81
+ const effort = inline
82
+ ? { param: inline[1]!, values: splitStrings(inline[2]!), defaultValue: inline[3]! }
83
+ : effortRef ? constants.get(effortRef) : undefined;
84
+ if (effortRef && !inline && !effort) return null; // unknown constant: structure changed
85
+ const cap = /outputCap:([\de.]+)/u.exec(tail)?.[1];
86
+ families.push({
87
+ id: m[1]!,
88
+ pattern,
89
+ ladder: effort?.values ?? [],
90
+ ...(effort ? { param: effort.param as CursorEffortFamily["param"], defaultValue: effort.defaultValue } : {}),
91
+ ...(cap ? { outputCap: Number(cap) } : {}),
92
+ requiresReasoningCapability: tail.includes("effortRequiresReasoningCapability:!0"),
93
+ });
94
+ }
95
+ if (families.length === 0 || families.length !== openers) return null;
96
+ // The tested variable and the returned constant are minifier-assigned names; bind by shape.
97
+ const bareRe = /if\(\/(\^gpt-5[^/]+)\/([a-z]*)\.test\([A-Za-z_$][\w$]*\)\)return ([A-Za-z_$][\w$]*)\}/u.exec(source);
98
+ const bareConst = bareRe ? constants.get(bareRe[3]!) : undefined;
99
+ let bareGpt5: CursorBareGpt5Rule | null = null;
100
+ if (bareRe && bareConst) {
101
+ let pattern: RegExp;
102
+ try { pattern = new RegExp(bareRe[1]!, bareRe[2]!); } catch { return null; }
103
+ bareGpt5 = { pattern, ladder: bareConst.values, defaultValue: bareConst.defaultValue };
104
+ }
105
+ return { families, bareGpt5 };
106
+ }
107
+
108
+ function splitStrings(list: string): string[] {
109
+ return [...list.matchAll(/"([^"]+)"/gu)].map(m => m[1]!);
110
+ }
111
+
112
+ export interface CursorEffortTableDeps {
113
+ platform: string;
114
+ stat(path: string): { mtimeMs: number; size: number } | null;
115
+ readText(path: string): string | null;
116
+ }
117
+
118
+ export function realCursorEffortTableDeps(): CursorEffortTableDeps {
119
+ return {
120
+ platform: process.platform,
121
+ stat: path => { try { const s = statSync(path); return { mtimeMs: s.mtimeMs, size: s.size }; } catch { return null; } },
122
+ readText: path => { try { return readFileSync(path, "utf8"); } catch { return null; } },
123
+ };
124
+ }
125
+
126
+ let cache: { key: string; table: CursorEffortTable | null } | null = null;
127
+
128
+ /** Table from the Private Inference install, else null (caller falls back to the static mirror). */
129
+ export function loadCursorEffortTable(install: CursorInstall | undefined, deps: CursorEffortTableDeps = realCursorEffortTableDeps()): CursorEffortTable | null {
130
+ if (!install) return null;
131
+ const bundlePath = cursorAgentBundlePath(install, deps.platform);
132
+ const st = deps.stat(bundlePath);
133
+ if (!st || st.size > BUNDLE_MAX_BYTES) return null;
134
+ const key = `${bundlePath}|${st.mtimeMs}|${st.size}`;
135
+ if (cache?.key === key) return cache.table;
136
+ const text = deps.readText(bundlePath);
137
+ const parsed = text ? parseCursorEffortTable(text) : null;
138
+ const table = parsed ? { ...parsed, version: install.version, bundlePath } : null;
139
+ cache = { key, table };
140
+ return table;
141
+ }
142
+
143
+ export function resetCursorEffortTableCacheForTests(): void { cache = null; }
@@ -369,7 +369,7 @@ export function exportContextOf(input: {
369
369
  * loopback, and every client we write into deserves the same answer the
370
370
  * export command already gives.
371
371
  */
372
- baseUrl: opencodeProxyBaseUrl(input.port, input.config.hostname),
372
+ baseUrl: opencodeProxyBaseUrl(input.port, input.config.hostname, input.config),
373
373
  models: input.models,
374
374
  config: input.config,
375
375
  };
@@ -12,7 +12,7 @@
12
12
  import { homedir } from "node:os";
13
13
  import { dirname } from "node:path";
14
14
  import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
15
- import { isLoopbackHostname } from "../codex/inject";
15
+ import { shouldInjectApiAuthHeader } from "../codex/inject";
16
16
  import type { OcxConfig } from "../types";
17
17
  import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
18
18
  import {
@@ -290,7 +290,7 @@ function applyOrRefreshIntegration(
290
290
  if (io.statKind(detectDir) !== "dir") {
291
291
  return refuse(clientId, "not_installed", "absent", `${clientId} is not installed`);
292
292
  }
293
- if (isLoopbackOnly(clientId) && !isLoopbackHostname(input.config.hostname)) {
293
+ if (isLoopbackOnly(clientId) && shouldInjectApiAuthHeader(input.config)) {
294
294
  return refuse(clientId, "non_loopback", classified.state,
295
295
  `The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`);
296
296
  }
@@ -38,6 +38,10 @@ import {
38
38
  discardRetainedUsageSnapshot,
39
39
  retainedUsageSnapshotStats,
40
40
  } from "../usage/log";
41
+ import {
42
+ discardRetainedUsageAggregate,
43
+ usageAggregateRetainedStats,
44
+ } from "../server/management/usage-aggregate-cache";
41
45
  import {
42
46
  cursorBlobRetainedStoreSnapshot,
43
47
  evictOldestCursorBlobForBudget,
@@ -61,18 +65,33 @@ function ringSnapshot(metrics: { entries: number; bytes: number; oldestAt: numbe
61
65
  };
62
66
  }
63
67
 
64
- /** The retained usage tail is a single all-or-nothing entry: evicting it drops the whole tail. */
68
+ /** Legacy parsed tail and streaming aggregate share one stable public store id. */
65
69
  function usageSnapshotRetainedStoreSnapshot(): RetainedStoreSnapshot {
66
- const stats = retainedUsageSnapshotStats();
70
+ const legacy = retainedUsageSnapshotStats();
71
+ const aggregate = usageAggregateRetainedStats();
72
+ const oldest = [legacy.oldestAt, aggregate.oldestAt]
73
+ .filter((value): value is number => value !== null)
74
+ .sort((a, b) => a - b)[0] ?? null;
67
75
  return {
68
- count: stats.count,
69
- bytes: stats.bytes,
70
- evictableBytes: stats.bytes,
71
- pinnedBytes: 0,
72
- oldestAt: stats.oldestAt,
76
+ count: legacy.count + aggregate.count,
77
+ bytes: legacy.bytes + aggregate.bytes,
78
+ evictableBytes: legacy.bytes + aggregate.evictableBytes,
79
+ pinnedBytes: aggregate.pinnedBytes,
80
+ oldestAt: oldest,
73
81
  };
74
82
  }
75
83
 
84
+ function evictOldestUsageSnapshot(): number {
85
+ const legacy = retainedUsageSnapshotStats();
86
+ const aggregate = usageAggregateRetainedStats();
87
+ if (legacy.bytes > 0
88
+ && (aggregate.evictableBytes === 0
89
+ || (legacy.oldestAt ?? Number.POSITIVE_INFINITY) <= (aggregate.oldestAt ?? Number.POSITIVE_INFINITY))) {
90
+ return discardRetainedUsageSnapshot();
91
+ }
92
+ return discardRetainedUsageAggregate();
93
+ }
94
+
76
95
  function providerDebugSnapshot(): RetainedStoreSnapshot {
77
96
  return ringSnapshot(debugBufferMetrics());
78
97
  }
@@ -154,7 +173,7 @@ export const APP_OWNED_RETAINED_STORE_REGISTRATIONS = [
154
173
  id: "usage_snapshot",
155
174
  category: "caches",
156
175
  snapshot: usageSnapshotRetainedStoreSnapshot,
157
- evictOldest: discardRetainedUsageSnapshot,
176
+ evictOldest: evictOldestUsageSnapshot,
158
177
  },
159
178
  {
160
179
  id: "cursor_blobs",
@@ -1,3 +1,5 @@
1
+ import { idleDeadline } from "./abort";
2
+
1
3
  /** Maximum number of response-body bytes that may be retained for an error. */
2
4
  export const BOUNDED_BODY_MAX_BYTES = 65_536;
3
5
 
@@ -49,6 +51,8 @@ export interface BoundedBytesOptions {
49
51
  signal?: AbortSignal;
50
52
  /** Maximum number of raw bytes retained from the response body. */
51
53
  maxBytes: number;
54
+ /** Deadline between non-empty raw chunks. Omitted means no body-read deadline. */
55
+ inactivityTimeoutMs?: number;
52
56
  }
53
57
 
54
58
  export interface BoundedBytesResult {
@@ -136,6 +140,15 @@ export async function readBoundedResponseBytes(
136
140
  let retainedBytes = 0;
137
141
  let mustCancel = false;
138
142
  let cancelReason: unknown;
143
+ const inactivityReason = new DOMException("Response body stalled", "TimeoutError");
144
+ let rejectForInactivity: ((reason: unknown) => void) | undefined;
145
+ const inactive = new Promise<never>((_resolve, reject) => {
146
+ rejectForInactivity = reject;
147
+ });
148
+ const inactivity = options.inactivityTimeoutMs === undefined
149
+ ? null
150
+ : idleDeadline(options.inactivityTimeoutMs, () => rejectForInactivity?.(inactivityReason));
151
+ inactivity?.reset();
139
152
 
140
153
  let rejectForAbort: ((reason: unknown) => void) | undefined;
141
154
  const aborted = new Promise<never>((_resolve, reject) => {
@@ -151,7 +164,7 @@ export async function readBoundedResponseBytes(
151
164
  const read = reader.read();
152
165
  // Observe a late read rejection when abort/cancellation wins the race.
153
166
  void read.catch(() => undefined);
154
- const outcome = await Promise.race([read, aborted]);
167
+ const outcome = await Promise.race([read, aborted, inactive]);
155
168
  if (signal?.aborted) {
156
169
  mustCancel = true;
157
170
  cancelReason = signal.reason;
@@ -163,6 +176,7 @@ export async function readBoundedResponseBytes(
163
176
  return { bytes: retained.subarray(0, retainedBytes), oversized: false };
164
177
  }
165
178
  if (!value || value.byteLength === 0) continue;
179
+ inactivity?.reset();
166
180
 
167
181
  if (value.byteLength > maxBytes - retainedBytes) {
168
182
  mustCancel = true;
@@ -187,6 +201,7 @@ export async function readBoundedResponseBytes(
187
201
  cancelReason = error;
188
202
  throw error;
189
203
  } finally {
204
+ inactivity?.cancel();
190
205
  signal?.removeEventListener("abort", onAbort);
191
206
  if (mustCancel) cancelWithoutWaiting(reader, cancelReason);
192
207
  try {
@@ -10,7 +10,7 @@
10
10
  * categories answer the only question rotation asks — "which of these is most likely to
11
11
  * serve the retry" — and within the healthy group a simple headroom sort is enough.
12
12
  */
13
- import { getCachedProviderAccountQuota } from "../providers/quota";
13
+ import { getCachedProviderAccountQuota, hasPassiveAccountQuota } from "../providers/quota";
14
14
  import { getKiroAccountExhaustion } from "../providers/kiro-usage";
15
15
 
16
16
  /** Lower sorts earlier. Unknown sits between measured-healthy and measured-empty. */
@@ -27,6 +27,24 @@ interface Ranked {
27
27
  index: number;
28
28
  }
29
29
 
30
+ /**
31
+ * How old a PASSIVELY observed quota may be and still steer routing.
32
+ *
33
+ * A probed row is fresh by construction: it exists only because a probe wrote it, and
34
+ * `fetchAccountQuota` re-probes once `ACCOUNT_QUOTA_TTL_MS` has passed. So no caller has
35
+ * ever needed an explicit age check, and `getCachedProviderAccountQuota` does not apply
36
+ * one.
37
+ *
38
+ * A passive row breaks that invariant — nothing re-probes it, so it can be hours or days
39
+ * old. Routing on such a reading is worse than routing on none: the unranked ring at
40
+ * least rotates, while a stale ranking sends every first attempt to an account that may
41
+ * have been spent since. The bound is longer than the probe TTL (an hour-old reading of
42
+ * a five-hour window is still informative) and far shorter than the six-hour disk
43
+ * horizon, which exists to preserve a value for DISPLAY — where the age is shown to the
44
+ * user and no automatic decision rides on it.
45
+ */
46
+ const PASSIVE_HEADROOM_MAX_AGE_MS = 60 * 60_000;
47
+
30
48
  /**
31
49
  * Remaining headroom across every window the provider reports.
32
50
  *
@@ -36,6 +54,9 @@ interface Ranked {
36
54
  function headroomOf(provider: string, accountId: string): number | null {
37
55
  const quota = getCachedProviderAccountQuota(provider, accountId);
38
56
  if (!quota) return null;
57
+ // Null, not a low rank: this must reproduce "no evidence" so a stale roster degrades to
58
+ // the unranked ring rather than to a differently wrong answer.
59
+ if (hasPassiveAccountQuota(provider) && Date.now() - quota.updatedAt > PASSIVE_HEADROOM_MAX_AGE_MS) return null;
39
60
  const percents = [
40
61
  quota.fiveHourPercent,
41
62
  quota.weeklyPercent,
@@ -56,6 +77,12 @@ export function rankAccountsByHeadroom(provider: string, ring: readonly string[]
56
77
  if (ring.length < 2) return [...ring];
57
78
 
58
79
  let sawEvidence = false;
80
+ // Same rule as hasHeadroomEvidence: a passive provider's partial roster must not rank
81
+ // at all. The failover path calls this directly (selectFailoverAccount), so the guard
82
+ // cannot live only in the pre-dispatch predicate.
83
+ if (hasPassiveAccountQuota(provider) && !ring.every(id => headroomOf(provider, id) !== null)) {
84
+ return [...ring];
85
+ }
59
86
  const ranked: Ranked[] = ring.map((id, index) => {
60
87
  // A provider-declared exhaustion verdict outranks the percentage: an account may sit at
61
88
  // 100% and still be servable when overage is enabled, and the verdict knows that.
@@ -84,6 +111,18 @@ export function rankAccountsByHeadroom(provider: string, ring: readonly string[]
84
111
  * can decline to act on a roster it knows nothing about.
85
112
  */
86
113
  export function hasHeadroomEvidence(provider: string, ids: readonly string[]): boolean {
114
+ // A PASSIVE provider needs evidence for EVERY candidate, not any one of them.
115
+ //
116
+ // A probe fills the whole roster in one pass (fetchProviderAccountQuotas), so "any"
117
+ // and "every" coincide there. An observation arrives one account at a time, so the
118
+ // normal passive state is "one measured, N unknown" -- and RANK_UNKNOWN (1) sorts
119
+ // AFTER RANK_HEALTHY (0), including behind a measured row sitting at 100% with zero
120
+ // headroom. Accepting partial evidence would therefore redirect the first attempt
121
+ // AWAY from an unmeasured account and TOWARD the one account known to be spent, which
122
+ // is the exact inversion of what ranking is for.
123
+ if (hasPassiveAccountQuota(provider)) {
124
+ return ids.length > 0 && ids.every(id => headroomOf(provider, id) !== null);
125
+ }
87
126
  return ids.some(id =>
88
127
  headroomOf(provider, id) !== null
89
128
  || (provider === "kiro" && getKiroAccountExhaustion(`${provider}\u0000${id}`) !== null));
@@ -0,0 +1,187 @@
1
+ import type { OAuthController, OAuthCredentials } from "./types";
2
+ import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken } from "./chatgpt";
3
+
4
+ /**
5
+ * OpenAI deviceauth (device-code) grant for the ChatGPT/Codex provider.
6
+ *
7
+ * The callback flow in `./chatgpt` needs a browser and a listener on
8
+ * localhost:1455. A hub running headless in a container or over SSH has
9
+ * neither, which left "copy the long redirect URL out of the browser error
10
+ * page" as the only way to add an account there (#3366).
11
+ *
12
+ * This is the same grant Codex CLI uses. Three steps, and the middle one is
13
+ * where it differs from RFC 8628: the poll returns an authorization code plus
14
+ * a SERVER-generated PKCE verifier, which is then spent at the ordinary token
15
+ * endpoint. We never generate the verifier ourselves here.
16
+ */
17
+ const USERCODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode";
18
+ const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token";
19
+ const DEVICE_REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
20
+
21
+ /** Where the user types the short code. Fixed, and safe to show anywhere. */
22
+ export const DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device";
23
+
24
+ /** The grant's own lifetime. Polling past this only produces a worse error message. */
25
+ const DEVICE_FLOW_TTL_MS = 15 * 60 * 1000;
26
+ const DEFAULT_POLL_INTERVAL_MS = 5_000;
27
+ const MIN_POLL_INTERVAL_MS = 1_000;
28
+ /**
29
+ * Above ~2^31 ms a timer overflows and fires immediately, which would turn a
30
+ * hostile or corrupt `interval` into a hot loop against an auth endpoint. The
31
+ * grant only lives 15 minutes, so anything longer is meaningless anyway.
32
+ */
33
+ const MAX_POLL_INTERVAL_MS = DEVICE_FLOW_TTL_MS;
34
+
35
+ /**
36
+ * Upstream sends `interval` as a number in some responses and a string in
37
+ * others. A string would make `setTimeout` treat it as 0 and turn the poll
38
+ * into a hot loop against an auth endpoint, so coerce and floor it.
39
+ */
40
+ function normalizeIntervalMs(raw: unknown): number {
41
+ const seconds = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
42
+ if (!Number.isFinite(seconds) || seconds <= 0) return DEFAULT_POLL_INTERVAL_MS;
43
+ const ms = Math.round(seconds * 1000);
44
+ return Math.min(MAX_POLL_INTERVAL_MS, Math.max(MIN_POLL_INTERVAL_MS, ms));
45
+ }
46
+
47
+ function nonEmptyString(value: unknown): string | undefined {
48
+ return typeof value === "string" && value.length > 0 ? value : undefined;
49
+ }
50
+
51
+ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
52
+ if (signal?.aborted) throw new Error("Login cancelled");
53
+ await new Promise<void>((resolve, reject) => {
54
+ const timer = setTimeout(() => {
55
+ signal?.removeEventListener("abort", onAbort);
56
+ resolve();
57
+ }, ms);
58
+ const onAbort = (): void => {
59
+ clearTimeout(timer);
60
+ reject(new Error("Login cancelled"));
61
+ };
62
+ signal?.addEventListener("abort", onAbort, { once: true });
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Device-flow errors carry the HTTP status and nothing else.
68
+ *
69
+ * The callback flow's `safeErrorDescription` reflects the upstream body into
70
+ * the message, which is fine for an OAuth error envelope but not here: these
71
+ * endpoints can echo request material, and this message reaches CLI output,
72
+ * the GUI, and issue reports.
73
+ */
74
+ function deviceError(stage: string, status: number): Error {
75
+ return new Error(`ChatGPT device authorization ${stage} failed: HTTP ${status}`);
76
+ }
77
+
78
+ interface DeviceUserCode {
79
+ deviceAuthId: string;
80
+ userCode: string;
81
+ intervalMs: number;
82
+ }
83
+
84
+ async function requestUserCode(signal?: AbortSignal): Promise<DeviceUserCode> {
85
+ const response = await fetch(USERCODE_URL, {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/json" },
88
+ body: JSON.stringify({ client_id: CHATGPT_CLIENT_ID }),
89
+ signal,
90
+ });
91
+ if (!response.ok) throw deviceError("request", response.status);
92
+ const payload = (await response.json()) as Record<string, unknown>;
93
+ const deviceAuthId = nonEmptyString(payload.device_auth_id);
94
+ // Upstream accepts both spellings, so a response using the alias must not be
95
+ // rejected as malformed.
96
+ const userCode = nonEmptyString(payload.user_code) ?? nonEmptyString(payload.usercode);
97
+ if (!deviceAuthId || !userCode) {
98
+ throw new Error("ChatGPT device authorization response missing required fields");
99
+ }
100
+ return { deviceAuthId, userCode, intervalMs: normalizeIntervalMs(payload.interval) };
101
+ }
102
+
103
+ interface DeviceGrant {
104
+ authorizationCode: string;
105
+ codeVerifier: string;
106
+ }
107
+
108
+ /**
109
+ * Poll until the user finishes at the verification page.
110
+ *
111
+ * Pending is signalled by 403/404 rather than an `authorization_pending` body,
112
+ * so status is the whole protocol here: any other non-2xx is terminal, and
113
+ * treating it as pending would keep hammering a permanently failing endpoint.
114
+ */
115
+ async function pollForGrant(
116
+ device: DeviceUserCode,
117
+ signal?: AbortSignal,
118
+ ): Promise<DeviceGrant> {
119
+ const deadline = Date.now() + DEVICE_FLOW_TTL_MS;
120
+ while (Date.now() < deadline) {
121
+ if (signal?.aborted) throw new Error("Login cancelled");
122
+ const response = await fetch(DEVICE_TOKEN_URL, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify({ device_auth_id: device.deviceAuthId, user_code: device.userCode }),
126
+ signal,
127
+ });
128
+ if (response.status === 403 || response.status === 404) {
129
+ // Cap the wait at the time actually left. Sleeping a full interval past
130
+ // the deadline is how a 15-minute grant turns into a 20-minute wait.
131
+ const remaining = deadline - Date.now();
132
+ if (remaining <= 0) break;
133
+ await sleep(Math.min(device.intervalMs, remaining), signal);
134
+ continue;
135
+ }
136
+ if (!response.ok) throw deviceError("poll", response.status);
137
+ // The deadline is checked again here, not only at the top of the loop: a
138
+ // single poll can itself outlive the grant, and accepting a code that
139
+ // expired mid-flight just moves the failure to the token exchange.
140
+ if (Date.now() >= deadline) break;
141
+ const payload = (await response.json()) as Record<string, unknown>;
142
+ const authorizationCode = nonEmptyString(payload.authorization_code);
143
+ const codeVerifier = nonEmptyString(payload.code_verifier);
144
+ if (!authorizationCode || !codeVerifier) {
145
+ throw new Error("ChatGPT device authorization response missing required fields");
146
+ }
147
+ return { authorizationCode, codeVerifier };
148
+ }
149
+ throw new Error("ChatGPT device authorization expired");
150
+ }
151
+
152
+ async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise<OAuthCredentials> {
153
+ const response = await fetch(CHATGPT_TOKEN_URL, {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
156
+ body: new URLSearchParams({
157
+ grant_type: "authorization_code",
158
+ client_id: CHATGPT_CLIENT_ID,
159
+ code: grant.authorizationCode,
160
+ code_verifier: grant.codeVerifier,
161
+ redirect_uri: DEVICE_REDIRECT_URI,
162
+ }).toString(),
163
+ signal,
164
+ });
165
+ if (!response.ok) throw deviceError("token exchange", response.status);
166
+ return credsFromToken((await response.json()) as Record<string, unknown>);
167
+ }
168
+
169
+ /**
170
+ * Run the device flow to completion.
171
+ *
172
+ * `deviceCode` in the `onAuth` payload is the HUMAN code, matching kimi, nous,
173
+ * and github-copilot. The opaque `device_auth_id` never leaves this module:
174
+ * every device-code surface renders `deviceCode` verbatim, and the management
175
+ * login route also uses its presence to decide a flow must not be handed to a
176
+ * local browser spawn.
177
+ */
178
+ export async function loginChatGPTDevice(ctrl: OAuthController): Promise<OAuthCredentials> {
179
+ const device = await requestUserCode(ctrl.signal);
180
+ ctrl.onAuth?.({
181
+ url: DEVICE_VERIFICATION_URL,
182
+ instructions: `Enter code: ${device.userCode}`,
183
+ deviceCode: device.userCode,
184
+ });
185
+ const grant = await pollForGrant(device, ctrl.signal);
186
+ return exchangeGrant(grant, ctrl.signal);
187
+ }