mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Merge Azure OpenAI auth into local main
Remote main already contains PR #217. Local main had provider context-window and auth-strategy work, so this merge keeps both context resolvers and reconciles the desktop Bun lockfile with the merged desktop package manifest. The accidental remote .nvimlog artifact is excluded from the merge result. Constraint: Local main was 15 commits ahead and 8 commits behind origin/main after PR #217 was merged remotely Rejected: Fast-forward pull | local main had unpushed commits Rejected: Keep remote desktop lockfile verbatim | failed bun install --frozen-lockfile against the merged package.json Confidence: high Scope-risk: broad Directive: Keep provider-configured context windows ahead of OpenAI OAuth model windows so explicit user/provider settings win Tested: bun install --frozen-lockfile; cd desktop && bun install --frozen-lockfile; git diff --check; bun run check:server; bun run check:desktop; ALLOW_CLI_CORE_CHANGE=1 bun run quality:pr Not-tested: live provider OAuth login with a real OpenAI account
This commit is contained in:
commit
8a81636757
132
desktop/bun.lock
132
desktop/bun.lock
@ -29,11 +29,11 @@
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^25.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.7",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^3.0.4",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@ -56,36 +56,18 @@
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.2", "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.2", "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
@ -116,6 +98,12 @@
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "https://registry.npmmirror.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="],
|
||||
|
||||
"@emotion/cache": ["@emotion/cache@11.14.0", "https://registry.npmmirror.com/@emotion/cache/-/cache-11.14.0.tgz", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="],
|
||||
@ -208,7 +196,41 @@
|
||||
|
||||
"@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.1.0.tgz", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.127.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.127.0.tgz", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
|
||||
|
||||
@ -350,16 +372,10 @@
|
||||
|
||||
"@testing-library/react": ["@testing-library/react@16.3.2", "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.2.tgz", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/d3": ["@types/d3@7.4.3", "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
|
||||
@ -460,7 +476,7 @@
|
||||
|
||||
"@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "https://registry.npmmirror.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@3.2.4", "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.4.tgz", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||
|
||||
@ -498,10 +514,6 @@
|
||||
|
||||
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "https://registry.npmmirror.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.14", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", { "bin": "dist/cli.cjs" }, "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
@ -510,8 +522,6 @@
|
||||
|
||||
"camelcase": ["camelcase@5.3.1", "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001784", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", {}, "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
@ -548,7 +558,7 @@
|
||||
|
||||
"confbox": ["confbox@0.1.8", "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
"convert-source-map": ["convert-source-map@1.9.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-1.9.0.tgz", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
|
||||
|
||||
"cose-base": ["cose-base@1.0.3", "https://registry.npmmirror.com/cose-base/-/cose-base-1.0.3.tgz", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
|
||||
|
||||
@ -668,8 +678,6 @@
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.331", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||
@ -690,8 +698,6 @@
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
|
||||
@ -712,8 +718,6 @@
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
@ -786,8 +790,6 @@
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"katex": ["katex@0.16.45", "https://registry.npmmirror.com/katex/-/katex-0.16.45.tgz", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="],
|
||||
|
||||
"khroma": ["khroma@2.1.0", "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
|
||||
@ -832,7 +834,7 @@
|
||||
|
||||
"loupe": ["loupe@3.2.1", "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
"lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.469.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.469.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="],
|
||||
|
||||
@ -918,8 +920,6 @@
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.37", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.37.tgz", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||
|
||||
"nwsapi": ["nwsapi@2.2.23", "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.23.tgz", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
|
||||
|
||||
"oniguruma-parser": ["oniguruma-parser@0.12.1", "https://registry.npmmirror.com/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
|
||||
@ -966,7 +966,7 @@
|
||||
|
||||
"points-on-path": ["points-on-path@0.2.1", "https://registry.npmmirror.com/points-on-path/-/points-on-path-0.2.1.tgz", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
"postcss": ["postcss@8.5.13", "https://registry.npmmirror.com/postcss/-/postcss-8.5.13.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||
|
||||
@ -988,8 +988,6 @@
|
||||
|
||||
"react-is": ["react-is@17.0.2", "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"react-shiki": ["react-shiki@0.9.2", "https://registry.npmmirror.com/react-shiki/-/react-shiki-0.9.2.tgz", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^4.0.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" } }, "sha512-TMj2IYm9mWUhWF+OEffdfnaYEpYLr3vs5b8xRcnuRYR4Dkk2TU2zT9RlbSlktl5mLNdiioDnkEqZYzsGYjYQNA=="],
|
||||
|
||||
"redent": ["redent@3.0.0", "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
@ -1010,6 +1008,8 @@
|
||||
|
||||
"robust-predicates": ["robust-predicates@3.0.3", "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.17", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.17.tgz", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="],
|
||||
|
||||
"rollup": ["rollup@4.60.1", "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
||||
|
||||
"roughjs": ["roughjs@4.6.6", "https://registry.npmmirror.com/roughjs/-/roughjs-4.6.6.tgz", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
|
||||
@ -1024,8 +1024,6 @@
|
||||
|
||||
"scheduler": ["scheduler@0.23.2", "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
|
||||
"shiki": ["shiki@4.0.2", "https://registry.npmmirror.com/shiki/-/shiki-4.0.2.tgz", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
|
||||
@ -1070,7 +1068,7 @@
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"tinypool": ["tinypool@1.1.1", "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||
|
||||
@ -1090,6 +1088,8 @@
|
||||
|
||||
"ts-dedent": ["ts-dedent@2.2.0", "https://registry.npmmirror.com/ts-dedent/-/ts-dedent-2.2.0.tgz", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ufo": ["ufo@1.6.3", "https://registry.npmmirror.com/ufo/-/ufo-1.6.3.tgz", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
|
||||
@ -1104,8 +1104,6 @@
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@11.1.0", "https://registry.npmmirror.com/uuid/-/uuid-11.1.0.tgz", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||
@ -1114,7 +1112,7 @@
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@6.4.1", "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx"], "bin": "bin/vite.js" }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
"vite": ["vite@8.0.10", "https://registry.npmmirror.com/vite/-/vite-8.0.10.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="],
|
||||
|
||||
"vite-node": ["vite-node@3.2.4", "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": "vite-node.mjs" }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||
|
||||
@ -1156,8 +1154,6 @@
|
||||
|
||||
"y18n": ["y18n@4.0.3", "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yaml": ["yaml@2.8.3", "https://registry.npmmirror.com/yaml/-/yaml-2.8.3.tgz", { "bin": "bin.mjs" }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
|
||||
|
||||
"yargs": ["yargs@15.4.1", "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||
@ -1170,10 +1166,6 @@
|
||||
|
||||
"@antfu/install-pkg/tinyexec": ["tinyexec@1.1.1", "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.1.tgz", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
|
||||
|
||||
"@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-1.9.0.tgz", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
|
||||
|
||||
"@emotion/babel-plugin/stylis": ["stylis@4.2.0", "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
|
||||
|
||||
"@emotion/cache/stylis": ["stylis@4.2.0", "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
|
||||
@ -1200,12 +1192,26 @@
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="],
|
||||
|
||||
"strip-literal/js-tokens": ["js-tokens@9.0.1", "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
|
||||
"vite-node/vite": ["vite@6.4.1", "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx"], "bin": "bin/vite.js" }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
"vitest/tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"vitest/vite": ["vite@6.4.1", "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx"], "bin": "bin/vite.js" }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "https://registry.npmmirror.com/layout-base/-/layout-base-2.0.1.tgz", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
|
||||
|
||||
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
||||
|
||||
"vite-node/vite/postcss": ["postcss@8.5.8", "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"vite-node/vite/tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"vitest/vite/postcss": ["postcss@8.5.8", "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,11 +40,11 @@
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^25.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.7",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^3.0.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@ -20,7 +20,6 @@ import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import {
|
||||
getFileExtension,
|
||||
normalizePrismLanguage,
|
||||
WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD,
|
||||
WORKSPACE_PREVIEW_LINE_LIMIT,
|
||||
WorkspaceDiffSurface,
|
||||
workspacePrismTheme,
|
||||
@ -321,7 +320,7 @@ function CodeSurface({
|
||||
const lines = value.split('\n')
|
||||
const visibleLines = showAllLines ? lines : lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT)
|
||||
const activeQuote = commentLine ? visibleLines[commentLine - 1] ?? '' : ''
|
||||
const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD
|
||||
const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PREVIEW_LINE_LIMIT
|
||||
const visibleCode = usePlainLargePreview ? '' : visibleLines.join('\n')
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -20,13 +20,19 @@ import {
|
||||
import { getOauthProfileFromOauthToken } from '../../services/oauth/getOauthProfile.js'
|
||||
import { OAuthService } from '../../services/oauth/index.js'
|
||||
import type { OAuthTokens } from '../../services/oauth/types.js'
|
||||
import { OpenAIOAuthService } from '../../services/openaiAuth/index.js'
|
||||
import { getOpenAIOAuthTokens } from '../../services/openaiAuth/storage.js'
|
||||
import type { OpenAIOAuthTokens } from '../../services/openaiAuth/types.js'
|
||||
import {
|
||||
clearStoredClaudeAIOAuthTokens,
|
||||
clearOAuthTokenCache,
|
||||
getAnthropicApiKeyWithSource,
|
||||
getAuthTokenSource,
|
||||
getOpenAIAuthOverrideWarning,
|
||||
getOauthAccountInfo,
|
||||
getSubscriptionType,
|
||||
isUsing3PServices,
|
||||
removeApiKey,
|
||||
saveOAuthTokensIfNeeded,
|
||||
validateForceLoginOrg,
|
||||
} from '../../utils/auth.js'
|
||||
@ -109,17 +115,75 @@ export async function installOAuthTokens(tokens: OAuthTokens): Promise<void> {
|
||||
await clearAuthRelatedCaches()
|
||||
}
|
||||
|
||||
export async function installOpenAIOAuthTokens(
|
||||
tokens: OpenAIOAuthTokens,
|
||||
): Promise<string | null> {
|
||||
await removeApiKey()
|
||||
const clearClaudeOauth = clearStoredClaudeAIOAuthTokens()
|
||||
if (!clearClaudeOauth.success) {
|
||||
throw new Error(
|
||||
clearClaudeOauth.warning ?? 'Failed to disable existing Claude auth state',
|
||||
)
|
||||
}
|
||||
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
oauthAccount: undefined,
|
||||
}))
|
||||
|
||||
if (!tokens.refreshToken) {
|
||||
throw new Error('OpenAI OAuth tokens are incomplete.')
|
||||
}
|
||||
|
||||
await clearAuthRelatedCaches()
|
||||
return getOpenAIAuthOverrideWarning()
|
||||
}
|
||||
|
||||
export async function authLogin({
|
||||
email,
|
||||
sso,
|
||||
console: useConsole,
|
||||
claudeai,
|
||||
openai,
|
||||
}: {
|
||||
email?: string
|
||||
sso?: boolean
|
||||
console?: boolean
|
||||
claudeai?: boolean
|
||||
openai?: boolean
|
||||
}): Promise<void> {
|
||||
if (openai) {
|
||||
if (email || sso || useConsole || claudeai) {
|
||||
process.stderr.write(
|
||||
'Error: --openai cannot be combined with --email, --sso, --console, or --claudeai.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const openaiOAuthService = new OpenAIOAuthService()
|
||||
|
||||
try {
|
||||
const tokens = await openaiOAuthService.startOAuthFlow(async url => {
|
||||
process.stdout.write('Opening browser to sign in to OpenAI…\n')
|
||||
process.stdout.write(`If the browser did not open, visit: ${url}\n`)
|
||||
})
|
||||
|
||||
const warning = await installOpenAIOAuthTokens(tokens)
|
||||
|
||||
process.stdout.write('OpenAI login successful.\n')
|
||||
if (warning) {
|
||||
process.stdout.write(`${warning}\n`)
|
||||
}
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
logError(err)
|
||||
process.stderr.write(`OpenAI login failed: ${errorMessage(err)}\n`)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
openaiOAuthService.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
if (useConsole && claudeai) {
|
||||
process.stderr.write(
|
||||
'Error: --console and --claudeai cannot be used together.\n',
|
||||
@ -232,7 +296,52 @@ export async function authLogin({
|
||||
export async function authStatus(opts: {
|
||||
json?: boolean
|
||||
text?: boolean
|
||||
openai?: boolean
|
||||
}): Promise<void> {
|
||||
if (opts.openai) {
|
||||
const openaiTokens = getOpenAIOAuthTokens()
|
||||
const loggedIn =
|
||||
!!openaiTokens &&
|
||||
openaiTokens.refreshToken.length > 0 &&
|
||||
openaiTokens.expiresAt > 0
|
||||
|
||||
if (opts.text) {
|
||||
if (!loggedIn) {
|
||||
process.stdout.write(
|
||||
'Not logged in to OpenAI. Run claude auth login --openai to authenticate.\n',
|
||||
)
|
||||
} else {
|
||||
process.stdout.write('Provider: openai\n')
|
||||
if (openaiTokens.email) {
|
||||
process.stdout.write(`Email: ${openaiTokens.email}\n`)
|
||||
}
|
||||
if (openaiTokens.accountId) {
|
||||
process.stdout.write(`Account ID: ${openaiTokens.accountId}\n`)
|
||||
}
|
||||
process.stdout.write(
|
||||
`Expires At: ${new Date(openaiTokens.expiresAt).toISOString()}\n`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(
|
||||
jsonStringify(
|
||||
{
|
||||
loggedIn,
|
||||
authMethod: loggedIn ? 'openai_oauth' : 'none',
|
||||
provider: 'openai',
|
||||
email: openaiTokens?.email ?? null,
|
||||
accountId: openaiTokens?.accountId ?? null,
|
||||
expiresAt: openaiTokens?.expiresAt ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
)
|
||||
}
|
||||
|
||||
process.exit(loggedIn ? 0 : 1)
|
||||
}
|
||||
|
||||
const { source: authTokenSource, hasToken } = getAuthTokenSource()
|
||||
const { source: apiKeySource } = getAnthropicApiKeyWithSource()
|
||||
const hasApiKeyEnvVar =
|
||||
@ -240,12 +349,25 @@ export async function authStatus(opts: {
|
||||
const oauthAccount = getOauthAccountInfo()
|
||||
const subscriptionType = getSubscriptionType()
|
||||
const using3P = isUsing3PServices()
|
||||
const openaiTokens = getOpenAIOAuthTokens()
|
||||
const usingOpenAI =
|
||||
!!openaiTokens?.refreshToken &&
|
||||
!hasToken &&
|
||||
apiKeySource === 'none' &&
|
||||
!hasApiKeyEnvVar &&
|
||||
!using3P
|
||||
const loggedIn =
|
||||
hasToken || apiKeySource !== 'none' || hasApiKeyEnvVar || using3P
|
||||
hasToken ||
|
||||
apiKeySource !== 'none' ||
|
||||
hasApiKeyEnvVar ||
|
||||
using3P ||
|
||||
usingOpenAI
|
||||
|
||||
// Determine auth method
|
||||
let authMethod: string = 'none'
|
||||
if (using3P) {
|
||||
if (usingOpenAI) {
|
||||
authMethod = 'openai_oauth'
|
||||
} else if (using3P) {
|
||||
authMethod = 'third_party'
|
||||
} else if (authTokenSource === 'claude.ai') {
|
||||
authMethod = 'claude.ai'
|
||||
@ -311,6 +433,11 @@ export async function authStatus(opts: {
|
||||
output.orgId = oauthAccount?.organizationUuid ?? null
|
||||
output.orgName = oauthAccount?.organizationName ?? null
|
||||
output.subscriptionType = subscriptionType ?? null
|
||||
} else if (authMethod === 'openai_oauth') {
|
||||
output.provider = 'openai'
|
||||
output.email = openaiTokens?.email ?? null
|
||||
output.accountId = openaiTokens?.accountId ?? null
|
||||
output.subscriptionType = 'ChatGPT Pro/Plus'
|
||||
}
|
||||
|
||||
process.stdout.write(jsonStringify(output, null, 2) + '\n')
|
||||
@ -318,7 +445,18 @@ export async function authStatus(opts: {
|
||||
process.exit(loggedIn ? 0 : 1)
|
||||
}
|
||||
|
||||
export async function authLogout(): Promise<void> {
|
||||
export async function authLogout(opts?: { openai?: boolean }): Promise<void> {
|
||||
if (opts?.openai) {
|
||||
const openaiOAuthService = new OpenAIOAuthService()
|
||||
const success = openaiOAuthService.logout()
|
||||
if (!success) {
|
||||
process.stderr.write('Failed to log out from OpenAI.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write('Successfully logged out from your OpenAI account.\n')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
await performLogout({ clearOnboarding: false })
|
||||
} catch {
|
||||
|
||||
File diff suppressed because one or more lines are too long
286
src/components/OpenAILoginFlow.tsx
Normal file
286
src/components/OpenAILoginFlow.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { installOpenAIOAuthTokens } from '../cli/handlers/auth.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { setClipboard } from '../ink/termio/osc.js'
|
||||
import { useTerminalNotification } from '../ink/useTerminalNotification.js'
|
||||
import { Box, Link, Text } from '../ink.js'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
import { getSSLErrorHint } from '../services/api/errorUtils.js'
|
||||
import { sendNotification } from '../services/notifier.js'
|
||||
import { OpenAIOAuthService } from '../services/openaiAuth/index.js'
|
||||
import { getOpenAIOAuthTokens } from '../services/openaiAuth/storage.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'
|
||||
import { Spinner } from './Spinner.js'
|
||||
import TextInput from './TextInput.js'
|
||||
|
||||
type Props = {
|
||||
onDone(): void
|
||||
startingMessage?: string
|
||||
}
|
||||
|
||||
type OpenAILoginStatus =
|
||||
| { state: 'ready_to_start' }
|
||||
| { state: 'waiting_for_login'; url: string }
|
||||
| { state: 'success'; warning?: string | null }
|
||||
| { state: 'error'; message: string; toRetry?: OpenAILoginStatus }
|
||||
| { state: 'about_to_retry'; nextState: OpenAILoginStatus }
|
||||
|
||||
const PASTE_HERE_MSG = 'Paste code here if prompted > '
|
||||
|
||||
export function OpenAILoginFlow({
|
||||
onDone,
|
||||
startingMessage,
|
||||
}: Props): React.ReactNode {
|
||||
const [oauthService] = useState(() => new OpenAIOAuthService())
|
||||
const [status, setStatus] = useState<OpenAILoginStatus>({
|
||||
state: 'ready_to_start',
|
||||
})
|
||||
|
||||
// After a few seconds we suggest the user to copy/paste url if the
|
||||
// browser did not open automatically.
|
||||
const [showPastePrompt, setShowPastePrompt] = useState(false)
|
||||
const [urlCopied, setUrlCopied] = useState(false)
|
||||
const [pastedCode, setPastedCode] = useState('')
|
||||
const [cursorOffset, setCursorOffset] = useState(0)
|
||||
const terminal = useTerminalNotification()
|
||||
const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1
|
||||
|
||||
// Handle Enter to continue on success state
|
||||
useKeybinding(
|
||||
'confirm:yes',
|
||||
() => {
|
||||
logEvent('tengu_openai_oauth_success', {})
|
||||
onDone()
|
||||
},
|
||||
{
|
||||
context: 'Confirmation',
|
||||
isActive: status.state === 'success',
|
||||
},
|
||||
)
|
||||
|
||||
// Handle Enter to retry on error state
|
||||
useKeybinding(
|
||||
'confirm:yes',
|
||||
() => {
|
||||
if (status.state === 'error' && status.toRetry) {
|
||||
setPastedCode('')
|
||||
setStatus({
|
||||
state: 'about_to_retry',
|
||||
nextState: status.toRetry,
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
context: 'Confirmation',
|
||||
isActive: status.state === 'error' && !!status.toRetry,
|
||||
},
|
||||
)
|
||||
|
||||
// Clipboard copy: type 'c' to copy the auth URL
|
||||
useEffect(() => {
|
||||
if (
|
||||
pastedCode === 'c' &&
|
||||
status.state === 'waiting_for_login' &&
|
||||
showPastePrompt &&
|
||||
!urlCopied
|
||||
) {
|
||||
void setClipboard(status.url).then(raw => {
|
||||
if (raw) process.stdout.write(raw)
|
||||
setUrlCopied(true)
|
||||
setTimeout(setUrlCopied, 2000, false)
|
||||
})
|
||||
setPastedCode('')
|
||||
}
|
||||
}, [pastedCode, status, showPastePrompt, urlCopied])
|
||||
|
||||
// Retry logic
|
||||
useEffect(() => {
|
||||
if (status.state === 'about_to_retry') {
|
||||
const timer = setTimeout(setStatus, 1000, status.nextState)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [status])
|
||||
|
||||
const handleSubmitCode = useCallback(
|
||||
async (value: string, url: string) => {
|
||||
try {
|
||||
const [authorizationCode, state] = value.split('#')
|
||||
if (!authorizationCode || !state) {
|
||||
setStatus({
|
||||
state: 'error',
|
||||
message: 'Invalid code. Please make sure the full code was copied',
|
||||
toRetry: { state: 'waiting_for_login', url },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logEvent('tengu_openai_oauth_manual_entry', {})
|
||||
oauthService.handleManualAuthCodeInput({
|
||||
authorizationCode,
|
||||
state,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
logError(err)
|
||||
setStatus({
|
||||
state: 'error',
|
||||
message: (err as Error).message,
|
||||
toRetry: { state: 'waiting_for_login', url },
|
||||
})
|
||||
}
|
||||
},
|
||||
[oauthService],
|
||||
)
|
||||
|
||||
const startOAuth = useCallback(async () => {
|
||||
try {
|
||||
logEvent('tengu_openai_oauth_flow_start', {})
|
||||
const tokens = await oauthService.startOAuthFlow(
|
||||
async url => {
|
||||
setStatus({ state: 'waiting_for_login', url })
|
||||
setTimeout(setShowPastePrompt, 3000, true)
|
||||
},
|
||||
)
|
||||
const warning = await installOpenAIOAuthTokens(tokens)
|
||||
setStatus({ state: 'success', warning })
|
||||
void sendNotification(
|
||||
{
|
||||
message: 'Claude Code Haha OpenAI login successful',
|
||||
notificationType: 'auth_success',
|
||||
},
|
||||
terminal,
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message
|
||||
const sslHint = getSSLErrorHint(error)
|
||||
logError(error)
|
||||
setStatus({
|
||||
state: 'error',
|
||||
message: sslHint ?? errorMessage,
|
||||
toRetry: { state: 'ready_to_start' },
|
||||
})
|
||||
logEvent('tengu_openai_oauth_error', {
|
||||
error: errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
ssl_error: sslHint !== null,
|
||||
})
|
||||
}
|
||||
}, [oauthService, terminal])
|
||||
|
||||
const pendingOAuthStartRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
status.state === 'ready_to_start' &&
|
||||
!pendingOAuthStartRef.current
|
||||
) {
|
||||
pendingOAuthStartRef.current = true
|
||||
process.nextTick(() => {
|
||||
void startOAuth()
|
||||
pendingOAuthStartRef.current = false
|
||||
})
|
||||
}
|
||||
}, [status.state, startOAuth])
|
||||
|
||||
// Cleanup OAuth service when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
oauthService.cleanup()
|
||||
}
|
||||
}, [oauthService])
|
||||
|
||||
const account = getOpenAIOAuthTokens()
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{status.state === 'waiting_for_login' && showPastePrompt && (
|
||||
<Box flexDirection="column" key="urlToCopy" gap={1} paddingBottom={1}>
|
||||
<Box paddingX={1}>
|
||||
<Text dimColor>
|
||||
Browser didn't open? Use the url below to sign in{' '}
|
||||
</Text>
|
||||
{urlCopied ? (
|
||||
<Text color="success">(Copied!)</Text>
|
||||
) : (
|
||||
<Text dimColor>
|
||||
<KeyboardShortcutHint shortcut="c" action="copy" parens />
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Link url={status.url}>
|
||||
<Text dimColor>{status.url}</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text bold>
|
||||
{startingMessage ??
|
||||
'Claude Code can also run through your ChatGPT subscription via OpenAI auth.'}
|
||||
</Text>
|
||||
|
||||
{status.state === 'waiting_for_login' && !showPastePrompt && (
|
||||
<Box>
|
||||
<Spinner />
|
||||
<Text>Opening browser to sign in to OpenAI…</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status.state === 'waiting_for_login' && showPastePrompt && (
|
||||
<Box>
|
||||
<Text>{PASTE_HERE_MSG}</Text>
|
||||
<TextInput
|
||||
value={pastedCode}
|
||||
onChange={setPastedCode}
|
||||
onSubmit={(value: string) =>
|
||||
handleSubmitCode(value, status.url)
|
||||
}
|
||||
cursorOffset={cursorOffset}
|
||||
onChangeCursorOffset={setCursorOffset}
|
||||
columns={textInputColumns}
|
||||
mask="*"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status.state === 'about_to_retry' && (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="permission">Retrying…</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status.state === 'success' && (
|
||||
<Box flexDirection="column">
|
||||
{account?.email ? (
|
||||
<Text dimColor>
|
||||
Logged in as <Text>{account.email}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{status.warning ? (
|
||||
<Text color="warning">{status.warning}</Text>
|
||||
) : null}
|
||||
<Text color="success">
|
||||
Login successful. Press <Text bold>Enter</Text> to continue…
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status.state === 'error' && (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="error">OpenAI OAuth error: {status.message}</Text>
|
||||
{status.toRetry && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="permission">
|
||||
Press <Text bold>Enter</Text> to retry.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
157
src/components/SlashLoginFlow.tsx
Normal file
157
src/components/SlashLoginFlow.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Box, Link, Text } from '../ink.js'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
|
||||
import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js'
|
||||
import { Select } from './CustomSelect/select.js'
|
||||
import { OpenAILoginFlow } from './OpenAILoginFlow.js'
|
||||
|
||||
type Props = {
|
||||
onDone(): void
|
||||
startingMessage?: string
|
||||
}
|
||||
|
||||
type LoginSelection = 'claudeai' | 'console' | 'openai' | 'platform' | 'idle'
|
||||
|
||||
function PlatformSetupFlow({
|
||||
onBack,
|
||||
}: {
|
||||
onBack(): void
|
||||
}): React.ReactNode {
|
||||
useKeybinding(
|
||||
'confirm:yes',
|
||||
() => {
|
||||
onBack()
|
||||
},
|
||||
{
|
||||
context: 'Confirmation',
|
||||
isActive: true,
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
<Text bold>Using 3rd-party platforms</Text>
|
||||
<Text>
|
||||
Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex AI.
|
||||
Set the required environment variables, then restart Claude Code.
|
||||
</Text>
|
||||
<Text>
|
||||
If you are part of an enterprise organization, contact your
|
||||
administrator for setup instructions.
|
||||
</Text>
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Documentation:</Text>
|
||||
<Text>
|
||||
· Amazon Bedrock:{' '}
|
||||
<Link url="https://code.claude.com/docs/en/amazon-bedrock" />
|
||||
</Text>
|
||||
<Text>
|
||||
· Microsoft Foundry:{' '}
|
||||
<Link url="https://code.claude.com/docs/en/microsoft-foundry" />
|
||||
</Text>
|
||||
<Text>
|
||||
· Vertex AI:{' '}
|
||||
<Link url="https://code.claude.com/docs/en/google-vertex-ai" />
|
||||
</Text>
|
||||
</Box>
|
||||
<Text dimColor>
|
||||
Press <Text bold>Enter</Text> to go back to login options.
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function SlashLoginFlow({
|
||||
onDone,
|
||||
startingMessage,
|
||||
}: Props): React.ReactNode {
|
||||
const settings = getSettings_DEPRECATED() || {}
|
||||
const forceLoginMethod = settings.forceLoginMethod
|
||||
|
||||
const [selection, setSelection] = useState<LoginSelection>(() => {
|
||||
if (forceLoginMethod === 'claudeai' || forceLoginMethod === 'console') {
|
||||
return forceLoginMethod
|
||||
}
|
||||
return 'idle'
|
||||
})
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
Claude account with subscription ·{' '}
|
||||
<Text dimColor>Pro, Max, Team, or Enterprise</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'claudeai' as const,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
Anthropic Console account ·{' '}
|
||||
<Text dimColor>API usage billing</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'console' as const,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
OpenAI account · <Text dimColor>ChatGPT Pro/Plus</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'openai' as const,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
3rd-party platform ·{' '}
|
||||
<Text dimColor>Amazon Bedrock, Microsoft Foundry, or Vertex AI</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'platform' as const,
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
if (selection === 'claudeai' || selection === 'console') {
|
||||
return (
|
||||
<ConsoleOAuthFlow
|
||||
onDone={onDone}
|
||||
startingMessage={startingMessage}
|
||||
forceLoginMethod={selection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (selection === 'openai') {
|
||||
return <OpenAILoginFlow onDone={onDone} startingMessage={startingMessage} />
|
||||
}
|
||||
|
||||
if (selection === 'platform') {
|
||||
return <PlatformSetupFlow onBack={() => setSelection('idle')} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
<Text bold>
|
||||
{startingMessage ??
|
||||
'Claude Code can be used with your Claude subscription, Anthropic Console billing, or your ChatGPT subscription.'}
|
||||
</Text>
|
||||
<Text>Select login method:</Text>
|
||||
<Box>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={value => setSelection(value as LoginSelection)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@ -6,6 +6,7 @@ import {
|
||||
getApiKeyFromApiKeyHelper,
|
||||
isAnthropicAuthEnabled,
|
||||
isClaudeAISubscriber,
|
||||
isOpenAIAuthActive,
|
||||
} from '../utils/auth.js'
|
||||
|
||||
export type VerificationStatus =
|
||||
@ -23,7 +24,7 @@ export type ApiKeyVerificationResult = {
|
||||
|
||||
export function useApiKeyVerification(): ApiKeyVerificationResult {
|
||||
const [status, setStatus] = useState<VerificationStatus>(() => {
|
||||
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
|
||||
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber() || isOpenAIAuthActive()) {
|
||||
return 'valid'
|
||||
}
|
||||
// Use skipRetrievingKeyFromApiKeyHelper to avoid executing apiKeyHelper
|
||||
@ -41,7 +42,7 @@ export function useApiKeyVerification(): ApiKeyVerificationResult {
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const verify = useCallback(async (): Promise<void> => {
|
||||
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
|
||||
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber() || isOpenAIAuthActive()) {
|
||||
setStatus('valid')
|
||||
return
|
||||
}
|
||||
|
||||
18
src/main.tsx
18
src/main.tsx
@ -4141,16 +4141,18 @@ async function run(): Promise<CommanderCommand> {
|
||||
// claude auth
|
||||
|
||||
const auth = program.command('auth').description('Manage authentication').configureHelp(createSortedHelpConfig());
|
||||
auth.command('login').description('Sign in to your Anthropic account').option('--email <email>', 'Pre-populate email address on the login page').option('--sso', 'Force SSO login flow').option('--console', 'Use Anthropic Console (API usage billing) instead of Claude subscription').option('--claudeai', 'Use Claude subscription (default)').action(async ({
|
||||
auth.command('login').description('Sign in to your Anthropic or OpenAI account').option('--email <email>', 'Pre-populate email address on the login page').option('--sso', 'Force SSO login flow').option('--console', 'Use Anthropic Console (API usage billing) instead of Claude subscription').option('--claudeai', 'Use Claude subscription (default)').option('--openai', 'Use OpenAI ChatGPT/Codex OAuth login').action(async ({
|
||||
email,
|
||||
sso,
|
||||
console: useConsole,
|
||||
claudeai
|
||||
claudeai,
|
||||
openai,
|
||||
}: {
|
||||
email?: string;
|
||||
sso?: boolean;
|
||||
console?: boolean;
|
||||
claudeai?: boolean;
|
||||
openai?: boolean;
|
||||
}) => {
|
||||
const {
|
||||
authLogin
|
||||
@ -4159,23 +4161,27 @@ async function run(): Promise<CommanderCommand> {
|
||||
email,
|
||||
sso,
|
||||
console: useConsole,
|
||||
claudeai
|
||||
claudeai,
|
||||
openai,
|
||||
});
|
||||
});
|
||||
auth.command('status').description('Show authentication status').option('--json', 'Output as JSON (default)').option('--text', 'Output as human-readable text').action(async (opts: {
|
||||
auth.command('status').description('Show authentication status').option('--json', 'Output as JSON (default)').option('--text', 'Output as human-readable text').option('--openai', 'Show OpenAI OAuth status').action(async (opts: {
|
||||
json?: boolean;
|
||||
text?: boolean;
|
||||
openai?: boolean;
|
||||
}) => {
|
||||
const {
|
||||
authStatus
|
||||
} = await import('./cli/handlers/auth.js');
|
||||
await authStatus(opts);
|
||||
});
|
||||
auth.command('logout').description('Log out from your Anthropic account').action(async () => {
|
||||
auth.command('logout').description('Log out from your Anthropic or OpenAI account').option('--openai', 'Log out from OpenAI OAuth only').action(async (opts: {
|
||||
openai?: boolean;
|
||||
}) => {
|
||||
const {
|
||||
authLogout
|
||||
} = await import('./cli/handlers/auth.js');
|
||||
await authLogout();
|
||||
await authLogout(opts);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
159
src/server/__tests__/haha-openai-oauth-api.test.ts
Normal file
159
src/server/__tests__/haha-openai-oauth-api.test.ts
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Integration tests for /api/haha-openai-oauth/* endpoints.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { handleHahaOpenAIOAuthApi } from '../api/haha-openai-oauth.js'
|
||||
import { hahaOpenAIOAuthService } from '../services/hahaOpenAIOAuthService.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
async function setup() {
|
||||
tmpDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'haha-openai-oauth-api-test-'),
|
||||
)
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function buildReq(
|
||||
method: string,
|
||||
pathname: string,
|
||||
body?: unknown,
|
||||
): { req: Request; url: URL; segments: string[] } {
|
||||
const url = new URL(`http://localhost:3456${pathname}`)
|
||||
const req = new Request(url.toString(), {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
return { req, url, segments }
|
||||
}
|
||||
|
||||
describe('POST /api/haha-openai-oauth/start', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
|
||||
test('returns authorize URL with PKCE challenge', async () => {
|
||||
const { req, url, segments } = buildReq(
|
||||
'POST',
|
||||
'/api/haha-openai-oauth/start',
|
||||
{ serverPort: 54321 },
|
||||
)
|
||||
const res = await handleHahaOpenAIOAuthApi(req, url, segments)
|
||||
expect(res.status).toBe(200)
|
||||
const data = (await res.json()) as { authorizeUrl: string; state: string }
|
||||
expect(data.authorizeUrl).toContain('code_challenge_method=S256')
|
||||
expect(data.authorizeUrl).toContain(
|
||||
'codex_cli_simplified_flow=true',
|
||||
)
|
||||
expect(data.authorizeUrl).toContain(
|
||||
encodeURIComponent('http://localhost:54321/auth/callback'),
|
||||
)
|
||||
expect(data.state).toMatch(/^[A-Za-z0-9_-]+$/)
|
||||
})
|
||||
|
||||
test('400 if serverPort missing', async () => {
|
||||
const { req, url, segments } = buildReq(
|
||||
'POST',
|
||||
'/api/haha-openai-oauth/start',
|
||||
{},
|
||||
)
|
||||
const res = await handleHahaOpenAIOAuthApi(req, url, segments)
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: string; message?: string }
|
||||
expect(body.error).toBe('BAD_REQUEST')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/haha-openai-oauth', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
|
||||
test('returns loggedIn=false when no token file', async () => {
|
||||
const { req, url, segments } = buildReq('GET', '/api/haha-openai-oauth')
|
||||
const res = await handleHahaOpenAIOAuthApi(req, url, segments)
|
||||
expect(res.status).toBe(200)
|
||||
const data = (await res.json()) as { loggedIn: boolean }
|
||||
expect(data.loggedIn).toBe(false)
|
||||
})
|
||||
|
||||
test('returns loggedIn=true + metadata when token saved', async () => {
|
||||
await hahaOpenAIOAuthService.saveTokens({
|
||||
accessToken: 'openai-access-token-xxx',
|
||||
refreshToken: 'openai-refresh-token-xxx',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
email: 'test@example.com',
|
||||
accountId: 'acct_123',
|
||||
})
|
||||
|
||||
const { req, url, segments } = buildReq('GET', '/api/haha-openai-oauth')
|
||||
const res = await handleHahaOpenAIOAuthApi(req, url, segments)
|
||||
expect(res.status).toBe(200)
|
||||
const data = (await res.json()) as {
|
||||
loggedIn: boolean
|
||||
expiresAt: number | null
|
||||
email: string | null
|
||||
accountId: string | null
|
||||
}
|
||||
expect(data.loggedIn).toBe(true)
|
||||
expect(data.email).toBe('test@example.com')
|
||||
expect(data.accountId).toBe('acct_123')
|
||||
// Never leak token values
|
||||
expect(JSON.stringify(data)).not.toContain('openai-access-token')
|
||||
expect(JSON.stringify(data)).not.toContain('openai-refresh-token')
|
||||
})
|
||||
|
||||
test('returns loggedIn=false when stored token is expired and refresh fails', async () => {
|
||||
await hahaOpenAIOAuthService.saveTokens({
|
||||
accessToken: 'expired-token',
|
||||
refreshToken: 'revoked-refresh-token',
|
||||
expiresAt: Date.now() - 1_000,
|
||||
email: 'test@example.com',
|
||||
accountId: 'acct_123',
|
||||
})
|
||||
hahaOpenAIOAuthService.setRefreshFn(async () => {
|
||||
throw new Error('refresh revoked')
|
||||
})
|
||||
|
||||
const { req, url, segments } = buildReq('GET', '/api/haha-openai-oauth')
|
||||
const res = await handleHahaOpenAIOAuthApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ loggedIn: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/haha-openai-oauth', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
|
||||
test('clears token file', async () => {
|
||||
await hahaOpenAIOAuthService.saveTokens({
|
||||
accessToken: 'a',
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
email: null,
|
||||
accountId: null,
|
||||
})
|
||||
|
||||
const { req, url, segments } = buildReq('DELETE', '/api/haha-openai-oauth')
|
||||
const res = await handleHahaOpenAIOAuthApi(req, url, segments)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await hahaOpenAIOAuthService.loadTokens()).toBeNull()
|
||||
})
|
||||
})
|
||||
184
src/server/__tests__/haha-openai-oauth-service.test.ts
Normal file
184
src/server/__tests__/haha-openai-oauth-service.test.ts
Normal file
@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Unit tests for HahaOpenAIOAuthService — haha 自管 OpenAI OAuth 的核心 service 层。
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import {
|
||||
HahaOpenAIOAuthService,
|
||||
type StoredOpenAIOAuthTokens,
|
||||
} from '../services/hahaOpenAIOAuthService.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
let service: HahaOpenAIOAuthService
|
||||
|
||||
async function setup() {
|
||||
tmpDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'haha-openai-oauth-test-'),
|
||||
)
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
service = new HahaOpenAIOAuthService()
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
describe('HahaOpenAIOAuthService — file storage', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
|
||||
test('loadTokens returns null when file does not exist', async () => {
|
||||
expect(await service.loadTokens()).toBeNull()
|
||||
})
|
||||
|
||||
test('saveTokens writes file with 0600 permissions', async () => {
|
||||
const tokens: StoredOpenAIOAuthTokens = {
|
||||
accessToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.mock-access',
|
||||
refreshToken: 'eyJhbGciOiJSUzI1NiJ9.mock-refresh',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
email: 'test@example.com',
|
||||
accountId: 'acct_123',
|
||||
}
|
||||
await service.saveTokens(tokens)
|
||||
|
||||
const oauthPath = path.join(tmpDir, 'cc-haha', 'openai-oauth.json')
|
||||
const stat = await fs.stat(oauthPath)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(stat.mode & 0o777).toBe(0o600)
|
||||
}
|
||||
|
||||
const loaded = await service.loadTokens()
|
||||
expect(loaded).toEqual(tokens)
|
||||
})
|
||||
|
||||
test('deleteTokens removes file', async () => {
|
||||
await service.saveTokens({
|
||||
accessToken: 'a',
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
email: null,
|
||||
accountId: null,
|
||||
})
|
||||
await service.deleteTokens()
|
||||
expect(await service.loadTokens()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HahaOpenAIOAuthService — session management', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
|
||||
test('startSession creates session with PKCE + state', () => {
|
||||
const session = service.startSession({ serverPort: 54321 })
|
||||
expect(session.state).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
expect(session.codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
expect(session.authorizeUrl).toContain('code_challenge_method=S256')
|
||||
expect(session.authorizeUrl).toContain(
|
||||
`state=${encodeURIComponent(session.state)}`,
|
||||
)
|
||||
expect(session.authorizeUrl).toContain(
|
||||
'codex_cli_simplified_flow=true',
|
||||
)
|
||||
expect(session.authorizeUrl).toContain(
|
||||
encodeURIComponent('http://localhost:54321/auth/callback'),
|
||||
)
|
||||
})
|
||||
|
||||
test('getSession returns stored session by state', () => {
|
||||
const session = service.startSession({ serverPort: 54321 })
|
||||
const found = service.getSession(session.state)
|
||||
expect(found?.codeVerifier).toBe(session.codeVerifier)
|
||||
})
|
||||
|
||||
test('getSession returns null for unknown state', () => {
|
||||
expect(service.getSession('unknown-state')).toBeNull()
|
||||
})
|
||||
|
||||
test('consumeSession removes session after fetch', () => {
|
||||
const session = service.startSession({ serverPort: 54321 })
|
||||
expect(service.consumeSession(session.state)).not.toBeNull()
|
||||
expect(service.getSession(session.state)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HahaOpenAIOAuthService — ensureFreshAccessToken', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
|
||||
test('returns null when no token file exists', async () => {
|
||||
expect(await service.ensureFreshAccessToken()).toBeNull()
|
||||
})
|
||||
|
||||
test('returns token unchanged if not expired', async () => {
|
||||
const tokens: StoredOpenAIOAuthTokens = {
|
||||
accessToken: 'still-valid',
|
||||
refreshToken: 'refresh-xxx',
|
||||
expiresAt: Date.now() + 30 * 60_000,
|
||||
email: 'test@example.com',
|
||||
accountId: 'acct_123',
|
||||
}
|
||||
await service.saveTokens(tokens)
|
||||
|
||||
expect(await service.ensureFreshAccessToken()).toBe('still-valid')
|
||||
})
|
||||
|
||||
test('returns null when tokens expired and no refresh token', async () => {
|
||||
await service.saveTokens({
|
||||
accessToken: 'expired',
|
||||
refreshToken: null,
|
||||
expiresAt: Date.now() - 1_000,
|
||||
email: null,
|
||||
accountId: null,
|
||||
})
|
||||
|
||||
expect(await service.ensureFreshAccessToken()).toBeNull()
|
||||
})
|
||||
|
||||
test('refreshes token when expired (within 5-min buffer)', async () => {
|
||||
await service.saveTokens({
|
||||
accessToken: 'expired',
|
||||
refreshToken: 'refresh-xxx',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
email: 'test@example.com',
|
||||
accountId: 'acct_123',
|
||||
})
|
||||
|
||||
service.setRefreshFn(async () => ({
|
||||
access_token: 'new-fresh-token',
|
||||
refresh_token: 'new-refresh-xxx',
|
||||
expires_in: 3600,
|
||||
id_token: 'mock-id-token',
|
||||
}))
|
||||
|
||||
const fresh = await service.ensureFreshAccessToken()
|
||||
expect(fresh).toBe('new-fresh-token')
|
||||
|
||||
const loaded = await service.loadTokens()
|
||||
expect(loaded?.accessToken).toBe('new-fresh-token')
|
||||
})
|
||||
|
||||
test('returns null when refresh fails', async () => {
|
||||
await service.saveTokens({
|
||||
accessToken: 'expired',
|
||||
refreshToken: 'bad-refresh',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
email: null,
|
||||
accountId: null,
|
||||
})
|
||||
service.setRefreshFn(async () => {
|
||||
throw new Error('401 Unauthorized')
|
||||
})
|
||||
|
||||
expect(await service.ensureFreshAccessToken()).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -281,6 +281,12 @@ describe('openaiResponsesStreamToAnthropic', () => {
|
||||
const texts = textDeltas.map((e) => (e.data.delta as Record<string, unknown>).text)
|
||||
expect(texts).toContain('Hello')
|
||||
expect(texts).toContain(' world')
|
||||
|
||||
const msgDelta = events.find((e) => e.event === 'message_delta')!
|
||||
expect(msgDelta.data.usage).toEqual({
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
})
|
||||
})
|
||||
|
||||
test('function call streaming', async () => {
|
||||
|
||||
@ -303,10 +303,33 @@ describe('anthropicToOpenaiResponses', () => {
|
||||
const result = anthropicToOpenaiResponses(req)
|
||||
expect(result.model).toBe('gpt-4o')
|
||||
expect(result.instructions).toBe('Be helpful')
|
||||
expect(result.store).toBe(false)
|
||||
expect(result.tools).toBeUndefined()
|
||||
expect(result.max_output_tokens).toBeUndefined()
|
||||
expect(result.input).toEqual([{ type: 'message', role: 'user', content: 'Hello' }])
|
||||
})
|
||||
|
||||
test('tools conversion uses top-level name', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4o',
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
tools: [{
|
||||
name: 'get_weather',
|
||||
description: 'Get weather',
|
||||
input_schema: { type: 'object', properties: { city: { type: 'string' } } },
|
||||
}],
|
||||
}
|
||||
const result = anthropicToOpenaiResponses(req)
|
||||
expect(result.tools).toHaveLength(1)
|
||||
expect(result.tools![0]).toEqual({
|
||||
type: 'function',
|
||||
name: 'get_weather',
|
||||
description: 'Get weather',
|
||||
parameters: { type: 'object', properties: { city: { type: 'string' } } },
|
||||
})
|
||||
})
|
||||
|
||||
test('tool_use lifted to function_call', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4o',
|
||||
|
||||
@ -11,6 +11,16 @@ import { handleSettingsApi } from '../api/settings.js'
|
||||
import { handleModelsApi } from '../api/models.js'
|
||||
import { handleStatusApi, resetUsage, addUsage } from '../api/status.js'
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
import {
|
||||
clearOpenAIOAuthTokenCache,
|
||||
} from '../../services/openaiAuth/storage.js'
|
||||
import { plainTextStorage } from '../../utils/secureStorage/plainTextStorage.js'
|
||||
import {
|
||||
clearKeychainCache,
|
||||
primeKeychainCacheFromPrefetch,
|
||||
} from '../../utils/secureStorage/macOsKeychainHelpers.js'
|
||||
import type { OpenAIOAuthTokens } from '../../services/openaiAuth/types.js'
|
||||
import { getModelOptions } from '../../utils/model/modelOptions.js'
|
||||
|
||||
// ─── Test helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@ -21,6 +31,12 @@ let originalUserProfile: string | undefined
|
||||
let originalShell: string | undefined
|
||||
let originalPath: string | undefined
|
||||
let originalCliPath: string | undefined
|
||||
let originalAnthropicApiKey: string | undefined
|
||||
let originalAnthropicBaseUrl: string | undefined
|
||||
let originalAnthropicModel: string | undefined
|
||||
let originalAnthropicDefaultHaikuModel: string | undefined
|
||||
let originalAnthropicDefaultSonnetModel: string | undefined
|
||||
let originalAnthropicDefaultOpusModel: string | undefined
|
||||
|
||||
async function setup() {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-test-'))
|
||||
@ -30,14 +46,33 @@ async function setup() {
|
||||
originalShell = process.env.SHELL
|
||||
originalPath = process.env.PATH
|
||||
originalCliPath = process.env.CLAUDE_CLI_PATH
|
||||
originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY
|
||||
originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL
|
||||
originalAnthropicModel = process.env.ANTHROPIC_MODEL
|
||||
originalAnthropicDefaultHaikuModel = process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
originalAnthropicDefaultSonnetModel = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
originalAnthropicDefaultOpusModel = process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
process.env.HOME = tmpDir
|
||||
process.env.USERPROFILE = tmpDir
|
||||
process.env.SHELL = '/bin/zsh'
|
||||
process.env.PATH = ''
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
delete process.env.ANTHROPIC_BASE_URL
|
||||
delete process.env.ANTHROPIC_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
clearKeychainCache()
|
||||
primeKeychainCacheFromPrefetch(null)
|
||||
clearOpenAIOAuthTokenCache()
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
plainTextStorage.delete()
|
||||
clearKeychainCache()
|
||||
clearOpenAIOAuthTokenCache()
|
||||
|
||||
if (originalConfigDir !== undefined) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
} else {
|
||||
@ -74,9 +109,50 @@ async function teardown() {
|
||||
delete process.env.CLAUDE_CLI_PATH
|
||||
}
|
||||
|
||||
if (originalAnthropicApiKey !== undefined) {
|
||||
process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey
|
||||
} else {
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
}
|
||||
|
||||
if (originalAnthropicBaseUrl !== undefined) {
|
||||
process.env.ANTHROPIC_BASE_URL = originalAnthropicBaseUrl
|
||||
} else {
|
||||
delete process.env.ANTHROPIC_BASE_URL
|
||||
}
|
||||
|
||||
if (originalAnthropicModel !== undefined) {
|
||||
process.env.ANTHROPIC_MODEL = originalAnthropicModel
|
||||
} else {
|
||||
delete process.env.ANTHROPIC_MODEL
|
||||
}
|
||||
|
||||
if (originalAnthropicDefaultHaikuModel !== undefined) {
|
||||
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = originalAnthropicDefaultHaikuModel
|
||||
} else {
|
||||
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
}
|
||||
|
||||
if (originalAnthropicDefaultSonnetModel !== undefined) {
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = originalAnthropicDefaultSonnetModel
|
||||
} else {
|
||||
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
}
|
||||
|
||||
if (originalAnthropicDefaultOpusModel !== undefined) {
|
||||
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = originalAnthropicDefaultOpusModel
|
||||
} else {
|
||||
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
}
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function saveTestOpenAIOAuthTokens(tokens: OpenAIOAuthTokens) {
|
||||
plainTextStorage.update({ openaiCodexOauth: tokens })
|
||||
clearOpenAIOAuthTokenCache()
|
||||
}
|
||||
|
||||
/** 创建一个模拟 Request */
|
||||
function makeRequest(
|
||||
method: string,
|
||||
@ -324,6 +400,34 @@ describe('Models API', () => {
|
||||
expect(body.models[0].id).toContain('claude')
|
||||
})
|
||||
|
||||
it('GET /api/models should merge env-configured provider models with saved OpenAI OAuth models', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'deepseek-key'
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://api.deepseek.com/anthropic'
|
||||
process.env.ANTHROPIC_MODEL = 'deepseek-v4-pro'
|
||||
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'deepseek-v4-flash'
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'deepseek-v4-pro'
|
||||
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'deepseek-v4-pro'
|
||||
saveTestOpenAIOAuthTokens({
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
})
|
||||
|
||||
const { req, url, segments } = makeRequest('GET', '/api/models')
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const ids = body.models.map((model: { id: string }) => model.id)
|
||||
|
||||
expect(ids).toContain('deepseek-v4-pro')
|
||||
expect(ids).toContain('deepseek-v4-flash')
|
||||
expect(ids).toContain('gpt-5.3-codex')
|
||||
expect(ids).toContain('gpt-5.4')
|
||||
expect(ids).toContain('gpt-5.4-mini')
|
||||
expect(ids.filter((id: string) => id === 'deepseek-v4-pro')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('GET /api/models/current should return default model when not set', async () => {
|
||||
const { req, url, segments } = makeRequest('GET', '/api/models/current')
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
@ -333,6 +437,17 @@ describe('Models API', () => {
|
||||
expect(body.model.id).toBe('claude-opus-4-7')
|
||||
})
|
||||
|
||||
it('GET /api/models/current should respect env-configured default model when no provider is active', async () => {
|
||||
process.env.ANTHROPIC_MODEL = 'deepseek-v4-pro'
|
||||
|
||||
const { req, url, segments } = makeRequest('GET', '/api/models/current')
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.model.id).toBe('deepseek-v4-pro')
|
||||
})
|
||||
|
||||
it('PUT /api/models/current should switch model', async () => {
|
||||
const { req, url, segments } = makeRequest('PUT', '/api/models/current', {
|
||||
modelId: 'claude-opus-4-7',
|
||||
@ -450,6 +565,37 @@ describe('Models API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Model Options', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
|
||||
it('should keep OpenAI OAuth models visible alongside env-configured provider models', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'deepseek-key'
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://api.deepseek.com/anthropic'
|
||||
process.env.ANTHROPIC_MODEL = 'deepseek-v4-pro'
|
||||
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'deepseek-v4-flash'
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'deepseek-v4-pro'
|
||||
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'deepseek-v4-pro'
|
||||
saveTestOpenAIOAuthTokens({
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
})
|
||||
|
||||
const options = getModelOptions()
|
||||
const values = options
|
||||
.map(option => option.value)
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
const labels = options.map(option => option.label)
|
||||
|
||||
expect(values).toContain('gpt-5.3-codex')
|
||||
expect(values).toContain('gpt-5.4')
|
||||
expect(values).toContain('gpt-5.4-mini')
|
||||
expect(labels).toContain('deepseek-v4-pro')
|
||||
expect(labels).toContain('deepseek-v4-flash')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Status API
|
||||
// =============================================================================
|
||||
|
||||
121
src/server/api/haha-openai-oauth.ts
Normal file
121
src/server/api/haha-openai-oauth.ts
Normal file
@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Haha OpenAI OAuth REST API
|
||||
*
|
||||
* POST /api/haha-openai-oauth/start — 生成 PKCE+state,返回 authorize URL
|
||||
* GET /callback/openai — 用户浏览器 redirect 到此,完成 token 交换
|
||||
* GET /api/haha-openai-oauth — 查询当前登录状态(不回传 token 本体)
|
||||
* DELETE /api/haha-openai-oauth — 登出,删除 token 文件
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { hahaOpenAIOAuthService } from '../services/hahaOpenAIOAuthService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
const StartRequestSchema = z.object({
|
||||
serverPort: z.number().int().positive(),
|
||||
})
|
||||
|
||||
function html(body: string): Response {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function handleHahaOpenAIOAuthApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const action = segments[2] // segments: ['api', 'haha-openai-oauth', <action?>]
|
||||
|
||||
if (action === 'start' && req.method === 'POST') {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
const parsed = StartRequestSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
throw ApiError.badRequest('serverPort (positive integer) required')
|
||||
}
|
||||
const session = hahaOpenAIOAuthService.startSession({
|
||||
serverPort: parsed.data.serverPort,
|
||||
})
|
||||
return Response.json({
|
||||
authorizeUrl: session.authorizeUrl,
|
||||
state: session.state,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === undefined && req.method === 'GET') {
|
||||
const tokens = await hahaOpenAIOAuthService.ensureFreshTokens()
|
||||
if (!tokens) {
|
||||
return Response.json({ loggedIn: false })
|
||||
}
|
||||
return Response.json({
|
||||
loggedIn: true,
|
||||
expiresAt: tokens.expiresAt,
|
||||
email: tokens.email,
|
||||
accountId: tokens.accountId,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === undefined && req.method === 'DELETE') {
|
||||
await hahaOpenAIOAuthService.deleteTokens()
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
return Response.json({ error: 'Not Found' }, { status: 404 })
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleHahaOpenAIOAuthCallback(url: URL): Promise<Response> {
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const error = url.searchParams.get('error')
|
||||
|
||||
if (error) {
|
||||
return html(renderCallbackPage(false, `OAuth provider returned: ${error}`))
|
||||
}
|
||||
if (!code || !state) {
|
||||
return html(renderCallbackPage(false, 'Missing code or state parameter'))
|
||||
}
|
||||
|
||||
try {
|
||||
await hahaOpenAIOAuthService.completeSession(code, state)
|
||||
return html(renderCallbackPage(true, null))
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return html(renderCallbackPage(false, msg))
|
||||
}
|
||||
}
|
||||
|
||||
function renderCallbackPage(success: boolean, errorMsg: string | null): string {
|
||||
if (success) {
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>OpenAI Login Success</title>
|
||||
<style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#fafafa;color:#333}.card{text-align:center;padding:40px;background:white;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.06)}h1{color:#16a34a;margin:0 0 12px}p{color:#666}</style>
|
||||
</head><body><div class="card"><h1>✓ OpenAI Login Successful</h1><p>You can close this window and return to Claude Code Haha.</p></div>
|
||||
<script>setTimeout(() => window.close(), 1500)</script>
|
||||
</body></html>`
|
||||
}
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>OpenAI Login Failed</title>
|
||||
<style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#fafafa;color:#333}.card{text-align:center;padding:40px;background:white;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.06)}h1{color:#dc2626;margin:0 0 12px}pre{color:#666;white-space:pre-wrap;word-break:break-word;text-align:left;background:#f5f5f5;padding:12px;border-radius:6px}</style>
|
||||
</head><body><div class="card"><h1>✗ OpenAI Login Failed</h1><pre>${escapeHtml(errorMsg ?? 'Unknown error')}</pre></div>
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@ -11,6 +11,8 @@
|
||||
import { SettingsService } from '../services/settingsService.js'
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import { hasOpenAIAuthLogin } from '../../utils/auth.js'
|
||||
import { OPENAI_CODEX_MODEL_CATALOG } from '../../services/openaiAuth/models.js'
|
||||
|
||||
// ─── Fallback models (used when no provider is configured) ────────────────────
|
||||
|
||||
@ -43,6 +45,106 @@ const DEFAULT_EFFORT = 'medium'
|
||||
const settingsService = new SettingsService()
|
||||
const providerService = new ProviderService()
|
||||
|
||||
type ApiModelInfo = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
context: string
|
||||
}
|
||||
|
||||
function addUniqueModel(
|
||||
models: ApiModelInfo[],
|
||||
model: ApiModelInfo | null,
|
||||
): void {
|
||||
if (!model || !model.id.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (models.some(existing => existing.id === model.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
models.push(model)
|
||||
}
|
||||
|
||||
function buildProviderModelList(models: {
|
||||
main: string
|
||||
haiku: string
|
||||
sonnet: string
|
||||
opus: string
|
||||
}): ApiModelInfo[] {
|
||||
const modelList: ApiModelInfo[] = []
|
||||
|
||||
addUniqueModel(modelList, {
|
||||
id: models.main,
|
||||
name: models.main,
|
||||
description: 'Main model',
|
||||
context: '',
|
||||
})
|
||||
addUniqueModel(modelList, models.haiku
|
||||
? {
|
||||
id: models.haiku,
|
||||
name: models.haiku,
|
||||
description: 'Haiku model',
|
||||
context: '',
|
||||
}
|
||||
: null)
|
||||
addUniqueModel(modelList, models.sonnet
|
||||
? {
|
||||
id: models.sonnet,
|
||||
name: models.sonnet,
|
||||
description: 'Sonnet model',
|
||||
context: '',
|
||||
}
|
||||
: null)
|
||||
addUniqueModel(modelList, models.opus
|
||||
? {
|
||||
id: models.opus,
|
||||
name: models.opus,
|
||||
description: 'Opus model',
|
||||
context: '',
|
||||
}
|
||||
: null)
|
||||
|
||||
return modelList
|
||||
}
|
||||
|
||||
function getEnvConfiguredAnthropicModels(): ApiModelInfo[] {
|
||||
return buildProviderModelList({
|
||||
main: process.env.ANTHROPIC_MODEL?.trim() || '',
|
||||
haiku: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.trim() || '',
|
||||
sonnet: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || '',
|
||||
opus: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || '',
|
||||
})
|
||||
}
|
||||
|
||||
function getOpenAIAuthModels(): ApiModelInfo[] {
|
||||
if (!hasOpenAIAuthLogin()) {
|
||||
return []
|
||||
}
|
||||
|
||||
return OPENAI_CODEX_MODEL_CATALOG.map(model => ({
|
||||
id: model.value,
|
||||
name: model.label,
|
||||
description: model.description,
|
||||
context: '',
|
||||
}))
|
||||
}
|
||||
|
||||
function getStandaloneModelList(): ApiModelInfo[] {
|
||||
const models = [...getEnvConfiguredAnthropicModels()]
|
||||
|
||||
if (models.length === 0) {
|
||||
models.push(...DEFAULT_MODELS)
|
||||
}
|
||||
|
||||
for (const model of getOpenAIAuthModels()) {
|
||||
addUniqueModel(models, model)
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleModelsApi(
|
||||
@ -83,19 +185,13 @@ async function handleModelsList(): Promise<Response> {
|
||||
const { providers, activeId } = await providerService.listProviders()
|
||||
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
|
||||
if (activeProvider) {
|
||||
// Convert ModelMapping to model list for API compatibility
|
||||
const modelList = [
|
||||
{ id: activeProvider.models.main, name: activeProvider.models.main, description: 'Main model', context: '' },
|
||||
...(activeProvider.models.haiku !== activeProvider.models.main ? [{ id: activeProvider.models.haiku, name: activeProvider.models.haiku, description: 'Haiku model', context: '' }] : []),
|
||||
...(activeProvider.models.sonnet !== activeProvider.models.main ? [{ id: activeProvider.models.sonnet, name: activeProvider.models.sonnet, description: 'Sonnet model', context: '' }] : []),
|
||||
...(activeProvider.models.opus !== activeProvider.models.main ? [{ id: activeProvider.models.opus, name: activeProvider.models.opus, description: 'Opus model', context: '' }] : []),
|
||||
]
|
||||
const modelList = buildProviderModelList(activeProvider.models)
|
||||
return Response.json({
|
||||
models: modelList,
|
||||
provider: { id: activeProvider.id, name: activeProvider.name },
|
||||
})
|
||||
}
|
||||
return Response.json({ models: DEFAULT_MODELS, provider: null })
|
||||
return Response.json({ models: getStandaloneModelList(), provider: null })
|
||||
}
|
||||
|
||||
async function handleCurrentModel(req: Request): Promise<Response> {
|
||||
@ -109,6 +205,7 @@ async function handleCurrentModel(req: Request): Promise<Response> {
|
||||
const explicitModel = (settings.model as string) || ''
|
||||
const contextTier = (settings.modelContext as string) || undefined
|
||||
const env = (settings.env as Record<string, string>) || {}
|
||||
const envModel = process.env.ANTHROPIC_MODEL?.trim() || ''
|
||||
|
||||
let currentModelId: string
|
||||
let currentModelName: string
|
||||
@ -127,7 +224,7 @@ async function handleCurrentModel(req: Request): Promise<Response> {
|
||||
}
|
||||
} else {
|
||||
// No provider — use settings model with context tier
|
||||
currentModelId = explicitModel || DEFAULT_MODEL
|
||||
currentModelId = explicitModel || envModel || DEFAULT_MODEL
|
||||
currentModelName = currentModelId
|
||||
}
|
||||
|
||||
@ -135,13 +232,8 @@ async function handleCurrentModel(req: Request): Promise<Response> {
|
||||
|
||||
// Build available models for name lookup
|
||||
const availableModels = activeProvider
|
||||
? [
|
||||
{ id: activeProvider.models.main, name: activeProvider.models.main, description: 'Main model', context: '' },
|
||||
...(activeProvider.models.haiku && activeProvider.models.haiku !== activeProvider.models.main ? [{ id: activeProvider.models.haiku, name: activeProvider.models.haiku, description: 'Haiku model', context: '' }] : []),
|
||||
...(activeProvider.models.sonnet && activeProvider.models.sonnet !== activeProvider.models.main ? [{ id: activeProvider.models.sonnet, name: activeProvider.models.sonnet, description: 'Sonnet model', context: '' }] : []),
|
||||
...(activeProvider.models.opus && activeProvider.models.opus !== activeProvider.models.main ? [{ id: activeProvider.models.opus, name: activeProvider.models.opus, description: 'Opus model', context: '' }] : []),
|
||||
]
|
||||
: DEFAULT_MODELS
|
||||
? buildProviderModelList(activeProvider.models)
|
||||
: getStandaloneModelList()
|
||||
|
||||
const modelEntry = availableModels.find((m) => m.id === lookupId)
|
||||
|| availableModels.find((m) => m.id === currentModelId)
|
||||
|
||||
@ -14,6 +14,7 @@ import { cronScheduler } from './services/cronScheduler.js'
|
||||
import { handleProxyRequest } from './proxy/handler.js'
|
||||
import { ProviderService } from './services/providerService.js'
|
||||
import { handleHahaOAuthCallback } from './api/haha-oauth.js'
|
||||
import { handleHahaOpenAIOAuthCallback } from './api/haha-openai-oauth.js'
|
||||
import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js'
|
||||
import { enableConfigs } from '../utils/config.js'
|
||||
import { diagnosticsService } from './services/diagnosticsService.js'
|
||||
@ -139,6 +140,10 @@ export function startServer(port = PORT, host = HOST) {
|
||||
return handleHahaOAuthCallback(url)
|
||||
}
|
||||
|
||||
if (url.pathname === '/callback/openai') {
|
||||
return handleHahaOpenAIOAuthCallback(url)
|
||||
}
|
||||
|
||||
// REST API
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
// Enforce authentication when required
|
||||
|
||||
@ -265,7 +265,10 @@ function processEvent(
|
||||
controller.enqueue(encoder.encode(formatSse('message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
||||
usage: { output_tokens: usage?.output_tokens ?? 0 },
|
||||
usage: {
|
||||
input_tokens: usage?.input_tokens ?? 0,
|
||||
output_tokens: usage?.output_tokens ?? 0,
|
||||
},
|
||||
})))
|
||||
if (!state.messageStopped) {
|
||||
state.messageStopped = true
|
||||
|
||||
@ -10,7 +10,6 @@ import type {
|
||||
AnthropicMessage,
|
||||
OpenAIResponsesRequest,
|
||||
OpenAIResponsesInputItem,
|
||||
OpenAITool,
|
||||
OpenAIChatContentPart,
|
||||
} from './types.js'
|
||||
|
||||
@ -29,6 +28,7 @@ export function anthropicToOpenaiResponses(body: AnthropicRequest): OpenAIRespon
|
||||
model: body.model,
|
||||
input,
|
||||
stream: body.stream,
|
||||
store: false,
|
||||
}
|
||||
|
||||
// system → instructions
|
||||
@ -51,13 +51,11 @@ export function anthropicToOpenaiResponses(body: AnthropicRequest): OpenAIRespon
|
||||
if (body.tools && body.tools.length > 0) {
|
||||
result.tools = body.tools
|
||||
.filter((t) => t.name !== 'BatchTool')
|
||||
.map((t): OpenAITool => ({
|
||||
.map((t) => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.input_schema,
|
||||
},
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.input_schema,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -110,11 +110,17 @@ export type OpenAIResponsesRequest = {
|
||||
model: string
|
||||
input: OpenAIResponsesInputItem[]
|
||||
instructions?: string
|
||||
store?: boolean
|
||||
max_output_tokens?: number
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
stream?: boolean
|
||||
tools?: OpenAITool[]
|
||||
tools?: Array<{
|
||||
type: 'function'
|
||||
name: string
|
||||
description?: string
|
||||
parameters?: Record<string, unknown>
|
||||
}>
|
||||
tool_choice?: unknown
|
||||
reasoning?: { effort?: 'low' | 'medium' | 'high' }
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ import { handlePluginsApi } from './api/plugins.js'
|
||||
import { handleSkillsApi } from './api/skills.js'
|
||||
import { handleComputerUseApi } from './api/computer-use.js'
|
||||
import { handleHahaOAuthApi } from './api/haha-oauth.js'
|
||||
import { handleHahaOpenAIOAuthApi } from './api/haha-openai-oauth.js'
|
||||
import { handleMcpApi } from './api/mcp.js'
|
||||
import { handleDiagnosticsApi } from './api/diagnostics.js'
|
||||
|
||||
@ -73,6 +74,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'haha-oauth':
|
||||
return handleHahaOAuthApi(req, url, segments)
|
||||
|
||||
case 'haha-openai-oauth':
|
||||
return handleHahaOpenAIOAuthApi(req, url, segments)
|
||||
|
||||
case 'adapters':
|
||||
return handleAdaptersApi(req, url, segments)
|
||||
|
||||
|
||||
208
src/server/services/hahaOpenAIOAuthService.ts
Normal file
208
src/server/services/hahaOpenAIOAuthService.ts
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* HahaOpenAIOAuthService — 桌面端自管 OpenAI OAuth token
|
||||
*
|
||||
* 为什么存在: macOS Keychain ACL 在 .app 被打上 quarantine 属性后
|
||||
* 对无 UI sidecar 静默拒绝,导致 CLI 读不到 OAuth token → 403。
|
||||
* 这个 service 把 token 存到 haha 自己的目录,并通过 env 注入给 CLI。
|
||||
*
|
||||
* 复用 src/services/openaiAuth/client.ts 里的 PKCE + token exchange 逻辑,
|
||||
* 不复制粘贴 —— 保证跟 CLI 走同一套协议实现。
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import {
|
||||
generateCodeVerifier,
|
||||
generateCodeChallenge,
|
||||
generateState,
|
||||
} from '../../services/oauth/crypto.js'
|
||||
import {
|
||||
buildOpenAIAuthorizeUrl,
|
||||
exchangeOpenAICodeForTokens,
|
||||
refreshOpenAITokens,
|
||||
isOpenAITokenExpired,
|
||||
normalizeOpenAITokens,
|
||||
OPENAI_CODEX_OAUTH_PORT,
|
||||
OPENAI_CODEX_REDIRECT_PATH,
|
||||
} from '../../services/openaiAuth/client.js'
|
||||
import type {
|
||||
OpenAIOAuthTokens,
|
||||
OpenAIOAuthTokenResponse,
|
||||
} from '../../services/openaiAuth/types.js'
|
||||
|
||||
export type StoredOpenAIOAuthTokens = {
|
||||
accessToken: string
|
||||
refreshToken: string | null
|
||||
expiresAt: number | null
|
||||
email: string | null
|
||||
accountId: string | null
|
||||
}
|
||||
|
||||
export type OpenAIOAuthSession = {
|
||||
state: string
|
||||
codeVerifier: string
|
||||
authorizeUrl: string
|
||||
serverPort: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
type OpenAIRefreshFn = (
|
||||
refreshToken: string,
|
||||
) => Promise<OpenAIOAuthTokenResponse>
|
||||
|
||||
const SESSION_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
export class HahaOpenAIOAuthService {
|
||||
private sessions = new Map<string, OpenAIOAuthSession>()
|
||||
private refreshFn: OpenAIRefreshFn = refreshOpenAITokens
|
||||
|
||||
setRefreshFn(fn: OpenAIRefreshFn): void {
|
||||
this.refreshFn = fn
|
||||
}
|
||||
|
||||
private getOAuthFilePath(): string {
|
||||
const configDir =
|
||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
return path.join(configDir, 'cc-haha', 'openai-oauth.json')
|
||||
}
|
||||
|
||||
async loadTokens(): Promise<StoredOpenAIOAuthTokens | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(this.getOAuthFilePath(), 'utf-8')
|
||||
return JSON.parse(raw) as StoredOpenAIOAuthTokens
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async saveTokens(tokens: StoredOpenAIOAuthTokens): Promise<void> {
|
||||
const filePath = this.getOAuthFilePath()
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
const tmp = `${filePath}.tmp.${process.pid}`
|
||||
await fs.writeFile(tmp, JSON.stringify(tokens, null, 2), { mode: 0o600 })
|
||||
await fs.rename(tmp, filePath)
|
||||
}
|
||||
|
||||
async deleteTokens(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.getOAuthFilePath())
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
||||
}
|
||||
}
|
||||
|
||||
startSession({ serverPort }: { serverPort: number }): OpenAIOAuthSession {
|
||||
this.pruneExpiredSessions()
|
||||
|
||||
const codeVerifier = generateCodeVerifier()
|
||||
const state = generateState()
|
||||
|
||||
const redirectUri = `http://localhost:${serverPort}${OPENAI_CODEX_REDIRECT_PATH}`
|
||||
const authorizeUrl = buildOpenAIAuthorizeUrl({
|
||||
redirectUri,
|
||||
codeVerifier,
|
||||
state,
|
||||
})
|
||||
|
||||
const session: OpenAIOAuthSession = {
|
||||
state,
|
||||
codeVerifier,
|
||||
authorizeUrl,
|
||||
serverPort,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
this.sessions.set(state, session)
|
||||
return session
|
||||
}
|
||||
|
||||
getSession(state: string): OpenAIOAuthSession | null {
|
||||
const s = this.sessions.get(state)
|
||||
if (!s) return null
|
||||
if (Date.now() - s.createdAt > SESSION_TTL_MS) {
|
||||
this.sessions.delete(state)
|
||||
return null
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
consumeSession(state: string): OpenAIOAuthSession | null {
|
||||
const s = this.getSession(state)
|
||||
if (s) this.sessions.delete(state)
|
||||
return s
|
||||
}
|
||||
|
||||
private pruneExpiredSessions(): void {
|
||||
const now = Date.now()
|
||||
for (const [state, s] of this.sessions.entries()) {
|
||||
if (now - s.createdAt > SESSION_TTL_MS) this.sessions.delete(state)
|
||||
}
|
||||
}
|
||||
|
||||
async completeSession(
|
||||
authorizationCode: string,
|
||||
state: string,
|
||||
): Promise<StoredOpenAIOAuthTokens> {
|
||||
const session = this.consumeSession(state)
|
||||
if (!session) {
|
||||
throw new Error('OpenAI OAuth session not found or expired')
|
||||
}
|
||||
|
||||
const redirectUri = `http://localhost:${session.serverPort}${OPENAI_CODEX_REDIRECT_PATH}`
|
||||
const response = await exchangeOpenAICodeForTokens({
|
||||
code: authorizationCode,
|
||||
redirectUri,
|
||||
codeVerifier: session.codeVerifier,
|
||||
})
|
||||
|
||||
const normalized = normalizeOpenAITokens(response)
|
||||
const tokens: StoredOpenAIOAuthTokens = {
|
||||
accessToken: normalized.accessToken,
|
||||
refreshToken: normalized.refreshToken,
|
||||
expiresAt: normalized.expiresAt,
|
||||
email: normalized.email ?? null,
|
||||
accountId: normalized.accountId ?? null,
|
||||
}
|
||||
await this.saveTokens(tokens)
|
||||
return tokens
|
||||
}
|
||||
|
||||
async ensureFreshTokens(): Promise<StoredOpenAIOAuthTokens | null> {
|
||||
const tokens = await this.loadTokens()
|
||||
if (!tokens) return null
|
||||
|
||||
if (tokens.expiresAt === null) return tokens
|
||||
|
||||
if (!isOpenAITokenExpired(tokens.expiresAt)) return tokens
|
||||
|
||||
if (!tokens.refreshToken) return null
|
||||
|
||||
try {
|
||||
const refreshed = await this.refreshFn(tokens.refreshToken)
|
||||
const normalized = normalizeOpenAITokens(refreshed)
|
||||
const updated: StoredOpenAIOAuthTokens = {
|
||||
accessToken: normalized.accessToken,
|
||||
refreshToken: normalized.refreshToken ?? tokens.refreshToken,
|
||||
expiresAt: normalized.expiresAt,
|
||||
email: normalized.email ?? tokens.email,
|
||||
accountId: normalized.accountId ?? tokens.accountId,
|
||||
}
|
||||
await this.saveTokens(updated)
|
||||
return updated
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[HahaOpenAIOAuthService] token refresh failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async ensureFreshAccessToken(): Promise<string | null> {
|
||||
const tokens = await this.ensureFreshTokens()
|
||||
return tokens?.accessToken ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export const hahaOpenAIOAuthService = new HahaOpenAIOAuthService()
|
||||
@ -22,6 +22,12 @@ import {
|
||||
getSessionId,
|
||||
} from '../../bootstrap/state.js'
|
||||
import { getOauthConfig } from '../../constants/oauth.js'
|
||||
import {
|
||||
buildOpenAICodexFetch,
|
||||
OPENAI_OAUTH_DUMMY_KEY,
|
||||
shouldUseOpenAICodexAuth,
|
||||
} from '../openaiAuth/fetch.js'
|
||||
import { isOpenAIResponsesModel } from '../openaiAuth/models.js'
|
||||
import { isDebugToStdErr, logForDebugging } from '../../utils/debug.js'
|
||||
import {
|
||||
getAWSRegion,
|
||||
@ -132,11 +138,21 @@ export async function getAnthropicClient({
|
||||
await checkAndRefreshOAuthTokenIfNeeded()
|
||||
logForDebugging('[API:auth] OAuth token check complete')
|
||||
|
||||
if (!isClaudeAISubscriber()) {
|
||||
const isOpenAIModel = model ? isOpenAIResponsesModel(model) : false
|
||||
const usingOpenAICodex =
|
||||
shouldUseOpenAICodexAuth() &&
|
||||
!isClaudeAISubscriber() &&
|
||||
(isOpenAIModel ||
|
||||
(!process.env.ANTHROPIC_AUTH_TOKEN &&
|
||||
!(apiKey || getAnthropicApiKey())))
|
||||
|
||||
if (!isClaudeAISubscriber() && !usingOpenAICodex) {
|
||||
await configureApiKeyHeaders(defaultHeaders, getIsNonInteractiveSession())
|
||||
}
|
||||
|
||||
const resolvedFetch = buildFetch(fetchOverride, source)
|
||||
const resolvedFetch = usingOpenAICodex
|
||||
? buildOpenAICodexFetch(fetchOverride, source)
|
||||
: buildFetch(fetchOverride, source)
|
||||
|
||||
const ARGS = {
|
||||
defaultHeaders,
|
||||
@ -299,7 +315,11 @@ export async function getAnthropicClient({
|
||||
|
||||
// Determine authentication method based on available tokens
|
||||
const clientConfig: ConstructorParameters<typeof Anthropic>[0] = {
|
||||
apiKey: isClaudeAISubscriber() ? null : apiKey || getAnthropicApiKey(),
|
||||
apiKey: isClaudeAISubscriber()
|
||||
? null
|
||||
: usingOpenAICodex
|
||||
? OPENAI_OAUTH_DUMMY_KEY
|
||||
: apiKey || getAnthropicApiKey(),
|
||||
authToken: isClaudeAISubscriber()
|
||||
? getClaudeAIOAuthTokens()?.accessToken
|
||||
: undefined,
|
||||
|
||||
141
src/services/openaiAuth/client.ts
Normal file
141
src/services/openaiAuth/client.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { generateCodeChallenge } from '../oauth/crypto.js'
|
||||
import type {
|
||||
OpenAIJwtClaims,
|
||||
OpenAIOAuthTokenResponse,
|
||||
OpenAIOAuthTokens,
|
||||
} from './types.js'
|
||||
|
||||
export const OPENAI_AUTH_ISSUER = 'https://auth.openai.com'
|
||||
export const OPENAI_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
|
||||
export const OPENAI_CODEX_API_ENDPOINT =
|
||||
'https://chatgpt.com/backend-api/codex/responses'
|
||||
export const OPENAI_CODEX_OAUTH_PORT = 1455
|
||||
export const OPENAI_CODEX_REDIRECT_PATH = '/auth/callback'
|
||||
|
||||
const DEFAULT_TOKEN_LIFETIME_MS = 3600 * 1000
|
||||
|
||||
export function buildOpenAIAuthorizeUrl(input: {
|
||||
redirectUri: string
|
||||
codeVerifier: string
|
||||
state: string
|
||||
}): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: OPENAI_CODEX_CLIENT_ID,
|
||||
redirect_uri: input.redirectUri,
|
||||
scope: 'openid profile email offline_access',
|
||||
code_challenge: generateCodeChallenge(input.codeVerifier),
|
||||
code_challenge_method: 'S256',
|
||||
id_token_add_organizations: 'true',
|
||||
codex_cli_simplified_flow: 'true',
|
||||
state: input.state,
|
||||
originator: 'opencode',
|
||||
})
|
||||
|
||||
return `${OPENAI_AUTH_ISSUER}/oauth/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
export async function exchangeOpenAICodeForTokens(input: {
|
||||
code: string
|
||||
redirectUri: string
|
||||
codeVerifier: string
|
||||
}): Promise<OpenAIOAuthTokenResponse> {
|
||||
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: input.code,
|
||||
redirect_uri: input.redirectUri,
|
||||
client_id: OPENAI_CODEX_CLIENT_ID,
|
||||
code_verifier: input.codeVerifier,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI token exchange failed: ${response.status}`)
|
||||
}
|
||||
|
||||
return (await response.json()) as OpenAIOAuthTokenResponse
|
||||
}
|
||||
|
||||
export async function refreshOpenAITokens(
|
||||
refreshToken: string,
|
||||
): Promise<OpenAIOAuthTokenResponse> {
|
||||
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: OPENAI_CODEX_CLIENT_ID,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI token refresh failed: ${response.status}`)
|
||||
}
|
||||
|
||||
return (await response.json()) as OpenAIOAuthTokenResponse
|
||||
}
|
||||
|
||||
export function parseOpenAIJwtClaims(
|
||||
token?: string,
|
||||
): OpenAIJwtClaims | undefined {
|
||||
if (!token) return undefined
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return undefined
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.from(parts[1]!, 'base64url').toString())
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function extractOpenAIAccountId(
|
||||
claims?: OpenAIJwtClaims,
|
||||
): string | undefined {
|
||||
if (!claims) return undefined
|
||||
|
||||
return (
|
||||
claims.chatgpt_account_id ||
|
||||
claims['https://api.openai.com/auth']?.chatgpt_account_id ||
|
||||
claims.organizations?.[0]?.id
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeOpenAITokens(
|
||||
response: OpenAIOAuthTokenResponse,
|
||||
): OpenAIOAuthTokens {
|
||||
const claims =
|
||||
parseOpenAIJwtClaims(response.id_token) ??
|
||||
parseOpenAIJwtClaims(response.access_token)
|
||||
|
||||
return {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
expiresAt: Date.now() + (response.expires_in ?? 3600) * 1000,
|
||||
idToken: response.id_token,
|
||||
accountId: extractOpenAIAccountId(claims),
|
||||
email: claims?.email,
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpenAITokenExpired(expiresAt: number): boolean {
|
||||
return expiresAt - Date.now() <= 5 * 60 * 1000
|
||||
}
|
||||
|
||||
export function withRefreshedAccessToken(
|
||||
existing: OpenAIOAuthTokens,
|
||||
refreshed: OpenAIOAuthTokenResponse,
|
||||
): OpenAIOAuthTokens {
|
||||
const next = normalizeOpenAITokens(refreshed)
|
||||
|
||||
return {
|
||||
...next,
|
||||
accountId: next.accountId ?? existing.accountId,
|
||||
email: next.email ?? existing.email,
|
||||
idToken: next.idToken ?? existing.idToken,
|
||||
}
|
||||
}
|
||||
132
src/services/openaiAuth/fetch.ts
Normal file
132
src/services/openaiAuth/fetch.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import type { ClientOptions } from '@anthropic-ai/sdk'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { OPENAI_CODEX_API_ENDPOINT } from './client.js'
|
||||
import { ensureFreshOpenAITokens } from './index.js'
|
||||
import { resolveOpenAICodexModel } from './models.js'
|
||||
import { getOpenAIOAuthTokens } from './storage.js'
|
||||
import { anthropicToOpenaiResponses } from '../../server/proxy/transform/anthropicToOpenaiResponses.js'
|
||||
import { openaiResponsesToAnthropic } from '../../server/proxy/transform/openaiResponsesToAnthropic.js'
|
||||
import { openaiResponsesStreamToAnthropic } from '../../server/proxy/streaming/openaiResponsesStreamToAnthropic.js'
|
||||
import type { AnthropicRequest } from '../../server/proxy/transform/types.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
|
||||
export const OPENAI_OAUTH_DUMMY_KEY = 'openai-oauth-dummy-key'
|
||||
|
||||
export function shouldUseOpenAICodexAuth(): boolean {
|
||||
const openaiTokens = getOpenAIOAuthTokens()
|
||||
return !!openaiTokens?.refreshToken
|
||||
}
|
||||
|
||||
export function buildOpenAICodexFetch(
|
||||
fetchOverride: ClientOptions['fetch'],
|
||||
source: string | undefined,
|
||||
): ClientOptions['fetch'] {
|
||||
const inner = fetchOverride ?? globalThis.fetch
|
||||
|
||||
return async (input, init) => {
|
||||
const url = input instanceof Request ? new URL(input.url) : new URL(String(input))
|
||||
|
||||
if (!url.pathname.endsWith('/v1/messages')) {
|
||||
return inner(input, init)
|
||||
}
|
||||
|
||||
const originalBody = await readAnthropicBody(input, init)
|
||||
const mappedModel = resolveOpenAICodexModel(originalBody.model)
|
||||
const transformedBody = anthropicToOpenaiResponses({
|
||||
...originalBody,
|
||||
model: mappedModel,
|
||||
})
|
||||
|
||||
const tokens = await ensureFreshOpenAITokens()
|
||||
if (!tokens) {
|
||||
throw new Error(
|
||||
'OpenAI OAuth token is missing or expired. Run claude auth login --openai again.',
|
||||
)
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
headers.set('Content-Type', 'application/json')
|
||||
headers.set('Authorization', `Bearer ${tokens.accessToken}`)
|
||||
if (tokens.accountId) {
|
||||
headers.set('ChatGPT-Account-Id', tokens.accountId)
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[API REQUEST] ${url.pathname} remapped_to=OpenAI/Codex model=${mappedModel} source=${source ?? 'unknown'} request_id=${randomUUID()}`,
|
||||
)
|
||||
|
||||
const upstream = await inner(OPENAI_CODEX_API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal: init?.signal,
|
||||
})
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errorText = await upstream.text().catch(() => '')
|
||||
return Response.json(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: `OpenAI upstream returned HTTP ${upstream.status}: ${errorText.slice(0, 500)}`,
|
||||
},
|
||||
},
|
||||
{ status: upstream.status },
|
||||
)
|
||||
}
|
||||
|
||||
if (transformedBody.stream) {
|
||||
if (!upstream.body) {
|
||||
return Response.json(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: 'OpenAI upstream returned no body for stream',
|
||||
},
|
||||
},
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(
|
||||
openaiResponsesStreamToAnthropic(upstream.body, mappedModel),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const responseBody = await upstream.json()
|
||||
return Response.json(
|
||||
openaiResponsesToAnthropic(responseBody, mappedModel),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function readAnthropicBody(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<AnthropicRequest> {
|
||||
const directBody = init?.body
|
||||
|
||||
if (typeof directBody === 'string') {
|
||||
return JSON.parse(directBody) as AnthropicRequest
|
||||
}
|
||||
|
||||
if (directBody instanceof Uint8Array || directBody instanceof ArrayBuffer) {
|
||||
return JSON.parse(Buffer.from(directBody).toString('utf8')) as AnthropicRequest
|
||||
}
|
||||
|
||||
if (input instanceof Request) {
|
||||
return (await input.clone().json()) as AnthropicRequest
|
||||
}
|
||||
|
||||
throw new Error('Unable to read Anthropic request body for OpenAI/Codex transformation')
|
||||
}
|
||||
210
src/services/openaiAuth/index.ts
Normal file
210
src/services/openaiAuth/index.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { openBrowser } from '../../utils/browser.js'
|
||||
import { AuthCodeListener } from '../oauth/auth-code-listener.js'
|
||||
import { generateCodeVerifier, generateState } from '../oauth/crypto.js'
|
||||
import {
|
||||
buildOpenAIAuthorizeUrl,
|
||||
exchangeOpenAICodeForTokens,
|
||||
isOpenAITokenExpired,
|
||||
OPENAI_CODEX_OAUTH_PORT,
|
||||
OPENAI_CODEX_REDIRECT_PATH,
|
||||
refreshOpenAITokens,
|
||||
normalizeOpenAITokens,
|
||||
withRefreshedAccessToken,
|
||||
} from './client.js'
|
||||
import {
|
||||
clearOpenAIOAuthTokenCache,
|
||||
deleteOpenAIOAuthTokens,
|
||||
getOpenAIOAuthTokensAsync,
|
||||
saveOpenAIOAuthTokens,
|
||||
} from './storage.js'
|
||||
import type { OpenAIOAuthTokens } from './types.js'
|
||||
|
||||
const HTML_SUCCESS = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>cc-haha OpenAI Authorization Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display:flex; justify-content:center; align-items:center; height:100vh; margin:0; background:#131010; color:#f1ecec; }
|
||||
.container { text-align:center; padding:2rem; }
|
||||
p { color:#b7b1b1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Successful</h1>
|
||||
<p>You can close this window and return to Claude Code Haha.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 2000)</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const HTML_ERROR = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>cc-haha OpenAI Authorization Failed</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>You can close this window and return to Claude Code Haha.</p>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
export class OpenAIOAuthService {
|
||||
private codeVerifier: string
|
||||
private authCodeListener: AuthCodeListener | null = null
|
||||
private port: number | null = null
|
||||
private manualAuthCodeResolver: ((authorizationCode: string) => void) | null =
|
||||
null
|
||||
|
||||
constructor() {
|
||||
this.codeVerifier = generateCodeVerifier()
|
||||
}
|
||||
|
||||
async startOAuthFlow(
|
||||
authURLHandler: (url: string, automaticUrl?: string) => Promise<void>,
|
||||
options?: {
|
||||
skipBrowserOpen?: boolean
|
||||
},
|
||||
): Promise<OpenAIOAuthTokens> {
|
||||
this.authCodeListener = new AuthCodeListener(OPENAI_CODEX_REDIRECT_PATH)
|
||||
this.port = await this.authCodeListener.start(OPENAI_CODEX_OAUTH_PORT)
|
||||
|
||||
const state = generateState()
|
||||
const redirectUri = `http://localhost:${this.port}${OPENAI_CODEX_REDIRECT_PATH}`
|
||||
const authorizeUrl = buildOpenAIAuthorizeUrl({
|
||||
redirectUri,
|
||||
codeVerifier: this.codeVerifier,
|
||||
state,
|
||||
})
|
||||
|
||||
const authorizationCode = await this.waitForAuthorizationCode(
|
||||
state,
|
||||
async () => {
|
||||
if (options?.skipBrowserOpen) {
|
||||
await authURLHandler(authorizeUrl, authorizeUrl)
|
||||
} else {
|
||||
await authURLHandler(authorizeUrl)
|
||||
await openBrowser(authorizeUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await exchangeOpenAICodeForTokens({
|
||||
code: authorizationCode,
|
||||
redirectUri,
|
||||
codeVerifier: this.codeVerifier,
|
||||
})
|
||||
|
||||
const tokens = normalizeOpenAITokens(response)
|
||||
const storage = saveOpenAIOAuthTokens(tokens)
|
||||
if (!storage.success) {
|
||||
throw new Error(storage.warning ?? 'Failed to persist OpenAI OAuth tokens')
|
||||
}
|
||||
|
||||
this.authCodeListener?.handleSuccessRedirect([], (res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||
res.end(HTML_SUCCESS)
|
||||
})
|
||||
|
||||
return tokens
|
||||
} catch (error) {
|
||||
this.authCodeListener?.handleSuccessRedirect([], (res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||
res.end(HTML_ERROR)
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
this.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForAuthorizationCode(
|
||||
state: string,
|
||||
onReady: () => Promise<void>,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.manualAuthCodeResolver = resolve
|
||||
|
||||
this.authCodeListener
|
||||
?.waitForAuthorization(state, onReady)
|
||||
.then((authorizationCode) => {
|
||||
this.manualAuthCodeResolver = null
|
||||
resolve(authorizationCode)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.manualAuthCodeResolver = null
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle manual auth code input — e.g., user pastes the authorization code
|
||||
* from the browser into the desktop UI when automatic redirect didn't work.
|
||||
*/
|
||||
handleManualAuthCodeInput(params: {
|
||||
authorizationCode: string
|
||||
state: string
|
||||
}): void {
|
||||
if (this.manualAuthCodeResolver) {
|
||||
this.manualAuthCodeResolver(params.authorizationCode)
|
||||
this.manualAuthCodeResolver = null
|
||||
this.authCodeListener?.close()
|
||||
}
|
||||
}
|
||||
|
||||
async ensureFreshTokens(): Promise<OpenAIOAuthTokens | null> {
|
||||
clearOpenAIOAuthTokenCache()
|
||||
const tokens = await getOpenAIOAuthTokensAsync()
|
||||
if (!tokens) return null
|
||||
if (!isOpenAITokenExpired(tokens.expiresAt)) return tokens
|
||||
|
||||
const refreshed = await refreshOpenAITokens(tokens.refreshToken)
|
||||
const updated = withRefreshedAccessToken(tokens, refreshed)
|
||||
const storage = saveOpenAIOAuthTokens(updated)
|
||||
if (!storage.success) {
|
||||
throw new Error(storage.warning ?? 'Failed to persist refreshed OpenAI tokens')
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
async ensureFreshAccessToken(): Promise<string | null> {
|
||||
const tokens = await this.ensureFreshTokens()
|
||||
return tokens?.accessToken ?? null
|
||||
}
|
||||
|
||||
logout(): boolean {
|
||||
clearOpenAIOAuthTokenCache()
|
||||
return deleteOpenAIOAuthTokens()
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.authCodeListener?.close()
|
||||
this.authCodeListener = null
|
||||
this.port = null
|
||||
this.manualAuthCodeResolver = null
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureFreshOpenAITokens(): Promise<OpenAIOAuthTokens | null> {
|
||||
clearOpenAIOAuthTokenCache()
|
||||
const tokens = await getOpenAIOAuthTokensAsync()
|
||||
if (!tokens) return null
|
||||
if (!isOpenAITokenExpired(tokens.expiresAt)) return tokens
|
||||
|
||||
try {
|
||||
const refreshed = await refreshOpenAITokens(tokens.refreshToken)
|
||||
const updated = withRefreshedAccessToken(tokens, refreshed)
|
||||
const storage = saveOpenAIOAuthTokens(updated)
|
||||
if (!storage.success) {
|
||||
throw new Error(storage.warning ?? 'Failed to persist refreshed OpenAI tokens')
|
||||
}
|
||||
return updated
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
42
src/services/openaiAuth/models.test.ts
Normal file
42
src/services/openaiAuth/models.test.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
OPENAI_DEFAULT_MAIN_MODEL,
|
||||
OPENAI_GPT5_LARGE_CONTEXT_WINDOW,
|
||||
OPENAI_GPT5_STANDARD_CONTEXT_WINDOW,
|
||||
getOpenAIContextWindowForModel,
|
||||
isOpenAIResponsesModel,
|
||||
resolveOpenAICodexModel,
|
||||
} from './models.js'
|
||||
|
||||
describe('openai auth model resolution', () => {
|
||||
test('does not treat opus as an OpenAI Responses model', () => {
|
||||
expect(isOpenAIResponsesModel('opus')).toBe(false)
|
||||
})
|
||||
|
||||
test('accepts gpt and o-series models', () => {
|
||||
expect(isOpenAIResponsesModel('gpt-5.4')).toBe(true)
|
||||
expect(isOpenAIResponsesModel('o3-mini')).toBe(true)
|
||||
})
|
||||
|
||||
test('maps opus aliases to the OpenAI default model', () => {
|
||||
expect(resolveOpenAICodexModel('opus')).toBe(OPENAI_DEFAULT_MAIN_MODEL)
|
||||
})
|
||||
|
||||
test('maps large-context GPT-5 models to a 1.05M context window', () => {
|
||||
expect(getOpenAIContextWindowForModel('gpt-5.5')).toBe(
|
||||
OPENAI_GPT5_LARGE_CONTEXT_WINDOW,
|
||||
)
|
||||
expect(getOpenAIContextWindowForModel('gpt-5.4')).toBe(
|
||||
OPENAI_GPT5_LARGE_CONTEXT_WINDOW,
|
||||
)
|
||||
})
|
||||
|
||||
test('maps standard GPT-5 models to a 400k context window', () => {
|
||||
expect(getOpenAIContextWindowForModel('gpt-5.3-codex')).toBe(
|
||||
OPENAI_GPT5_STANDARD_CONTEXT_WINDOW,
|
||||
)
|
||||
expect(getOpenAIContextWindowForModel('gpt-5.4-mini')).toBe(
|
||||
OPENAI_GPT5_STANDARD_CONTEXT_WINDOW,
|
||||
)
|
||||
})
|
||||
})
|
||||
137
src/services/openaiAuth/models.ts
Normal file
137
src/services/openaiAuth/models.ts
Normal file
@ -0,0 +1,137 @@
|
||||
export const OPENAI_DEFAULT_MAIN_MODEL = 'gpt-5.3-codex'
|
||||
export const OPENAI_DEFAULT_SONNET_MODEL = 'gpt-5.4'
|
||||
export const OPENAI_DEFAULT_HAIKU_MODEL = 'gpt-5.4-mini'
|
||||
export const OPENAI_GPT5_STANDARD_CONTEXT_WINDOW = 400_000
|
||||
export const OPENAI_GPT5_LARGE_CONTEXT_WINDOW = 1_050_000
|
||||
|
||||
export type OpenAIModelCatalogEntry = {
|
||||
value: string
|
||||
label: string
|
||||
description: string
|
||||
descriptionForModel?: string
|
||||
}
|
||||
|
||||
export const OPENAI_CODEX_MODEL_CATALOG: OpenAIModelCatalogEntry[] = [
|
||||
{
|
||||
value: OPENAI_DEFAULT_MAIN_MODEL,
|
||||
label: 'GPT-5.3 Codex',
|
||||
description: 'Best for coding and agentic work',
|
||||
descriptionForModel: 'GPT-5.3 Codex - best for coding and agentic work',
|
||||
},
|
||||
{
|
||||
value: OPENAI_DEFAULT_SONNET_MODEL,
|
||||
label: 'GPT-5.4',
|
||||
description: 'Strong general-purpose model',
|
||||
descriptionForModel: 'GPT-5.4 - strong general-purpose model',
|
||||
},
|
||||
{
|
||||
value: 'gpt-5.5',
|
||||
label: 'GPT-5.5',
|
||||
description: 'Latest general-purpose model',
|
||||
descriptionForModel: 'GPT-5.5 - latest general-purpose model',
|
||||
},
|
||||
{
|
||||
value: OPENAI_DEFAULT_HAIKU_MODEL,
|
||||
label: 'GPT-5.4 Mini',
|
||||
description: 'Fastest for quick tasks',
|
||||
descriptionForModel: 'GPT-5.4 Mini - fastest for quick tasks',
|
||||
},
|
||||
]
|
||||
|
||||
export function isOpenAIResponsesModel(model: string): boolean {
|
||||
const normalized = model.trim().toLowerCase()
|
||||
return normalized.startsWith('gpt-') || /^o\d/.test(normalized)
|
||||
}
|
||||
|
||||
export function resolveOpenAICodexModel(model: string): string {
|
||||
if (process.env.OPENAI_CODEX_MODEL?.trim()) {
|
||||
return process.env.OPENAI_CODEX_MODEL.trim()
|
||||
}
|
||||
|
||||
const normalized = model.trim().toLowerCase()
|
||||
if (isOpenAIResponsesModel(normalized)) {
|
||||
return model
|
||||
}
|
||||
|
||||
if (normalized.includes('haiku')) {
|
||||
return (
|
||||
process.env.OPENAI_CODEX_HAIKU_MODEL?.trim() ||
|
||||
OPENAI_DEFAULT_HAIKU_MODEL
|
||||
)
|
||||
}
|
||||
|
||||
if (normalized.includes('sonnet')) {
|
||||
return (
|
||||
process.env.OPENAI_CODEX_SONNET_MODEL?.trim() ||
|
||||
OPENAI_DEFAULT_SONNET_MODEL
|
||||
)
|
||||
}
|
||||
|
||||
if (normalized.includes('opus')) {
|
||||
return (
|
||||
process.env.OPENAI_CODEX_OPUS_MODEL?.trim() || OPENAI_DEFAULT_MAIN_MODEL
|
||||
)
|
||||
}
|
||||
|
||||
return OPENAI_DEFAULT_MAIN_MODEL
|
||||
}
|
||||
|
||||
export function getOpenAIModelDisplayName(model: string): string | null {
|
||||
switch (model.trim().toLowerCase()) {
|
||||
case 'gpt-5.3-codex':
|
||||
return 'GPT-5.3 Codex'
|
||||
case 'gpt-5.5':
|
||||
return 'GPT-5.5'
|
||||
case 'gpt-5.4':
|
||||
return 'GPT-5.4'
|
||||
case 'gpt-5.4-mini':
|
||||
return 'GPT-5.4 Mini'
|
||||
case 'gpt-5.2':
|
||||
return 'GPT-5.2'
|
||||
case 'gpt-5.2-codex':
|
||||
return 'GPT-5.2 Codex'
|
||||
case 'gpt-5.1-codex':
|
||||
return 'GPT-5.1 Codex'
|
||||
case 'gpt-5.1-codex-max':
|
||||
return 'GPT-5.1 Codex Max'
|
||||
case 'gpt-5.1-codex-mini':
|
||||
return 'GPT-5.1 Codex Mini'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getOpenAIContextWindowForModel(model: string): number | null {
|
||||
const normalized = model.trim().toLowerCase()
|
||||
|
||||
// Current OpenAI model docs split GPT-5-family context windows into
|
||||
// standard 400k models and larger 1.05M models.
|
||||
if (
|
||||
normalized === 'gpt-5.5' ||
|
||||
normalized === 'gpt-5.5-pro' ||
|
||||
normalized === 'gpt-5.4' ||
|
||||
normalized === 'gpt-5.4-pro'
|
||||
) {
|
||||
return OPENAI_GPT5_LARGE_CONTEXT_WINDOW
|
||||
}
|
||||
|
||||
if (
|
||||
normalized === 'gpt-5.4-mini' ||
|
||||
normalized === 'gpt-5.4-nano' ||
|
||||
normalized === 'gpt-5.3-codex' ||
|
||||
normalized === 'gpt-5.2' ||
|
||||
normalized === 'gpt-5.2-codex' ||
|
||||
normalized === 'gpt-5.1' ||
|
||||
normalized === 'gpt-5.1-codex' ||
|
||||
normalized === 'gpt-5.1-codex-max' ||
|
||||
normalized === 'gpt-5.1-codex-mini' ||
|
||||
normalized === 'gpt-5-codex' ||
|
||||
normalized === 'gpt-5' ||
|
||||
normalized === 'gpt-5-mini' ||
|
||||
normalized === 'gpt-5-nano'
|
||||
) {
|
||||
return OPENAI_GPT5_STANDARD_CONTEXT_WINDOW
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
71
src/services/openaiAuth/storage.ts
Normal file
71
src/services/openaiAuth/storage.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { getSecureStorage } from '../../utils/secureStorage/index.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import type { OpenAIOAuthTokens } from './types.js'
|
||||
|
||||
const STORAGE_KEY = 'openaiCodexOauth'
|
||||
|
||||
type SecureStorageShape = Record<string, unknown> & {
|
||||
openaiCodexOauth?: OpenAIOAuthTokens
|
||||
}
|
||||
|
||||
export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): {
|
||||
success: boolean
|
||||
warning?: string
|
||||
} {
|
||||
try {
|
||||
const storage = getSecureStorage()
|
||||
const data = (storage.read() ?? {}) as SecureStorageShape
|
||||
data[STORAGE_KEY] = tokens
|
||||
const result = storage.update(data)
|
||||
clearOpenAIOAuthTokenCache()
|
||||
return result
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return {
|
||||
success: false,
|
||||
warning: `Failed to save OpenAI OAuth tokens: ${errorMessage(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getOpenAIOAuthTokens = memoize((): OpenAIOAuthTokens | null => {
|
||||
try {
|
||||
const storage = getSecureStorage()
|
||||
const data = storage.read() as SecureStorageShape | null
|
||||
return data?.openaiCodexOauth ?? null
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export async function getOpenAIOAuthTokensAsync(): Promise<OpenAIOAuthTokens | null> {
|
||||
try {
|
||||
const storage = getSecureStorage()
|
||||
const data = (await storage.readAsync()) as SecureStorageShape | null
|
||||
return data?.openaiCodexOauth ?? null
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearOpenAIOAuthTokenCache(): void {
|
||||
getOpenAIOAuthTokens.cache?.clear?.()
|
||||
}
|
||||
|
||||
export function deleteOpenAIOAuthTokens(): boolean {
|
||||
try {
|
||||
const storage = getSecureStorage()
|
||||
const data = (storage.read() ?? {}) as SecureStorageShape
|
||||
delete data[STORAGE_KEY]
|
||||
const result = storage.update(data)
|
||||
clearOpenAIOAuthTokenCache()
|
||||
return result.success
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
24
src/services/openaiAuth/types.ts
Normal file
24
src/services/openaiAuth/types.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export type OpenAIOAuthTokenResponse = {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
id_token?: string
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
export type OpenAIOAuthTokens = {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: number
|
||||
idToken?: string
|
||||
accountId?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export type OpenAIJwtClaims = {
|
||||
chatgpt_account_id?: string
|
||||
organizations?: Array<{ id: string }>
|
||||
email?: string
|
||||
'https://api.openai.com/auth'?: {
|
||||
chatgpt_account_id?: string
|
||||
}
|
||||
}
|
||||
42
src/utils/__tests__/tokens.test.ts
Normal file
42
src/utils/__tests__/tokens.test.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { getCurrentUsage } from '../tokens.js'
|
||||
|
||||
describe('getCurrentUsage', () => {
|
||||
test('skips zero placeholder usage and returns the latest meaningful usage', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
model: 'gpt-5.5',
|
||||
content: [{ type: 'text', text: 'older' }],
|
||||
usage: {
|
||||
input_tokens: 123,
|
||||
output_tokens: 45,
|
||||
cache_creation_input_tokens: 6,
|
||||
cache_read_input_tokens: 7,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
model: 'gpt-5.5',
|
||||
content: [{ type: 'text', text: 'placeholder' }],
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const
|
||||
|
||||
expect(getCurrentUsage(messages as never)).toEqual({
|
||||
input_tokens: 123,
|
||||
output_tokens: 45,
|
||||
cache_creation_input_tokens: 6,
|
||||
cache_read_input_tokens: 7,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -19,6 +19,7 @@ import {
|
||||
getMockSubscriptionType,
|
||||
shouldUseMockSubscription,
|
||||
} from '../services/mockRateLimits.js'
|
||||
import { getOpenAIOAuthTokens } from '../services/openaiAuth/storage.js'
|
||||
import {
|
||||
isOAuthTokenExpired,
|
||||
refreshOAuthToken,
|
||||
@ -1183,6 +1184,27 @@ export async function removeApiKey(): Promise<void> {
|
||||
clearLegacyApiKeyPrefetch()
|
||||
}
|
||||
|
||||
export function clearStoredClaudeAIOAuthTokens(): {
|
||||
success: boolean
|
||||
warning?: string
|
||||
} {
|
||||
try {
|
||||
const secureStorage = getSecureStorage()
|
||||
const storageData = secureStorage.read() || {}
|
||||
|
||||
if ('claudeAiOauth' in storageData) {
|
||||
delete storageData.claudeAiOauth
|
||||
}
|
||||
|
||||
const result = secureStorage.update(storageData)
|
||||
clearOAuthTokenCache()
|
||||
return result
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return { success: false, warning: 'Failed to clear stored Claude OAuth tokens' }
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeRemoveApiKeyFromMacOSKeychain(): Promise<void> {
|
||||
try {
|
||||
await maybeRemoveApiKeyFromMacOSKeychainThrows()
|
||||
@ -1856,11 +1878,72 @@ export function isConsumerSubscriber(): boolean {
|
||||
}
|
||||
|
||||
export type UserAccountInfo = {
|
||||
provider?: 'anthropic' | 'openai'
|
||||
subscription?: string
|
||||
tokenSource?: string
|
||||
apiKeySource?: ApiKeySource
|
||||
organization?: string
|
||||
email?: string
|
||||
accountId?: string
|
||||
}
|
||||
|
||||
export function getOpenAISubscriptionName(): string {
|
||||
return 'ChatGPT Pro/Plus'
|
||||
}
|
||||
|
||||
export function getOpenAIAuthOverrideWarning(): string | null {
|
||||
if (isUsing3PServices()) {
|
||||
return 'OpenAI login is saved, but this shell is configured for a third-party provider.'
|
||||
}
|
||||
|
||||
const { source: authTokenSource } = getAuthTokenSource()
|
||||
if (authTokenSource !== 'none') {
|
||||
return `OpenAI login is saved, but this shell is still using ${authTokenSource} for Anthropic-compatible auth.`
|
||||
}
|
||||
|
||||
const { source: apiKeySource } = getAnthropicApiKeyWithSource({
|
||||
skipRetrievingKeyFromApiKeyHelper: true,
|
||||
})
|
||||
if (apiKeySource !== 'none') {
|
||||
return `OpenAI login is saved, but this shell is still using ${apiKeySource} for Anthropic-compatible auth.`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function hasOpenAIAuthLogin(): boolean {
|
||||
const openaiTokens = getOpenAIOAuthTokens()
|
||||
return !!openaiTokens?.refreshToken && !isUsing3PServices()
|
||||
}
|
||||
|
||||
export function isOpenAIAuthActive(): boolean {
|
||||
return getOpenAIAccountInformationIfActive() !== undefined
|
||||
}
|
||||
|
||||
function getOpenAIAccountInformationIfActive(input?: {
|
||||
authTokenSource?: ReturnType<typeof getAuthTokenSource>['source']
|
||||
apiKeySource?: ApiKeySource
|
||||
}): UserAccountInfo | undefined {
|
||||
const openaiTokens = getOpenAIOAuthTokens()
|
||||
if (!openaiTokens?.refreshToken || isUsing3PServices()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const authTokenSource = input?.authTokenSource ?? getAuthTokenSource().source
|
||||
const apiKeySource =
|
||||
input?.apiKeySource ?? getAnthropicApiKeyWithSource().source
|
||||
|
||||
if (authTokenSource !== 'none' || apiKeySource !== 'none') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'openai',
|
||||
subscription: getOpenAISubscriptionName(),
|
||||
tokenSource: 'OpenAI OAuth',
|
||||
email: openaiTokens.email,
|
||||
accountId: openaiTokens.accountId,
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccountInformation() {
|
||||
@ -1870,7 +1953,17 @@ export function getAccountInformation() {
|
||||
return undefined
|
||||
}
|
||||
const { source: authTokenSource } = getAuthTokenSource()
|
||||
const { key: apiKey, source: apiKeySource } = getAnthropicApiKeyWithSource()
|
||||
const openAIAccountInfo = getOpenAIAccountInformationIfActive({
|
||||
authTokenSource,
|
||||
apiKeySource,
|
||||
})
|
||||
if (openAIAccountInfo) {
|
||||
return openAIAccountInfo
|
||||
}
|
||||
|
||||
const accountInfo: UserAccountInfo = {}
|
||||
accountInfo.provider = 'anthropic'
|
||||
if (
|
||||
authTokenSource === 'CLAUDE_CODE_OAUTH_TOKEN' ||
|
||||
authTokenSource === 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR'
|
||||
@ -1881,7 +1974,6 @@ export function getAccountInformation() {
|
||||
} else {
|
||||
accountInfo.tokenSource = authTokenSource
|
||||
}
|
||||
const { key: apiKey, source: apiKeySource } = getAnthropicApiKeyWithSource()
|
||||
if (apiKey) {
|
||||
accountInfo.apiKeySource = apiKeySource
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
import { CONTEXT_1M_BETA_HEADER } from '../constants/betas.js'
|
||||
import { getOpenAIContextWindowForModel } from '../services/openaiAuth/models.js'
|
||||
import { getGlobalConfig } from './config.js'
|
||||
import { isEnvTruthy } from './envUtils.js'
|
||||
import { getCanonicalName } from './model/model.js'
|
||||
import { getModelCapability } from './model/modelCapabilities.js'
|
||||
import { getConfiguredOrBuiltInModelContextWindow } from './model/modelContextWindows.js'
|
||||
|
||||
// Model context window size (200k tokens for all models right now)
|
||||
// Default fallback when the model-specific capability is unknown.
|
||||
export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000
|
||||
|
||||
// Maximum output tokens for compact operations
|
||||
@ -83,6 +84,11 @@ export function getContextWindowForModel(
|
||||
return configuredWindow
|
||||
}
|
||||
|
||||
const openAIContextWindow = getOpenAIContextWindowForModel(model)
|
||||
if (openAIContextWindow) {
|
||||
return openAIContextWindow
|
||||
}
|
||||
|
||||
const cap = getModelCapability(model)
|
||||
if (cap?.max_input_tokens && cap.max_input_tokens >= 100_000) {
|
||||
if (
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
getSubscriptionType,
|
||||
isClaudeAISubscriber,
|
||||
isMaxSubscriber,
|
||||
isOpenAIAuthActive,
|
||||
isProSubscriber,
|
||||
isTeamPremiumSubscriber,
|
||||
} from '../auth.js'
|
||||
@ -27,6 +28,10 @@ import { getAPIProvider } from './providers.js'
|
||||
import { LIGHTNING_BOLT } from '../../constants/figures.js'
|
||||
import { isModelAllowed } from './modelAllowlist.js'
|
||||
import { type ModelAlias, isModelAlias } from './aliases.js'
|
||||
import {
|
||||
getOpenAIModelDisplayName,
|
||||
resolveOpenAICodexModel,
|
||||
} from '../../services/openaiAuth/models.js'
|
||||
import { capitalize } from '../stringUtils.js'
|
||||
|
||||
export type ModelShortName = string
|
||||
@ -103,6 +108,9 @@ export function getBestModel(): ModelName {
|
||||
|
||||
// @[MODEL LAUNCH]: Update the default Opus model (3P providers may lag so keep defaults unchanged).
|
||||
export function getDefaultOpusModel(): ModelName {
|
||||
if (isOpenAIAuthActive()) {
|
||||
return resolveOpenAICodexModel('opus')
|
||||
}
|
||||
if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
}
|
||||
@ -117,6 +125,9 @@ export function getDefaultOpusModel(): ModelName {
|
||||
|
||||
// @[MODEL LAUNCH]: Update the default Sonnet model (3P providers may lag so keep defaults unchanged).
|
||||
export function getDefaultSonnetModel(): ModelName {
|
||||
if (isOpenAIAuthActive()) {
|
||||
return resolveOpenAICodexModel('sonnet')
|
||||
}
|
||||
if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
}
|
||||
@ -129,6 +140,9 @@ export function getDefaultSonnetModel(): ModelName {
|
||||
|
||||
// @[MODEL LAUNCH]: Update the default Haiku model (3P providers may lag so keep defaults unchanged).
|
||||
export function getDefaultHaikuModel(): ModelName {
|
||||
if (isOpenAIAuthActive()) {
|
||||
return resolveOpenAICodexModel('haiku')
|
||||
}
|
||||
if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
}
|
||||
@ -188,6 +202,10 @@ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias {
|
||||
)
|
||||
}
|
||||
|
||||
if (isOpenAIAuthActive()) {
|
||||
return resolveOpenAICodexModel('opus')
|
||||
}
|
||||
|
||||
// Max users get Opus as default
|
||||
if (isMaxSubscriber()) {
|
||||
return getDefaultOpusModel() + (isOpus1mMergeEnabled() ? '[1m]' : '')
|
||||
@ -290,6 +308,12 @@ export function getCanonicalName(fullModelName: ModelName): ModelShortName {
|
||||
export function getClaudeAiUserDefaultModelDescription(
|
||||
fastMode = false,
|
||||
): string {
|
||||
if (isOpenAIAuthActive()) {
|
||||
const defaultModel = renderDefaultModelSetting(
|
||||
getDefaultMainLoopModelSetting(),
|
||||
)
|
||||
return `${defaultModel} · OpenAI/Codex default`
|
||||
}
|
||||
if (isMaxSubscriber() || isTeamPremiumSubscriber()) {
|
||||
if (isOpus1mMergeEnabled()) {
|
||||
return `Opus 4.7 with 1M context · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
|
||||
@ -351,6 +375,10 @@ export function renderModelSetting(setting: ModelName | ModelAlias): string {
|
||||
* if the model is not recognized as a public model.
|
||||
*/
|
||||
export function getPublicModelDisplayName(model: ModelName): string | null {
|
||||
const openAIModelName = getOpenAIModelDisplayName(model)
|
||||
if (openAIModelName) {
|
||||
return openAIModelName
|
||||
}
|
||||
switch (model) {
|
||||
case getModelStrings().opus46:
|
||||
return 'Opus 4.7'
|
||||
@ -572,6 +600,10 @@ export function modelDisplayString(model: ModelSetting): string {
|
||||
|
||||
// @[MODEL LAUNCH]: Add a marketing name mapping for the new model below.
|
||||
export function getMarketingNameForModel(modelId: string): string | undefined {
|
||||
const openAIModelName = getOpenAIModelDisplayName(modelId)
|
||||
if (openAIModelName) {
|
||||
return openAIModelName
|
||||
}
|
||||
if (getAPIProvider() === 'foundry') {
|
||||
// deployment ID is user-defined in Foundry, so it may have no relation to the actual model
|
||||
return undefined
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
import { getInitialMainLoopModel } from '../../bootstrap/state.js'
|
||||
import {
|
||||
hasOpenAIAuthLogin,
|
||||
isClaudeAISubscriber,
|
||||
isMaxSubscriber,
|
||||
isOpenAIAuthActive,
|
||||
isTeamPremiumSubscriber,
|
||||
} from '../auth.js'
|
||||
import { getModelStrings } from './modelStrings.js'
|
||||
@ -14,7 +16,7 @@ import {
|
||||
} from '../modelCost.js'
|
||||
import { getSettings_DEPRECATED } from '../settings/settings.js'
|
||||
import { checkOpus1mAccess, checkSonnet1mAccess } from './check1mAccess.js'
|
||||
import { getAPIProvider } from './providers.js'
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './providers.js'
|
||||
import { isModelAllowed } from './modelAllowlist.js'
|
||||
import {
|
||||
getCanonicalName,
|
||||
@ -32,6 +34,7 @@ import {
|
||||
} from './model.js'
|
||||
import { has1mContext } from '../context.js'
|
||||
import { getGlobalConfig } from '../config.js'
|
||||
import { OPENAI_CODEX_MODEL_CATALOG } from '../../services/openaiAuth/models.js'
|
||||
|
||||
// @[MODEL LAUNCH]: Update all the available and default model option strings below.
|
||||
|
||||
@ -42,6 +45,25 @@ export type ModelOption = {
|
||||
descriptionForModel?: string
|
||||
}
|
||||
|
||||
function hasAnthropicCompatibleThirdPartyConfig(): boolean {
|
||||
return getAPIProvider() !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()
|
||||
}
|
||||
|
||||
function pushUniqueOption(
|
||||
options: ModelOption[],
|
||||
option: ModelOption | undefined,
|
||||
): void {
|
||||
if (!option) {
|
||||
return
|
||||
}
|
||||
|
||||
if (options.some(existing => existing.value === option.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
options.push(option)
|
||||
}
|
||||
|
||||
export function getDefaultOptionForUser(fastMode = false): ModelOption {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
const currentModel = renderDefaultModelSetting(
|
||||
@ -55,6 +77,18 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption {
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpenAIAuthActive()) {
|
||||
const currentModel = renderDefaultModelSetting(
|
||||
getDefaultMainLoopModelSetting(),
|
||||
)
|
||||
return {
|
||||
value: null,
|
||||
label: 'Default (recommended)',
|
||||
description: `Use the OpenAI/Codex default model (currently ${currentModel})`,
|
||||
descriptionForModel: `OpenAI/Codex default model (currently ${currentModel})`,
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribers
|
||||
if (isClaudeAISubscriber()) {
|
||||
return {
|
||||
@ -65,7 +99,7 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption {
|
||||
}
|
||||
|
||||
// PAYG
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const is3P = hasAnthropicCompatibleThirdPartyConfig()
|
||||
return {
|
||||
value: null,
|
||||
label: 'Default (recommended)',
|
||||
@ -74,7 +108,7 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption {
|
||||
}
|
||||
|
||||
function getCustomSonnetOption(): ModelOption | undefined {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const is3P = hasAnthropicCompatibleThirdPartyConfig()
|
||||
const customSonnetModel = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
// When a 3P user has a custom sonnet model string, show it directly
|
||||
if (is3P && customSonnetModel) {
|
||||
@ -94,7 +128,7 @@ function getCustomSonnetOption(): ModelOption | undefined {
|
||||
// @[MODEL LAUNCH]: Update or add model option functions (getSonnetXXOption, getOpusXXOption, etc.)
|
||||
// with the new model's label and description. These appear in the /model picker.
|
||||
function getSonnet46Option(): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const is3P = hasAnthropicCompatibleThirdPartyConfig()
|
||||
return {
|
||||
value: is3P ? getModelStrings().sonnet46 : 'sonnet',
|
||||
label: 'Sonnet',
|
||||
@ -105,7 +139,7 @@ function getSonnet46Option(): ModelOption {
|
||||
}
|
||||
|
||||
function getCustomOpusOption(): ModelOption | undefined {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const is3P = hasAnthropicCompatibleThirdPartyConfig()
|
||||
const customOpusModel = process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
// When a 3P user has a custom opus model string, show it directly
|
||||
if (is3P && customOpusModel) {
|
||||
@ -141,7 +175,7 @@ function getOpus46Option(fastMode = false): ModelOption {
|
||||
}
|
||||
|
||||
export function getSonnet46_1MOption(): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const is3P = hasAnthropicCompatibleThirdPartyConfig()
|
||||
return {
|
||||
value: is3P ? getModelStrings().sonnet46 + '[1m]' : 'sonnet[1m]',
|
||||
label: 'Sonnet (1M context)',
|
||||
@ -152,7 +186,7 @@ export function getSonnet46_1MOption(): ModelOption {
|
||||
}
|
||||
|
||||
export function getOpus46_1MOption(fastMode = false): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const is3P = hasAnthropicCompatibleThirdPartyConfig()
|
||||
return {
|
||||
value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
|
||||
label: 'Opus (1M context)',
|
||||
@ -163,7 +197,7 @@ export function getOpus46_1MOption(fastMode = false): ModelOption {
|
||||
}
|
||||
|
||||
function getCustomHaikuOption(): ModelOption | undefined {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const is3P = hasAnthropicCompatibleThirdPartyConfig()
|
||||
const customHaikuModel = process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
// When a 3P user has a custom haiku model string, show it directly
|
||||
if (is3P && customHaikuModel) {
|
||||
@ -179,7 +213,7 @@ function getCustomHaikuOption(): ModelOption | undefined {
|
||||
}
|
||||
|
||||
function getHaiku45Option(): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const is3P = hasAnthropicCompatibleThirdPartyConfig()
|
||||
return {
|
||||
value: 'haiku',
|
||||
label: 'Haiku',
|
||||
@ -217,7 +251,7 @@ function getMaxOpusOption(fastMode = false): ModelOption {
|
||||
}
|
||||
|
||||
export function getMaxSonnet46_1MOption(): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const is3P = hasAnthropicCompatibleThirdPartyConfig()
|
||||
const billingInfo = isClaudeAISubscriber() ? ' · Billed as extra usage' : ''
|
||||
return {
|
||||
value: 'sonnet[1m]',
|
||||
@ -236,7 +270,7 @@ export function getMaxOpus46_1MOption(fastMode = false): ModelOption {
|
||||
}
|
||||
|
||||
function getMergedOpus1MOption(fastMode = false): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const is3P = hasAnthropicCompatibleThirdPartyConfig()
|
||||
return {
|
||||
value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
|
||||
label: 'Opus (1M context)',
|
||||
@ -295,6 +329,27 @@ function getAzureOpenAICodexOptions(): ModelOption[] {
|
||||
]
|
||||
}
|
||||
|
||||
function getOpenAIModelOptions(): ModelOption[] {
|
||||
const options = [
|
||||
getDefaultOptionForUser(),
|
||||
...OPENAI_CODEX_MODEL_CATALOG.map(model => ({
|
||||
value: model.value,
|
||||
label: model.label,
|
||||
description: model.description,
|
||||
descriptionForModel: model.descriptionForModel,
|
||||
})),
|
||||
]
|
||||
|
||||
if (!hasAnthropicCompatibleThirdPartyConfig()) {
|
||||
return options
|
||||
}
|
||||
|
||||
pushUniqueOption(options, getCustomSonnetOption())
|
||||
pushUniqueOption(options, getCustomOpusOption())
|
||||
pushUniqueOption(options, getCustomHaikuOption())
|
||||
|
||||
return options
|
||||
}
|
||||
// @[MODEL LAUNCH]: Update the model picker lists below to include/reorder options for the new model.
|
||||
// Each user tier (ant, Max/Team Premium, Pro/Team Standard/Enterprise, PAYG 1P, PAYG 3P) has its own list.
|
||||
function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||
@ -320,6 +375,10 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||
]
|
||||
}
|
||||
|
||||
if (hasOpenAIAuthLogin()) {
|
||||
return getOpenAIModelOptions(fastMode)
|
||||
}
|
||||
|
||||
if (isClaudeAISubscriber()) {
|
||||
if (isMaxSubscriber() || isTeamPremiumSubscriber()) {
|
||||
// Max and Team Premium users: Opus is default, show Sonnet as alternative
|
||||
@ -357,7 +416,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||
}
|
||||
|
||||
// PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.7 + Opus 1M + Haiku
|
||||
if (getAPIProvider() === 'firstParty') {
|
||||
if (!hasAnthropicCompatibleThirdPartyConfig()) {
|
||||
const payg1POptions = [getDefaultOptionForUser(fastMode)]
|
||||
if (checkSonnet1mAccess()) {
|
||||
payg1POptions.push(getSonnet46_1MOption())
|
||||
@ -379,7 +438,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||
|
||||
const customSonnet = getCustomSonnetOption()
|
||||
if (customSonnet !== undefined) {
|
||||
payg3pOptions.push(customSonnet)
|
||||
pushUniqueOption(payg3pOptions, customSonnet)
|
||||
} else {
|
||||
// Add Sonnet 4.6 since Sonnet 4.5 is the default
|
||||
payg3pOptions.push(getSonnet46Option())
|
||||
@ -390,7 +449,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||
|
||||
const customOpus = getCustomOpusOption()
|
||||
if (customOpus !== undefined) {
|
||||
payg3pOptions.push(customOpus)
|
||||
pushUniqueOption(payg3pOptions, customOpus)
|
||||
} else {
|
||||
// Add Opus 4.1, Opus 4.7 and Opus 4.7 1M
|
||||
payg3pOptions.push(getOpus41Option()) // This is the default opus
|
||||
@ -401,7 +460,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||
}
|
||||
const customHaiku = getCustomHaikuOption()
|
||||
if (customHaiku !== undefined) {
|
||||
payg3pOptions.push(customHaiku)
|
||||
pushUniqueOption(payg3pOptions, customHaiku)
|
||||
} else {
|
||||
payg3pOptions.push(getHaikuOption())
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { MODEL_ALIASES } from './aliases.js'
|
||||
import { isModelAllowed } from './modelAllowlist.js'
|
||||
import { getAPIProvider } from './providers.js'
|
||||
import { sideQuery } from '../sideQuery.js'
|
||||
import { isOpenAIAuthActive } from '../auth.js'
|
||||
import {
|
||||
NotFoundError,
|
||||
APIError,
|
||||
@ -10,6 +11,7 @@ import {
|
||||
AuthenticationError,
|
||||
} from '@anthropic-ai/sdk'
|
||||
import { getModelStrings } from './modelStrings.js'
|
||||
import { isOpenAIResponsesModel } from '../../services/openaiAuth/models.js'
|
||||
|
||||
// Cache valid models to avoid repeated API calls
|
||||
const validModelCache = new Map<string, boolean>()
|
||||
@ -109,6 +111,11 @@ export async function validateModel(
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
if (isOpenAIAuthActive() && isOpenAIResponsesModel(normalizedModel)) {
|
||||
validModelCache.set(normalizedModel, true)
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (validModelCache.has(normalizedModel)) {
|
||||
return { valid: true }
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -145,12 +145,30 @@ export function getCurrentUsage(messages: Message[]): {
|
||||
const message = messages[i]
|
||||
const usage = message ? getTokenUsage(message) : undefined
|
||||
if (usage) {
|
||||
return {
|
||||
const normalizedUsage = {
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
|
||||
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
|
||||
}
|
||||
|
||||
// Zero-token usage is used in a few recovery/compaction paths as a
|
||||
// placeholder for "stale or unavailable", not as a real token count.
|
||||
if (
|
||||
normalizedUsage.input_tokens === 0 &&
|
||||
normalizedUsage.output_tokens === 0 &&
|
||||
normalizedUsage.cache_creation_input_tokens === 0 &&
|
||||
normalizedUsage.cache_read_input_tokens === 0
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
return {
|
||||
input_tokens: normalizedUsage.input_tokens,
|
||||
output_tokens: normalizedUsage.output_tokens,
|
||||
cache_creation_input_tokens: normalizedUsage.cache_creation_input_tokens,
|
||||
cache_read_input_tokens: normalizedUsage.cache_read_input_tokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user