@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.
- package/README.md +4 -0
- package/gui/dist/assets/index-BU1tE0sr.js +112 -0
- package/gui/dist/assets/index-DL9-iS6J.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/meta.svg +1 -0
- package/package.json +4 -3
- package/src/adapters/cursor/catalog.ts +71 -29
- package/src/adapters/cursor/claude-id.ts +76 -0
- package/src/adapters/cursor/discovery.ts +16 -3
- package/src/adapters/cursor/effort-map.ts +27 -12
- package/src/adapters/cursor/protobuf-request.ts +41 -21
- package/src/adapters/google.ts +39 -2
- package/src/adapters/identity.ts +8 -2
- package/src/adapters/openai-responses.ts +57 -4
- package/src/bridge.ts +25 -3
- package/src/cli/account-auth.ts +28 -3
- package/src/cli/account-extended.ts +7 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/claude.ts +11 -2
- package/src/cli/connect.ts +7 -1
- package/src/cli/observe.ts +3 -1
- package/src/cli/registry.ts +1 -1
- package/src/cli/status.ts +19 -4
- package/src/client/connect.ts +5 -1
- package/src/client/hub-client.ts +29 -5
- package/src/clients/config-export.ts +12 -2
- package/src/codex/auth-api.ts +102 -9
- package/src/codex/catalog/aggregation.ts +8 -0
- package/src/codex/catalog/effort.ts +15 -2
- package/src/codex/catalog/metadata.ts +119 -9
- package/src/codex/catalog/native-models.ts +71 -0
- package/src/codex/catalog/parsing.ts +5 -3
- package/src/codex/catalog/provider-fetch.ts +166 -28
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/data/upstream-models.json +169 -0
- package/src/codex/desired-state.ts +18 -11
- package/src/codex/inject.ts +96 -6
- package/src/codex/injected-marker.ts +30 -4
- package/src/codex/journal.ts +14 -0
- package/src/combos/failover.ts +185 -6
- package/src/combos/index.ts +6 -0
- package/src/combos/resolve.ts +43 -6
- package/src/config.ts +5 -1
- package/src/generated/compatibility-version.json +115 -83
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/sync.ts +10 -2
- package/src/integrations/cursor-effort-table.ts +143 -0
- package/src/integrations/state.ts +1 -1
- package/src/integrations/writer.ts +2 -2
- package/src/lib/app-owned-memory-stores.ts +27 -8
- package/src/lib/bounded-body.ts +16 -1
- package/src/oauth/account-quota-rank.ts +40 -1
- package/src/oauth/chatgpt-device.ts +187 -0
- package/src/oauth/chatgpt.ts +31 -4
- package/src/oauth/generic-account-failover.ts +2 -2
- package/src/oauth/index.ts +24 -3
- package/src/oauth/log.ts +3 -0
- package/src/oauth/meta-muse.ts +235 -0
- package/src/providers/antigravity-models.ts +71 -13
- package/src/providers/command-code-efforts.ts +15 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/muse-subscription-usage.ts +95 -0
- package/src/providers/quota.ts +96 -0
- package/src/providers/registry.ts +116 -8
- package/src/responses/code-mode-helper-compat.ts +4 -1
- package/src/responses/state.ts +5 -4
- package/src/server/auth-cors.ts +241 -56
- package/src/server/chat-completions.ts +11 -2
- package/src/server/chat-native.ts +30 -4
- package/src/server/claude-messages.ts +17 -3
- package/src/server/effort-row.ts +131 -0
- package/src/server/index.ts +82 -45
- package/src/server/live.ts +18 -4
- package/src/server/management/api-key-rotation.ts +2 -1
- package/src/server/management/api-key-usage.ts +97 -43
- package/src/server/management/context.ts +3 -0
- package/src/server/management/cursor-integration-routes.ts +36 -7
- package/src/server/management/logs-usage-routes.ts +64 -87
- package/src/server/management/oauth-account-routes.ts +10 -3
- package/src/server/management/provider-routes.ts +218 -1
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management/usage-aggregate-cache.ts +464 -0
- package/src/server/management/usage-summary-cache.ts +4 -0
- package/src/server/models-capabilities.ts +60 -5
- package/src/server/responses/core.ts +95 -7
- package/src/server/responses/empty-completion-guard.ts +4 -0
- package/src/types/config.ts +10 -1
- package/src/types/request.ts +8 -0
- package/src/types/tools.ts +12 -9
- package/src/usage/expected-prices.ts +43 -7
- package/src/usage/ledger-scanner.ts +448 -0
- package/src/usage/log.ts +1 -1
- package/src/usage/summary.ts +915 -655
- package/src/web-search/index.ts +1 -1
- package/gui/dist/assets/index-BHe2rl_C.js +0 -112
- 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
|
-
|
|
51
|
-
|
|
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 {
|
|
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) &&
|
|
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
|
-
/**
|
|
68
|
+
/** Legacy parsed tail and streaming aggregate share one stable public store id. */
|
|
65
69
|
function usageSnapshotRetainedStoreSnapshot(): RetainedStoreSnapshot {
|
|
66
|
-
const
|
|
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:
|
|
69
|
-
bytes:
|
|
70
|
-
evictableBytes:
|
|
71
|
-
pinnedBytes:
|
|
72
|
-
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:
|
|
176
|
+
evictOldest: evictOldestUsageSnapshot,
|
|
158
177
|
},
|
|
159
178
|
{
|
|
160
179
|
id: "cursor_blobs",
|
package/src/lib/bounded-body.ts
CHANGED
|
@@ -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
|
+
}
|