@bitkyc08/opencodex 2.41.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/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/package.json +1 -1
- package/src/adapters/cursor/protobuf-request.ts +41 -21
- package/src/adapters/identity.ts +8 -2
- package/src/adapters/openai-responses.ts +43 -3
- 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/observe.ts +3 -1
- package/src/codex/auth-api.ts +102 -9
- package/src/codex/catalog/effort.ts +15 -2
- package/src/codex/catalog/metadata.ts +114 -9
- package/src/codex/catalog/native-models.ts +71 -0
- package/src/codex/catalog/parsing.ts +3 -3
- package/src/codex/catalog/provider-fetch.ts +4 -3
- package/src/codex/catalog.ts +1 -1
- package/src/codex/data/upstream-models.json +169 -0
- package/src/codex/inject.ts +96 -6
- package/src/codex/injected-marker.ts +30 -4
- package/src/codex/journal.ts +14 -0
- package/src/generated/compatibility-version.json +40 -32
- 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/index.ts +13 -3
- package/src/oauth/log.ts +3 -0
- package/src/providers/muse-subscription-usage.ts +95 -0
- package/src/providers/quota.ts +96 -0
- package/src/providers/registry.ts +1 -1
- package/src/server/index.ts +15 -7
- package/src/server/live.ts +18 -4
- package/src/server/management/oauth-account-routes.ts +10 -3
- package/src/server/responses/core.ts +34 -0
- package/src/server/responses/empty-completion-guard.ts +4 -0
- package/src/types/request.ts +8 -0
- package/gui/dist/assets/index-B2YjLA-i.css +0 -1
- package/gui/dist/assets/index-aPup8CKb.js +0 -112
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
12
|
"path": "package.json",
|
|
13
|
-
"sha256": "
|
|
13
|
+
"sha256": "583fb118e7f8db57d275f6d81958a67042afa446eb6d78f218f588c8812d5272"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "scripts/model-metadata.source.json",
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
},
|
|
195
195
|
{
|
|
196
196
|
"path": "src/adapters/cursor/protobuf-request.ts",
|
|
197
|
-
"sha256": "
|
|
197
|
+
"sha256": "3e2aa6f21bd9e6c4d4d78480bb1755424828fd41bc50361bb7696812cd25e62e"
|
|
198
198
|
},
|
|
199
199
|
{
|
|
200
200
|
"path": "src/adapters/cursor/request-builder.ts",
|
|
@@ -266,7 +266,7 @@
|
|
|
266
266
|
},
|
|
267
267
|
{
|
|
268
268
|
"path": "src/adapters/identity.ts",
|
|
269
|
-
"sha256": "
|
|
269
|
+
"sha256": "21a147b96b5c5d6741d1b793be71d8b73944649f84f0c4c351cd149d9bf9e562"
|
|
270
270
|
},
|
|
271
271
|
{
|
|
272
272
|
"path": "src/adapters/image.ts",
|
|
@@ -342,7 +342,7 @@
|
|
|
342
342
|
},
|
|
343
343
|
{
|
|
344
344
|
"path": "src/adapters/openai-responses.ts",
|
|
345
|
-
"sha256": "
|
|
345
|
+
"sha256": "19e94674fedee992fd52ac88c33e9d1aaeaf5a310d296698ee92b4bbeaaa9819"
|
|
346
346
|
},
|
|
347
347
|
{
|
|
348
348
|
"path": "src/adapters/registry.ts",
|
|
@@ -378,7 +378,7 @@
|
|
|
378
378
|
},
|
|
379
379
|
{
|
|
380
380
|
"path": "src/bridge.ts",
|
|
381
|
-
"sha256": "
|
|
381
|
+
"sha256": "6dc37628f3651a8db77d528265b87ca6c61e9ede9e49808c55c6b6e721ec38dc"
|
|
382
382
|
},
|
|
383
383
|
{
|
|
384
384
|
"path": "src/chat/inbound.ts",
|
|
@@ -470,7 +470,7 @@
|
|
|
470
470
|
},
|
|
471
471
|
{
|
|
472
472
|
"path": "src/cli/account-auth.ts",
|
|
473
|
-
"sha256": "
|
|
473
|
+
"sha256": "afabb1f192fbb1be02fd60315deba1c6ed906a771cf6ec4374090166fea9e944"
|
|
474
474
|
},
|
|
475
475
|
{
|
|
476
476
|
"path": "src/cli/account-catalog-refresh.ts",
|
|
@@ -478,7 +478,7 @@
|
|
|
478
478
|
},
|
|
479
479
|
{
|
|
480
480
|
"path": "src/cli/account-extended.ts",
|
|
481
|
-
"sha256": "
|
|
481
|
+
"sha256": "9308c5fc1ee6ea481749cc353288be347d78d2cf35b85839749b94813115020d"
|
|
482
482
|
},
|
|
483
483
|
{
|
|
484
484
|
"path": "src/cli/account-main.ts",
|
|
@@ -506,7 +506,7 @@
|
|
|
506
506
|
},
|
|
507
507
|
{
|
|
508
508
|
"path": "src/cli/capabilities.ts",
|
|
509
|
-
"sha256": "
|
|
509
|
+
"sha256": "acf94353c1ffcbce3badc1f144ab2423d12e0653c763edd6ccebea8d24d33c90"
|
|
510
510
|
},
|
|
511
511
|
{
|
|
512
512
|
"path": "src/cli/catalog-prewarm.ts",
|
|
@@ -630,7 +630,7 @@
|
|
|
630
630
|
},
|
|
631
631
|
{
|
|
632
632
|
"path": "src/cli/observe.ts",
|
|
633
|
-
"sha256": "
|
|
633
|
+
"sha256": "7b46284dfcacaf9b902f5951bf622c17adff74cc4d59bf8b2c4dc6b605b5f6c1"
|
|
634
634
|
},
|
|
635
635
|
{
|
|
636
636
|
"path": "src/cli/opencode.ts",
|
|
@@ -802,7 +802,7 @@
|
|
|
802
802
|
},
|
|
803
803
|
{
|
|
804
804
|
"path": "src/codex/auth-api.ts",
|
|
805
|
-
"sha256": "
|
|
805
|
+
"sha256": "ede7eba228b9185e57f8d142aa27b7fef2255b6c6d253cd7bc79fd089824abee"
|
|
806
806
|
},
|
|
807
807
|
{
|
|
808
808
|
"path": "src/codex/auth-collision.ts",
|
|
@@ -830,7 +830,7 @@
|
|
|
830
830
|
},
|
|
831
831
|
{
|
|
832
832
|
"path": "src/codex/catalog.ts",
|
|
833
|
-
"sha256": "
|
|
833
|
+
"sha256": "29907ddc709e5a287a732bcb3716211faa07eb4faf5873fe2bbc73b9d6c12142"
|
|
834
834
|
},
|
|
835
835
|
{
|
|
836
836
|
"path": "src/codex/catalog/account-models.ts",
|
|
@@ -846,7 +846,7 @@
|
|
|
846
846
|
},
|
|
847
847
|
{
|
|
848
848
|
"path": "src/codex/catalog/effort.ts",
|
|
849
|
-
"sha256": "
|
|
849
|
+
"sha256": "ab599d14e7e2c8e84745e0481198b2ff7274772518225f67bcd849a866628c28"
|
|
850
850
|
},
|
|
851
851
|
{
|
|
852
852
|
"path": "src/codex/catalog/filesystem-evidence.ts",
|
|
@@ -858,19 +858,19 @@
|
|
|
858
858
|
},
|
|
859
859
|
{
|
|
860
860
|
"path": "src/codex/catalog/metadata.ts",
|
|
861
|
-
"sha256": "
|
|
861
|
+
"sha256": "bfed4ab1d763c0349acc6814507d97039f13b7a8365465cc014eaab62bae51d0"
|
|
862
862
|
},
|
|
863
863
|
{
|
|
864
864
|
"path": "src/codex/catalog/native-models.ts",
|
|
865
|
-
"sha256": "
|
|
865
|
+
"sha256": "24bc83bbe17d58783eb3d137d9072313340814ca0e2e48759a7fc20077e2265a"
|
|
866
866
|
},
|
|
867
867
|
{
|
|
868
868
|
"path": "src/codex/catalog/parsing.ts",
|
|
869
|
-
"sha256": "
|
|
869
|
+
"sha256": "517edd354acc48611a5968ca8e8c9085de4749dc1b05561db9ea504859b11c52"
|
|
870
870
|
},
|
|
871
871
|
{
|
|
872
872
|
"path": "src/codex/catalog/provider-fetch.ts",
|
|
873
|
-
"sha256": "
|
|
873
|
+
"sha256": "69ad89553d387a69795d56706adce0406218bc28a0b7ab584e4cf1c9285b5bf7"
|
|
874
874
|
},
|
|
875
875
|
{
|
|
876
876
|
"path": "src/codex/catalog/sync.ts",
|
|
@@ -906,7 +906,7 @@
|
|
|
906
906
|
},
|
|
907
907
|
{
|
|
908
908
|
"path": "src/codex/data/upstream-models.json",
|
|
909
|
-
"sha256": "
|
|
909
|
+
"sha256": "3f83e796c1cbec028a47526a19eb9a1a314cf6444f2cbef10e6935ff3bc4398c"
|
|
910
910
|
},
|
|
911
911
|
{
|
|
912
912
|
"path": "src/codex/desired-state.ts",
|
|
@@ -966,11 +966,11 @@
|
|
|
966
966
|
},
|
|
967
967
|
{
|
|
968
968
|
"path": "src/codex/inject.ts",
|
|
969
|
-
"sha256": "
|
|
969
|
+
"sha256": "9bc9759d276c0eaf59d136bd98341994e676339e6399af894b5d8318d827183e"
|
|
970
970
|
},
|
|
971
971
|
{
|
|
972
972
|
"path": "src/codex/injected-marker.ts",
|
|
973
|
-
"sha256": "
|
|
973
|
+
"sha256": "e5f2ad207839ef89ee03db9866bfc9b9ba82494f4cc4ca3331d7595094d1cbac"
|
|
974
974
|
},
|
|
975
975
|
{
|
|
976
976
|
"path": "src/codex/integration-record.ts",
|
|
@@ -986,7 +986,7 @@
|
|
|
986
986
|
},
|
|
987
987
|
{
|
|
988
988
|
"path": "src/codex/journal.ts",
|
|
989
|
-
"sha256": "
|
|
989
|
+
"sha256": "8985c66c13982144e83ab49802c0cf19b06e912aeed4c653eeca43607cba6241"
|
|
990
990
|
},
|
|
991
991
|
{
|
|
992
992
|
"path": "src/codex/log-guard/inspect.ts",
|
|
@@ -2238,7 +2238,7 @@
|
|
|
2238
2238
|
},
|
|
2239
2239
|
{
|
|
2240
2240
|
"path": "src/oauth/account-quota-rank.ts",
|
|
2241
|
-
"sha256": "
|
|
2241
|
+
"sha256": "7c458eb8c109548586dcfba64e0ce0c9939fe8e801917cb83393e50bf4e0116d"
|
|
2242
2242
|
},
|
|
2243
2243
|
{
|
|
2244
2244
|
"path": "src/oauth/anthropic-routing.ts",
|
|
@@ -2252,9 +2252,13 @@
|
|
|
2252
2252
|
"path": "src/oauth/callback-server.ts",
|
|
2253
2253
|
"sha256": "3c481c4b26bcaaee246aff098be3fa402e1c0f813adf0f00a4122af92d8d5264"
|
|
2254
2254
|
},
|
|
2255
|
+
{
|
|
2256
|
+
"path": "src/oauth/chatgpt-device.ts",
|
|
2257
|
+
"sha256": "c3021b86c4c94b4b64bb0ee26d67e57cf0b155390a7a7a8d1ab56c8281c4ef8a"
|
|
2258
|
+
},
|
|
2255
2259
|
{
|
|
2256
2260
|
"path": "src/oauth/chatgpt.ts",
|
|
2257
|
-
"sha256": "
|
|
2261
|
+
"sha256": "bbb17b33b4de58afd94086885d993906e96ed06652f1a41dee9a8081372f657e"
|
|
2258
2262
|
},
|
|
2259
2263
|
{
|
|
2260
2264
|
"path": "src/oauth/command-code.ts",
|
|
@@ -2282,7 +2286,7 @@
|
|
|
2282
2286
|
},
|
|
2283
2287
|
{
|
|
2284
2288
|
"path": "src/oauth/index.ts",
|
|
2285
|
-
"sha256": "
|
|
2289
|
+
"sha256": "9e84daa06ac81db3da3e8af026add2ed59085ff367da15ba9a53123ccc4d0346"
|
|
2286
2290
|
},
|
|
2287
2291
|
{
|
|
2288
2292
|
"path": "src/oauth/key-providers.ts",
|
|
@@ -2306,7 +2310,7 @@
|
|
|
2306
2310
|
},
|
|
2307
2311
|
{
|
|
2308
2312
|
"path": "src/oauth/log.ts",
|
|
2309
|
-
"sha256": "
|
|
2313
|
+
"sha256": "bafd097b059bc2aaa81b1f0a50dad867ab32673218732ea592d7e84b5bc376d0"
|
|
2310
2314
|
},
|
|
2311
2315
|
{
|
|
2312
2316
|
"path": "src/oauth/login-cli.ts",
|
|
@@ -2460,6 +2464,10 @@
|
|
|
2460
2464
|
"path": "src/providers/model-rename-startup.ts",
|
|
2461
2465
|
"sha256": "8a143284d736759bf63c8cb296c19e61dec9596dfc781d8dd87038778153f55f"
|
|
2462
2466
|
},
|
|
2467
|
+
{
|
|
2468
|
+
"path": "src/providers/muse-subscription-usage.ts",
|
|
2469
|
+
"sha256": "e4e7ee3aa1632122177d2552137e1a97faedd5686b58d304c56741fff78fd564"
|
|
2470
|
+
},
|
|
2463
2471
|
{
|
|
2464
2472
|
"path": "src/providers/new-model-policy.ts",
|
|
2465
2473
|
"sha256": "02c7c4000aa9a8bdd8264027fa4e07844f09c92eda516afec7c2270818ae1618"
|
|
@@ -2510,11 +2518,11 @@
|
|
|
2510
2518
|
},
|
|
2511
2519
|
{
|
|
2512
2520
|
"path": "src/providers/quota.ts",
|
|
2513
|
-
"sha256": "
|
|
2521
|
+
"sha256": "112799e24b19bb0ef3403ebe530f32ab462e581982c4dfd285f249907af2210b"
|
|
2514
2522
|
},
|
|
2515
2523
|
{
|
|
2516
2524
|
"path": "src/providers/registry.ts",
|
|
2517
|
-
"sha256": "
|
|
2525
|
+
"sha256": "8fcf151ab118c70cea0e2a07de6f516bfcadbd5834cadb65063c803294152032"
|
|
2518
2526
|
},
|
|
2519
2527
|
{
|
|
2520
2528
|
"path": "src/providers/request-pacing.ts",
|
|
@@ -2798,7 +2806,7 @@
|
|
|
2798
2806
|
},
|
|
2799
2807
|
{
|
|
2800
2808
|
"path": "src/server/index.ts",
|
|
2801
|
-
"sha256": "
|
|
2809
|
+
"sha256": "6d920629f3dccac784e3f1bdf34068a39b64d17fe403efe696c241718613e82f"
|
|
2802
2810
|
},
|
|
2803
2811
|
{
|
|
2804
2812
|
"path": "src/server/lifecycle.ts",
|
|
@@ -2806,7 +2814,7 @@
|
|
|
2806
2814
|
},
|
|
2807
2815
|
{
|
|
2808
2816
|
"path": "src/server/live.ts",
|
|
2809
|
-
"sha256": "
|
|
2817
|
+
"sha256": "6347a6dd273bb6d5d2c1ba9826497087edb59f0c61d4c10196ddfbd01f3b6205"
|
|
2810
2818
|
},
|
|
2811
2819
|
{
|
|
2812
2820
|
"path": "src/server/local-management-read-client.ts",
|
|
@@ -2894,7 +2902,7 @@
|
|
|
2894
2902
|
},
|
|
2895
2903
|
{
|
|
2896
2904
|
"path": "src/server/management/oauth-account-routes.ts",
|
|
2897
|
-
"sha256": "
|
|
2905
|
+
"sha256": "7f63a560692d3fe3e7c4a9614819c44fe183f970a38c7120fe7bd3a345c6ae88"
|
|
2898
2906
|
},
|
|
2899
2907
|
{
|
|
2900
2908
|
"path": "src/server/management/provider-capability-config.ts",
|
|
@@ -3094,11 +3102,11 @@
|
|
|
3094
3102
|
},
|
|
3095
3103
|
{
|
|
3096
3104
|
"path": "src/server/responses/core.ts",
|
|
3097
|
-
"sha256": "
|
|
3105
|
+
"sha256": "73dc56d86793a3b7e738bb2ac0a7b60be003688df060d748aa7fd30ef7950674"
|
|
3098
3106
|
},
|
|
3099
3107
|
{
|
|
3100
3108
|
"path": "src/server/responses/empty-completion-guard.ts",
|
|
3101
|
-
"sha256": "
|
|
3109
|
+
"sha256": "069cdad33a1fd996d37541d13be189223dda4971e2e54b9bd65c76dda97102ce"
|
|
3102
3110
|
},
|
|
3103
3111
|
{
|
|
3104
3112
|
"path": "src/server/responses/encrypted-payload.ts",
|
|
@@ -3290,7 +3298,7 @@
|
|
|
3290
3298
|
},
|
|
3291
3299
|
{
|
|
3292
3300
|
"path": "src/types/request.ts",
|
|
3293
|
-
"sha256": "
|
|
3301
|
+
"sha256": "30272ac9e6abf66754171d938b2a8f6be8b44d2f9f153380e22e7a786fd8f08e"
|
|
3294
3302
|
},
|
|
3295
3303
|
{
|
|
3296
3304
|
"path": "src/types/tools.ts",
|
|
@@ -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
|
+
}
|
package/src/oauth/chatgpt.ts
CHANGED
|
@@ -5,6 +5,10 @@ import { generatePKCE } from "./pkce";
|
|
|
5
5
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
6
6
|
const AUTH_URL = "https://auth.openai.com/oauth/authorize";
|
|
7
7
|
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
8
|
+
|
|
9
|
+
/** Shared with the deviceauth grant in `./chatgpt-device`: same public PKCE client. */
|
|
10
|
+
export const CHATGPT_CLIENT_ID = CLIENT_ID;
|
|
11
|
+
export const CHATGPT_TOKEN_URL = TOKEN_URL;
|
|
8
12
|
const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
|
|
9
13
|
const CALLBACK_PORT = 1455;
|
|
10
14
|
const CALLBACK_PATH = "/auth/callback";
|
|
@@ -46,9 +50,17 @@ export function extractEmail(idToken?: string, accessToken?: string): string | u
|
|
|
46
50
|
return undefined;
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
|
|
53
|
+
export function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
|
|
50
54
|
const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
|
|
51
|
-
|
|
55
|
+
// This parses a response from an external boundary, so the access token is
|
|
56
|
+
// validated rather than cast. A 200 carrying no access_token would otherwise
|
|
57
|
+
// resolve a login as successful with an undefined credential, which then gets
|
|
58
|
+
// silently declined at persistence — a success message and no account.
|
|
59
|
+
const accessToken = typeof data.access_token === "string" && data.access_token.length > 0
|
|
60
|
+
? data.access_token
|
|
61
|
+
: undefined;
|
|
62
|
+
if (!accessToken) throw new Error("ChatGPT token response missing access token");
|
|
63
|
+
const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
|
|
52
64
|
// ?? only guards null/undefined; NaN or a string expires_in would otherwise
|
|
53
65
|
// produce a NaN expiry that never compares as expired, and a negative duration
|
|
54
66
|
// would stamp an already-past expiry — both block refresh semantics.
|
|
@@ -62,7 +74,7 @@ function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
|
|
|
62
74
|
const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000;
|
|
63
75
|
return {
|
|
64
76
|
access: accessToken,
|
|
65
|
-
refresh:
|
|
77
|
+
refresh: refreshToken,
|
|
66
78
|
expires,
|
|
67
79
|
accountId: extractAccountId(idToken, accessToken),
|
|
68
80
|
email: extractEmail(idToken, accessToken),
|
|
@@ -135,7 +147,22 @@ function safeErrorDescription(resp: Response): Promise<string> {
|
|
|
135
147
|
});
|
|
136
148
|
}
|
|
137
149
|
|
|
138
|
-
|
|
150
|
+
/**
|
|
151
|
+
* How the user proves identity. `browser` runs the localhost:1455 callback flow;
|
|
152
|
+
* `device` runs the deviceauth grant, which needs no local browser or listener
|
|
153
|
+
* and is the only workable path on a headless or remote hub (#3366).
|
|
154
|
+
*/
|
|
155
|
+
export type ChatGPTLoginFlow = "browser" | "device";
|
|
156
|
+
|
|
157
|
+
export async function loginChatGPT(
|
|
158
|
+
ctrl: OAuthController,
|
|
159
|
+
opts?: { forceLogin?: boolean; flow?: ChatGPTLoginFlow },
|
|
160
|
+
): Promise<OAuthCredentials> {
|
|
161
|
+
if (opts?.flow === "device") {
|
|
162
|
+
// Imported lazily so the callback flow does not pay for a module it never uses.
|
|
163
|
+
const { loginChatGPTDevice } = await import("./chatgpt-device");
|
|
164
|
+
return loginChatGPTDevice(ctrl);
|
|
165
|
+
}
|
|
139
166
|
const flow = new ChatGPTOAuthFlow(ctrl);
|
|
140
167
|
if (opts?.forceLogin) flow.forceLogin = true;
|
|
141
168
|
return flow.login();
|
package/src/oauth/index.ts
CHANGED
|
@@ -34,7 +34,7 @@ import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenReques
|
|
|
34
34
|
import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
35
35
|
import { loginKimi, refreshKimiToken } from "./kimi";
|
|
36
36
|
import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous";
|
|
37
|
-
import { loginChatGPT, refreshChatGPTToken } from "./chatgpt";
|
|
37
|
+
import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt";
|
|
38
38
|
import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
|
|
39
39
|
import { loginCursor, refreshCursorToken } from "./cursor";
|
|
40
40
|
import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
|
|
@@ -161,7 +161,17 @@ function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${
|
|
|
161
161
|
function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=verdictKey(p,a,c),u=permanentRefreshFailures.get(k);if(u===undefined)return false;if(u<=now()){permanentRefreshFailures.delete(k);return false;}return true;}
|
|
162
162
|
export function sweepExpiredXaiPermanentFailureVerdicts(now=Date.now()):number{let removed=0;for(const[key,until]of permanentRefreshFailures){if(until>now)continue;permanentRefreshFailures.delete(key);removed+=1;}return removed;}
|
|
163
163
|
|
|
164
|
-
export interface LoginOpts {
|
|
164
|
+
export interface LoginOpts {
|
|
165
|
+
forceLogin?: boolean;
|
|
166
|
+
/** When set, persist into this account slot and require matching identity. */
|
|
167
|
+
reauthAccountId?: string;
|
|
168
|
+
/**
|
|
169
|
+
* ChatGPT only: `device` selects the deviceauth grant instead of the
|
|
170
|
+
* localhost:1455 callback flow, for hosts with no browser or no loopback
|
|
171
|
+
* listener (#3366). Ignored by every other provider.
|
|
172
|
+
*/
|
|
173
|
+
flow?: ChatGPTLoginFlow;
|
|
174
|
+
}
|
|
165
175
|
|
|
166
176
|
export interface LoginFlowLifecycle {
|
|
167
177
|
/** Runs after background credential/config persistence settles, before status becomes done. */
|
|
@@ -282,7 +292,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
|
|
|
282
292
|
defaultRefreshPolicy: "lazy-only",
|
|
283
293
|
},
|
|
284
294
|
chatgpt: {
|
|
285
|
-
login: loginChatGPT,
|
|
295
|
+
login: (ctrl, opts) => loginChatGPT(ctrl, { forceLogin: opts?.forceLogin, flow: opts?.flow }),
|
|
286
296
|
refresh: (rt) => refreshChatGPTToken(rt),
|
|
287
297
|
providerConfig: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const },
|
|
288
298
|
defaultModel: "gpt-5.4",
|
package/src/oauth/log.ts
CHANGED
|
@@ -23,6 +23,9 @@ const FORBIDDEN_NORMALIZED = new Set([
|
|
|
23
23
|
"oauth_code",
|
|
24
24
|
"code_verifier",
|
|
25
25
|
"clientsecret",
|
|
26
|
+
// Device-flow polling handle. Not a token, but it is the bearer of an
|
|
27
|
+
// in-flight authorization and must not be logged.
|
|
28
|
+
"device_auth_id",
|
|
26
29
|
]);
|
|
27
30
|
|
|
28
31
|
function isForbiddenFieldKey(key: string): boolean {
|