diff --git a/apps/maple-research/frontend/bun.lock b/apps/maple-research/frontend/bun.lock index 5dd9297b2..5fabcd601 100644 --- a/apps/maple-research/frontend/bun.lock +++ b/apps/maple-research/frontend/bun.lock @@ -5,7 +5,7 @@ "": { "name": "maple", "dependencies": { - "@mapleai/sdk": "4.1.1", + "@mapleai/sdk": "file:../../../sdk", "@opensecret/react-v1": "npm:@opensecret/react@3.4.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -237,7 +237,17 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@mapleai/sdk": ["@mapleai/sdk@4.1.1", "", { "dependencies": { "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", "@stablelib/random": "2.0.1", "cbor2": "1.12.0", "openai": "5.23.2", "tweetnacl": "1.0.3", "zod": "3.25.76" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-znbUk+3mfHpeh5fU/ES7B/+p4P0Pyk0/F8b3k0u/GiWE7h1O0yt7TSuX+LdcMpQfCBywtPRasVl16lN6uleKhw=="], + "@mapleai/sdk": ["@mapleai/sdk@file:../../../sdk", { "dependencies": { "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", "@stablelib/random": "2.0.1", "cbor2": "1.12.0", "openai": "6.0.0", "tweetnacl": "1.0.3", "zod": "4.6.7" }, "devDependencies": { "@eslint/js": "9.39.5", "@noble/curves": "1.9.7", "@noble/hashes": "1.8.0", "@types/bun": "1.1.13", "@types/react": "19.2.18", "@vitejs/plugin-react": "4.7.0", "ajv": "8.20.0", "eslint": "9.39.5", "eslint-plugin-react-hooks": "5.2.0", "globals": "15.15.0", "prettier": "3.9.6", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.4", "vite-plugin-dts": "4.5.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }], + + "@microsoft/api-extractor": ["@microsoft/api-extractor@7.59.4", "", { "dependencies": { "@microsoft/api-extractor-model": "7.33.15", "@microsoft/tsdoc": "~0.17.0", "@microsoft/tsdoc-config": "~0.18.2", "@rushstack/node-core-library": "5.25.1", "@rushstack/rig-package": "0.7.4", "@rushstack/terminal": "0.24.7", "@rushstack/ts-command-line": "5.3.17", "diff": "~8.0.2", "minimatch": "10.2.3", "resolve": "~1.22.1", "semver": "~7.7.4", "source-map": "~0.6.1", "typescript": "5.9.3" }, "bin": { "api-extractor": "bin/api-extractor" } }, "sha512-XqseDKIw736qlhQwS9SOpbY4veOiP2Nc7YiWmgXXBmeA3kY9qGXw+Tu7DFpU9FvXywE1z8SvLJKUVxFpcX0kig=="], + + "@microsoft/api-extractor-model": ["@microsoft/api-extractor-model@7.33.15", "", { "dependencies": { "@microsoft/tsdoc": "~0.17.0", "@microsoft/tsdoc-config": "~0.18.2", "@rushstack/node-core-library": "5.25.1" } }, "sha512-UpCnItIJwu03iNTWtJVKv8Qbf0MXN7WLQZUail9cpAQmnZiIX36YPRRoRxz0pocwytzUpmJhstsLkyW5tsaHBA=="], + + "@microsoft/tsdoc": ["@microsoft/tsdoc@0.17.1", "", {}, "sha512-VHyYyyoH63jFoO3shpw9Zfijk5v/9p/VrClW35z0/q6ooK7sSmxpzOkS4iZPxs3vO4XtBW4H38HRAKkAMIK7DA=="], + + "@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.18.3", "", { "dependencies": { "@microsoft/tsdoc": "0.17.1", "ajv": "~8.18.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-0I0rGXo1eJziw3EEErkDS9p/0RzBcyOiV+zbSDqOZTeURmhtut+WIvpih0LM5g+ZpZ9SyFxDfkhAcl/r8e2zGw=="], + + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], @@ -355,6 +365,8 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], @@ -405,6 +417,16 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], + "@rushstack/node-core-library": ["@rushstack/node-core-library@5.25.1", "", { "dependencies": { "ajv": "~8.20.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", "import-lazy": "~4.0.0", "jju": "~1.4.0", "resolve": "~1.22.1", "semver": "~7.7.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ozoRDwIx3jStOYpx86rFyXH4SaUR+DVT6HubQ59avLplJkWXSm5InztYH7RrOzzq9PJXTdq9/+lKX06SfkLVsA=="], + + "@rushstack/problem-matcher": ["@rushstack/problem-matcher@0.2.1", "", { "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog=="], + + "@rushstack/rig-package": ["@rushstack/rig-package@0.7.4", "", { "dependencies": { "jju": "~1.4.0", "resolve": "~1.22.1" } }, "sha512-SHZdzc9b2ptUb5uvRWALtAceD/QKZ4UAnZjcaKviTMPmHBnW14gs7XDocn6sDv2EqgnxxFPFkpV7/I3cUijCbw=="], + + "@rushstack/terminal": ["@rushstack/terminal@0.24.7", "", { "dependencies": { "@rushstack/node-core-library": "5.25.1", "@rushstack/problem-matcher": "0.2.1", "supports-color": "~8.1.1" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-o8J9nHOIwfYb0s0eG2WYhx7ed3YIvBXQHVFxYdXCmVdBOkTkdArJcgVzRS8ewUepmlic4nGgJPjKIVaNKSqEnA=="], + + "@rushstack/ts-command-line": ["@rushstack/ts-command-line@5.3.17", "", { "dependencies": { "@rushstack/terminal": "0.24.7", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" } }, "sha512-pIwhWawZXAT0nTXwvcE/InQ7uaVAgSeIzILMb2mBrI01Hz1yEvxrhV2+DE2Fzux8aZY9OJTdLsI+sk9kZDuAJw=="], + "@stablelib/aead": ["@stablelib/aead@2.0.0", "", {}, "sha512-U/RMANRxbT/ahIpYsPSiFwDFNjADHdnCFfmo09MO1ai2XmerPAOPtMl0qmX7XVvygnACC6ijKDyHBoT2rGyElg=="], "@stablelib/base64": ["@stablelib/base64@2.0.1", "", {}, "sha512-P2z89A7N1ETt6RxgpVdDT2xlg8cnm3n6td0lY9gyK7EiWK3wdq388yFX/hLknkCC0we05OZAD1rfxlQJUbl5VQ=="], @@ -481,6 +503,8 @@ "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], + "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "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.6.8", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw=="], @@ -521,6 +545,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="], @@ -545,12 +571,34 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "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=="], + "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], + + "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], + + "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], + + "@vue/compiler-core": ["@vue/compiler-core@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.44", "", { "dependencies": { "@vue/compiler-core": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg=="], + + "@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="], + + "@vue/language-core": ["@vue/language-core@2.2.0", "", { "dependencies": { "@volar/language-core": "~2.4.11", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^0.4.9", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw=="], + + "@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "alien-signals": ["alien-signals@0.4.14", "", {}, "sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q=="], + "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -623,6 +671,10 @@ "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], + + "confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], @@ -633,6 +685,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], "decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="], @@ -685,8 +739,12 @@ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "exsolve": ["exsolve@1.1.3", "", {}, "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -697,6 +755,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], + "fastq": ["fastq@1.19.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -715,6 +775,8 @@ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -729,6 +791,8 @@ "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -755,6 +819,8 @@ "hastscript": ["hastscript@9.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw=="], + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -763,6 +829,8 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-lazy": ["import-lazy@4.0.0", "", {}, "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], @@ -797,6 +865,8 @@ "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], @@ -811,16 +881,22 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "katex": ["katex@0.18.11", "", { "dependencies": { "commander": "^15.0.0" }, "bin": { "katex": "cli.js" } }, "sha512-yvyz/2VMKqoho/35Hat5w37wIEdWJ5ElVMidTAlWrxr5uW4aBGsEpveTmKh18/zw08v/7lGswCPon4OhqmqrTA=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "local-pkg": ["local-pkg@1.2.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], @@ -939,8 +1015,12 @@ "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], @@ -971,6 +1051,8 @@ "parse5": ["parse5@7.2.1", "", { "dependencies": { "entities": "^4.5.0" } }, "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -989,6 +1071,8 @@ "pirates": ["pirates@4.0.6", "", {}, "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg=="], + "pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="], + "postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], @@ -1015,6 +1099,8 @@ "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], @@ -1063,6 +1149,8 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -1087,10 +1175,16 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1145,6 +1239,8 @@ "typescript-eslint": ["typescript-eslint@8.59.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.0", "@typescript-eslint/parser": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw=="], + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -1163,6 +1259,8 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unplugin": ["unplugin@3.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.4", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg=="], "update-browserslist-db": ["update-browserslist-db@1.3.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="], @@ -1187,6 +1285,10 @@ "vite": ["vite@6.4.4", "", { "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", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q=="], + "vite-plugin-dts": ["vite-plugin-dts@4.5.4", "", { "dependencies": { "@microsoft/api-extractor": "^7.50.1", "@rollup/pluginutils": "^5.1.4", "@volar/typescript": "^2.4.11", "@vue/language-core": "2.2.0", "compare-versions": "^6.1.1", "debug": "^4.4.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "magic-string": "^0.30.17" }, "peerDependencies": { "typescript": "*", "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg=="], + + "vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], @@ -1227,9 +1329,27 @@ "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], - "@mapleai/sdk/openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="], + "@mapleai/sdk/@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="], + + "@mapleai/sdk/@types/bun": ["@types/bun@1.1.13", "", { "dependencies": { "bun-types": "1.1.34" } }, "sha512-KmQxSBgVWCl6RSuerlLGZlIWfdxkKqat0nxN61+qu4y1KDn0Ll3j7v1Pl8GnaL3a/U6GGWVTJh75ap62kR1E8Q=="], + + "@mapleai/sdk/@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + + "@mapleai/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "@mapleai/sdk/eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], + + "@mapleai/sdk/openai": ["openai@6.0.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-J7LEmTn3WLZnbyEmMYcMPyT5A0fGzhPwSvVUcNRKy6j2hJIbqSFrJERnUHYNkcoCCalRumypnj9AVoe5bVHd3Q=="], - "@mapleai/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@mapleai/sdk/prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], + + "@mapleai/sdk/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + + "@mapleai/sdk/typescript-eslint": ["typescript-eslint@8.66.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.66.0", "@typescript-eslint/parser": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0", "@typescript-eslint/utils": "8.66.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw=="], + + "@microsoft/api-extractor/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@microsoft/tsdoc-config/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "@opensecret/react-v1/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -1251,6 +1371,14 @@ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@rushstack/node-core-library/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "@rushstack/node-core-library/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@rushstack/terminal/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "@rushstack/ts-command-line/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "@tanstack/router-generator/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "@types/babel__core/@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": "./bin/babel-parser.js" }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], @@ -1283,6 +1411,12 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "babel-dead-code-elimination/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], @@ -1297,6 +1431,8 @@ "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -1313,6 +1449,8 @@ "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "vite-plugin-dts/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1323,6 +1461,30 @@ "@jridgewell/remapping/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@mapleai/sdk/@types/bun/bun-types": ["bun-types@1.1.34", "", { "dependencies": { "@types/node": "~20.12.8", "@types/ws": "~8.5.10" } }, "sha512-br5QygTEL/TwB4uQOb96Ky22j4Gq2WxWH/8Oqv20fk5HagwKXo/akB+LiYgSfzexCt6kkcUaVm+bKiPl71xPvw=="], + + "@mapleai/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@mapleai/sdk/eslint/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@mapleai/sdk/eslint/@eslint/eslintrc": ["@eslint/eslintrc@3.3.7", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.3.2", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw=="], + + "@mapleai/sdk/eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "@mapleai/sdk/eslint/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.66.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/type-utils": "8.66.0", "@typescript-eslint/utils": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.66.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.66.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/types": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.66.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.66.0", "@typescript-eslint/tsconfig-utils": "8.66.0", "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.66.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/types": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA=="], + + "@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@rushstack/node-core-library/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@types/babel__core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], "@types/babel__core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], @@ -1339,6 +1501,8 @@ "@types/babel__traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "babel-dead-code-elimination/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "babel-dead-code-elimination/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "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=="], @@ -1351,6 +1515,8 @@ "babel-dead-code-elimination/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1361,10 +1527,68 @@ "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@mapleai/sdk/@types/bun/bun-types/@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], + + "@mapleai/sdk/eslint/@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0" } }, "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0", "@typescript-eslint/utils": "8.66.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0" } }, "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.66.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.66.0", "@typescript-eslint/types": "^8.66.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.66.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0" } }, "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], + "babel-dead-code-elimination/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "babel-dead-code-elimination/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + "@mapleai/sdk/@types/bun/bun-types/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="], + "babel-dead-code-elimination/@babel/traverse/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], } } diff --git a/apps/maple-research/frontend/package.json b/apps/maple-research/frontend/package.json index db1243c09..f3c4c2682 100644 --- a/apps/maple-research/frontend/package.json +++ b/apps/maple-research/frontend/package.json @@ -48,7 +48,7 @@ "source-map-js": "^1.2.2" }, "dependencies": { - "@mapleai/sdk": "4.1.1", + "@mapleai/sdk": "file:../../../sdk", "@opensecret/react-v1": "npm:@opensecret/react@3.4.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/apps/maple-research/frontend/src/components/ModelSelector.tsx b/apps/maple-research/frontend/src/components/ModelSelector.tsx index 116e438e7..6b448233c 100644 --- a/apps/maple-research/frontend/src/components/ModelSelector.tsx +++ b/apps/maple-research/frontend/src/components/ModelSelector.tsx @@ -8,7 +8,14 @@ import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; import { useBillingState, useModelState } from "@/state/useLocalState"; -import { useOpenSecret } from "@mapleai/sdk"; +import { useOpenSecret, type ModelCatalogV2Response } from "@mapleai/sdk"; +import { + canUseModel, + normalizeModelCatalogV2, + isCatalogEndpointAbsent, + planLabel, + type ModelPlanId +} from "@/services/modelAccess"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { UpgradePromptDialog } from "@/components/UpgradePromptDialog"; import { POWERFUL_MODEL_ALIAS, QUICK_MODEL_ALIAS } from "@/utils/utils"; @@ -39,6 +46,7 @@ const PRIMARY_MODELS = [ ] as const; type ModelCatalogClient = { + fetchModelCatalogV2?: () => Promise; fetchModelCatalog?: () => Promise; fetchModels?: () => Promise; }; @@ -96,14 +104,17 @@ export function ModelSelector({ } = useModelState(); const { billingStatus } = useBillingState(); const os = useOpenSecret(); + const catalogClientRef = useRef(os); + catalogClientRef.current = os; + const catalogOwnerId = os.auth.user?.user.id; const isManagedSelection = onModelChange != null; const model = isManagedSelection ? (controlledModel ?? "") : unmanagedModel; - const isFetching = useRef(false); - const hasFetched = useRef(false); const currentModelRef = useRef(model); const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); const [selectedModelName, setSelectedModelName] = useState(""); + const [eligiblePlans, setEligiblePlans] = useState(); const [showAdvanced, setShowAdvanced] = useState(false); + const [catalogRefresh, setCatalogRefresh] = useState(0); const setModel = useCallback( (modelId: string, metadata?: OpenSecretModel) => { @@ -143,64 +154,75 @@ export function ModelSelector({ [setModel] ); - const fetchCatalog = useCallback(async () => { - if (hasFetched.current || isFetching.current) return; - - isFetching.current = true; - - try { - const modelClient = os as unknown as ModelCatalogClient; - - if (modelClient.fetchModelCatalog) { + useEffect(() => { + if (isManagedSelection) return; + let current = true; + setAvailableModels([]); + setModelAliases([]); + setUpgradeDialogOpen(false); + const publish = (catalog: OpenSecretModelCatalog) => { + if (!current) return; + const models = catalog.data.filter(isSelectableChatModel); + setAvailableModels(models); + setModelAliases(catalog.aliases); + setHasWhisperModel( + catalog.audio?.transcription?.available ?? + catalog.data.some((model) => model.id === "whisper-large-v3") + ); + reconcileSelectedConcreteModel(models); + }; + const fetchCatalog = async () => { + const client = catalogClientRef.current as unknown as ModelCatalogClient; + if (client.fetchModelCatalogV2) { try { - const catalog = await modelClient.fetchModelCatalog(); - const selectableModels = catalog.data.filter(isSelectableChatModel); - const hasCatalogWhisperModel = catalog.data.some( - (catalogModel) => catalogModel.id === "whisper-large-v3" - ); - hasFetched.current = true; - setAvailableModels(selectableModels); - setModelAliases(catalog.aliases); - setHasWhisperModel(catalog.audio?.transcription?.available ?? hasCatalogWhisperModel); - reconcileSelectedConcreteModel(selectableModels); - + publish(normalizeModelCatalogV2(await client.fetchModelCatalogV2())); return; } catch (error) { - if (import.meta.env.DEV) { - console.warn("Failed to fetch model catalog, falling back to fetchModels:", error); - } + if (!isCatalogEndpointAbsent(error)) throw error; } } - - if (modelClient.fetchModels) { - const models = await modelClient.fetchModels(); - const availableGenerateModels = models.filter((availableModel) => { - const tasks = availableModel.tasks || []; - if (tasks.length > 0) return tasks.includes("generate"); - const id = availableModel.id.toLowerCase(); - return !id.includes("whisper") && !id.includes("embed"); - }); - hasFetched.current = true; - setHasWhisperModel( - models.some((availableModel) => availableModel.id === "whisper-large-v3") - ); - setAvailableModels(availableGenerateModels); - setModelAliases(buildFallbackModelAliases(availableGenerateModels)); - reconcileSelectedConcreteModel(availableGenerateModels); + if (client.fetchModelCatalog) { + try { + publish(await client.fetchModelCatalog()); + return; + } catch (error) { + if (!isCatalogEndpointAbsent(error)) throw error; + } } - } catch (error) { - if (import.meta.env.DEV) { - console.warn("Failed to fetch model metadata:", error); + if (client.fetchModels) { + const models = await client.fetchModels(); + const generate = models.filter((model) => + model.tasks?.length ? model.tasks.includes("generate") : !/whisper|embed/i.test(model.id) + ); + publish({ + object: "list", + data: generate, + aliases: buildFallbackModelAliases(generate), + audio: { + transcription: { + available: models.some((model) => model.id === "whisper-large-v3"), + model: "whisper-large-v3" + } + } + }); } - } finally { - isFetching.current = false; - } - }, [os, reconcileSelectedConcreteModel, setAvailableModels, setHasWhisperModel, setModelAliases]); - - useEffect(() => { - if (isManagedSelection) return; - void fetchCatalog(); - }, [fetchCatalog, isManagedSelection]); + }; + void fetchCatalog().catch((error: unknown) => { + if (current && import.meta.env.DEV) console.warn("Failed to fetch model catalog:", error); + }); + return () => { + current = false; + }; + }, [ + catalogOwnerId, + billingStatus?.product_name, + isManagedSelection, + catalogRefresh, + setAvailableModels, + setModelAliases, + setHasWhisperModel, + reconcileSelectedConcreteModel + ]); const getAlias = useCallback( (modelId: string): OpenSecretModelAlias | undefined => { @@ -231,28 +253,25 @@ export function ModelSelector({ [modelById] ); - const getAccess = useCallback( - (modelId: string): ModelAccessTier => { + const getModelAccess = useCallback( + (modelId: string) => { const alias = getAlias(modelId); - if (alias) { - return getTargetModel(alias)?.access || alias.access || "free"; - } - return modelById.get(modelId)?.access || "free"; + return alias ? (getTargetModel(alias) ?? alias) : modelById.get(modelId); }, [getAlias, getTargetModel, modelById] ); const hasAccessToModel = useCallback( - (modelId: string) => { - const access = getAccess(modelId); - if (access === "free") return true; - - const planName = billingStatus?.product_name?.toLowerCase() || ""; - return planName.includes("pro") || planName.includes("max") || planName.includes("team"); - }, - [billingStatus?.product_name, getAccess] + (modelId: string) => canUseModel(getModelAccess(modelId), billingStatus?.product_name), + [getModelAccess, billingStatus?.product_name] ); + const openModelUpgrade = (modelId: string, name: string) => { + setSelectedModelName(name); + setEligiblePlans(getModelAccess(modelId)?.accessPolicy?.eligible_plans); + setUpgradeDialogOpen(true); + }; + const getDisplayLabel = (modelId: string): string => { const alias = getAlias(modelId); if (alias) return alias.short_name || alias.label; @@ -272,8 +291,7 @@ export function ModelSelector({ // Handle primary option selection const handlePrimarySelect = (targetModel: string) => { if (!hasAccessToModel(targetModel)) { - setSelectedModelName(getDisplayNameText(targetModel)); - setUpgradeDialogOpen(true); + openModelUpgrade(targetModel, getDisplayNameText(targetModel)); return; } @@ -347,7 +365,12 @@ export function ModelSelector({ return ( <> - + { + if (open && !isManagedSelection) setCatalogRefresh((value) => value + 1); + }} + > )} {/* Show Buy Credits button for paid users hitting usage limits */} diff --git a/apps/maple-research/frontend/src/services/chatAccessError.test.ts b/apps/maple-research/frontend/src/services/chatAccessError.test.ts index 26e2d7f15..392800ab1 100644 --- a/apps/maple-research/frontend/src/services/chatAccessError.test.ts +++ b/apps/maple-research/frontend/src/services/chatAccessError.test.ts @@ -6,7 +6,11 @@ import { createConversationChatKey } from "./chatRuntimeStore"; import { handleChatAccessError } from "./chatAccessError"; import { classifyChatLimitFailure, isChatPlanAccessDeniedError } from "./chatResponseErrors"; -async function serverFailure(status: number, code?: string): Promise { +async function serverFailure( + status: number, + code?: string, + details: Record = {} +): Promise { const message = "Server display text is independent of dialog routing"; const headers = code ? new Headers({ @@ -20,7 +24,7 @@ async function serverFailure(status: number, code?: string): Promise { maxRetries: 0, fetch: async () => Response.json( - { status, message, error: { message, ...(code ? { code } : {}) } }, + { status, message, error: { message, ...(code ? { code } : {}), ...details } }, { status, headers @@ -282,3 +286,63 @@ describe("chat access error presentation", () => { expect(fixture.store.get(fixture.key)).toBeUndefined(); }); }); + +test("model entitlement denials restore the draft and show the required plans", async () => { + const fixture = runtimeFixture(); + const error = await serverFailure(403, "model_not_available_on_plan", { + model: "uncensored", + eligible_plans: ["max", "teams"] + }); + let shown: unknown; + expect( + handleChatAccessError({ + ...fixture.options, + error, + productName: "Pro", + showModelUpgradeDialog: (requirement) => { + shown = requirement; + } + }) + ).toBe(true); + expect(shown).toEqual({ model: "uncensored", eligiblePlans: ["max", "teams"] }); + expect(fixture.events).toContain("restore"); +}); + +test("unknown model requirement plans remain a neutral denial", async () => { + const fixture = runtimeFixture(); + const error = await serverFailure(403, "model_not_available_on_plan", { + model: "uncensored", + eligible_plans: ["unknown"] + }); + let shown = false; + expect( + handleChatAccessError({ + ...fixture.options, + error, + showModelUpgradeDialog: () => { + shown = true; + } + }) + ).toBe(true); + expect(shown).toBe(false); +}); + +test("a model upgrade dialog remains owned by the current visible restored run", async () => { + const error = await serverFailure(403, "model_not_available_on_plan", { + model: "uncensored", + eligible_plans: ["max", "teams"] + }); + for (const guard of ["restoreTurn", "isRunCurrent", "isRuntimeSelected"] as const) { + const fixture = runtimeFixture(); + let shown = false; + handleChatAccessError({ + ...fixture.options, + error, + [guard]: () => false, + showModelUpgradeDialog: () => { + shown = true; + } + }); + expect(shown).toBe(false); + } +}); diff --git a/apps/maple-research/frontend/src/services/chatAccessError.ts b/apps/maple-research/frontend/src/services/chatAccessError.ts index 3b3fd89f1..fb286d96b 100644 --- a/apps/maple-research/frontend/src/services/chatAccessError.ts +++ b/apps/maple-research/frontend/src/services/chatAccessError.ts @@ -1,4 +1,9 @@ -import { classifyChatLimitFailure, isChatPlanAccessDeniedError } from "./chatResponseErrors"; +import { planLabel, type ModelUpgradeRequirement } from "./modelAccess"; +import { + classifyChatLimitFailure, + isChatPlanAccessDeniedError, + chatModelUpgradeRequirement +} from "./chatResponseErrors"; type ChatAccessErrorOptions = { error: unknown; @@ -8,6 +13,7 @@ type ChatAccessErrorOptions = { isRuntimeSelected: () => boolean; showUpgradeDialog: (feature: "usage" | "tokens") => void; showContextLimitDialog: () => void; + showModelUpgradeDialog?: (requirement: ModelUpgradeRequirement) => void; }; /** Restore a rejected turn before publishing a dialog owned by its selected run. */ @@ -18,12 +24,21 @@ export function handleChatAccessError({ isRunCurrent, isRuntimeSelected, showUpgradeDialog, - showContextLimitDialog + showContextLimitDialog, + showModelUpgradeDialog }: ChatAccessErrorOptions): boolean { const failure = classifyChatLimitFailure(error); if (!failure) { if (!isChatPlanAccessDeniedError(error)) return false; - restoreTurn("This model or feature is not available on your current plan."); + const requirement = chatModelUpgradeRequirement(error); + const restored = restoreTurn( + requirement + ? `This model requires ${planLabel(requirement.eligiblePlans)}.` + : "This model or feature is not available on your current plan." + ); + if (requirement && restored && isRunCurrent() && isRuntimeSelected()) { + showModelUpgradeDialog?.(requirement); + } return true; } diff --git a/apps/maple-research/frontend/src/services/chatResponseErrors.ts b/apps/maple-research/frontend/src/services/chatResponseErrors.ts index 4a6167b1d..53a4b4882 100644 --- a/apps/maple-research/frontend/src/services/chatResponseErrors.ts +++ b/apps/maple-research/frontend/src/services/chatResponseErrors.ts @@ -1,3 +1,5 @@ +import type { ModelUpgradeRequirement, ModelPlanId } from "./modelAccess"; + const ERROR_CONTRACT_HEADER = "x-opensecret-error-contract"; const ERROR_CODE_HEADER = "x-opensecret-error-code"; const ERROR_CONTRACT_VERSION = "1"; @@ -109,7 +111,12 @@ function errorCauseChain(error: unknown): readonly ErrorResponseMetadata[] { } type ResolvedChatResponseError = - | { status: unknown; code: unknown; legacy?: never } + | { + status: unknown; + code: unknown; + details?: { model?: unknown; eligible_plans?: unknown }; + legacy?: never; + } | { status: 403 | 413; legacy: LegacyStatusFailure; code?: never }; function resolveChatResponseError(error: unknown): ResolvedChatResponseError | null { @@ -119,7 +126,7 @@ function resolveChatResponseError(error: unknown): ResolvedChatResponseError | n const headerCode = headers?.get(ERROR_CODE_HEADER); const body = typeof metadata.error === "object" && metadata.error !== null - ? (metadata.error as { code?: unknown }) + ? (metadata.error as { code?: unknown; model?: unknown; eligible_plans?: unknown }) : null; const codes = [headerCode, metadata.code, body?.code].filter( (code) => code !== undefined && code !== null @@ -136,7 +143,7 @@ function resolveChatResponseError(error: unknown): ResolvedChatResponseError | n return null; } if (codes.some((code) => typeof code !== "string" || code !== codes[0])) return null; - return { status: metadata.status, code: codes[0] }; + return { status: metadata.status, code: codes[0], details: body ?? undefined }; } const legacy = legacyStatusFailure(metadata); @@ -163,6 +170,25 @@ export function isChatPlanAccessDeniedError(error: unknown): boolean { return resolved?.status === 403 && resolved.code === "model_not_available_on_plan"; } +/** Only an authoritative plan denial with valid model requirements opens an upsell. */ +export function chatModelUpgradeRequirement(error: unknown): ModelUpgradeRequirement | null { + const resolved = resolveChatResponseError(error); + if (resolved?.status !== 403 || resolved.code !== "model_not_available_on_plan") return null; + const details = resolved.details; + if ( + typeof details?.model !== "string" || + !details.model || + details.model.length > 128 || + !Array.isArray(details.eligible_plans) || + details.eligible_plans.length === 0 || + details.eligible_plans.some( + (plan: unknown) => typeof plan !== "string" || !["pro", "max", "teams"].includes(plan) + ) + ) + return null; + return { model: details.model, eligiblePlans: details.eligible_plans as ModelPlanId[] }; +} + export function isChatRequestDefinitelyNotDispatchedError(error: unknown): boolean { return errorCauseChain(error).some( (metadata) => diff --git a/apps/maple-research/frontend/src/services/modelAccess.test.ts b/apps/maple-research/frontend/src/services/modelAccess.test.ts new file mode 100644 index 000000000..2196ecd4d --- /dev/null +++ b/apps/maple-research/frontend/src/services/modelAccess.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { + canUseModel, + normalizeModelCatalogV2, + planLabel, + isCatalogEndpointAbsent +} from "./modelAccess"; +import type { ModelCatalogV2Response } from "@mapleai/sdk"; + +describe("inclusive model access", () => { + test("a server flag grant overrides the account's Free plan", () => { + expect( + canUseModel( + { + access: "pro", + accessPolicy: { + allowed: true, + eligible_plans: ["max", "teams"], + reason: null + } + }, + "Free" + ) + ).toBe(true); + }); + test("a Pro account cannot infer permission from paid-model metadata", () => { + expect( + canUseModel( + { + access: "pro", + accessPolicy: { + allowed: false, + eligible_plans: ["max", "teams"], + reason: "plan_upgrade_required" + } + }, + "Pro" + ) + ).toBe(false); + }); + test("v1 metadata keeps its existing paid-plan interpretation", () => { + expect(canUseModel({ access: "pro" }, "Pro")).toBe(true); + expect(canUseModel({ access: "pro" }, "Free")).toBe(false); + }); + test("v2 metadata remains attached when adapting to existing model state", () => { + const catalog = { + object: "list", + data: [ + { + id: "uncensored", + access: { + allowed: false, + eligible_plans: ["max", "teams"], + reason: "plan_upgrade_required" + } + } + ], + aliases: [], + defaults: { quick: "auto:quick", powerful: "auto:powerful" } + } as unknown as ModelCatalogV2Response; + const model = normalizeModelCatalogV2(catalog).data[0]; + expect(model.accessPolicy).toEqual(catalog.data[0].access); + expect(canUseModel(model, "Pro")).toBe(false); + }); + test("labels and endpoint fallback do not turn denial or outages into upgrades", () => { + expect(planLabel(["max", "teams"])).toBe("Max or Team"); + expect(planLabel(["pro", "max", "teams"])).toBe("Pro or above"); + for (const status of [401, 403, 429, 500, 503]) + expect(isCatalogEndpointAbsent({ status })).toBe(false); + expect(isCatalogEndpointAbsent({ status: 404 })).toBe(true); + expect(isCatalogEndpointAbsent({ status: 405 })).toBe(true); + }); +}); diff --git a/apps/maple-research/frontend/src/services/modelAccess.ts b/apps/maple-research/frontend/src/services/modelAccess.ts new file mode 100644 index 000000000..46ee2f0ba --- /dev/null +++ b/apps/maple-research/frontend/src/services/modelAccess.ts @@ -0,0 +1,45 @@ +import type { ModelAccess, ModelCatalogV2Response } from "@mapleai/sdk"; +import type { OpenSecretModel, OpenSecretModelCatalog } from "@/state/LocalStateContextDef"; + +export type ModelPlanId = ModelAccess["eligible_plans"][number]; +export type ModelUpgradeRequirement = { model: string; eligiblePlans: ModelPlanId[] }; + +export function planLabel(plans: readonly ModelPlanId[]): string { + const labels: Record = { + free: "Free", + pro: "Pro", + max: "Max", + teams: "Team" + }; + // Pro already includes the higher subscription tiers. + if (plans.includes("pro")) return "Pro or above"; + return plans.map((plan) => labels[plan]).join(" or "); +} + +export function normalizeModelCatalogV2(catalog: ModelCatalogV2Response): OpenSecretModelCatalog { + const adapt = (model: T) => { + const { access, ...metadata } = model; + return { + ...metadata, + access: access.eligible_plans.includes("free") ? ("free" as const) : ("pro" as const), + accessPolicy: access + }; + }; + return { ...catalog, data: catalog.data.map(adapt), aliases: catalog.aliases.map(adapt) }; +} + +export function canUseModel( + model: Pick | undefined, + productName?: string | null +): boolean { + if (model?.accessPolicy) return model.accessPolicy.allowed; + if (!model?.access || model.access === "free") return true; + const plan = productName?.toLowerCase() || ""; + return plan.includes("pro") || plan.includes("max") || plan.includes("team"); +} + +export function isCatalogEndpointAbsent(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const status = (error as { status?: unknown }).status; + return status === 404 || status === 405; +} diff --git a/apps/maple-research/frontend/src/state/LocalStateContext.test.tsx b/apps/maple-research/frontend/src/state/LocalStateContext.test.tsx index 4315f06d4..d0d7a464d 100644 --- a/apps/maple-research/frontend/src/state/LocalStateContext.test.tsx +++ b/apps/maple-research/frontend/src/state/LocalStateContext.test.tsx @@ -416,4 +416,41 @@ describe("LocalStateProvider", () => { expect(snapshots.sidebarSearch?.searchQuery).toBe("persist across routes"); expect(snapshots.selectedProject?.selectedProjectId).toBe("project-2"); }); + test("cached model metadata cannot restore an affirmative account grant", () => { + const storage = new CountingMemoryStorage(); + const snapshots: DomainSnapshots = {}; + storage.setItem("selectedModel", "uncensored"); + storage.setItem(SELECTED_MODEL_RESET_AT_KEY, new Date().toISOString()); + storage.setItem( + "selectedModelMetadata", + JSON.stringify({ + id: "uncensored", + object: "model", + created: 0, + owned_by: "maple", + access: "pro", + accessPolicy: { allowed: true, eligible_plans: ["max", "teams"], reason: null } + }) + ); + act(() => { + renderer = create( + + + + ); + }); + expect(snapshots.model?.availableModels[0].accessPolicy?.allowed).toBe(false); + act(() => + snapshots.model?.setModel("uncensored", { + id: "uncensored", + object: "model", + created: 0, + owned_by: "maple", + access: "pro", + accessPolicy: { allowed: true, eligible_plans: ["max", "teams"], reason: null } + }) + ); + expect(snapshots.model?.availableModels[0].accessPolicy?.allowed).toBe(true); + expect(JSON.parse(storage.getItem("selectedModelMetadata")!).accessPolicy.allowed).toBe(false); + }); }); diff --git a/apps/maple-research/frontend/src/state/LocalStateContext.tsx b/apps/maple-research/frontend/src/state/LocalStateContext.tsx index 3f12f7a57..44d2a01a1 100644 --- a/apps/maple-research/frontend/src/state/LocalStateContext.tsx +++ b/apps/maple-research/frontend/src/state/LocalStateContext.tsx @@ -160,6 +160,13 @@ function getCachedSelectedModelMetadata( return { ...parsedMetadata, + accessPolicy: parsedMetadata.accessPolicy + ? { + ...parsedMetadata.accessPolicy, + allowed: false, + reason: "plan_upgrade_required" + } + : undefined, object: "model", created: parsedMetadata.created || Date.now(), owned_by: parsedMetadata.owned_by || "opensecret" @@ -185,6 +192,13 @@ function cacheSelectedModelMetadata( const cacheableMetadata: OpenSecretModel = { ...modelMetadata, + accessPolicy: modelMetadata.accessPolicy + ? { + ...modelMetadata.accessPolicy, + allowed: false, + reason: "plan_upgrade_required" + } + : undefined, id: modelId, object: "model", created: modelMetadata.created || Date.now(), diff --git a/apps/maple-research/frontend/src/state/LocalStateContextDef.ts b/apps/maple-research/frontend/src/state/LocalStateContextDef.ts index 2d5bfaeef..0cfccaab2 100644 --- a/apps/maple-research/frontend/src/state/LocalStateContextDef.ts +++ b/apps/maple-research/frontend/src/state/LocalStateContextDef.ts @@ -1,5 +1,6 @@ import { createContext } from "react"; import { BillingStatus } from "@/billing/billingApi"; +import type { ModelAccess } from "@mapleai/sdk"; import type { Model } from "openai/resources/models.js"; // Extended Model type for OpenSecret API which includes additional properties @@ -38,6 +39,7 @@ export interface OpenSecretModel extends Model { max_context_tokens?: number; max_completion_tokens?: number; access?: ModelAccessTier; + accessPolicy?: ModelAccess; capabilities?: ModelCapabilities; reasoning?: ModelReasoning | null; badges?: string[]; @@ -53,6 +55,7 @@ export type OpenSecretModelAlias = { description: string; target_model: string; access?: ModelAccessTier; + accessPolicy?: ModelAccess; capabilities?: ModelCapabilities; context_window?: number; max_completion_tokens?: number; @@ -64,8 +67,8 @@ export type OpenSecretModelCatalog = { data: OpenSecretModel[]; aliases: OpenSecretModelAlias[]; defaults?: { - quick: "auto:quick"; - powerful: "auto:powerful"; + quick: "auto:quick" | "auto:powerful"; + powerful: "auto:quick" | "auto:powerful"; }; audio?: { transcription?: { diff --git a/sdk/rust/src/client.rs b/sdk/rust/src/client.rs index 0b31ee086..d9a17a90b 100644 --- a/sdk/rust/src/client.rs +++ b/sdk/rust/src/client.rs @@ -484,6 +484,7 @@ fn is_allowed_inference_endpoint(method: &http::Method, path: &str) -> bool { (method.as_str(), path), ("GET", "/v1/models") | ("GET", "/v1/models/catalog") + | ("GET", "/v2/models/catalog") | ("POST", "/v1/chat/completions") | ("POST", "/v1/embeddings") | ("POST", "/v1/systemone") @@ -2457,6 +2458,12 @@ impl OpenSecretClient { .await } + /// Fetches every listed model with the caller's current entitlement decision. + pub async fn get_model_catalog_v2(&self) -> Result { + self.encrypted_openai_call("/v2/models/catalog", "GET", None::<()>) + .await + } + /// Creates embeddings for the given input text(s) /// /// # Example diff --git a/sdk/rust/src/types.rs b/sdk/rust/src/types.rs index 305a5c744..1a2dbfe8f 100644 --- a/sdk/rust/src/types.rs +++ b/sdk/rust/src/types.rs @@ -811,6 +811,37 @@ pub struct ModelCatalogResponse { pub defaults: CatalogDefaults, } +/// Model entitlement from the inclusive V2 catalog, independent of usage quota. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelAccess { + pub allowed: bool, + pub eligible_plans: Vec, + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CatalogModelV2 { + #[serde(flatten)] + pub model: CatalogModel, + pub access: ModelAccess, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CatalogAliasV2 { + #[serde(flatten)] + pub alias: CatalogAlias, + pub access: ModelAccess, +} + +/// Inclusive catalog; V1 models and aliases retain their original wire types. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelCatalogV2Response { + pub object: String, + pub data: Vec, + pub aliases: Vec, + pub defaults: CatalogDefaults, +} + fn default_model_object() -> String { "model".to_string() } @@ -1596,3 +1627,21 @@ mod tests { assert!(response.is_err()); } } + +#[cfg(test)] +mod model_catalog_v2_tests { + use super::*; + + #[test] + fn inclusive_catalog_retains_access_while_v1_keeps_legacy_shape() { + let base = serde_json::json!({"id":"uncensored","object":"model","access":"pro"}); + let legacy: CatalogModel = serde_json::from_value(base.clone()).unwrap(); + assert_eq!(legacy.id, "uncensored"); + let mut modern = base; + modern["access"] = serde_json::json!({"allowed":false,"eligible_plans":["max","teams"],"reason":"plan_upgrade_required"}); + let model: CatalogModelV2 = serde_json::from_value(modern).unwrap(); + assert!(!model.access.allowed); + assert_eq!(model.access.eligible_plans, ["max", "teams"]); + assert_eq!(model.model.id, "uncensored"); + } +} diff --git a/sdk/src/lib/api.ts b/sdk/src/lib/api.ts index eec8c1f33..cd3b05528 100644 --- a/sdk/src/lib/api.ts +++ b/sdk/src/lib/api.ts @@ -1464,6 +1464,53 @@ export async function fetchModelCatalog(apiKey?: string): Promise & { + data: (Omit & { access: ModelAccess })[]; + aliases: (Omit & { access: ModelAccess })[]; +}; + +/** Fetches the inclusive catalog and caller-specific model entitlements. */ +export async function fetchModelCatalogV2(apiKey?: string): Promise { + const response = await openAiAuthenticatedApiCall( + `${apiUrl}/v2/models/catalog`, + "GET", + undefined, + "Failed to fetch model catalog", + apiKey + ); + if ( + response?.object !== "list" || + !Array.isArray(response.data) || + !Array.isArray(response.aliases) || + !isModelAliasId(response.defaults?.quick) || + !isModelAliasId(response.defaults?.powerful) + ) { + throw new Error("Invalid v2 model catalog response"); + } + const knownPlans = new Set(["free", "pro", "max", "teams"]); + for (const model of [...response.data, ...response.aliases]) { + const access = model.access; + if ( + typeof access?.allowed !== "boolean" || + !Array.isArray(access.eligible_plans) || + access.eligible_plans.length === 0 || + access.eligible_plans.some((plan) => !knownPlans.has(plan)) || + access.reason !== (access.allowed ? null : "plan_upgrade_required") + ) { + throw new Error("Invalid v2 model access policy"); + } + } + return response; +} + export type DocumentUploadRequest = { filename: string; content_base64: string; diff --git a/sdk/src/lib/index.ts b/sdk/src/lib/index.ts index 76ca6890d..665810376 100644 --- a/sdk/src/lib/index.ts +++ b/sdk/src/lib/index.ts @@ -21,6 +21,8 @@ export type { ModelAliasId, ModelId, ModelAccessTier, + ModelAccess, + ModelCatalogV2Response, ModelCapabilities, ReasoningEffort, ModelReasoning, @@ -91,7 +93,7 @@ export { type NativeUserAuthState } from "./transportV2/nativeAuth"; -export { fetchModels, fetchModelCatalog } from "./api"; +export { fetchModels, fetchModelCatalog, fetchModelCatalogV2 } from "./api"; // Export conversation and conversation-project API functions export { diff --git a/sdk/src/lib/main.tsx b/sdk/src/lib/main.tsx index 28edb082d..38ecf8f51 100644 --- a/sdk/src/lib/main.tsx +++ b/sdk/src/lib/main.tsx @@ -458,6 +458,7 @@ export type OpenSecretContextType = { * Fetches OpenSecret's model catalog with display metadata and stable aliases. */ fetchModelCatalog: () => Promise; + fetchModelCatalogV2: () => Promise; /** * Uploads a document for text extraction and processing @@ -917,6 +918,7 @@ export const OpenSecretContext = createContext({ decryptData: api.decryptData, fetchModels: api.fetchModels, fetchModelCatalog: api.fetchModelCatalog, + fetchModelCatalogV2: api.fetchModelCatalogV2, uploadDocument: api.uploadDocument, checkDocumentStatus: api.checkDocumentStatus, uploadDocumentWithPolling: api.uploadDocumentWithPolling, @@ -1345,6 +1347,7 @@ export function OpenSecretProvider({ decryptData: api.decryptData, fetchModels: () => api.fetchModels(apiKey), fetchModelCatalog: () => api.fetchModelCatalog(apiKey), + fetchModelCatalogV2: () => api.fetchModelCatalogV2(apiKey), uploadDocument: api.uploadDocument, checkDocumentStatus: api.checkDocumentStatus, uploadDocumentWithPolling: api.uploadDocumentWithPolling, diff --git a/sdk/src/lib/test/models.test.ts b/sdk/src/lib/test/models.test.ts index 6abc50e61..8af87eb9c 100644 --- a/sdk/src/lib/test/models.test.ts +++ b/sdk/src/lib/test/models.test.ts @@ -1,7 +1,14 @@ import { afterEach, beforeEach, expect, mock, test } from "bun:test"; import { encodeURLSafe } from "@stablelib/base64"; import type { PcrConfig } from "../pcr"; -import { fetchModelCatalog, fetchModels, getApiPcrConfig, getApiUrl, setApiUrl } from "../api"; +import { + fetchModelCatalogV2, + fetchModelCatalog, + fetchModels, + getApiPcrConfig, + getApiUrl, + setApiUrl +} from "../api"; import { clearTransportV2Credentials, installTransportV2Credentials } from "../transportV2/auth"; import { transportV2Runtime, @@ -139,3 +146,48 @@ test("fetchModelCatalog remains authentication-required", async () => { await expect(fetchModelCatalog()).rejects.toThrow("No access token available"); expect(request).toHaveBeenCalledTimes(0); }); + +const v2Catalog = { + object: "list" as const, + data: [ + { + ...modelsResponse.data[0], + access: { allowed: false, eligible_plans: ["max", "teams"], reason: "plan_upgrade_required" } + } + ], + aliases: [], + defaults: { quick: "auto:quick", powerful: "auto:powerful" } +}; + +test("inclusive model catalog uses the authenticated encrypted v2 target", async () => { + installUserCredentials(); + let seen: TransportV2RuntimeRequest | undefined; + transportV2Runtime.request = mock(async (input) => { + seen = input; + return exchange(Response.json(v2Catalog)); + }); + await expect(fetchModelCatalogV2()).resolves.toEqual(v2Catalog); + expect(seen?.request.target).toBe("/v2/models/catalog"); + expect(seen?.request.credential?.kind).toBe("bearer"); +}); + +test("v2 catalog rejects missing, unknown and inconsistent access policies", async () => { + installUserCredentials(); + for (const access of [ + undefined, + { allowed: "true", eligible_plans: ["max"], reason: null }, + { allowed: true, eligible_plans: ["unknown"], reason: null }, + { allowed: true, eligible_plans: ["max"], reason: "plan_upgrade_required" }, + { allowed: false, eligible_plans: [], reason: "plan_upgrade_required" } + ]) { + transportV2Runtime.request = mock(async () => + exchange( + Response.json({ + ...v2Catalog, + data: [{ ...v2Catalog.data[0], access }] + }) + ) + ); + await expect(fetchModelCatalogV2()).rejects.toThrow("Invalid v2 model access policy"); + } +}); diff --git a/services/opensecret/docs/model-access.md b/services/opensecret/docs/model-access.md new file mode 100644 index 000000000..e34f9aec1 --- /dev/null +++ b/services/opensecret/docs/model-access.md @@ -0,0 +1,44 @@ +# Model discovery and access + +OpenSecret owns model authorization. Catalog visibility, subscription eligibility, +experimental grants and usage admission are separate decisions. + +`GET /v1/models` and `GET /v1/models/catalog` retain their existing schemas and +feature-flag visibility. The uncensored DeepSeek entry remains hidden unless +`uncensored_deepseek` evaluates true. Its V1 access label remains `pro` because +older clients do not understand higher model tiers. + +`GET /v2/models/catalog` is an authenticated, encrypted catalog that includes all +listed models, including uncensored DeepSeek. Each model and alias has an `access` +object with `allowed`, `eligible_plans` and `reason`. `allowed` describes model +entitlement rather than quota. The catalog API version is independent of the +encrypted transport version. + +For uncensored DeepSeek, an explicit true `uncensored_deepseek` evaluation grants +model access on any plan. Otherwise Max or Teams is required. A false, absent or +unavailable flag falls through to subscription eligibility; it is not a kill +switch for eligible subscribers. Other models retain their existing policy. +Authentication, guest restrictions, usage, feature and context limits still apply. + +Chat Completions and Responses capture this access policy from authenticated +billing/flag results before provider work. The immutable snapshot follows the +logical request through routing, preparation and provider execution. Selecting a +model or calling an older inference endpoint never grants permission. + +Billing's V2 usage response retains separate API/frontend admission decisions and +adds the selected plan from the same usage entitlement resolution, including +Apple, Team and Stripe-cache paths. API-credit balances do not confer a plan. +Older billing responses retain ordinary Free/Paid behavior but cannot grant +Max/Teams-only access without the experimental flag. + +Premium model denials retain HTTP 403, the existing flat error fields and +`model_not_available_on_plan`, with additive nested `model` and `eligible_plans` +metadata for an updated client. Quota denials remain a separate error contract. + +Research opts into the inclusive catalog. It falls back to V1 only when a catalog +endpoint is absent (404/405), handles server access decisions rather than guessing +from the account's display name, and presents the required plans in its upgrade +dialog. Catalog responses are scoped to the current account, refreshed after plan +changes and when opening the selector, and ignored after disposal. Persisted model +metadata never restores an affirmative entitlement grant. Other frontends can +continue using their existing V1 discovery contract. diff --git a/services/opensecret/src/billing.rs b/services/opensecret/src/billing.rs index 8ef588397..393651ad5 100644 --- a/services/opensecret/src/billing.rs +++ b/services/opensecret/src/billing.rs @@ -11,15 +11,29 @@ pub struct UsageResponse { pub is_free: bool, } -/// V2 reports API admission and frontend plan identity together. All fields are -/// required: an incomplete response must not become a permissive decision or -/// trigger a second, potentially different authorization evaluation. -#[derive(Debug, Deserialize)] +/// V2 reports API/frontend admission and plan identity together. Admission +/// fields are required; older responses without a tier keep legacy Free/Paid +/// behavior and never grant premium models. Malformed admission does not fall +/// back to another authorization evaluation. +#[derive(Debug, Deserialize, Clone, Copy)] struct UsageResponseV2 { api_can_use: bool, - #[serde(rename = "frontend_can_use")] - _frontend_can_use: bool, + frontend_can_use: bool, is_free: bool, + #[serde(default)] + plan: BillingPlan, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +enum BillingPlan { + Free, + Pro, + Max, + Teams, + #[default] + #[serde(other)] + Unknown, } const fn default_is_free() -> bool { @@ -28,6 +42,7 @@ const fn default_is_free() -> bool { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct ChatBillingAccess { + plan: BillingPlan, can_use: bool, is_free: bool, } @@ -35,6 +50,7 @@ pub(crate) struct ChatBillingAccess { impl ChatBillingAccess { fn from_frontend_usage(usage: UsageResponse) -> Self { Self { + plan: BillingPlan::Unknown, can_use: usage.can_use, is_free: usage.is_free, } @@ -42,6 +58,7 @@ impl ChatBillingAccess { fn from_api_and_plan_usage(api_usage: UsageResponse, plan_usage: UsageResponse) -> Self { Self { + plan: BillingPlan::Unknown, can_use: api_usage.can_use, is_free: plan_usage.is_free, } @@ -49,7 +66,24 @@ impl ChatBillingAccess { #[cfg(test)] pub(crate) const fn for_tests(can_use: bool, is_free: bool) -> Self { - Self { can_use, is_free } + Self { + plan: BillingPlan::Unknown, + can_use, + is_free, + } + } + + #[cfg(test)] + pub(crate) fn for_tests_with_plan(can_use: bool, is_free: bool, plan: &str) -> Self { + Self { + can_use, + is_free, + plan: serde_json::from_value(serde_json::json!(plan)).unwrap(), + } + } + + pub(crate) fn has_premium_models(self) -> bool { + self.is_paid() && matches!(self.plan, BillingPlan::Max | BillingPlan::Teams) } pub(crate) const fn can_use(self) -> bool { @@ -266,23 +300,24 @@ impl BillingClient { user_id: Uuid, is_api: bool, ) -> Result { - if !is_api { - return self - .check_usage(user_id, false) - .await - .map(ChatBillingAccess::from_frontend_usage); - } - if let Some(usage) = self.check_usage_v2(user_id).await? { return Ok(ChatBillingAccess { - can_use: usage.api_can_use, - // Preserve the existing API-denial behavior: quota denial must - // never grant paid-model access, regardless of plan metadata. - is_free: usage.is_free || !usage.api_can_use, + can_use: if is_api { + usage.api_can_use + } else { + usage.frontend_can_use + }, + is_free: usage.is_free || (is_api && !usage.api_can_use), + plan: usage.plan, }); } - - self.api_chat_access_v1(user_id).await + if is_api { + self.api_chat_access_v1(user_id).await + } else { + self.check_usage(user_id, false) + .await + .map(ChatBillingAccess::from_frontend_usage) + } } async fn api_chat_access_v1(&self, user_id: Uuid) -> Result { @@ -301,6 +336,7 @@ impl BillingClient { let api_usage = api_usage?; if !api_usage.can_use { return Ok(ChatBillingAccess { + plan: BillingPlan::Unknown, can_use: false, is_free: true, }); @@ -313,6 +349,7 @@ impl BillingClient { }; if !api_usage.can_use { return Ok(ChatBillingAccess { + plan: BillingPlan::Unknown, can_use: false, is_free: true, }); @@ -688,41 +725,15 @@ mod tests { } #[tokio::test] - async fn frontend_chat_access_keeps_the_single_v1_request() { - use axum::http::StatusCode; - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }; - let v2_calls = Arc::new(AtomicUsize::new(0)); - let observed_v2 = v2_calls.clone(); - let v1_calls = Arc::new(AtomicUsize::new(0)); - let observed_v1 = v1_calls.clone(); - let (url, server) = deletion_server( - Router::new() - .route( - "/v2/admin/check-usage", - get(move || { - observed_v2.fetch_add(1, Ordering::SeqCst); - async { StatusCode::OK } - }), - ) - .route( - "/v1/admin/check-usage", - get(move |Query(query): Query>| { - observed_v1.fetch_add(1, Ordering::SeqCst); - assert!(!query.contains_key("api")); - async { Json(serde_json::json!({"can_use":true,"is_free":false})) } - }), - ), - ) - .await; + async fn frontend_chat_access_uses_its_v2_admission_and_selected_plan() { + let (url, server) = deletion_server(Router::new().route("/v2/admin/check-usage", get(|| async { + Json(serde_json::json!({"api_can_use":true,"frontend_can_use":false,"is_free":false,"plan":"max"})) + }))).await; let client = BillingClient::new("synthetic-admin-key".into(), url); let access = client.chat_access(Uuid::new_v4(), false).await.unwrap(); - assert!(access.can_use()); + assert!(!access.can_use()); assert!(access.is_paid()); - assert_eq!(v1_calls.load(Ordering::SeqCst), 1); - assert_eq!(v2_calls.load(Ordering::SeqCst), 0); + assert!(access.has_premium_models()); server.abort(); } diff --git a/services/opensecret/src/inference.rs b/services/opensecret/src/inference.rs index 4f4a28d72..7b553a3d7 100644 --- a/services/opensecret/src/inference.rs +++ b/services/opensecret/src/inference.rs @@ -373,7 +373,7 @@ mod tests { Uuid::nil(), AUTO_POWERFUL_MODEL_ID, "glm-5-3", - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ); @@ -390,7 +390,7 @@ mod tests { Uuid::nil(), "kimi-k3", "kimi-k3", - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::ChatCompletions, WorkloadClass::Interactive, ); @@ -405,7 +405,7 @@ mod tests { Uuid::nil(), "glm-5-3", "glm-5-3", - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ); diff --git a/services/opensecret/src/inference/auto_model.rs b/services/opensecret/src/inference/auto_model.rs index abcc3dc18..e8865fb36 100644 --- a/services/opensecret/src/inference/auto_model.rs +++ b/services/opensecret/src/inference/auto_model.rs @@ -41,10 +41,10 @@ pub(crate) fn auto_model_candidates( ) -> Option> { let selector = mode.alias()?; let preferred = ModelAliasTargets::for_plan(plan).resolve(selector); - let alternates = match (mode, plan) { - (ModelSelectionMode::AutoQuick, ModelPlan::Paid) => PAID_QUICK_ALTERNATES, - (ModelSelectionMode::AutoPowerful, ModelPlan::Paid) => PAID_POWERFUL_ALTERNATES, - (_, ModelPlan::Free) => FREE_ALTERNATES, + let alternates = match (mode, plan.is_paid()) { + (ModelSelectionMode::AutoQuick, true) => PAID_QUICK_ALTERNATES, + (ModelSelectionMode::AutoPowerful, true) => PAID_POWERFUL_ALTERNATES, + (_, false) => FREE_ALTERNATES, (ModelSelectionMode::Explicit, _) => return None, }; let mut candidates = Vec::with_capacity(1 + alternates.len()); @@ -426,7 +426,7 @@ mod tests { #[test] fn candidate_tables_start_with_the_alias_target_and_stay_in_plan() { - for plan in [ModelPlan::Free, ModelPlan::Paid] { + for plan in [ModelPlan::FREE, ModelPlan::PAID] { let targets = ModelAliasTargets::for_plan(plan); for (mode, selector) in [ (ModelSelectionMode::AutoQuick, AUTO_QUICK_MODEL_ID), @@ -469,19 +469,19 @@ mod tests { ); } assert_eq!( - auto_model_candidates(ModelSelectionMode::AutoQuick, ModelPlan::Paid), + auto_model_candidates(ModelSelectionMode::AutoQuick, ModelPlan::PAID), Some(vec![GLM_5_3_FLASH_MODEL_ID, DEEPSEEK_V4_1_FLASH_MODEL_ID]) ); assert_eq!( - auto_model_candidates(ModelSelectionMode::AutoPowerful, ModelPlan::Paid), + auto_model_candidates(ModelSelectionMode::AutoPowerful, ModelPlan::PAID), Some(vec![GLM_5_3_MODEL_ID, KIMI_K3_MODEL_ID]) ); assert_eq!( - auto_model_candidates(ModelSelectionMode::AutoQuick, ModelPlan::Free), + auto_model_candidates(ModelSelectionMode::AutoQuick, ModelPlan::FREE), Some(vec![QUICK_MODEL_ID]) ); assert_eq!( - auto_model_candidates(ModelSelectionMode::AutoPowerful, ModelPlan::Free), + auto_model_candidates(ModelSelectionMode::AutoPowerful, ModelPlan::FREE), Some(vec![GLM_5_3_MODEL_ID]) ); assert_eq!(AUTO_MODEL_POLICY_VERSION, "auto-model-v2"); @@ -495,7 +495,7 @@ mod tests { ]); let decision = select( ModelSelectionMode::AutoQuick, - ModelPlan::Paid, + ModelPlan::PAID, None, requirements(), &table, @@ -519,7 +519,7 @@ mod tests { ]); let decision = select( ModelSelectionMode::AutoQuick, - ModelPlan::Paid, + ModelPlan::PAID, None, requirements(), &table, @@ -545,7 +545,7 @@ mod tests { ]); let decision = select( ModelSelectionMode::AutoPowerful, - ModelPlan::Paid, + ModelPlan::PAID, None, requirements(), &powerful, @@ -563,7 +563,7 @@ mod tests { ]); let decision = select( ModelSelectionMode::AutoQuick, - ModelPlan::Paid, + ModelPlan::PAID, Some(DEEPSEEK_V4_1_FLASH_MODEL_ID), requirements(), &both_available, @@ -579,7 +579,7 @@ mod tests { ]); let decision = select( ModelSelectionMode::AutoQuick, - ModelPlan::Paid, + ModelPlan::PAID, Some(DEEPSEEK_V4_1_FLASH_MODEL_ID), requirements(), &alternate_open, @@ -598,7 +598,7 @@ mod tests { ] { let decision = select( ModelSelectionMode::AutoQuick, - ModelPlan::Paid, + ModelPlan::PAID, sticky, requirements(), &both_available, @@ -624,7 +624,7 @@ mod tests { let fits = select( ModelSelectionMode::AutoQuick, - ModelPlan::Paid, + ModelPlan::PAID, None, AutoModelRequirements { vision: true, @@ -720,7 +720,7 @@ mod tests { let lookup_available = |_: &str| available(); select_auto_model(AutoModelSelectionInput { mode: ModelSelectionMode::AutoPowerful, - plan: ModelPlan::Paid, + plan: ModelPlan::PAID, sticky_model_id: Some(KIMI_K3_MODEL_ID), excluded: None, requirements: AutoModelRequirements { @@ -740,7 +740,7 @@ mod tests { ]); select( ModelSelectionMode::AutoQuick, - ModelPlan::Paid, + ModelPlan::PAID, Some(DEEPSEEK_V4_1_FLASH_MODEL_ID), AutoModelRequirements { vision: false, @@ -764,7 +764,7 @@ mod tests { ]); let error = select( ModelSelectionMode::AutoPowerful, - ModelPlan::Paid, + ModelPlan::PAID, None, AutoModelRequirements { kimi_tool_history_compatible: false, @@ -783,7 +783,7 @@ mod tests { // alternate less capable than the preferred model. let decision = select( ModelSelectionMode::AutoPowerful, - ModelPlan::Paid, + ModelPlan::PAID, None, AutoModelRequirements { vision: true, @@ -809,7 +809,7 @@ mod tests { ]); let error = select( ModelSelectionMode::AutoQuick, - ModelPlan::Free, + ModelPlan::FREE, Some(DEEPSEEK_V4_1_FLASH_MODEL_ID), requirements(), &table, @@ -824,7 +824,7 @@ mod tests { assert_eq!( select( ModelSelectionMode::AutoPowerful, - ModelPlan::Free, + ModelPlan::FREE, None, requirements(), &table, @@ -841,7 +841,7 @@ mod tests { ]); let error = select( ModelSelectionMode::AutoQuick, - ModelPlan::Paid, + ModelPlan::PAID, None, requirements(), &table, @@ -859,7 +859,7 @@ mod tests { assert_eq!( select( ModelSelectionMode::AutoQuick, - ModelPlan::Paid, + ModelPlan::PAID, None, requirements(), &unconfigured, @@ -869,7 +869,7 @@ mod tests { let alternate_missing = availability(&[(GLM_5_3_FLASH_MODEL_ID, unavailable(30))]); let error = select( ModelSelectionMode::AutoQuick, - ModelPlan::Paid, + ModelPlan::PAID, None, requirements(), &alternate_missing, @@ -891,7 +891,7 @@ mod tests { // The remembered alternate wins the first decision... let first = select( ModelSelectionMode::AutoQuick, - ModelPlan::Paid, + ModelPlan::PAID, Some(DEEPSEEK_V4_1_FLASH_MODEL_ID), requirements(), &both_available, @@ -904,7 +904,7 @@ mod tests { let overflowed = ExcludedAutoCandidate::context_overflow(DEEPSEEK_V4_1_FLASH_MODEL_ID); let second = select_excluding( ModelSelectionMode::AutoQuick, - ModelPlan::Paid, + ModelPlan::PAID, Some(DEEPSEEK_V4_1_FLASH_MODEL_ID), Some(&overflowed), requirements(), @@ -929,7 +929,7 @@ mod tests { ]); let error = select_excluding( ModelSelectionMode::AutoQuick, - ModelPlan::Paid, + ModelPlan::PAID, None, Some(&overflowed), requirements(), @@ -957,7 +957,7 @@ mod tests { assert_eq!( select_excluding( ModelSelectionMode::AutoQuick, - ModelPlan::Free, + ModelPlan::FREE, None, Some(&lost), requirements(), @@ -981,7 +981,7 @@ mod tests { assert!(matches!( select_excluding( ModelSelectionMode::AutoQuick, - ModelPlan::Free, + ModelPlan::FREE, None, Some(&lost_without_hint), requirements(), @@ -1002,7 +1002,7 @@ mod tests { assert_eq!( select_excluding( ModelSelectionMode::AutoPowerful, - ModelPlan::Paid, + ModelPlan::PAID, None, Some(&lost), AutoModelRequirements { @@ -1033,7 +1033,7 @@ mod tests { // With a compatible alternate the second decision simply moves on. let moved = select_excluding( ModelSelectionMode::AutoPowerful, - ModelPlan::Paid, + ModelPlan::PAID, None, Some(&lost), requirements(), @@ -1059,7 +1059,7 @@ mod tests { assert_eq!( select_auto_model(AutoModelSelectionInput { mode: ModelSelectionMode::Explicit, - plan: ModelPlan::Paid, + plan: ModelPlan::PAID, sticky_model_id: None, excluded: None, requirements: requirements(), diff --git a/services/opensecret/src/inference_planning.rs b/services/opensecret/src/inference_planning.rs index 399f5b535..ae8b028f1 100644 --- a/services/opensecret/src/inference_planning.rs +++ b/services/opensecret/src/inference_planning.rs @@ -296,7 +296,7 @@ mod tests { Uuid::from_u128(73), requested_model, public_model, - ModelPlan::Paid, + ModelPlan::PAID, crate::inference::InferenceSurface::Responses, crate::inference::WorkloadClass::Interactive, ) @@ -455,7 +455,7 @@ mod tests { Uuid::from_u128(u128::from(bucket)), model, model, - ModelPlan::Paid, + ModelPlan::PAID, surface, workload, ); @@ -635,7 +635,7 @@ mod tests { Uuid::from_u128(73), DEEPSEEK_V4_1_FLASH_MODEL_ID, DEEPSEEK_V4_1_FLASH_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, surface, workload, ); diff --git a/services/opensecret/src/main.rs b/services/opensecret/src/main.rs index 6dca77103..258ea2a3d 100644 --- a/services/opensecret/src/main.rs +++ b/services/opensecret/src/main.rs @@ -452,6 +452,12 @@ pub enum ApiError { #[error("Model not available on current plan")] ModelNotAvailableOnPlan, + #[error("Model not available on current plan")] + ModelUpgradeRequired { + model: &'static str, + eligible_plans: &'static [&'static str], + }, + #[error("Model not found or not available to this account")] ModelNotAvailable, @@ -513,7 +519,9 @@ impl IntoResponse for ApiError { ApiError::EmailAlreadyExists => StatusCode::CONFLICT, ApiError::UsageLimitReached => StatusCode::FORBIDDEN, ApiError::FreeTokenLimitExceeded => StatusCode::FORBIDDEN, - ApiError::ModelNotAvailableOnPlan => StatusCode::FORBIDDEN, + ApiError::ModelNotAvailableOnPlan | ApiError::ModelUpgradeRequired { .. } => { + StatusCode::FORBIDDEN + } ApiError::ModelNotAvailable => StatusCode::NOT_FOUND, ApiError::MessageExceedsContextLimit => StatusCode::PAYLOAD_TOO_LARGE, ApiError::NotFound => StatusCode::NOT_FOUND, @@ -527,7 +535,9 @@ impl IntoResponse for ApiError { ApiError::InferenceRateLimited { .. } => Some("rate_limit_exceeded"), ApiError::UsageLimitReached => Some("usage_limit_reached"), ApiError::FreeTokenLimitExceeded => Some("free_tier_token_limit_exceeded"), - ApiError::ModelNotAvailableOnPlan => Some("model_not_available_on_plan"), + ApiError::ModelNotAvailableOnPlan | ApiError::ModelUpgradeRequired { .. } => { + Some("model_not_available_on_plan") + } ApiError::ModelNotAvailable => Some("model_not_found"), ApiError::MessageExceedsContextLimit => Some("message_exceeds_context_limit"), ApiError::SystemOneRequest(error) => Some(error.code()), @@ -561,6 +571,14 @@ impl IntoResponse for ApiError { code, error_type, param: error_param, + model: match &self { + ApiError::ModelUpgradeRequired { model, .. } => Some(*model), + _ => None, + }, + eligible_plans: match &self { + ApiError::ModelUpgradeRequired { eligible_plans, .. } => Some(*eligible_plans), + _ => None, + }, }); let mut response = ( status, @@ -654,6 +672,10 @@ pub struct ErrorResponse { #[derive(Debug, Serialize)] struct OpenAIErrorResponse { + #[serde(skip_serializing_if = "Option::is_none")] + model: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + eligible_plans: Option<&'static [&'static str]>, message: String, code: &'static str, #[serde(rename = "type", skip_serializing_if = "Option::is_none")] @@ -666,6 +688,30 @@ struct OpenAIErrorResponse { mod api_error_contract_tests { use super::*; + #[tokio::test] + async fn model_upgrade_denial_keeps_the_existing_code_and_adds_requirements() { + let response = ApiError::ModelUpgradeRequired { + model: "deepseek-v4-1-flash-1017-uncensored", + eligible_plans: &["max", "teams"], + } + .into_response(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response.headers()[ERROR_CODE_HEADER], + "model_not_available_on_plan" + ); + let body = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["status"], 403); + assert_eq!(body["message"], "Model not available on current plan"); + assert_eq!( + body["error"]["eligible_plans"], + serde_json::json!(["max", "teams"]) + ); + } + async fn assert_error_response( error: ApiError, expected_status: StatusCode, diff --git a/services/opensecret/src/model_config.rs b/services/opensecret/src/model_config.rs index bbb42e6b4..6ecfbecb1 100644 --- a/services/opensecret/src/model_config.rs +++ b/services/opensecret/src/model_config.rs @@ -389,13 +389,16 @@ struct ModelConfigEntry { pub enum ModelAccessTier { Free, Pro, + Max, } +/// Immutable, authenticated model access for one logical request. Subscription +/// identity and the experimental override remain independent of usage quota. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub(crate) enum ModelPlan { - #[default] - Free, - Paid, +pub(crate) struct ModelPlan { + paid: bool, + premium: bool, + uncensored_override: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -728,44 +731,100 @@ impl ModelAccessTier { const fn as_str(self) -> &'static str { match self { Self::Free => "free", - Self::Pro => "pro", + // V1 cannot represent premium tiers. Only the existing flagged + // model is exposed there; V2 publishes the actual requirement. + Self::Pro | Self::Max => "pro", + } + } + + const fn eligible_plans(self) -> &'static [&'static str] { + match self { + Self::Free => &["free", "pro", "max", "teams"], + Self::Pro => &["pro", "max", "teams"], + Self::Max => &["max", "teams"], } } const fn is_available_to(self, plan: ModelPlan) -> bool { - matches!(self, Self::Free) || matches!(plan, ModelPlan::Paid) + match self { + Self::Free => true, + Self::Pro => plan.is_paid(), + Self::Max => plan.premium, + } } } impl ModelPlan { + pub(crate) const FREE: Self = Self { + paid: false, + premium: false, + uncensored_override: false, + }; + pub(crate) const PAID: Self = Self { + paid: true, + premium: false, + uncensored_override: false, + }; + pub(crate) const fn from_is_paid(is_paid: bool) -> Self { if is_paid { - Self::Paid + Self::PAID } else { - Self::Free + Self::FREE + } + } + + pub(crate) fn from_billing_access(access: Option) -> Self { + Self { + premium: access.is_some_and(crate::billing::ChatBillingAccess::has_premium_models), + ..Self::from_is_paid(access.is_some_and(crate::billing::ChatBillingAccess::is_paid)) + } + } + + pub(crate) const fn with_uncensored_override(self, enabled: bool) -> Self { + Self { + uncensored_override: enabled, + ..self } } pub(crate) const fn is_paid(self) -> bool { - matches!(self, Self::Paid) + self.paid } pub(crate) fn allows_model(self, model: &str) -> bool { let canonical = alias_target(model).unwrap_or(model); + if canonical == DEEPSEEK_V4_1_FLASH_UNCENSORED_MODEL_ID && self.uncensored_override { + return true; + } if let Some(decision) = decision_model(canonical) { return decision.access.is_available_to(self); } model_entry(canonical).is_none_or(|entry| entry.access.is_available_to(self)) } + + fn access_json(self, model: &str) -> Value { + let canonical = alias_target(model).unwrap_or(model); + let eligible_plans = model_entry(canonical) + .map_or(ModelAccessTier::Free, |entry| entry.access) + .eligible_plans(); + let allowed = self.allows_model(model); + json!({ + "allowed": allowed, + "eligible_plans": eligible_plans, + "reason": if allowed { None } else { Some("plan_upgrade_required") }, + }) + } } impl ModelAliasTargets { /// Fixed alias targets for the account plan. Resolving an alias does not /// grant access to its target model. pub(crate) const fn for_plan(plan: ModelPlan) -> Self { - match plan { - ModelPlan::Free => FREE_MODEL_ALIAS_TARGETS, - ModelPlan::Paid => PAID_MODEL_ALIAS_TARGETS, + if plan.is_paid() { + PAID_MODEL_ALIAS_TARGETS + } else { + FREE_MODEL_ALIAS_TARGETS } } @@ -788,7 +847,7 @@ impl ModelAliasTargets { impl Default for ModelAliasTargets { fn default() -> Self { - Self::for_plan(ModelPlan::Free) + Self::for_plan(ModelPlan::FREE) } } @@ -1089,7 +1148,7 @@ const MODEL_CONFIGS: &[ModelConfigEntry] = &[ "DeepSeek V4.1 Flash Uncensored", "DeepSeek Uncensored", "Dealign's uncensored DeepSeek 1017 reasoning and tool-use model.", - ModelAccessTier::Pro, + ModelAccessTier::Max, ModelCapabilities::chat(true, true), &["Beta"], true, @@ -1274,6 +1333,26 @@ pub(crate) fn model_catalog_response(plan: ModelPlan, uncensored_enabled: bool) }) } +/// V2 advertises every listed model. Access is evaluated with the same request +/// snapshot used by Chat and Responses; the legacy catalogs remain unchanged. +pub(crate) fn model_catalog_v2_response(plan: ModelPlan) -> Value { + let mut response = model_catalog_response(plan, true); + for key in ["data", "aliases"] { + for model in response[key].as_array_mut().expect("compiled catalog") { + let id = model["id"].as_str().expect("compiled model id"); + let target = if key == "aliases" { + model["target_model"] + .as_str() + .expect("compiled alias target") + } else { + id + }; + model["access"] = plan.access_json(target); + } + } + response +} + /// The OpenAI-compatible catalog has the same provider-independent model /// identity and listing behavior as Research. pub fn openai_models_response(uncensored_enabled: bool) -> Value { @@ -1337,7 +1416,7 @@ mod tests { #[test] fn uncensored_catalog_is_account_gated_without_changing_stock_or_defaults() { let id = DEEPSEEK_V4_1_FLASH_UNCENSORED_MODEL_ID; - for plan in [ModelPlan::Free, ModelPlan::Paid] { + for plan in [ModelPlan::FREE, ModelPlan::PAID] { let off = model_catalog_response(plan, false); let mut on = model_catalog_response(plan, true); assert!(!has_model(&off, id)); @@ -1362,8 +1441,46 @@ mod tests { .retain(|model| model["id"] != id); assert_eq!(on, off); assert_eq!(resolve_public_model_id(id), Some(id)); - assert!(!ModelPlan::Free.allows_model(id)); - assert!(ModelPlan::Paid.allows_model(id)); + assert!(!ModelPlan::FREE.allows_model(id)); + assert!(!ModelPlan::PAID.allows_model(id)); + } + + #[test] + fn inclusive_catalog_and_inference_share_tier_and_flag_precedence() { + let id = DEEPSEEK_V4_1_FLASH_UNCENSORED_MODEL_ID; + for (tier, free, premium) in [ + ("free", true, false), + ("pro", false, false), + ("max", false, true), + ("teams", false, true), + ("unknown", false, false), + ] { + for flag in [false, true] { + let access = + crate::billing::ChatBillingAccess::for_tests_with_plan(true, free, tier); + let plan = + ModelPlan::from_billing_access(Some(access)).with_uncensored_override(flag); + let catalog = model_catalog_v2_response(plan); + let entry = catalog_model(&catalog, id); + assert_eq!( + entry["access"]["allowed"], + flag || premium, + "{tier} flag={flag}" + ); + assert_eq!(plan.allows_model(id), flag || premium); + assert_eq!(entry["access"]["eligible_plans"], json!(["max", "teams"])); + assert_eq!(plan.allows_model(KIMI_K3_MODEL_ID), !free); + assert_eq!( + ModelAliasTargets::for_plan(plan), + ModelAliasTargets::for_plan(ModelPlan::from_is_paid(!free)) + ); + assert!(!has_model(&model_catalog_response(plan, false), id)); + assert!(has_model(&model_catalog_response(plan, true), id)); + } + } + let free_with_api_credits = + crate::billing::ChatBillingAccess::for_tests_with_plan(true, true, "max"); + assert!(!ModelPlan::from_billing_access(Some(free_with_api_credits)).allows_model(id)); } fn has_model(response: &Value, model_id: &str) -> bool { @@ -1609,11 +1726,11 @@ mod tests { fn test_model_alias_targets_are_selected_by_plan() { assert_eq!(POWERFUL_MODEL_ID, GLM_5_3_MODEL_ID); - let free = ModelAliasTargets::for_plan(ModelPlan::Free); + let free = ModelAliasTargets::for_plan(ModelPlan::FREE); assert_eq!(free.resolve(AUTO_QUICK_MODEL_ID), QUICK_MODEL_ID); assert_eq!(free.resolve(AUTO_POWERFUL_MODEL_ID), GLM_5_3_MODEL_ID); - let paid = ModelAliasTargets::for_plan(ModelPlan::Paid); + let paid = ModelAliasTargets::for_plan(ModelPlan::PAID); assert_eq!(paid.resolve(AUTO_QUICK_MODEL_ID), GLM_5_3_FLASH_MODEL_ID); assert_eq!(paid.resolve(AUTO_POWERFUL_MODEL_ID), GLM_5_3_MODEL_ID); assert_eq!(paid.resolve(KIMI_K3_MODEL_ID), KIMI_K3_MODEL_ID); @@ -1622,8 +1739,8 @@ mod tests { #[test] fn test_auto_targets_use_glm_5_3_and_preserve_plan_specific_quick() { for (plan, expected_quick) in [ - (ModelPlan::Free, QUICK_MODEL_ID), - (ModelPlan::Paid, GLM_5_3_FLASH_MODEL_ID), + (ModelPlan::FREE, QUICK_MODEL_ID), + (ModelPlan::PAID, GLM_5_3_FLASH_MODEL_ID), ] { let targets = ModelAliasTargets::for_plan(plan); assert_eq!(targets.resolve(AUTO_QUICK_MODEL_ID), expected_quick); @@ -1641,7 +1758,7 @@ mod tests { #[test] fn test_alias_policy_preserves_explicit_model_identities() { - for plan in [ModelPlan::Free, ModelPlan::Paid] { + for plan in [ModelPlan::FREE, ModelPlan::PAID] { let targets = ModelAliasTargets::for_plan(plan); for model in enabled_api_completion_model_ids() { assert_eq!(targets.resolve(model), model, "plan={plan:?}"); @@ -1658,7 +1775,7 @@ mod tests { #[test] fn test_catalog_alias_metadata_matches_resolved_models() { - for plan in [ModelPlan::Free, ModelPlan::Paid] { + for plan in [ModelPlan::FREE, ModelPlan::PAID] { let targets = ModelAliasTargets::for_plan(plan); let catalog = model_catalog_response(plan, false); for alias in catalog["aliases"].as_array().expect("aliases") { @@ -1679,7 +1796,7 @@ mod tests { #[test] fn test_auto_targets_preserve_model_entitlements() { - for plan in [ModelPlan::Free, ModelPlan::Paid] { + for plan in [ModelPlan::FREE, ModelPlan::PAID] { let targets = ModelAliasTargets::for_plan(plan); assert!(plan.allows_model(targets.resolve(AUTO_QUICK_MODEL_ID))); assert_eq!( @@ -1695,10 +1812,10 @@ mod tests { #[test] fn test_golden_auto_alias_resolution_matrix_by_plan() { for (plan, selector, expected_target) in [ - (ModelPlan::Free, AUTO_QUICK_MODEL_ID, QUICK_MODEL_ID), - (ModelPlan::Paid, AUTO_QUICK_MODEL_ID, GLM_5_3_FLASH_MODEL_ID), - (ModelPlan::Free, AUTO_POWERFUL_MODEL_ID, GLM_5_3_MODEL_ID), - (ModelPlan::Paid, AUTO_POWERFUL_MODEL_ID, GLM_5_3_MODEL_ID), + (ModelPlan::FREE, AUTO_QUICK_MODEL_ID, QUICK_MODEL_ID), + (ModelPlan::PAID, AUTO_QUICK_MODEL_ID, GLM_5_3_FLASH_MODEL_ID), + (ModelPlan::FREE, AUTO_POWERFUL_MODEL_ID, GLM_5_3_MODEL_ID), + (ModelPlan::PAID, AUTO_POWERFUL_MODEL_ID, GLM_5_3_MODEL_ID), ] { let targets = ModelAliasTargets::for_plan(plan); assert_eq!(targets.resolve(selector), expected_target, "plan={plan:?}"); @@ -1707,7 +1824,7 @@ mod tests { #[test] fn test_golden_explicit_model_resolution_is_plan_invariant() { - for plan in [ModelPlan::Free, ModelPlan::Paid] { + for plan in [ModelPlan::FREE, ModelPlan::PAID] { let targets = ModelAliasTargets::for_plan(plan); for model in [ QUICK_MODEL_ID, @@ -1727,7 +1844,7 @@ mod tests { #[test] fn test_catalog_alias_metadata_tracks_paid_targets() { - let catalog = model_catalog_response(ModelPlan::Paid, false); + let catalog = model_catalog_response(ModelPlan::PAID, false); let aliases = catalog["aliases"].as_array().expect("aliases"); let quick = aliases .iter() @@ -1755,8 +1872,8 @@ mod tests { AUTO_QUICK_MODEL_ID, "unknown-model", ] { - assert!(ModelPlan::Free.allows_model(model), "free model: {model}"); - assert!(ModelPlan::Paid.allows_model(model), "paid model: {model}"); + assert!(ModelPlan::FREE.allows_model(model), "free model: {model}"); + assert!(ModelPlan::PAID.allows_model(model), "paid model: {model}"); } for model in [ @@ -1768,10 +1885,10 @@ mod tests { AUTO_POWERFUL_MODEL_ID, ] { assert!( - !ModelPlan::Free.allows_model(model), + !ModelPlan::FREE.allows_model(model), "free plan should deny: {model}" ); - assert!(ModelPlan::Paid.allows_model(model), "paid model: {model}"); + assert!(ModelPlan::PAID.allows_model(model), "paid model: {model}"); } } @@ -1786,12 +1903,12 @@ mod tests { DECISION_LARGE_MODEL_ID ] ); - let catalog = model_catalog_response(ModelPlan::Paid, true); + let catalog = model_catalog_response(ModelPlan::PAID, true); let openai = openai_models_response(true); for model in decision_models() { assert_eq!(decision_model(model.id), Some(*model)); - assert!(!ModelPlan::Free.allows_model(model.id), "{}", model.id); - assert!(ModelPlan::Paid.allows_model(model.id), "{}", model.id); + assert!(!ModelPlan::FREE.allows_model(model.id), "{}", model.id); + assert!(ModelPlan::PAID.allows_model(model.id), "{}", model.id); assert_eq!(model.context_window, 32_768); assert_eq!(model.max_choice_options, 26); // Not a completion model: chat and Responses cannot resolve it, and neither @@ -1806,7 +1923,7 @@ mod tests { #[test] fn test_catalog_hides_api_only_models_and_includes_aliases() { - let response = model_catalog_response(ModelPlan::Free, false); + let response = model_catalog_response(ModelPlan::FREE, false); let data = response["data"].as_array().expect("catalog data"); assert!(data.iter().any(|model| model["id"] == QUICK_MODEL_ID)); assert!(!data @@ -1823,8 +1940,8 @@ mod tests { .any(|alias| alias["id"] == AUTO_POWERFUL_MODEL_ID)); for (plan, expected_quick, expected_quick_access) in [ - (ModelPlan::Free, QUICK_MODEL_ID, "free"), - (ModelPlan::Paid, GLM_5_3_FLASH_MODEL_ID, "pro"), + (ModelPlan::FREE, QUICK_MODEL_ID, "free"), + (ModelPlan::PAID, GLM_5_3_FLASH_MODEL_ID, "pro"), ] { let catalog = model_catalog_response(plan, false); let aliases = catalog["aliases"].as_array().expect("aliases"); @@ -1859,7 +1976,7 @@ mod tests { #[test] fn test_catalog_advertises_voxtral_as_default_speech_model() { - let response = model_catalog_response(ModelPlan::Free, false); + let response = model_catalog_response(ModelPlan::FREE, false); assert_eq!(response["audio"]["speech"]["available"], true); assert_eq!(response["audio"]["speech"]["model"], "voxtral-tts"); @@ -1868,7 +1985,7 @@ mod tests { #[test] fn test_catalog_removes_deprecated_kimi_k2_6() { - let catalog = model_catalog_response(ModelPlan::Free, false); + let catalog = model_catalog_response(ModelPlan::FREE, false); let openai_models = openai_models_response(false); assert!(!has_model(&catalog, "kimi-k2-6")); @@ -1878,7 +1995,7 @@ mod tests { #[test] fn test_catalog_adds_glm_5_3_directly_through_continuum_without_changing_aliases() { - let catalog = model_catalog_response(ModelPlan::Paid, false); + let catalog = model_catalog_response(ModelPlan::PAID, false); let glm = catalog_model(&catalog, GLM_5_3_MODEL_ID); assert_eq!(glm["provider"], "continuum"); @@ -1902,7 +2019,7 @@ mod tests { #[test] fn test_catalog_adds_glm_5_3_flash_through_tinfoil_with_image_and_shared_1m_context() { - let catalog = model_catalog_response(ModelPlan::Paid, false); + let catalog = model_catalog_response(ModelPlan::PAID, false); let glm = catalog_model(&catalog, GLM_5_3_FLASH_MODEL_ID); assert_eq!(glm["provider"], "tinfoil"); @@ -1921,7 +2038,7 @@ mod tests { #[test] fn test_catalog_adds_deepseek_v4_1_flash_through_tinfoil_with_image_and_1m_context() { - let catalog = model_catalog_response(ModelPlan::Paid, false); + let catalog = model_catalog_response(ModelPlan::PAID, false); let deepseek = catalog_model(&catalog, DEEPSEEK_V4_1_FLASH_MODEL_ID); assert_eq!(deepseek["provider"], "tinfoil"); @@ -1940,7 +2057,7 @@ mod tests { #[test] fn test_new_tinfoil_models_are_generally_listed() { - let catalog = model_catalog_response(ModelPlan::Free, false); + let catalog = model_catalog_response(ModelPlan::FREE, false); let openai_models = openai_models_response(false); for model in [ @@ -1959,7 +2076,7 @@ mod tests { let expected = vec![DEEPSEEK_V4_1_FLASH_MODEL_ID.to_string()]; assert_eq!( - model_ids_with_badge(&model_catalog_response(ModelPlan::Free, false), "New"), + model_ids_with_badge(&model_catalog_response(ModelPlan::FREE, false), "New"), expected ); assert_eq!( @@ -1967,7 +2084,7 @@ mod tests { expected ); assert!( - model_ids_with_badge(&model_catalog_response(ModelPlan::Free, false), "Reasoning") + model_ids_with_badge(&model_catalog_response(ModelPlan::FREE, false), "Reasoning") .is_empty() ); assert!(model_ids_with_badge(&openai_models_response(false), "Reasoning").is_empty()); @@ -1975,7 +2092,7 @@ mod tests { #[test] fn test_enriched_catalog_has_verified_new_model_metadata() { - let catalog = model_catalog_response(ModelPlan::Free, false); + let catalog = model_catalog_response(ModelPlan::FREE, false); let kimi = catalog_model(&catalog, "kimi-k3"); assert_eq!(kimi["access"], "pro"); assert_eq!(kimi["provider_id"], "kimi-k3"); @@ -2123,7 +2240,7 @@ mod tests { #[test] fn test_reasoning_controls_match_between_catalog_and_models_endpoints() { - let catalog = model_catalog_response(ModelPlan::Paid, false); + let catalog = model_catalog_response(ModelPlan::PAID, false); let models = openai_models_response(false); for model in catalog["data"].as_array().expect("catalog data") { let listed = models["data"] @@ -2199,7 +2316,7 @@ mod tests { fn test_alias_reasoning_is_what_every_candidate_accepts() { // Paid Quick may run on GLM-5.3-Flash or DeepSeek: xhigh and none are // not shared, so neither is promised; Flash's mandatory reasoning wins. - let paid_quick = alias_reasoning(AUTO_QUICK_MODEL_ID, ModelPlan::Paid).unwrap(); + let paid_quick = alias_reasoning(AUTO_QUICK_MODEL_ID, ModelPlan::PAID).unwrap(); assert_eq!( paid_quick.json(), json!({ @@ -2210,20 +2327,20 @@ mod tests { }) ); // Paid Powerful may run on GLM-5.3 or Kimi K3. - let paid_powerful = alias_reasoning(AUTO_POWERFUL_MODEL_ID, ModelPlan::Paid).unwrap(); + let paid_powerful = alias_reasoning(AUTO_POWERFUL_MODEL_ID, ModelPlan::PAID).unwrap(); assert_eq!(paid_powerful, paid_quick); // Free aliases have no alternates and publish their target's controls. assert_eq!( - alias_reasoning(AUTO_QUICK_MODEL_ID, ModelPlan::Free), + alias_reasoning(AUTO_QUICK_MODEL_ID, ModelPlan::FREE), model_reasoning(QUICK_MODEL_ID) ); assert_eq!( - alias_reasoning(AUTO_POWERFUL_MODEL_ID, ModelPlan::Free), + alias_reasoning(AUTO_POWERFUL_MODEL_ID, ModelPlan::FREE), model_reasoning(GLM_5_3_MODEL_ID) ); - assert_eq!(alias_reasoning(GLM_5_3_MODEL_ID, ModelPlan::Paid), None); + assert_eq!(alias_reasoning(GLM_5_3_MODEL_ID, ModelPlan::PAID), None); - for plan in [ModelPlan::Free, ModelPlan::Paid] { + for plan in [ModelPlan::FREE, ModelPlan::PAID] { let catalog = model_catalog_response(plan, false); for alias in catalog["aliases"].as_array().expect("aliases") { let selector = alias["id"].as_str().expect("alias ID"); @@ -2248,8 +2365,8 @@ mod tests { #[test] fn deepseek_catalog_lists_only_the_canonical_public_model() { for catalog in [ - model_catalog_response(ModelPlan::Free, false), - model_catalog_response(ModelPlan::Paid, false), + model_catalog_response(ModelPlan::FREE, false), + model_catalog_response(ModelPlan::PAID, false), openai_models_response(false), ] { assert!(has_model(&catalog, DEEPSEEK_V4_1_FLASH_MODEL_ID)); @@ -2283,9 +2400,9 @@ mod tests { assert!( enabled_api_completion_model_ids().any(|model| model == DEEPSEEK_V4_1_FLASH_MODEL_ID) ); - assert!(!ModelPlan::Free.allows_model(DEEPSEEK_V4_1_FLASH_MODEL_ID)); - assert!(ModelPlan::Paid.allows_model(DEEPSEEK_V4_1_FLASH_MODEL_ID)); - let catalog = model_catalog_response(ModelPlan::Paid, false); + assert!(!ModelPlan::FREE.allows_model(DEEPSEEK_V4_1_FLASH_MODEL_ID)); + assert!(ModelPlan::PAID.allows_model(DEEPSEEK_V4_1_FLASH_MODEL_ID)); + let catalog = model_catalog_response(ModelPlan::PAID, false); let deepseek = catalog_model(&catalog, DEEPSEEK_V4_1_FLASH_MODEL_ID); assert_eq!(deepseek["provider_id"], DEEPSEEK_V4_1_FLASH_MODEL_ID); assert_eq!(deepseek["display_name"], "DeepSeek V4.1 Flash"); @@ -2294,19 +2411,19 @@ mod tests { #[test] fn deepseek_provider_rollout_preserves_auto_defaults_and_candidates() { let expected = [ - (ModelPlan::Free, AUTO_QUICK_MODEL_ID, vec![QUICK_MODEL_ID]), + (ModelPlan::FREE, AUTO_QUICK_MODEL_ID, vec![QUICK_MODEL_ID]), ( - ModelPlan::Free, + ModelPlan::FREE, AUTO_POWERFUL_MODEL_ID, vec![GLM_5_3_MODEL_ID], ), ( - ModelPlan::Paid, + ModelPlan::PAID, AUTO_QUICK_MODEL_ID, vec![GLM_5_3_FLASH_MODEL_ID, DEEPSEEK_V4_1_FLASH_MODEL_ID], ), ( - ModelPlan::Paid, + ModelPlan::PAID, AUTO_POWERFUL_MODEL_ID, vec![GLM_5_3_MODEL_ID, KIMI_K3_MODEL_ID], ), diff --git a/services/opensecret/src/provider_registry.rs b/services/opensecret/src/provider_registry.rs index 6bfd56137..f13e39edd 100644 --- a/services/opensecret/src/provider_registry.rs +++ b/services/opensecret/src/provider_registry.rs @@ -635,15 +635,15 @@ mod tests { use crate::inference::auto_model::auto_model_candidates; use crate::inference::ModelSelectionMode; use crate::model_config::{ModelAliasTargets, ModelPlan}; - let paid = ModelAliasTargets::for_plan(ModelPlan::Paid); + let paid = ModelAliasTargets::for_plan(ModelPlan::PAID); assert_eq!(paid.resolve("auto:quick"), GLM_5_3_FLASH_MODEL_ID); assert_eq!(paid.resolve("auto:powerful"), GLM_5_3_MODEL_ID); assert_eq!( - auto_model_candidates(ModelSelectionMode::AutoQuick, ModelPlan::Paid).unwrap(), + auto_model_candidates(ModelSelectionMode::AutoQuick, ModelPlan::PAID).unwrap(), [GLM_5_3_FLASH_MODEL_ID, DEEPSEEK_V4_1_FLASH_MODEL_ID] ); assert_eq!( - auto_model_candidates(ModelSelectionMode::AutoPowerful, ModelPlan::Paid).unwrap(), + auto_model_candidates(ModelSelectionMode::AutoPowerful, ModelPlan::PAID).unwrap(), [GLM_5_3_MODEL_ID, KIMI_K3_MODEL_ID] ); } diff --git a/services/opensecret/src/provider_routing.rs b/services/opensecret/src/provider_routing.rs index 56e0ef4c4..49b512dee 100644 --- a/services/opensecret/src/provider_routing.rs +++ b/services/opensecret/src/provider_routing.rs @@ -550,7 +550,7 @@ mod tests { uuid_for_bucket(0), requested_model, public_model, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ) @@ -727,7 +727,7 @@ mod tests { Case { name: "free auto quick", selector: AUTO_QUICK_MODEL_ID, - plan: ModelPlan::Free, + plan: ModelPlan::FREE, bucket: 73, continuum_available: true, expected_access: true, @@ -739,7 +739,7 @@ mod tests { Case { name: "free auto powerful remains unavailable", selector: AUTO_POWERFUL_MODEL_ID, - plan: ModelPlan::Free, + plan: ModelPlan::FREE, bucket: 73, continuum_available: true, expected_access: false, @@ -751,7 +751,7 @@ mod tests { Case { name: "paid auto quick Continuum bucket", selector: AUTO_QUICK_MODEL_ID, - plan: ModelPlan::Paid, + plan: ModelPlan::PAID, bucket: 73, continuum_available: true, expected_access: true, @@ -763,7 +763,7 @@ mod tests { Case { name: "paid auto quick former Tinfoil bucket", selector: AUTO_QUICK_MODEL_ID, - plan: ModelPlan::Paid, + plan: ModelPlan::PAID, bucket: 75, continuum_available: true, expected_access: true, @@ -775,7 +775,7 @@ mod tests { Case { name: "paid auto powerful uses GLM weighted route", selector: AUTO_POWERFUL_MODEL_ID, - plan: ModelPlan::Paid, + plan: ModelPlan::PAID, bucket: 73, continuum_available: true, expected_access: true, @@ -787,7 +787,7 @@ mod tests { Case { name: "explicit K3 is independent of the auto target", selector: KIMI_K3_MODEL_ID, - plan: ModelPlan::Paid, + plan: ModelPlan::PAID, bucket: 73, continuum_available: true, expected_access: true, @@ -799,7 +799,7 @@ mod tests { Case { name: "explicit GLM 5.3 Tinfoil bucket", selector: GLM_5_3_MODEL_ID, - plan: ModelPlan::Paid, + plan: ModelPlan::PAID, bucket: 75, continuum_available: true, expected_access: true, @@ -811,7 +811,7 @@ mod tests { Case { name: "explicit GLM 5.3 Continuum bucket", selector: GLM_5_3_MODEL_ID, - plan: ModelPlan::Paid, + plan: ModelPlan::PAID, bucket: 73, continuum_available: true, expected_access: true, @@ -823,7 +823,7 @@ mod tests { Case { name: "explicit GLM 5.3 Tinfoil bucket without a Continuum proxy", selector: GLM_5_3_MODEL_ID, - plan: ModelPlan::Paid, + plan: ModelPlan::PAID, bucket: 75, continuum_available: false, expected_access: true, @@ -951,7 +951,7 @@ mod tests { account_uuid, model, model, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::ChatCompletions, WorkloadClass::Interactive, ); @@ -1193,7 +1193,7 @@ mod tests { uuid_for_bucket(0), model, model, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ) @@ -1271,7 +1271,7 @@ mod tests { uuid_for_bucket(75), GLM_5_3_FLASH_MODEL_ID, GLM_5_3_FLASH_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ), @@ -1310,7 +1310,7 @@ mod tests { uuid_for_bucket(bucket), selector, GLM_5_3_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, surface, WorkloadClass::Interactive, ); @@ -1464,7 +1464,7 @@ mod tests { fn glm_flash_continuum_only_preserves_provider_mapping_and_public_identity() { let router = ProviderRouter::default(); let proxies = proxy_router_with_both_providers(); - let targets = ModelAliasTargets::for_plan(ModelPlan::Paid); + let targets = ModelAliasTargets::for_plan(ModelPlan::PAID); for surface in [ InferenceSurface::ChatCompletions, InferenceSurface::Responses, @@ -1623,12 +1623,12 @@ mod tests { "http://tinfoil.example.com".to_string(), ); - let targets = ModelAliasTargets::for_plan(ModelPlan::Free); + let targets = ModelAliasTargets::for_plan(ModelPlan::FREE); let request = InferenceIntent::new( uuid_for_bucket(50), AUTO_QUICK_MODEL_ID, targets.resolve(AUTO_QUICK_MODEL_ID), - ModelPlan::Free, + ModelPlan::FREE, InferenceSurface::ChatCompletions, WorkloadClass::Interactive, ); @@ -1916,7 +1916,7 @@ mod tests { ); select_auto_model(AutoModelSelectionInput { mode: ModelSelectionMode::AutoQuick, - plan: ModelPlan::Paid, + plan: ModelPlan::PAID, sticky_model_id, excluded: None, requirements: AutoModelRequirements { diff --git a/services/opensecret/src/web/openai.rs b/services/opensecret/src/web/openai.rs index b098a74ce..b7531343f 100644 --- a/services/opensecret/src/web/openai.rs +++ b/services/opensecret/src/web/openai.rs @@ -11,7 +11,8 @@ use crate::inference::{ }; use crate::inference_planning::{RoutePlan, RoutePlanningError}; use crate::model_config::{ - model_catalog_response, openai_models_response, ModelAliasTargets, ModelPlan, ReasoningEffort, + model_catalog_response, model_catalog_v2_response, openai_models_response, ModelAliasTargets, + ModelPlan, ReasoningEffort, }; use crate::models::token_usage::NewTokenUsage; use crate::models::users::User; @@ -134,9 +135,7 @@ pub(crate) fn completion_entitlement( billing_access: Option, surface: &str, ) -> Result { - let model_plan = ModelPlan::from_is_paid( - billing_access.is_some_and(crate::billing::ChatBillingAccess::is_paid), - ); + let model_plan = ModelPlan::from_billing_access(billing_access); if is_guest && !model_plan.is_paid() { error!( "Guest user without a paid plan attempted to use {}: {}", @@ -1561,6 +1560,13 @@ pub fn router(app_state: Arc) -> Router<()> { decrypt_request::<()>, )), ) + .route( + "/v2/models/catalog", + get(proxy_model_catalog_v2).layer(axum::middleware::from_fn_with_state( + app_state.clone(), + decrypt_request::<()>, + )), + ) .route( "/v1/audio/speech", post(proxy_tts).layer(axum::middleware::from_fn_with_state( @@ -1967,6 +1973,9 @@ async fn proxy_openai( })? .to_string(); + let model_plan = + model_plan_for_request(&state, user.uuid, model_plan, &requested_model_name).await; + let alias_targets = ModelAliasTargets::for_plan(model_plan); let alias_target = alias_targets.resolve(&requested_model_name).to_string(); let routing = InferenceRoutingContext::new(model_plan); @@ -2299,7 +2308,6 @@ pub(crate) async fn prepare_completion_request( } ensure_completion_model_access(&intent.public_model_id, intent.model_plan)?; - ensure_model_feature_access(state, user.uuid, &intent.public_model_id).await?; // Helpers and re-plans retain the authenticated account and plan. Model // access is checked above; configured providers are selected by Router v2. let shadow = state @@ -3169,21 +3177,30 @@ pub(crate) fn ensure_completion_model_access( "Paid completion model requested without entitlement: {}", model_name ); + if model_name == crate::model_config::DEEPSEEK_V4_1_FLASH_UNCENSORED_MODEL_ID { + return Err(ApiError::ModelUpgradeRequired { + model: crate::model_config::DEEPSEEK_V4_1_FLASH_UNCENSORED_MODEL_ID, + eligible_plans: &["max", "teams"], + }); + } return Err(ApiError::ModelNotAvailableOnPlan); } Ok(()) } -/// Only the experimental model depends on flags. A denied or unavailable flag -/// never permits provider work, and unrelated completions avoid that service. -pub(crate) async fn ensure_model_feature_access( +/// Resolve the experimental grant once at the authenticated entrypoint. A true +/// flag overrides subscription requirements; false (including unavailable) +/// falls through to the normal tier check, never revoking Max/Teams access. +pub(crate) async fn model_plan_for_request( state: &AppState, account_uuid: Uuid, + plan: ModelPlan, model: &str, -) -> Result<(), ApiError> { - ensure_model_feature_access_with( +) -> ModelPlan { + model_plan_for_request_with( model, + plan, state.is_feature_enabled( account_uuid, crate::os_flags::UNCENSORED_DEEPSEEK_FEATURE_FLAG_KEY, @@ -3192,17 +3209,15 @@ pub(crate) async fn ensure_model_feature_access( .await } -async fn ensure_model_feature_access_with( +async fn model_plan_for_request_with( model: &str, + plan: ModelPlan, enabled: impl std::future::Future, -) -> Result<(), ApiError> { +) -> ModelPlan { if model != crate::model_config::DEEPSEEK_V4_1_FLASH_UNCENSORED_MODEL_ID { - return Ok(()); - } - if !enabled.await { - return Err(ApiError::ModelNotAvailable); + return plan; } - Ok(()) + plan.with_uncensored_override(enabled.await) } // ============================================================================ @@ -3698,9 +3713,7 @@ async fn proxy_model_catalog( axum::Extension(_body): axum::Extension<()>, ) -> Result { let billing_access = state.chat_billing_access(user.uuid, false).await; - let model_plan = ModelPlan::from_is_paid( - billing_access.is_some_and(crate::billing::ChatBillingAccess::is_paid), - ); + let model_plan = ModelPlan::from_billing_access(billing_access); let uncensored_enabled = state .is_feature_enabled( user.uuid, @@ -3711,6 +3724,24 @@ async fn proxy_model_catalog( encrypt_response(&state, &session_id, &catalog_response).await } +async fn proxy_model_catalog_v2( + State(state): State>, + axum::Extension(session_id): axum::Extension, + axum::Extension(user): axum::Extension, + axum::Extension(_auth_method): axum::Extension, + axum::Extension(_body): axum::Extension<()>, +) -> Result { + let access = state.chat_billing_access(user.uuid, false).await; + let plan = model_plan_for_request( + &state, + user.uuid, + ModelPlan::from_billing_access(access), + crate::model_config::DEEPSEEK_V4_1_FLASH_UNCENSORED_MODEL_ID, + ) + .await; + encrypt_response(&state, &session_id, &model_catalog_v2_response(plan)).await +} + const TRANSCRIPTION_BILLING_TIMEOUT: Duration = Duration::from_secs(5); const MAX_CONCURRENT_TRANSCRIPTIONS: usize = 4; @@ -4599,7 +4630,7 @@ mod tests { Uuid::nil(), crate::model_config::AUTO_POWERFUL_MODEL_ID, "glm-5-3", - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ), @@ -4635,7 +4666,7 @@ mod tests { Uuid::nil(), crate::model_config::GLM_5_3_MODEL_ID, crate::model_config::GLM_5_3_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ); @@ -4679,7 +4710,7 @@ mod tests { Uuid::nil(), crate::model_config::AUTO_POWERFUL_MODEL_ID, crate::model_config::GLM_5_3_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::ChatCompletions, WorkloadClass::Interactive, ); @@ -4712,7 +4743,7 @@ mod tests { Uuid::nil(), crate::model_config::KIMI_K3_MODEL_ID, crate::model_config::KIMI_K3_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::ChatCompletions, WorkloadClass::Interactive, ); @@ -4741,7 +4772,7 @@ mod tests { Uuid::from_u128(bucket), model, model, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Internal, WorkloadClass::Interactive, ); @@ -4768,7 +4799,7 @@ mod tests { Uuid::nil(), crate::model_config::KIMI_K3_MODEL_ID, crate::model_config::KIMI_K3_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ); @@ -5150,7 +5181,7 @@ mod tests { Uuid::nil(), crate::model_config::GLM_5_3_MODEL_ID, crate::model_config::GLM_5_3_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ); @@ -5197,7 +5228,7 @@ mod tests { Uuid::nil(), crate::model_config::GLM_5_3_MODEL_ID, crate::model_config::GLM_5_3_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ); @@ -5311,7 +5342,7 @@ mod tests { Uuid::nil(), crate::model_config::GLM_5_3_MODEL_ID, crate::model_config::GLM_5_3_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ); @@ -5371,7 +5402,7 @@ mod tests { Uuid::from_u128(1), crate::model_config::GLM_5_3_MODEL_ID, crate::model_config::GLM_5_3_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ); @@ -5396,7 +5427,7 @@ mod tests { Uuid::nil(), crate::model_config::GLM_5_3_MODEL_ID, crate::model_config::GLM_5_3_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ); @@ -5873,7 +5904,7 @@ mod tests { Uuid::nil(), crate::model_config::GLM_5_3_MODEL_ID, crate::model_config::GLM_5_3_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ) @@ -6789,36 +6820,36 @@ mod tests { #[test] fn completion_model_access_enforces_plan_gates() { assert!(matches!( - ensure_completion_model_access("kimi-k3", ModelPlan::Free), + ensure_completion_model_access("kimi-k3", ModelPlan::FREE), Err(ApiError::ModelNotAvailableOnPlan) )); - assert!(ensure_completion_model_access("kimi-k3", ModelPlan::Paid).is_ok()); + assert!(ensure_completion_model_access("kimi-k3", ModelPlan::PAID).is_ok()); assert!(matches!( - ensure_completion_model_access("deepseek-v4-1-flash", ModelPlan::Free), + ensure_completion_model_access("deepseek-v4-1-flash", ModelPlan::FREE), Err(ApiError::ModelNotAvailableOnPlan) )); - assert!(ensure_completion_model_access("deepseek-v4-1-flash", ModelPlan::Paid).is_ok()); + assert!(ensure_completion_model_access("deepseek-v4-1-flash", ModelPlan::PAID).is_ok()); assert!(matches!( - ensure_completion_model_access("glm-5-3", ModelPlan::Free), + ensure_completion_model_access("glm-5-3", ModelPlan::FREE), Err(ApiError::ModelNotAvailableOnPlan) )); - assert!(ensure_completion_model_access("glm-5-3", ModelPlan::Paid).is_ok()); + assert!(ensure_completion_model_access("glm-5-3", ModelPlan::PAID).is_ok()); assert!(matches!( - ensure_completion_model_access("glm-5-3-flash", ModelPlan::Free), + ensure_completion_model_access("glm-5-3-flash", ModelPlan::FREE), Err(ApiError::ModelNotAvailableOnPlan) )); - assert!(ensure_completion_model_access("glm-5-3-flash", ModelPlan::Paid).is_ok()); + assert!(ensure_completion_model_access("glm-5-3-flash", ModelPlan::PAID).is_ok()); assert!(matches!( ensure_completion_model_access( crate::model_config::AUTO_POWERFUL_MODEL_ID, - ModelPlan::Free + ModelPlan::FREE ), Err(ApiError::ModelNotAvailableOnPlan) )); // Plan access does not replace normal model validation. Unknown IDs // continue to the existing provider-routing validation path. - assert!(ensure_completion_model_access("gpt-oss-120b", ModelPlan::Free).is_ok()); - assert!(ensure_completion_model_access("not-a-real-model", ModelPlan::Free).is_ok()); + assert!(ensure_completion_model_access("gpt-oss-120b", ModelPlan::FREE).is_ok()); + assert!(ensure_completion_model_access("not-a-real-model", ModelPlan::FREE).is_ok()); } fn tts_request(payload: Value) -> TTSRequest { @@ -7919,7 +7950,7 @@ mod tests { // A fresh account in bucket zero starts on Continuum under the unchanged weights. let account = Uuid::from_u128(0); let surface = InferenceSurface::ChatCompletions; - let plan = ModelPlan::Paid; + let plan = ModelPlan::PAID; // Each row is a distinct logical request. Its actual 503 is surfaced; // only the following request can use another provider or public model. @@ -8136,7 +8167,7 @@ mod tests { account, surface, AUTO_POWERFUL_MODEL_ID, - ModelPlan::Free + ModelPlan::FREE ), Err(ApiError::ModelNotAvailableOnPlan) )); @@ -8146,7 +8177,7 @@ mod tests { account, surface, AUTO_QUICK_MODEL_ID, - ModelPlan::Free, + ModelPlan::FREE, ) .expect("free quick"); assert_eq!(free_quick.public_model_id(), QUICK_MODEL_ID); @@ -8190,7 +8221,7 @@ mod tests { account, surface, AUTO_QUICK_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, ) .expect("retained DeepSeek"); assert_eq!(retained.public_model_id(), DEEPSEEK_V4_1_FLASH_MODEL_ID); @@ -8214,7 +8245,7 @@ mod tests { account, surface, AUTO_QUICK_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, ) .expect("GLM primary after idle"); assert_eq!(primary.public_model_id(), GLM_5_3_FLASH_MODEL_ID); @@ -8225,7 +8256,7 @@ mod tests { let intent = primary.intent( account, AUTO_QUICK_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, surface, WorkloadClass::Interactive, ); @@ -8257,7 +8288,7 @@ mod tests { account, public_model, public_model, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::ChatCompletions, WorkloadClass::Interactive, ); @@ -8297,7 +8328,7 @@ mod tests { account, InferenceSurface::ChatCompletions, AUTO_QUICK_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, ) }; match resolve().expect_err("no eligible Quick candidate") { @@ -8357,7 +8388,7 @@ mod tests { account, surface, AUTO_QUICK_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, ) .expect("retained alternate"); assert_eq!(first.public_model_id(), DEEPSEEK_V4_1_FLASH_MODEL_ID); @@ -8376,7 +8407,7 @@ mod tests { account, surface, AUTO_QUICK_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, Some(&overflowed), ) .expect("preferred model after overflow"); @@ -8425,7 +8456,7 @@ mod tests { account, surface, AUTO_QUICK_MODEL_ID, - ModelPlan::Free, + ModelPlan::FREE, Some(&lost), ) .expect_err("no alternate for free quick"); @@ -8447,7 +8478,7 @@ mod tests { account, surface, AUTO_QUICK_MODEL_ID, - ModelPlan::Free, + ModelPlan::FREE, Some(&lost), ) .expect_err("no alternate for free quick") @@ -8472,7 +8503,7 @@ mod tests { surface, requested_model_id: AUTO_POWERFUL_MODEL_ID, alias_target: GLM_5_3_MODEL_ID, - model_plan: ModelPlan::Paid, + model_plan: ModelPlan::PAID, excluded: Some(&lost), }, || AutoModelRequirements { @@ -8498,7 +8529,7 @@ mod tests { account, surface, AUTO_POWERFUL_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, Some(&lost), ) .expect("alternate takes the request"); @@ -8516,7 +8547,7 @@ mod tests { Uuid::from_u128(11), crate::model_config::AUTO_POWERFUL_MODEL_ID, crate::model_config::GLM_5_3_MODEL_ID, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ); @@ -8644,7 +8675,7 @@ mod tests { surface: InferenceSurface::ChatCompletions, requested_model_id: crate::model_config::DEEPSEEK_V4_1_FLASH_MODEL_ID, alias_target: crate::model_config::DEEPSEEK_V4_1_FLASH_MODEL_ID, - model_plan: ModelPlan::Paid, + model_plan: ModelPlan::PAID, excluded: None, }, || { diff --git a/services/opensecret/src/web/openai/maple_tests.rs b/services/opensecret/src/web/openai/maple_tests.rs index c99df7f9f..46c8dc513 100644 --- a/services/opensecret/src/web/openai/maple_tests.rs +++ b/services/opensecret/src/web/openai/maple_tests.rs @@ -3,29 +3,28 @@ use super::*; use crate::provider_registry::{MapleInstanceId, DUAL_INSTANCE_TEST_REGISTRY}; #[tokio::test] -async fn uncensored_flag_denies_or_allows_only_its_exact_model() { +async fn uncensored_flag_grants_only_its_exact_model_even_on_free() { let model = crate::model_config::DEEPSEEK_V4_1_FLASH_UNCENSORED_MODEL_ID; - assert!(matches!( - ensure_model_feature_access_with(model, std::future::ready(false)).await, - Err(ApiError::ModelNotAvailable) - )); - assert!( - ensure_model_feature_access_with(model, std::future::ready(true)) - .await - .is_ok() - ); - for ordinary_model in [ + for plan in [ModelPlan::FREE, ModelPlan::PAID] { + let off = model_plan_for_request_with(model, plan, std::future::ready(false)).await; + assert!(!off.allows_model(model)); + let on = model_plan_for_request_with(model, plan, std::future::ready(true)).await; + assert!(on.allows_model(model)); + assert_eq!(on.is_paid(), plan.is_paid()); + assert_eq!(on.allows_model("kimi-k3"), plan.is_paid()); + } + for model in [ "deepseek-v4-1-flash", "glm-5-3", "kimi-k3", "auto:quick", "auto:powerful", ] { - assert!(ensure_model_feature_access_with(ordinary_model, async { - panic!("unrelated completion must never poll the feature-flag request"); + let plan = model_plan_for_request_with(model, ModelPlan::PAID, async { + panic!("unrelated completion must not poll flags"); }) - .await - .is_ok()); + .await; + assert_eq!(plan, ModelPlan::PAID); } } @@ -74,16 +73,12 @@ async fn uncensored_flag_uses_account_identity_and_fails_closed_on_missing_or_se .await .unwrap_or(false) }; - let result = ensure_model_feature_access_with(model, checked).await; - if account == 4 { - assert!(result.is_ok()); - } else { - assert!(matches!(result, Err(ApiError::ModelNotAvailable))); - } + let plan = model_plan_for_request_with(model, ModelPlan::FREE, checked).await; + assert_eq!(plan.allows_model(model), account == 4); } assert_eq!(calls.load(Ordering::SeqCst), 4); assert!( - ensure_model_feature_access_with("deepseek-v4-1-flash", async { + model_plan_for_request_with("deepseek-v4-1-flash", ModelPlan::PAID, async { flags .is_enabled( Uuid::from_u128(5), @@ -93,7 +88,7 @@ async fn uncensored_flag_uses_account_identity_and_fails_closed_on_missing_or_se .unwrap_or(false) }) .await - .is_ok() + .allows_model("deepseek-v4-1-flash") ); assert_eq!(calls.load(Ordering::SeqCst), 4); server.abort(); @@ -102,10 +97,10 @@ async fn uncensored_flag_uses_account_identity_and_fails_closed_on_missing_or_se #[test] fn nested_and_pinned_completion_contexts_preserve_plan_without_provider_flag() { let cache = CompletionCachePolicy::LegacyV1; - let caller = InferenceRoutingContext::new(ModelPlan::Paid); - let child = caller.with_model_plan(ModelPlan::Free); - assert_eq!(child.model_plan(), ModelPlan::Free); - assert_eq!(caller.model_plan(), ModelPlan::Paid); + let caller = InferenceRoutingContext::new(ModelPlan::PAID); + let child = caller.with_model_plan(ModelPlan::FREE); + assert_eq!(child.model_plan(), ModelPlan::FREE); + assert_eq!(caller.model_plan(), ModelPlan::PAID); for provider in [ ProviderId::Maple(MapleInstanceId::I01), @@ -118,7 +113,7 @@ fn nested_and_pinned_completion_contexts_preserve_plan_without_provider_flag() { Uuid::from_u128(1), public_model, public_model, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Responses, WorkloadClass::Interactive, ), @@ -141,7 +136,7 @@ fn nested_and_pinned_completion_contexts_preserve_plan_without_provider_flag() { &pinned, &cache, ); - assert_eq!(context.routing.model_plan(), ModelPlan::Paid); + assert_eq!(context.routing.model_plan(), ModelPlan::PAID); } } @@ -157,7 +152,7 @@ fn maple_instance_routes_keep_shared_cache_namespace_and_billing_identity() { user, model, model, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::ChatCompletions, WorkloadClass::Interactive, ); diff --git a/services/opensecret/src/web/responses/handlers.rs b/services/opensecret/src/web/responses/handlers.rs index 9d68ef0f3..d781a5f9f 100644 --- a/services/opensecret/src/web/responses/handlers.rs +++ b/services/opensecret/src/web/responses/handlers.rs @@ -812,7 +812,7 @@ mod tests { surface: InferenceSurface::Responses, requested_model_id: AUTO_QUICK_MODEL_ID, alias_target: QUICK_MODEL_ID, - model_plan: ModelPlan::Free, + model_plan: ModelPlan::FREE, excluded: Some(&lost), }, || crate::inference::auto_model::AutoModelRequirements { @@ -840,7 +840,7 @@ mod tests { surface: InferenceSurface::Responses, requested_model_id: AUTO_QUICK_MODEL_ID, alias_target: QUICK_MODEL_ID, - model_plan: ModelPlan::Free, + model_plan: ModelPlan::FREE, excluded: Some(&lost), }, || crate::inference::auto_model::AutoModelRequirements { @@ -918,7 +918,7 @@ mod tests { .single() .expect("valid UTC timestamp"); - let prompt = build_internal_system_prompt_for_now(now, false, ModelPlan::Paid); + let prompt = build_internal_system_prompt_for_now(now, false, ModelPlan::PAID); assert!(prompt.contains("Automatic read_image tool results")); assert!(prompt.contains("untrusted user content")); @@ -1624,13 +1624,13 @@ mod tests { #[test] fn response_execution_policy_preserves_tool_and_model_turn_bounds() { - let free = ResponseExecutionPolicy::new(ModelPlan::Free, true); + let free = ResponseExecutionPolicy::new(ModelPlan::FREE, true); assert_eq!(free.max_tool_executions, MAX_WEB_SEARCH_TOOL_TURNS_FREE); assert_eq!(free.max_model_turns, MAX_WEB_SEARCH_TOOL_TURNS_FREE + 2); - let paid = ResponseExecutionPolicy::new(ModelPlan::Paid, true); + let paid = ResponseExecutionPolicy::new(ModelPlan::PAID, true); assert_eq!(paid.max_tool_executions, MAX_WEB_SEARCH_TOOL_TURNS_PAID); assert_eq!(paid.max_model_turns, MAX_WEB_SEARCH_TOOL_TURNS_PAID + 2); - let no_tools = ResponseExecutionPolicy::new(ModelPlan::Paid, false); + let no_tools = ResponseExecutionPolicy::new(ModelPlan::PAID, false); assert_eq!(no_tools.max_tool_executions, 0); assert_eq!(no_tools.max_model_turns, 1); } @@ -1640,7 +1640,7 @@ mod tests { Uuid::new_v4(), "llama3-3-70b", "llama3-3-70b", - ModelPlan::Free, + ModelPlan::FREE, InferenceSurface::Responses, WorkloadClass::Interactive, ); @@ -2386,51 +2386,79 @@ mod tests { fn test_resolve_responses_models_enforces_plan_gates() { let uncensored = crate::model_config::DEEPSEEK_V4_1_FLASH_UNCENSORED_MODEL_ID; assert_eq!( - resolve_responses_model(uncensored, "tinfoil", ModelPlan::Paid).unwrap(), + resolve_responses_model( + uncensored, + "tinfoil", + ModelPlan::FREE.with_uncensored_override(true) + ) + .unwrap(), + uncensored + ); + let max = crate::billing::ChatBillingAccess::for_tests_with_plan(true, false, "max"); + assert_eq!( + resolve_responses_model( + uncensored, + "tinfoil", + ModelPlan::from_billing_access(Some(max)) + ) + .unwrap(), uncensored ); assert!(matches!( - resolve_responses_model(uncensored, "tinfoil", ModelPlan::Free), - Err(ApiError::ModelNotAvailableOnPlan) + resolve_responses_model(uncensored, "tinfoil", ModelPlan::PAID), + Err(ApiError::ModelUpgradeRequired { .. }) + )); + assert_eq!( + resolve_responses_model( + uncensored, + "tinfoil", + ModelPlan::PAID.with_uncensored_override(true) + ) + .unwrap(), + uncensored + ); + assert!(matches!( + resolve_responses_model(uncensored, "tinfoil", ModelPlan::FREE), + Err(ApiError::ModelUpgradeRequired { .. }) )); assert_eq!( - resolve_responses_model("kimi-k3", "tinfoil", ModelPlan::Paid).unwrap(), + resolve_responses_model("kimi-k3", "tinfoil", ModelPlan::PAID).unwrap(), "kimi-k3" ); assert!(matches!( - resolve_responses_model("kimi-k3", "tinfoil", ModelPlan::Free), + resolve_responses_model("kimi-k3", "tinfoil", ModelPlan::FREE), Err(ApiError::ModelNotAvailableOnPlan) )); assert!(matches!( - resolve_responses_model("glm-5-3", "tinfoil", ModelPlan::Free), + resolve_responses_model("glm-5-3", "tinfoil", ModelPlan::FREE), Err(ApiError::ModelNotAvailableOnPlan) )); assert_eq!( - resolve_responses_model("glm-5-3", "tinfoil", ModelPlan::Paid).unwrap(), + resolve_responses_model("glm-5-3", "tinfoil", ModelPlan::PAID).unwrap(), "glm-5-3" ); assert!(matches!( - resolve_responses_model("glm-5-3", "continuum", ModelPlan::Free), + resolve_responses_model("glm-5-3", "continuum", ModelPlan::FREE), Err(ApiError::ModelNotAvailableOnPlan) )); assert_eq!( - resolve_responses_model("glm-5-3", "continuum", ModelPlan::Paid).unwrap(), + resolve_responses_model("glm-5-3", "continuum", ModelPlan::PAID).unwrap(), "glm-5-3" ); assert!(matches!( - resolve_responses_model("glm-5-3-flash", "tinfoil", ModelPlan::Free), + resolve_responses_model("glm-5-3-flash", "tinfoil", ModelPlan::FREE), Err(ApiError::ModelNotAvailableOnPlan) )); assert_eq!( - resolve_responses_model("glm-5-3-flash", "tinfoil", ModelPlan::Paid).unwrap(), + resolve_responses_model("glm-5-3-flash", "tinfoil", ModelPlan::PAID).unwrap(), "glm-5-3-flash" ); assert!(matches!( - resolve_responses_model("deepseek-v4-1-flash", "tinfoil", ModelPlan::Free), + resolve_responses_model("deepseek-v4-1-flash", "tinfoil", ModelPlan::FREE), Err(ApiError::ModelNotAvailableOnPlan) )); assert_eq!( - resolve_responses_model("deepseek-v4-1-flash", "tinfoil", ModelPlan::Paid).unwrap(), + resolve_responses_model("deepseek-v4-1-flash", "tinfoil", ModelPlan::PAID).unwrap(), "deepseek-v4-1-flash" ); } @@ -2439,15 +2467,15 @@ mod tests { fn canonical_deepseek_responses_resolution_has_no_versioned_alias() { let model = crate::model_config::DEEPSEEK_V4_1_FLASH_MODEL_ID; assert_eq!( - resolve_responses_model(model, "tinfoil", ModelPlan::Paid).unwrap(), + resolve_responses_model(model, "tinfoil", ModelPlan::PAID).unwrap(), model ); assert!(matches!( - resolve_responses_model(model, "tinfoil", ModelPlan::Free), + resolve_responses_model(model, "tinfoil", ModelPlan::FREE), Err(ApiError::ModelNotAvailableOnPlan) )); assert!(matches!( - resolve_responses_model("deepseek-v4-1-flash-1017", "tinfoil", ModelPlan::Paid), + resolve_responses_model("deepseek-v4-1-flash-1017", "tinfoil", ModelPlan::PAID), Err(ApiError::BadRequest) )); } @@ -2455,7 +2483,7 @@ mod tests { #[test] fn test_resolve_responses_model_allows_free_model() { assert_eq!( - resolve_responses_model("llama3-3-70b", "tinfoil", ModelPlan::Free).unwrap(), + resolve_responses_model("llama3-3-70b", "tinfoil", ModelPlan::FREE).unwrap(), "llama3-3-70b" ); } @@ -2475,7 +2503,7 @@ mod tests { #[test] fn test_build_model_turn_request_uses_resolved_alias_target() { - let targets = ModelAliasTargets::for_plan(ModelPlan::Free); + let targets = ModelAliasTargets::for_plan(ModelPlan::FREE); let body = responses_request_for_model(targets.resolve(crate::model_config::AUTO_QUICK_MODEL_ID)); let chat_request = @@ -2498,28 +2526,28 @@ mod tests { let cases = [ Case { name: "free auto quick", - plan: ModelPlan::Free, + plan: ModelPlan::FREE, selector: crate::model_config::AUTO_QUICK_MODEL_ID, expected_model: crate::model_config::QUICK_MODEL_ID, expected_access: true, }, Case { name: "free auto powerful", - plan: ModelPlan::Free, + plan: ModelPlan::FREE, selector: crate::model_config::AUTO_POWERFUL_MODEL_ID, expected_model: crate::model_config::GLM_5_3_MODEL_ID, expected_access: false, }, Case { name: "paid auto quick", - plan: ModelPlan::Paid, + plan: ModelPlan::PAID, selector: crate::model_config::AUTO_QUICK_MODEL_ID, expected_model: crate::model_config::GLM_5_3_FLASH_MODEL_ID, expected_access: true, }, Case { name: "paid auto powerful", - plan: ModelPlan::Paid, + plan: ModelPlan::PAID, selector: crate::model_config::AUTO_POWERFUL_MODEL_ID, expected_model: crate::model_config::GLM_5_3_MODEL_ID, expected_access: true, @@ -2573,7 +2601,7 @@ mod tests { true ); - let targets = ModelAliasTargets::for_plan(ModelPlan::Paid); + let targets = ModelAliasTargets::for_plan(ModelPlan::PAID); let auto_powerful = responses_request_for_model( targets.resolve(crate::model_config::AUTO_POWERFUL_MODEL_ID), ); @@ -2732,39 +2760,39 @@ mod tests { #[test] fn test_web_search_tool_turn_limit_allows_max_then_errors() { assert_eq!( - web_search_tool_turn_limit(ModelPlan::Free), + web_search_tool_turn_limit(ModelPlan::FREE), MAX_WEB_SEARCH_TOOL_TURNS_FREE ); assert_eq!( - web_search_tool_turn_limit(ModelPlan::Paid), + web_search_tool_turn_limit(ModelPlan::PAID), MAX_WEB_SEARCH_TOOL_TURNS_PAID ); assert!(!web_search_tool_turn_limit_reached( MAX_WEB_SEARCH_TOOL_TURNS_FREE, - ModelPlan::Free + ModelPlan::FREE )); assert!(web_search_tool_turn_limit_reached( MAX_WEB_SEARCH_TOOL_TURNS_FREE + 1, - ModelPlan::Free + ModelPlan::FREE )); assert!(!web_search_tool_turn_limit_reached( MAX_WEB_SEARCH_TOOL_TURNS_PAID, - ModelPlan::Paid + ModelPlan::PAID )); assert!(web_search_tool_turn_limit_reached( MAX_WEB_SEARCH_TOOL_TURNS_PAID + 1, - ModelPlan::Paid + ModelPlan::PAID )); assert!(!web_search_tool_turn_limit_reached( MAX_WEB_SEARCH_TOOL_TURNS_FREE + 1, - ModelPlan::Paid + ModelPlan::PAID )); } #[test] fn test_web_search_tool_turn_limit_error_is_scoped_to_this_response() { - let free_error = web_search_tool_turn_limit_error(ModelPlan::Free); - let paid_error = web_search_tool_turn_limit_error(ModelPlan::Paid); + let free_error = web_search_tool_turn_limit_error(ModelPlan::FREE); + let paid_error = web_search_tool_turn_limit_error(ModelPlan::PAID); let formatted = crate::web::responses::tools::format_tool_result(Err(free_error.clone())); assert!(free_error.contains(&MAX_WEB_SEARCH_TOOL_TURNS_FREE.to_string())); @@ -2836,7 +2864,7 @@ mod tests { .single() .expect("valid UTC timestamp"); - let prompt = build_internal_system_prompt_for_now(now, true, ModelPlan::Paid); + let prompt = build_internal_system_prompt_for_now(now, true, ModelPlan::PAID); assert!(prompt.contains("Current UTC date: Wednesday, 2026-04-15.")); assert!(prompt.contains(&maple_kagi_web_search_prompt( @@ -2862,7 +2890,7 @@ mod tests { .single() .expect("valid UTC timestamp"); - let prompt = build_internal_system_prompt_for_now(now, true, ModelPlan::Free); + let prompt = build_internal_system_prompt_for_now(now, true, ModelPlan::FREE); assert!(prompt.contains(&maple_kagi_web_search_prompt( MAX_WEB_SEARCH_TOOL_TURNS_FREE @@ -2884,7 +2912,7 @@ mod tests { .single() .expect("valid UTC timestamp"); - let prompt = build_internal_system_prompt_for_now(now, false, ModelPlan::Paid); + let prompt = build_internal_system_prompt_for_now(now, false, ModelPlan::PAID); assert!(prompt.contains("Current UTC date: Wednesday, 2026-04-15.")); assert!(!prompt.contains(&maple_kagi_web_search_prompt( @@ -2900,7 +2928,7 @@ mod tests { .single() .expect("valid UTC timestamp"); - let prompt = build_internal_system_prompt_for_now(now, true, ModelPlan::Paid); + let prompt = build_internal_system_prompt_for_now(now, true, ModelPlan::PAID); assert!(prompt.contains(&maple_kagi_web_search_prompt( MAX_WEB_SEARCH_TOOL_TURNS_PAID @@ -4108,7 +4136,8 @@ fn image_description_api_error(error: ApiError) -> ImageDescriptionAttemptError ApiError::BadRequest | ApiError::Unauthorized | ApiError::ModelNotAvailable - | ApiError::ModelNotAvailableOnPlan => ImageDescriptionFailureClass::Terminal, + | ApiError::ModelNotAvailableOnPlan + | ApiError::ModelUpgradeRequired { .. } => ImageDescriptionFailureClass::Terminal, ApiError::ServiceUnavailable | ApiError::InferenceCapacity { .. } => { ImageDescriptionFailureClass::PreAcceptance } @@ -4407,7 +4436,7 @@ fn spawn_title_generation_task( user.uuid, "llama3-3-70b", "llama3-3-70b", - ModelPlan::Free, + ModelPlan::FREE, InferenceSurface::Internal, WorkloadClass::Background, ); @@ -5897,8 +5926,13 @@ async fn create_response_stream( )?; let requested_model = body.model.clone(); let billing_access = state.chat_billing_access(user.uuid, false).await; - let model_plan = - ModelPlan::from_is_paid(billing_access.is_some_and(ChatBillingAccess::is_paid)); + let model_plan = crate::web::openai::model_plan_for_request( + &state, + user.uuid, + ModelPlan::from_billing_access(billing_access), + &requested_model, + ) + .await; if user.is_guest() && !model_plan.is_paid() { error!( "Guest user without a paid plan attempted to use Responses API: {}", @@ -5922,7 +5956,6 @@ async fn create_response_stream( ); } body.model = resolved_model; - crate::web::openai::ensure_model_feature_access(&state, user.uuid, &body.model).await?; // Validate the caller's effort against the tier's own model before any // write. An explicit model rejects an unsupported level; an alias is moved // to the nearest level its resolved model accepts when the turn is built. @@ -6076,7 +6109,7 @@ async fn create_response_stream( &state, &user, &cache_policy, - routing.with_model_plan(ModelPlan::Paid), + routing.with_model_plan(ModelPlan::PAID), access, &prepared.image_attachments, ) @@ -6276,7 +6309,7 @@ async fn create_response_stream( context.conversation.uuid, prepared.user_key, MessageContentConverter::extract_text_for_token_counting(&prepared.message_content), - routing.with_model_plan(ModelPlan::Free), + routing.with_model_plan(ModelPlan::FREE), ) }); let assistant_message_id = prepared.assistant_message_id; diff --git a/services/opensecret/src/web/responses/image_describer.rs b/services/opensecret/src/web/responses/image_describer.rs index badd48ceb..c9147e67f 100644 --- a/services/opensecret/src/web/responses/image_describer.rs +++ b/services/opensecret/src/web/responses/image_describer.rs @@ -532,7 +532,7 @@ mod tests { uuid::Uuid::from_u128(bucket), candidate.public_model_id, candidate.public_model_id, - ModelPlan::Paid, + ModelPlan::PAID, InferenceSurface::Internal, WorkloadClass::Interactive, );