diff --git a/.changeset/add-mcp-server.md b/.changeset/add-mcp-server.md new file mode 100644 index 0000000..cc8a732 --- /dev/null +++ b/.changeset/add-mcp-server.md @@ -0,0 +1,7 @@ +--- +'@tokenometer/mcp': minor +'@tokenometer/core': minor +'tokenometer': minor +--- + +Add `@tokenometer/mcp` — Model Context Protocol server wrapping `@tokenometer/core`. Exposes 10 tools (cost estimation, token counting, model info, vision cost, budget check, latency benchmarking) over stdio so any MCP client (Claude Desktop, Cursor, Zed) can call tokenometer natively. Run with `npx -y @tokenometer/mcp`. diff --git a/.changeset/add-react-components.md b/.changeset/add-react-components.md new file mode 100644 index 0000000..f51b9a0 --- /dev/null +++ b/.changeset/add-react-components.md @@ -0,0 +1,5 @@ +--- +'@tokenometer/react': minor +--- + +Initial release of `@tokenometer/react` — drop-in React hooks and components for LLM token cost dashboards. Includes `useTokenCount`, `useCostMatrix`, `useBudget`, `useDebouncedTokenCount`, `useModelList`, `usePricing` hooks plus ``, ``, ``, ``, ``, ``, ``, `` components. Headless-first with opt-in `@tokenometer/react/styled` wrappers. SSR / RSC compatible via `"use client"` banner. Peer deps: react >=18, react-dom >=18, @tokenometer/core >=1.0.1. diff --git a/.changeset/config.json b/.changeset/config.json index 74a05c6..a9250c5 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", "changelog": ["@changesets/changelog-github", { "repo": "faraa2m/tokenometer" }], "commit": false, - "fixed": [["tokenometer", "@tokenometer/core"]], + "fixed": [["tokenometer", "@tokenometer/core", "@tokenometer/mcp"]], "linked": [], "access": "public", "baseBranch": "main", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ef1ee2..5627d7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,8 +98,10 @@ jobs: run: | CORE_VERSION=$(node -p "require('./packages/core/package.json').version") CLI_VERSION=$(node -p "require('./packages/cli/package.json').version") + MCP_VERSION=$(node -p "require('./packages/mcp/package.json').version") echo "core_version=$CORE_VERSION" >> $GITHUB_OUTPUT echo "cli_version=$CLI_VERSION" >> $GITHUB_OUTPUT + echo "mcp_version=$MCP_VERSION" >> $GITHUB_OUTPUT NEEDS_PUBLISH=false if ! npm view "@tokenometer/core@$CORE_VERSION" version >/dev/null 2>&1; then @@ -114,6 +116,12 @@ jobs: else echo "tokenometer@$CLI_VERSION already on npm — skip" fi + if ! npm view "@tokenometer/mcp@$MCP_VERSION" version >/dev/null 2>&1; then + echo "@tokenometer/mcp@$MCP_VERSION not on npm — will publish" + NEEDS_PUBLISH=true + else + echo "@tokenometer/mcp@$MCP_VERSION already on npm — skip" + fi echo "needs_publish=$NEEDS_PUBLISH" >> $GITHUB_OUTPUT - name: Publish @tokenometer/core @@ -134,6 +142,15 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish @tokenometer/mcp + id: publish_mcp + if: ${{ steps.detect.outputs.needs_publish == 'true' }} + working-directory: packages/mcp + continue-on-error: true + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Set `published` based on step outcomes, NOT registry re-check. # npm registry has propagation lag (1-30s), so re-querying right after # a successful publish would intermittently return "not found" and @@ -150,16 +167,17 @@ jobs: NEEDS_PUBLISH="${{ steps.detect.outputs.needs_publish }}" CORE_OUTCOME="${{ steps.publish_core.outcome }}" CLI_OUTCOME="${{ steps.publish_cli.outcome }}" + MCP_OUTCOME="${{ steps.publish_mcp.outcome }}" if [ "$NEEDS_PUBLISH" = "false" ]; then PUBLISHED=true - echo "Both packages already on npm → published=true (idempotent path)" - elif [ "$CORE_OUTCOME" = "success" ] && [ "$CLI_OUTCOME" = "success" ]; then + echo "All packages already on npm → published=true (idempotent path)" + elif [ "$CORE_OUTCOME" = "success" ] && [ "$CLI_OUTCOME" = "success" ] && [ "$MCP_OUTCOME" = "success" ]; then PUBLISHED=true - echo "Both publish steps succeeded → published=true" + echo "All publish steps succeeded → published=true" else PUBLISHED=false - echo "Publish gate failed: needs_publish=$NEEDS_PUBLISH core=$CORE_OUTCOME cli=$CLI_OUTCOME" + echo "Publish gate failed: needs_publish=$NEEDS_PUBLISH core=$CORE_OUTCOME cli=$CLI_OUTCOME mcp=$MCP_OUTCOME" fi echo "published=$PUBLISHED" >> $GITHUB_OUTPUT diff --git a/package-lock.json b/package-lock.json index 1f8b8a0..75d4f94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1660,6 +1660,18 @@ "node": ">=18.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@inquirer/external-editor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", @@ -1814,6 +1826,46 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -3353,6 +3405,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@tokenometer/mcp": { + "resolved": "packages/mcp", + "link": true + }, "node_modules/@tokenometer/react": { "resolved": "packages/react", "link": true @@ -3897,6 +3953,44 @@ "node": ">=6.5" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3936,7 +4030,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3949,6 +4042,23 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -4164,6 +4274,30 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4313,6 +4447,15 @@ "esbuild": ">=0.18" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4340,7 +4483,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4576,6 +4718,28 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4596,11 +4760,36 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4738,7 +4927,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4885,6 +5073,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -5051,6 +5248,12 @@ "url": "https://bevry.me/fund" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.353", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", @@ -5065,6 +5268,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", @@ -5262,6 +5474,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -5286,6 +5504,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -5295,6 +5522,27 @@ "node": ">=6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -5316,6 +5564,101 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extendable-error": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", @@ -5327,7 +5670,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5357,7 +5699,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, "funding": [ { "type": "github", @@ -5393,6 +5734,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -5492,6 +5854,24 @@ "node": ">= 12.20" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -5757,6 +6137,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -5836,6 +6226,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5887,7 +6297,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5971,9 +6380,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", @@ -5983,6 +6390,24 @@ "license": "ISC", "optional": true }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-ci": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", @@ -6094,6 +6519,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-subdir": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", @@ -6137,7 +6568,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istextorbinary": { @@ -6185,6 +6615,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -6323,9 +6762,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6880,6 +7324,27 @@ "dev": true, "license": "MIT" }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7087,6 +7552,15 @@ "license": "MIT", "optional": true }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", @@ -7267,7 +7741,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7277,7 +7750,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7296,6 +7768,18 @@ "node": ">= 0.4" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7534,6 +8018,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7548,7 +8041,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7581,6 +8073,16 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -7655,6 +8157,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -7823,6 +8334,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -7859,7 +8383,6 @@ "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -7909,6 +8432,30 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -8138,7 +8685,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8217,6 +8763,22 @@ "dev": true, "license": "MIT" }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/rrweb-cssom": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", @@ -8286,7 +8848,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sax": { @@ -8423,17 +8984,92 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -8446,7 +9082,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8456,7 +9091,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8476,7 +9110,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8493,7 +9126,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8512,7 +9144,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8726,6 +9357,15 @@ "fast-sha256": "^1.3.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -9188,6 +9828,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tokenlens": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tokenlens/-/tokenlens-1.3.1.tgz", @@ -9346,6 +9995,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typed-rest-client": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", @@ -9437,6 +10125,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -9494,6 +10191,15 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/version-range": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", @@ -9803,7 +10509,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -9929,7 +10634,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -9979,6 +10683,25 @@ "buffer-crc32": "~0.2.3" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "packages/action": { "name": "@tokenometer/action", "version": "1.0.2", @@ -10532,6 +11255,25 @@ "node": ">=18.0.0" } }, + "packages/mcp": { + "name": "@tokenometer/mcp", + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "@tokenometer/core": "1.0.1", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.0" + }, + "bin": { + "tokenometer-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^3.0.0" + } + }, "packages/react": { "name": "@tokenometer/react", "version": "0.1.0", @@ -11050,6 +11792,7 @@ "@anthropic-ai/sdk": "^0.40.0", "@google/generative-ai": "^0.21.0", "@tokenometer/core": "1.0.1", + "@tokenometer/react": "0.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.15.0" diff --git a/package.json b/package.json index 1685c9a..bf1677b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "workspaces": ["packages/*", "apps/*"], "scripts": { - "build": "tsc -b", + "build": "tsc -b && npm run build --workspaces --if-present", "clean": "tsc -b --clean && rm -rf packages/*/dist apps/*/dist", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/action/README.md b/packages/action/README.md index 2e485ed..7e0e7fd 100644 --- a/packages/action/README.md +++ b/packages/action/README.md @@ -46,15 +46,52 @@ jobs: | `base-ref` | _auto_ | Falls back to `origin/` for PRs, `HEAD~1` otherwise | | `comment-marker` | `` | Sticky comment HTML marker | | `top-n-files` | `5` | Rows shown in the "Top changed files" table (clamped to `1`–`20`). Files beyond N are folded into a `
` block | +| `code-paths` | `src/**/*.{ts,tsx,js,jsx,py}` | Globs scanned for inline LLM prompts when `code-detection` is enabled | +| `code-detection` | `off` | `off` (default) `annotations` `sdk-regex` or `both`. Default keeps behaviour identical for existing users | +| `prompt-marker-comment` | `@tokenometer-prompt` | Annotation marker for inline prompts | +| `comment-mode` | `single` | `single` appends the code section to the existing comment; `split` posts a second sticky | +| `top-n-prompts` | `5` | Rows shown in the code-embedded prompts table (clamped to `1`–`20`) | | `github-token` | `${{ github.token }}` | Needs `pull-requests: write` | ## Outputs | Name | Notes | |---|---| -| `cost-delta` | Total head − base cost in USD (8 decimals) | +| `cost-delta` | Total head − base cost in USD (8 decimals), file-based only | +| `code-cost-delta` | USD cost delta from code-embedded prompt changes (8 decimals) | +| `total-cost-delta` | File-based + code-embedded total. Budget gate uses this number | | `comment-url` | URL of the sticky comment | +## Code-Embedded Prompts (beta) + +Set `code-detection: both` to also flag inline prompts in source code. The default is `off`, so existing pipelines see no behaviour change until they opt in. + +### Annotation syntax + +```ts +// @tokenometer-prompt model=claude-opus-4-7 +const SYSTEM = `You are a helpful assistant...`; +``` + +```python +# @tokenometer-prompt model=gpt-4o +SYSTEM = "You are a helpful assistant..." +``` + +### Detected SDKs + +- Anthropic (`anthropic.messages.create`) +- OpenAI (`openai.chat.completions.create`, `openai.responses.create`) +- Google (`model.generateContent`) +- Mistral (`mistral.chat.complete` / `mistralClient.chat`) +- Cohere (`cohere.chat`) + +### Troubleshooting + +- Non-literal prompts (variables, template interpolation) are skipped with a warning — no false flag. +- Refactor that moves a call across files appears as delete + add. +- Tune false-positive rate with `code-detection: annotations` (annotations only). + ## Comment shape The sticky comment opens with the existing total-cost line and per-model summary, then appends a "Top changed files" Δ table. When more files changed than `top-n-files`, the rest are folded into a collapsible `
` block: diff --git a/packages/action/action.yml b/packages/action/action.yml index 4a0ad78..39d230b 100644 --- a/packages/action/action.yml +++ b/packages/action/action.yml @@ -33,13 +33,37 @@ inputs: description: 'Top-N changed files to show in the per-file diff table (1-20).' required: false default: '5' + code-paths: + description: 'Glob of source files to scan for inline LLM prompts. Newline or comma separated.' + required: false + default: 'src/**/*.ts,src/**/*.tsx,src/**/*.js,src/**/*.jsx,src/**/*.py' + code-detection: + description: 'Mode for code-embedded prompt detection: off | annotations | sdk-regex | both. Default off for backwards compat.' + required: false + default: 'off' + prompt-marker-comment: + description: 'Annotation prefix used to mark inline prompts in code (e.g., @tokenometer-prompt model=...).' + required: false + default: '@tokenometer-prompt' + comment-mode: + description: 'PR comment shape: single | split. Single appends to the existing cost-diff comment; split posts a second sticky.' + required: false + default: 'single' + top-n-prompts: + description: 'Top-N code-embedded prompts to show in the comment table (1-20).' + required: false + default: '5' github-token: description: 'GITHUB_TOKEN with `pull-requests: write`.' required: false default: ${{ github.token }} outputs: cost-delta: - description: 'Total cost delta in USD (head minus base).' + description: 'Total cost delta in USD (head minus base, file-based only).' + code-cost-delta: + description: 'USD cost delta from code-embedded prompt changes.' + total-cost-delta: + description: 'Total USD cost delta (file-based + code-embedded). Used by the budget gate.' comment-url: description: 'URL of the sticky comment.' runs: diff --git a/packages/action/dist/index.cjs b/packages/action/dist/index.cjs index 23b0f10..d9d1ccd 100644 --- a/packages/action/dist/index.cjs +++ b/packages/action/dist/index.cjs @@ -62607,6 +62607,513 @@ minimatch.Minimatch = Minimatch; minimatch.escape = escape2; minimatch.unescape = unescape2; +// src/detectors/extracted-prompt.ts +var import_node_crypto = require("node:crypto"); +var computeMatchId = (file, sdk, enclosingHint) => { + const hash = (0, import_node_crypto.createHash)("sha1"); + hash.update(file); + hash.update("\0"); + hash.update(sdk); + hash.update("\0"); + hash.update(enclosingHint); + return hash.digest("hex"); +}; +var FN_PATTERNS = [ + /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)/, + /^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s+)?\(/, + /^\s*(\w+)\s*[:=]\s*(?:async\s+)?\([^)]*\)\s*=>/, + /^\s*(?:async\s+)?def\s+(\w+)/, + /^\s*class\s+(\w+)/ +]; +var findEnclosingFunction = (lines, line) => { + const start = Math.max(0, Math.min(line - 1, lines.length - 1)); + for (let i = start; i >= 0; i--) { + const raw = lines[i]; + if (raw === void 0) continue; + for (const pattern of FN_PATTERNS) { + const match2 = pattern.exec(raw); + if (match2?.[1]) return match2[1]; + } + } + return "top-level"; +}; + +// src/detectors/annotation-detector.ts +var INTERP_PLACEHOLDER = "__INTERP__"; +var parseAnnotationMeta = (rest) => { + const out = {}; + const modelMatch = /\bmodel\s*[:=]\s*"?'?([\w.\-:/]+)/.exec(rest); + if (modelMatch?.[1]) out.model = modelMatch[1]; + return out; +}; +var findAnnotations = (lines, marker) => { + const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`${escaped}([^\\n\\r]*)`); + const hits = []; + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + if (raw === void 0) continue; + const idx = raw.indexOf(marker); + if (idx < 0) continue; + const prefix = raw.slice(0, idx); + const looksLikeComment = /(\/\/|#|\*|"""|''')/.test(prefix) || /^\s*\*/.test(raw) || /^\s*#/.test(raw); + if (!looksLikeComment) continue; + const match2 = re.exec(raw); + if (!match2) continue; + const meta = parseAnnotationMeta(match2[1] ?? ""); + const hit = { line: i + 1, col: idx + 1 }; + if (meta.model !== void 0) hit.model = meta.model; + hits.push(hit); + } + return hits; +}; +var stripCommentLine = (line) => line.replace(/\/\/.*$/, "").replace(/\/\*.*?\*\//g, "").replace(/^\s*\*.*$/, "").replace(/#.*$/, ""); +var findNextLiteral = (lines, startLine) => { + const windowEnd = Math.min(lines.length, startLine + 16); + for (let i = startLine; i < windowEnd; i++) { + const raw = lines[i]; + if (raw === void 0) continue; + const stripped = stripCommentLine(raw); + const tripleMatch = /(?:f|F|r|R|rb|br|b|B)?("""|''')/.exec(stripped); + if (tripleMatch) { + const quote = tripleMatch[1]; + const startCol = raw.indexOf(quote); + const startQuoteIdx = stripped.indexOf(quote); + const afterQuote = stripped.slice(startQuoteIdx + 3); + const closingIdx = afterQuote.indexOf(quote); + if (closingIdx >= 0) { + const text = afterQuote.slice(0, closingIdx).replace(/\{[^}]*\}/g, INTERP_PLACEHOLDER); + return { line: i + 1, col: startCol + 1, text }; + } + const buf = [afterQuote]; + for (let j = i + 1; j < lines.length; j++) { + const next = lines[j]; + if (next === void 0) continue; + const closeIdx = next.indexOf(quote); + if (closeIdx >= 0) { + buf.push(next.slice(0, closeIdx)); + const text = buf.join("\n").replace(/\{[^}]*\}/g, INTERP_PLACEHOLDER); + return { line: i + 1, col: startCol + 1, text }; + } + buf.push(next); + } + return null; + } + const backtickIdx = stripped.indexOf("`"); + if (backtickIdx >= 0) { + const fromBacktick = stripped.slice(backtickIdx + 1); + const closeIdx = fromBacktick.indexOf("`"); + if (closeIdx >= 0) { + const text = fromBacktick.slice(0, closeIdx).replace(/\$\{[^}]*\}/g, INTERP_PLACEHOLDER); + return { line: i + 1, col: backtickIdx + 1, text }; + } + const buf = [fromBacktick]; + for (let j = i + 1; j < lines.length; j++) { + const next = lines[j]; + if (next === void 0) continue; + const ci = next.indexOf("`"); + if (ci >= 0) { + buf.push(next.slice(0, ci)); + const text = buf.join("\n").replace(/\$\{[^}]*\}/g, INTERP_PLACEHOLDER); + return { line: i + 1, col: backtickIdx + 1, text }; + } + buf.push(next); + } + return null; + } + const literal = scanSingleQuotedLiteral(stripped); + if (literal) { + const colOffset = raw.indexOf(literal.raw); + const col = colOffset >= 0 ? colOffset + 1 : 1; + const text = literal.isFString ? literal.text.replace(/\{[^}]*\}/g, INTERP_PLACEHOLDER) : literal.text; + return { line: i + 1, col, text }; + } + } + return null; +}; +var scanSingleQuotedLiteral = (line) => { + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch !== '"' && ch !== "'") continue; + const prev = i > 0 ? line[i - 1] : ""; + const isFString = prev === "f" || prev === "F"; + let j = i + 1; + let escaped = false; + while (j < line.length) { + const c = line[j]; + if (escaped) { + escaped = false; + j++; + continue; + } + if (c === "\\") { + escaped = true; + j++; + continue; + } + if (c === ch) { + const text = line.slice(i + 1, j); + const raw = line.slice(i, j + 1); + return { raw, text, isFString }; + } + j++; + } + } + return null; +}; +var detectAnnotations = (content, file, options) => { + const lines = content.split("\n"); + const hits = findAnnotations(lines, options.marker); + const out = []; + for (const hit of hits) { + const literal = findNextLiteral(lines, hit.line); + if (!literal) continue; + const enclosing = findEnclosingFunction(lines, literal.line); + const matchId = computeMatchId(file, "annotation", enclosing); + const entry = { + file, + line: literal.line, + col: literal.col, + text: literal.text, + source: "annotation", + matchId + }; + if (hit.model !== void 0) entry.model = hit.model; + out.push(entry); + } + return out; +}; + +// src/detectors/sdk-regex-detector.ts +var SDK_PATTERNS = [ + { + sdk: "anthropic", + regex: /\b(?:anthropic|client)\s*\.\s*messages\s*\.\s*create\s*\(\s*\{([\s\S]*?)\}\s*\)/g, + modelHintRegex: /model\s*[:=]\s*['"]([^'"]+)['"]/ + }, + { + sdk: "openai", + regex: /\b(?:openai|client)\s*\.\s*chat\s*\.\s*completions\s*\.\s*create\s*\(\s*\{([\s\S]*?)\}\s*\)/g, + modelHintRegex: /model\s*[:=]\s*['"]([^'"]+)['"]/ + }, + { + sdk: "openai", + regex: /\b(?:openai|client)\s*\.\s*responses\s*\.\s*create\s*\(\s*\{([\s\S]*?)\}\s*\)/g, + modelHintRegex: /model\s*[:=]\s*['"]([^'"]+)['"]/ + }, + { + sdk: "google", + regex: /\bmodel\s*\.\s*generateContent\s*\(\s*\{([\s\S]*?)\}\s*\)/g + }, + { + sdk: "mistral", + regex: /\b(?:mistralClient|mistral|client)\s*\.\s*chat(?:\s*\.\s*complete)?\s*\(\s*\{([\s\S]*?)\}\s*\)/g, + modelHintRegex: /model\s*[:=]\s*['"]([^'"]+)['"]/ + }, + { + sdk: "cohere", + regex: /\b(?:cohere|cohereClient|client)\s*\.\s*chat\s*\(\s*\{([\s\S]*?)\}\s*\)/g, + modelHintRegex: /model\s*[:=]\s*['"]([^'"]+)['"]/ + } +]; +var KEYS_FOR_PROMPT = ["system", "prompt", "content", "text", "contents"]; +var INTERP_PLACEHOLDER2 = "__INTERP__"; +var extractKeyLiteral = (body, key) => { + const keyRe = new RegExp(`\\b${key}\\s*[:=]\\s*`, "g"); + let match2; + while ((match2 = keyRe.exec(body)) !== null) { + const start = match2.index + match2[0].length; + const rest = body.slice(start); + const literal = readStringLiteral(rest); + if (literal) return { text: literal, isLiteral: true }; + const next = rest.trim(); + if (next.length === 0) continue; + if (next.startsWith("[") || next.startsWith("{")) continue; + return { text: "", isLiteral: false }; + } + return null; +}; +var readStringLiteral = (text) => { + let i = 0; + while (i < text.length && /\s/.test(text[i] ?? "")) i++; + const quote = text[i]; + if (quote !== '"' && quote !== "'" && quote !== "`") return null; + let j = i + 1; + let escaped = false; + while (j < text.length) { + const c = text[j]; + if (escaped) { + escaped = false; + j++; + continue; + } + if (c === "\\") { + escaped = true; + j++; + continue; + } + if (c === quote) { + const raw = text.slice(i + 1, j); + if (quote === "`") return raw.replace(/\$\{[^}]*\}/g, INTERP_PLACEHOLDER2); + return raw; + } + j++; + } + return null; +}; +var extractMessagesContents = (body) => { + const literals = []; + let nonLiteralCount = 0; + const re = /\bmessages\s*[:=]\s*\[([\s\S]*?)\]/g; + let match2; + while ((match2 = re.exec(body)) !== null) { + const arr = match2[1] ?? ""; + const contentRe = /\bcontent\s*[:=]\s*/g; + let cMatch; + while ((cMatch = contentRe.exec(arr)) !== null) { + const after = arr.slice(cMatch.index + cMatch[0].length); + const literal = readStringLiteral(after); + if (literal !== null) literals.push(literal); + else nonLiteralCount++; + } + } + return { literals, nonLiteralCount }; +}; +var lineColForOffset = (content, offset) => { + let line = 1; + let lastNewline = -1; + for (let i = 0; i < offset && i < content.length; i++) { + if (content[i] === "\n") { + line++; + lastNewline = i; + } + } + return { line, col: offset - lastNewline }; +}; +var detectSdkPrompts = (content, file) => { + const lines = content.split("\n"); + const prompts = []; + const nonLiteralLocations = []; + for (const pattern of SDK_PATTERNS) { + pattern.regex.lastIndex = 0; + let match2; + while ((match2 = pattern.regex.exec(content)) !== null) { + const body = match2[1] ?? ""; + const { line, col } = lineColForOffset(content, match2.index); + const enclosing = findEnclosingFunction(lines, line); + const matchId = computeMatchId(file, pattern.sdk, enclosing); + const modelHint = pattern.modelHintRegex?.exec(body)?.[1]; + let foundLiteral = false; + let sawNonLiteral = false; + for (const key of KEYS_FOR_PROMPT) { + const extracted = extractKeyLiteral(body, key); + if (!extracted) continue; + if (!extracted.isLiteral) { + sawNonLiteral = true; + continue; + } + foundLiteral = true; + const entry = { + file, + line, + col, + text: extracted.text, + source: "sdk-regex", + sdk: pattern.sdk, + matchId + }; + if (modelHint !== void 0) entry.model = modelHint; + prompts.push(entry); + } + const msgs = extractMessagesContents(body); + for (const text of msgs.literals) { + foundLiteral = true; + const entry = { + file, + line, + col, + text, + source: "sdk-regex", + sdk: pattern.sdk, + matchId + }; + if (modelHint !== void 0) entry.model = modelHint; + prompts.push(entry); + } + if (msgs.nonLiteralCount > 0) sawNonLiteral = true; + if (!foundLiteral && sawNonLiteral) { + nonLiteralLocations.push({ file, line, sdk: pattern.sdk }); + } + } + } + return { prompts, nonLiteralLocations }; +}; + +// src/detectors/index.ts +var detectPrompts = (content, file, mode, marker) => { + if (mode === "off") return { prompts: [], nonLiteralLocations: [] }; + const prompts = []; + let nonLiteralLocations = []; + if (mode === "annotations" || mode === "both") { + prompts.push(...detectAnnotations(content, file, { marker })); + } + if (mode === "sdk-regex" || mode === "both") { + const sdkResult = detectSdkPrompts(content, file); + prompts.push(...sdkResult.prompts); + nonLiteralLocations = sdkResult.nonLiteralLocations; + } + return { prompts, nonLiteralLocations }; +}; +var SKIP_GLOBS = [ + /(^|\/)node_modules\//, + /(^|\/)dist\//, + /(^|\/)build\//, + /(^|\/)\.next\//, + /(^|\/)vendor\//, + /\.min\.(js|ts)$/ +]; +var MAX_FILE_BYTES = 2e5; +var MAX_AVG_LINE_LEN = 500; +var shouldSkipFile = (path3, content) => { + for (const re of SKIP_GLOBS) { + if (re.test(path3)) return true; + } + if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) return true; + const lines = content.split("\n"); + if (lines.length === 0) return false; + const avg = content.length / lines.length; + if (avg > MAX_AVG_LINE_LEN) return true; + return false; +}; + +// src/measure-code.ts +var FALLBACK_MATCH_RATIO = 0.6; +var levenshtein = (a, b) => { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + const lenA = a.length; + const lenB = b.length; + let prev = new Array(lenB + 1); + let curr = new Array(lenB + 1); + for (let j = 0; j <= lenB; j++) prev[j] = j; + for (let i = 1; i <= lenA; i++) { + curr[0] = i; + for (let j = 1; j <= lenB; j++) { + const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1; + curr[j] = Math.min((curr[j - 1] ?? 0) + 1, (prev[j] ?? 0) + 1, (prev[j - 1] ?? 0) + cost); + } + [prev, curr] = [curr, prev]; + } + return prev[lenB] ?? 0; +}; +var similarity = (a, b) => { + if (a.length === 0 && b.length === 0) return 1; + const dist = levenshtein(a, b); + const max = Math.max(a.length, b.length); + if (max === 0) return 1; + return 1 - dist / max; +}; +var pickModel = (prompt, defaultModels) => { + if (prompt.model) return prompt.model; + const first = defaultModels[0]; + if (!first) throw new Error("measureExtractedPrompts: defaultModels must have at least 1 entry"); + return first; +}; +var measureCost = (text, modelId, format) => { + if (text.length === 0) return { tokens: 0, cost: 0 }; + try { + const r = tokenize2({ format, modelId, prompt: text }); + return { tokens: r.inputTokens, cost: r.inputCost }; + } catch { + return { tokens: 0, cost: 0 }; + } +}; +var formatLocation = (p) => `${p.file}:${p.line}`; +var pairPrompts = (base, head) => { + const pairs = []; + const baseByMatchId = /* @__PURE__ */ new Map(); + for (const p of base) { + const list = baseByMatchId.get(p.matchId) ?? []; + list.push(p); + baseByMatchId.set(p.matchId, list); + } + const headUnmatched = []; + for (const h of head) { + const candidates = baseByMatchId.get(h.matchId); + if (candidates && candidates.length > 0) { + const b = candidates.shift(); + if (b) { + pairs.push({ base: b, head: h }); + continue; + } + } + headUnmatched.push(h); + } + const baseUnmatched = []; + for (const list of baseByMatchId.values()) baseUnmatched.push(...list); + const headByFile = /* @__PURE__ */ new Map(); + for (const h of headUnmatched) { + const list = headByFile.get(h.file) ?? []; + list.push(h); + headByFile.set(h.file, list); + } + for (const b of baseUnmatched) { + const candidates = headByFile.get(b.file); + if (!candidates || candidates.length === 0) { + pairs.push({ base: b, head: null }); + continue; + } + let bestIdx = -1; + let bestScore = FALLBACK_MATCH_RATIO; + for (let i = 0; i < candidates.length; i++) { + const h = candidates[i]; + if (!h) continue; + const score = similarity(b.text, h.text); + if (score >= bestScore) { + bestScore = score; + bestIdx = i; + } + } + if (bestIdx >= 0) { + const h = candidates.splice(bestIdx, 1)[0]; + if (h) { + pairs.push({ base: b, head: h }); + continue; + } + } + pairs.push({ base: b, head: null }); + } + for (const list of headByFile.values()) { + for (const h of list) pairs.push({ base: null, head: h }); + } + return pairs; +}; +var measureExtractedPrompts = (base, head, defaultModels, _formats) => { + if (defaultModels.length === 0) return []; + const format = "text"; + const pairs = pairPrompts(base, head); + const rows = []; + for (const pair of pairs) { + const sample = pair.head ?? pair.base; + if (!sample) continue; + const model = pickModel(sample, defaultModels); + const headMeasure = pair.head ? measureCost(pair.head.text, model, format) : { tokens: 0, cost: 0 }; + const baseMeasure = pair.base ? measureCost(pair.base.text, model, format) : { tokens: 0, cost: 0 }; + const location = formatLocation(sample); + rows.push({ + location, + model, + baseTokens: baseMeasure.tokens, + headTokens: headMeasure.tokens, + baseCost: baseMeasure.cost, + headCost: headMeasure.cost, + costDelta: headMeasure.cost - baseMeasure.cost + }); + } + return rows; +}; + // src/per-file-diff.ts var sumTokens = (cells) => cells.reduce((acc, c) => acc + c.tokens, 0); var sumCost = (cells) => cells.reduce((acc, c) => acc + c.cost, 0); @@ -62681,7 +63188,45 @@ var renderPerFileMarkdown = (result) => { return lines.join("\n"); }; +// src/render-code-section.ts +var TABLE_HEADER2 = ["| Location | Model | Tokens \u0394 | USD \u0394 |", "|---|---|---:|---:|"]; +var renderRow2 = (row) => { + const tokensDelta = row.headTokens - row.baseTokens; + return `| \`${row.location}\` | ${row.model} | ${formatTokensDelta(tokensDelta)} | ${formatUsdDelta(row.costDelta)} |`; +}; +var sortRows = (rows) => [...rows].sort((a, b) => { + const absCost = Math.abs(b.costDelta) - Math.abs(a.costDelta); + if (absCost !== 0) return absCost; + const absTok = Math.abs(b.headTokens - b.baseTokens) - Math.abs(a.headTokens - a.baseTokens); + if (absTok !== 0) return absTok; + return a.location.localeCompare(b.location); +}); +var renderCodeSection = (rows, topN) => { + if (rows.length === 0) return ""; + const clampedTopN = Math.max(1, Math.min(20, Math.trunc(topN))); + const sorted = sortRows(rows); + const top = sorted.slice(0, clampedTopN); + const lines = []; + lines.push(`### Code-Embedded Prompts (${sorted.length})`); + lines.push(""); + lines.push(...TABLE_HEADER2); + for (const row of top) lines.push(renderRow2(row)); + if (sorted.length > top.length) { + lines.push(""); + lines.push(`
All ${sorted.length} prompts`); + lines.push(""); + lines.push(...TABLE_HEADER2); + for (const row of sorted) lines.push(renderRow2(row)); + lines.push(""); + lines.push("
"); + } + return lines.join("\n"); +}; + // src/index.ts +var CODE_DETECTION_MODES = ["off", "annotations", "sdk-regex", "both"]; +var isCodeDetectionMode = (s) => CODE_DETECTION_MODES.includes(s); +var isCommentMode = (s) => s === "single" || s === "split"; var readInputs = () => { const paths = core.getInput("paths").split(/[\n,]/).map((s) => s.trim()).filter(Boolean); const modelIds = core.getInput("models").split(",").map((s) => s.trim()).filter(Boolean); @@ -62707,15 +63252,40 @@ var readInputs = () => { throw new Error(`top-n-files must be an integer 1-20, got "${topNRaw}"`); } const topNFiles = Math.max(1, Math.min(20, topNParsed)); + const codePaths = core.getInput("code-paths").split(/[\n,]/).map((s) => s.trim()).filter(Boolean); + const codeDetectionRaw = core.getInput("code-detection").trim() || "off"; + if (!isCodeDetectionMode(codeDetectionRaw)) { + throw new Error( + `code-detection must be one of ${CODE_DETECTION_MODES.join(", ")}, got "${codeDetectionRaw}"` + ); + } + const codeDetection = codeDetectionRaw; + const promptMarkerComment = core.getInput("prompt-marker-comment").trim() || "@tokenometer-prompt"; + const commentModeRaw = core.getInput("comment-mode").trim() || "single"; + if (!isCommentMode(commentModeRaw)) { + throw new Error(`comment-mode must be 'single' or 'split', got "${commentModeRaw}"`); + } + const commentMode = commentModeRaw; + const topNPromptsRaw = core.getInput("top-n-prompts").trim(); + const topNPromptsParsed = topNPromptsRaw === "" ? 5 : Number.parseInt(topNPromptsRaw, 10); + if (!Number.isFinite(topNPromptsParsed)) { + throw new Error(`top-n-prompts must be an integer 1-20, got "${topNPromptsRaw}"`); + } + const topNPrompts = Math.max(1, Math.min(20, topNPromptsParsed)); return { baseRef: core.getInput("base-ref").trim(), budget, + codeDetection, + codePaths, commentMarker: core.getInput("comment-marker"), + commentMode, formats, githubToken: core.getInput("github-token"), modelIds, paths, - topNFiles + promptMarkerComment, + topNFiles, + topNPrompts }; }; var resolveBaseRef = async (input) => { @@ -62788,7 +63358,8 @@ var formatDelta = (delta) => { const sign = delta > 0 ? "+" : "\u2212"; return `${sign}${formatCost(Math.abs(delta))}`; }; -var renderMarkdown = (marker, results, models, formats, budget, topNFiles) => { +var renderMarkdown = (marker, results, models, formats, budget, topNFiles, opts = {}) => { + const renderBudget = opts.renderBudget ?? true; const totalHead = results.reduce((acc, r) => acc + sumCost2(r.head), 0); const totalBase = results.reduce((acc, r) => acc + (r.base ? sumCost2(r.base) : 0), 0); const totalDelta = totalHead - totalBase; @@ -62837,7 +63408,7 @@ var renderMarkdown = (marker, results, models, formats, budget, topNFiles) => { lines.push(perFileMd); } } - if (budget !== null) { + if (budget !== null && renderBudget) { const ok = totalDelta <= budget; lines.push(""); lines.push( @@ -62883,6 +63454,57 @@ var upsertStickyComment = async (token, marker, body) => { }); return created.data.html_url; }; +var collectCodePrompts = async (baseRef, inputs) => { + if (inputs.codeDetection === "off") { + return { rows: [], section: "", delta: 0 }; + } + const changedCode = await matchPaths(baseRef, inputs.codePaths); + core.info( + `Changed code files (for inline-prompt scan): ${changedCode.length === 0 ? "(none)" : changedCode.join(", ")}` + ); + const baseExtracted = []; + const headExtracted = []; + for (const path3 of changedCode) { + const headContent = await import_node_fs.promises.readFile((0, import_node_path.resolve)(path3), "utf8").catch(() => null); + if (headContent !== null && !shouldSkipFile(path3, headContent)) { + const result = detectPrompts( + headContent, + path3, + inputs.codeDetection, + inputs.promptMarkerComment + ); + headExtracted.push(...result.prompts); + for (const loc of result.nonLiteralLocations) { + core.warning(`prompt at ${loc.file}:${loc.line} (${loc.sdk}) is non-literal \u2014 skipping`); + } + } + const baseContent = await readFileAt(baseRef, path3); + if (baseContent !== null && !shouldSkipFile(path3, baseContent)) { + const result = detectPrompts( + baseContent, + path3, + inputs.codeDetection, + inputs.promptMarkerComment + ); + baseExtracted.push(...result.prompts); + } + } + const rows = measureExtractedPrompts( + baseExtracted, + headExtracted, + inputs.modelIds, + inputs.formats + ); + const delta = rows.reduce((acc, r) => acc + r.costDelta, 0); + const section = renderCodeSection(rows, inputs.topNPrompts); + return { rows, section, delta }; +}; +var composeBudgetLine = (budget, totalDelta) => { + if (budget === null) return ""; + const ok = totalDelta <= budget; + return `${ok ? "\u2705" : "\u274C"} Budget: ${formatCost(budget)} \xB7 \u0394 ${ok ? "within" : "exceeds"} budget.`; +}; +var CODE_COMMENT_MARKER = ""; var run = async () => { try { const inputs = readInputs(); @@ -62904,18 +63526,66 @@ var run = async () => { path: path3 }); } - const { body, totalDelta } = renderMarkdown( + const codeResult = await collectCodePrompts(baseRef, inputs); + const splitMode = inputs.commentMode === "split" && inputs.codeDetection !== "off"; + const { body: fileBody, totalDelta: filesCostDelta } = renderMarkdown( inputs.commentMarker, results, inputs.modelIds, inputs.formats, inputs.budget, - inputs.topNFiles + inputs.topNFiles, + { renderBudget: splitMode } ); - const commentUrl = await upsertStickyComment(inputs.githubToken, inputs.commentMarker, body); - core.setOutput("cost-delta", totalDelta.toFixed(8)); + const totalDelta = filesCostDelta + codeResult.delta; + let mainBody = fileBody; + if (!splitMode) { + const trailer = []; + if (codeResult.section) { + trailer.push(""); + trailer.push(codeResult.section); + } + if (codeResult.rows.length > 0) { + trailer.push(""); + trailer.push( + `**Total \u0394 (files + code-embedded):** ${formatDelta(totalDelta)} (files ${formatDelta(filesCostDelta)}, code ${formatDelta(codeResult.delta)})` + ); + } + const budgetLine = composeBudgetLine(inputs.budget, totalDelta); + if (budgetLine) { + trailer.push(""); + trailer.push(budgetLine); + } + mainBody = `${fileBody}${trailer.length > 0 ? ` +${trailer.join("\n")}` : ""}`; + } + const commentUrl = await upsertStickyComment( + inputs.githubToken, + inputs.commentMarker, + mainBody + ); + if (splitMode && codeResult.section) { + const codeBudgetLine = composeBudgetLine(inputs.budget, totalDelta); + const codeBodyLines = []; + codeBodyLines.push(CODE_COMMENT_MARKER); + codeBodyLines.push("## tokenometer \xB7 code-embedded prompts"); + codeBodyLines.push(""); + codeBodyLines.push(codeResult.section); + codeBodyLines.push(""); + codeBodyLines.push( + `**Total \u0394 (files + code-embedded):** ${formatDelta(totalDelta)} (files ${formatDelta(filesCostDelta)}, code ${formatDelta(codeResult.delta)})` + ); + if (codeBudgetLine) { + codeBodyLines.push(""); + codeBodyLines.push(codeBudgetLine); + } + await upsertStickyComment(inputs.githubToken, CODE_COMMENT_MARKER, codeBodyLines.join("\n")); + } + core.setOutput("cost-delta", filesCostDelta.toFixed(8)); + core.setOutput("code-cost-delta", codeResult.delta.toFixed(8)); + core.setOutput("total-cost-delta", totalDelta.toFixed(8)); if (commentUrl) core.setOutput("comment-url", commentUrl); - core.summary.addRaw(body).write(); + core.summary.addRaw(mainBody).write(); if (inputs.budget !== null && totalDelta > inputs.budget) { core.setFailed( `Cost delta ${formatCost(totalDelta)} exceeds budget ${formatCost(inputs.budget)}` diff --git a/packages/action/package.json b/packages/action/package.json index 30f0fc3..c795ab9 100644 --- a/packages/action/package.json +++ b/packages/action/package.json @@ -1,6 +1,6 @@ { "name": "@tokenometer/action", - "version": "1.0.2", + "version": "1.1.0", "description": "Tokenometer GitHub Action — sticky PR comment with prompt-cost diff, per-file attribution, and budget gate.", "license": "MIT", "private": true, diff --git a/packages/action/src/__snapshots__/render-code-section.test.ts.snap b/packages/action/src/__snapshots__/render-code-section.test.ts.snap new file mode 100644 index 0000000..18a60df --- /dev/null +++ b/packages/action/src/__snapshots__/render-code-section.test.ts.snap @@ -0,0 +1,10 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderCodeSection > matches snapshot for canonical fixture 1`] = ` +"### Code-Embedded Prompts (2) + +| Location | Model | Tokens Δ | USD Δ | +|---|---|---:|---:| +| \`src/router.ts:42\` | claude-opus-4-7 | +120 | +$0.001800 | +| \`src/agent.ts:10\` | gpt-4o | −80 | −$0.000800 |" +`; diff --git a/packages/action/src/detectors/annotation-detector.test.ts b/packages/action/src/detectors/annotation-detector.test.ts new file mode 100644 index 0000000..62b0633 --- /dev/null +++ b/packages/action/src/detectors/annotation-detector.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { detectAnnotations } from './annotation-detector.js'; + +const MARKER = '@tokenometer-prompt'; + +describe('detectAnnotations — JS/TS', () => { + it('finds a single-line // annotation followed by a backtick literal', () => { + const src = [ + '// @tokenometer-prompt model=claude-opus-4-7', + 'const SYSTEM = `You are a helpful assistant.`;', + ].join('\n'); + const hits = detectAnnotations(src, 'src/a.ts', { marker: MARKER }); + expect(hits).toHaveLength(1); + const hit = hits[0]; + if (!hit) throw new Error('expected hit'); + expect(hit.text).toBe('You are a helpful assistant.'); + expect(hit.model).toBe('claude-opus-4-7'); + expect(hit.source).toBe('annotation'); + expect(hit.file).toBe('src/a.ts'); + }); + + it('finds a /* */ block annotation', () => { + const src = ['/* @tokenometer-prompt model=gpt-4o */', 'const P = "hi";'].join('\n'); + const hits = detectAnnotations(src, 'src/b.ts', { marker: MARKER }); + expect(hits).toHaveLength(1); + expect(hits[0]?.text).toBe('hi'); + expect(hits[0]?.model).toBe('gpt-4o'); + }); + + it('collapses ${...} interpolation in template literals', () => { + const src = [ + '// @tokenometer-prompt model=gpt-4o', + 'const P = `Hello ${name}, welcome to ${app}.`;', + ].join('\n'); + const hits = detectAnnotations(src, 'src/c.ts', { marker: MARKER }); + expect(hits[0]?.text).toBe('Hello __INTERP__, welcome to __INTERP__.'); + }); + + it('handles multi-line template literals', () => { + const src = ['// @tokenometer-prompt', 'const P = `line one', 'line two', 'end`;'].join('\n'); + const hits = detectAnnotations(src, 'src/d.ts', { marker: MARKER }); + expect(hits[0]?.text).toContain('line one'); + expect(hits[0]?.text).toContain('end'); + }); + + it('skips annotations with no nearby literal', () => { + const src = [ + '// @tokenometer-prompt model=gpt-4o', + '// just another comment', + 'doSomething();', + 'anotherThing();', + ].join('\n'); + const hits = detectAnnotations(src, 'src/e.ts', { marker: MARKER }); + expect(hits).toHaveLength(0); + }); + + it('ignores marker appearing inside string literals', () => { + const src = ['const x = "@tokenometer-prompt is documented";'].join('\n'); + const hits = detectAnnotations(src, 'src/f.ts', { marker: MARKER }); + expect(hits).toHaveLength(0); + }); +}); + +describe('detectAnnotations — Python', () => { + it('finds a # annotation followed by a normal string', () => { + const src = [ + '# @tokenometer-prompt model=gpt-4o', + 'SYSTEM = "You are a helpful assistant."', + ].join('\n'); + const hits = detectAnnotations(src, 'svc/agent.py', { marker: MARKER }); + expect(hits).toHaveLength(1); + expect(hits[0]?.text).toBe('You are a helpful assistant.'); + expect(hits[0]?.model).toBe('gpt-4o'); + }); + + it('handles f-string interpolation', () => { + const src = ['# @tokenometer-prompt', 'P = f"Hi {name}, today is {day}"'].join('\n'); + const hits = detectAnnotations(src, 'svc/b.py', { marker: MARKER }); + expect(hits[0]?.text).toBe('Hi __INTERP__, today is __INTERP__'); + }); + + it('handles triple-quoted docstring literals', () => { + const src = ['# @tokenometer-prompt', 'P = """line a', 'line b', '"""'].join('\n'); + const hits = detectAnnotations(src, 'svc/c.py', { marker: MARKER }); + expect(hits[0]?.text).toContain('line a'); + expect(hits[0]?.text).toContain('line b'); + }); +}); + +describe('detectAnnotations — matchId stability', () => { + it('keeps the same matchId when prompt text changes within the same function', () => { + const before = [ + 'function router() {', + ' // @tokenometer-prompt', + ' const P = "hello";', + '}', + ].join('\n'); + const after = [ + 'function router() {', + ' // @tokenometer-prompt', + ' const P = "hi there";', + '}', + ].join('\n'); + const a = detectAnnotations(before, 'src/r.ts', { marker: MARKER }); + const b = detectAnnotations(after, 'src/r.ts', { marker: MARKER }); + expect(a[0]?.matchId).toBe(b[0]?.matchId); + }); +}); diff --git a/packages/action/src/detectors/annotation-detector.ts b/packages/action/src/detectors/annotation-detector.ts new file mode 100644 index 0000000..7418fa8 --- /dev/null +++ b/packages/action/src/detectors/annotation-detector.ts @@ -0,0 +1,190 @@ +import { computeMatchId, findEnclosingFunction } from './extracted-prompt.js'; +import type { ExtractedPrompt } from './extracted-prompt.js'; + +const INTERP_PLACEHOLDER = '__INTERP__'; + +interface AnnotationHit { + line: number; + col: number; + model?: string; +} + +const parseAnnotationMeta = (rest: string): { model?: string } => { + const out: { model?: string } = {}; + const modelMatch = /\bmodel\s*[:=]\s*"?'?([\w.\-:/]+)/.exec(rest); + if (modelMatch?.[1]) out.model = modelMatch[1]; + return out; +}; + +const findAnnotations = (lines: readonly string[], marker: string): AnnotationHit[] => { + const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`${escaped}([^\\n\\r]*)`); + const hits: AnnotationHit[] = []; + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + if (raw === undefined) continue; + const idx = raw.indexOf(marker); + if (idx < 0) continue; + const prefix = raw.slice(0, idx); + const looksLikeComment = + /(\/\/|#|\*|"""|''')/.test(prefix) || /^\s*\*/.test(raw) || /^\s*#/.test(raw); + if (!looksLikeComment) continue; + const match = re.exec(raw); + if (!match) continue; + const meta = parseAnnotationMeta(match[1] ?? ''); + const hit: AnnotationHit = { line: i + 1, col: idx + 1 }; + if (meta.model !== undefined) hit.model = meta.model; + hits.push(hit); + } + return hits; +}; + +const stripCommentLine = (line: string): string => + line + .replace(/\/\/.*$/, '') + .replace(/\/\*.*?\*\//g, '') + .replace(/^\s*\*.*$/, '') + .replace(/#.*$/, ''); + +interface ExtractedLiteral { + line: number; + col: number; + text: string; +} + +const findNextLiteral = (lines: readonly string[], startLine: number): ExtractedLiteral | null => { + const windowEnd = Math.min(lines.length, startLine + 16); + for (let i = startLine; i < windowEnd; i++) { + const raw = lines[i]; + if (raw === undefined) continue; + const stripped = stripCommentLine(raw); + + const tripleMatch = /(?:f|F|r|R|rb|br|b|B)?("""|''')/.exec(stripped); + if (tripleMatch) { + const quote = tripleMatch[1] as string; + const startCol = raw.indexOf(quote); + const startQuoteIdx = stripped.indexOf(quote); + const afterQuote = stripped.slice(startQuoteIdx + 3); + const closingIdx = afterQuote.indexOf(quote); + if (closingIdx >= 0) { + const text = afterQuote.slice(0, closingIdx).replace(/\{[^}]*\}/g, INTERP_PLACEHOLDER); + return { line: i + 1, col: startCol + 1, text }; + } + const buf: string[] = [afterQuote]; + for (let j = i + 1; j < lines.length; j++) { + const next = lines[j]; + if (next === undefined) continue; + const closeIdx = next.indexOf(quote); + if (closeIdx >= 0) { + buf.push(next.slice(0, closeIdx)); + const text = buf.join('\n').replace(/\{[^}]*\}/g, INTERP_PLACEHOLDER); + return { line: i + 1, col: startCol + 1, text }; + } + buf.push(next); + } + return null; + } + + const backtickIdx = stripped.indexOf('`'); + if (backtickIdx >= 0) { + const fromBacktick = stripped.slice(backtickIdx + 1); + const closeIdx = fromBacktick.indexOf('`'); + if (closeIdx >= 0) { + const text = fromBacktick.slice(0, closeIdx).replace(/\$\{[^}]*\}/g, INTERP_PLACEHOLDER); + return { line: i + 1, col: backtickIdx + 1, text }; + } + const buf: string[] = [fromBacktick]; + for (let j = i + 1; j < lines.length; j++) { + const next = lines[j]; + if (next === undefined) continue; + const ci = next.indexOf('`'); + if (ci >= 0) { + buf.push(next.slice(0, ci)); + const text = buf.join('\n').replace(/\$\{[^}]*\}/g, INTERP_PLACEHOLDER); + return { line: i + 1, col: backtickIdx + 1, text }; + } + buf.push(next); + } + return null; + } + + const literal = scanSingleQuotedLiteral(stripped); + if (literal) { + const colOffset = raw.indexOf(literal.raw); + const col = colOffset >= 0 ? colOffset + 1 : 1; + const text = literal.isFString + ? literal.text.replace(/\{[^}]*\}/g, INTERP_PLACEHOLDER) + : literal.text; + return { line: i + 1, col, text }; + } + } + return null; +}; + +interface ScannedLiteral { + raw: string; + text: string; + isFString: boolean; +} + +const scanSingleQuotedLiteral = (line: string): ScannedLiteral | null => { + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch !== '"' && ch !== "'") continue; + const prev = i > 0 ? line[i - 1] : ''; + const isFString = prev === 'f' || prev === 'F'; + let j = i + 1; + let escaped = false; + while (j < line.length) { + const c = line[j]; + if (escaped) { + escaped = false; + j++; + continue; + } + if (c === '\\') { + escaped = true; + j++; + continue; + } + if (c === ch) { + const text = line.slice(i + 1, j); + const raw = line.slice(i, j + 1); + return { raw, text, isFString }; + } + j++; + } + } + return null; +}; + +export interface AnnotationDetectorOptions { + marker: string; +} + +export const detectAnnotations = ( + content: string, + file: string, + options: AnnotationDetectorOptions, +): ExtractedPrompt[] => { + const lines = content.split('\n'); + const hits = findAnnotations(lines, options.marker); + const out: ExtractedPrompt[] = []; + for (const hit of hits) { + const literal = findNextLiteral(lines, hit.line); + if (!literal) continue; + const enclosing = findEnclosingFunction(lines, literal.line); + const matchId = computeMatchId(file, 'annotation', enclosing); + const entry: ExtractedPrompt = { + file, + line: literal.line, + col: literal.col, + text: literal.text, + source: 'annotation', + matchId, + }; + if (hit.model !== undefined) entry.model = hit.model; + out.push(entry); + } + return out; +}; diff --git a/packages/action/src/detectors/extracted-prompt.test.ts b/packages/action/src/detectors/extracted-prompt.test.ts new file mode 100644 index 0000000..28e99e8 --- /dev/null +++ b/packages/action/src/detectors/extracted-prompt.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { computeMatchId, findEnclosingFunction } from './extracted-prompt.js'; + +describe('computeMatchId', () => { + it('is deterministic for identical inputs', () => { + const a = computeMatchId('src/a.ts', 'openai', 'router'); + const b = computeMatchId('src/a.ts', 'openai', 'router'); + expect(a).toBe(b); + expect(a).toHaveLength(40); + }); + + it('differs when file, sdk, or enclosing hint changes', () => { + const base = computeMatchId('src/a.ts', 'openai', 'router'); + expect(base).not.toBe(computeMatchId('src/b.ts', 'openai', 'router')); + expect(base).not.toBe(computeMatchId('src/a.ts', 'anthropic', 'router')); + expect(base).not.toBe(computeMatchId('src/a.ts', 'openai', 'handler')); + }); +}); + +describe('findEnclosingFunction', () => { + it('finds plain function declarations', () => { + const src = ['function callRouter() {', ' const x = 1;', ' return x;', '}']; + expect(findEnclosingFunction(src, 3)).toBe('callRouter'); + }); + + it('finds exported async functions', () => { + const src = ['export async function doThing() {', ' return 1;', '}']; + expect(findEnclosingFunction(src, 2)).toBe('doThing'); + }); + + it('finds arrow function const assignments', () => { + const src = ['const handler = async (req) => {', ' return null;', '};']; + expect(findEnclosingFunction(src, 2)).toBe('handler'); + }); + + it('finds Python def', () => { + const src = ['def my_handler(request):', ' return 1', '']; + expect(findEnclosingFunction(src, 2)).toBe('my_handler'); + }); + + it('finds Python async def', () => { + const src = ['async def run():', ' return 1']; + expect(findEnclosingFunction(src, 2)).toBe('run'); + }); + + it('falls back to top-level', () => { + const src = ['const SYSTEM = "hello";', 'console.log(SYSTEM);']; + expect(findEnclosingFunction(src, 2)).toBe('top-level'); + }); +}); diff --git a/packages/action/src/detectors/extracted-prompt.ts b/packages/action/src/detectors/extracted-prompt.ts new file mode 100644 index 0000000..dcc196c --- /dev/null +++ b/packages/action/src/detectors/extracted-prompt.ts @@ -0,0 +1,49 @@ +import { createHash } from 'node:crypto'; + +export type Sdk = 'openai' | 'anthropic' | 'google' | 'mistral' | 'cohere'; + +export interface ExtractedPrompt { + file: string; + line: number; + col: number; + model?: string; + text: string; + source: 'annotation' | 'sdk-regex'; + sdk?: Sdk; + matchId: string; +} + +export const computeMatchId = ( + file: string, + sdk: Sdk | 'annotation', + enclosingHint: string, +): string => { + const hash = createHash('sha1'); + hash.update(file); + hash.update('\0'); + hash.update(sdk); + hash.update('\0'); + hash.update(enclosingHint); + return hash.digest('hex'); +}; + +const FN_PATTERNS: readonly RegExp[] = [ + /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)/, + /^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s+)?\(/, + /^\s*(\w+)\s*[:=]\s*(?:async\s+)?\([^)]*\)\s*=>/, + /^\s*(?:async\s+)?def\s+(\w+)/, + /^\s*class\s+(\w+)/, +]; + +export const findEnclosingFunction = (lines: readonly string[], line: number): string => { + const start = Math.max(0, Math.min(line - 1, lines.length - 1)); + for (let i = start; i >= 0; i--) { + const raw = lines[i]; + if (raw === undefined) continue; + for (const pattern of FN_PATTERNS) { + const match = pattern.exec(raw); + if (match?.[1]) return match[1]; + } + } + return 'top-level'; +}; diff --git a/packages/action/src/detectors/index.ts b/packages/action/src/detectors/index.ts new file mode 100644 index 0000000..39ea298 --- /dev/null +++ b/packages/action/src/detectors/index.ts @@ -0,0 +1,62 @@ +import { detectAnnotations } from './annotation-detector.js'; +import type { ExtractedPrompt, Sdk } from './extracted-prompt.js'; +import { detectSdkPrompts } from './sdk-regex-detector.js'; + +export type CodeDetectionMode = 'off' | 'annotations' | 'sdk-regex' | 'both'; + +export interface DetectResult { + prompts: ExtractedPrompt[]; + nonLiteralLocations: Array<{ file: string; line: number; sdk: Sdk }>; +} + +export const detectPrompts = ( + content: string, + file: string, + mode: CodeDetectionMode, + marker: string, +): DetectResult => { + if (mode === 'off') return { prompts: [], nonLiteralLocations: [] }; + + const prompts: ExtractedPrompt[] = []; + let nonLiteralLocations: Array<{ file: string; line: number; sdk: Sdk }> = []; + + if (mode === 'annotations' || mode === 'both') { + prompts.push(...detectAnnotations(content, file, { marker })); + } + if (mode === 'sdk-regex' || mode === 'both') { + const sdkResult = detectSdkPrompts(content, file); + prompts.push(...sdkResult.prompts); + nonLiteralLocations = sdkResult.nonLiteralLocations; + } + return { prompts, nonLiteralLocations }; +}; + +const SKIP_GLOBS: readonly RegExp[] = [ + /(^|\/)node_modules\//, + /(^|\/)dist\//, + /(^|\/)build\//, + /(^|\/)\.next\//, + /(^|\/)vendor\//, + /\.min\.(js|ts)$/, +]; + +const MAX_FILE_BYTES = 200_000; +const MAX_AVG_LINE_LEN = 500; + +export const shouldSkipFile = (path: string, content: string): boolean => { + for (const re of SKIP_GLOBS) { + if (re.test(path)) return true; + } + if (Buffer.byteLength(content, 'utf8') > MAX_FILE_BYTES) return true; + const lines = content.split('\n'); + if (lines.length === 0) return false; + const avg = content.length / lines.length; + if (avg > MAX_AVG_LINE_LEN) return true; + return false; +}; + +export { detectAnnotations } from './annotation-detector.js'; +export { detectSdkPrompts, SDK_PATTERNS } from './sdk-regex-detector.js'; +export type { SdkPattern } from './sdk-regex-detector.js'; +export type { ExtractedPrompt, Sdk } from './extracted-prompt.js'; +export { computeMatchId, findEnclosingFunction } from './extracted-prompt.js'; diff --git a/packages/action/src/detectors/sdk-regex-detector.test.ts b/packages/action/src/detectors/sdk-regex-detector.test.ts new file mode 100644 index 0000000..2ca263c --- /dev/null +++ b/packages/action/src/detectors/sdk-regex-detector.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { SDK_PATTERNS, detectSdkPrompts } from './sdk-regex-detector.js'; + +describe('detectSdkPrompts — anthropic', () => { + it('extracts a system literal', () => { + const src = [ + 'const r = await anthropic.messages.create({', + " model: 'claude-opus-4-7',", + " system: 'You are a helpful assistant.',", + ' messages: [],', + '});', + ].join('\n'); + const result = detectSdkPrompts(src, 'src/a.ts'); + expect(result.prompts.length).toBeGreaterThanOrEqual(1); + const sys = result.prompts.find((p) => p.text === 'You are a helpful assistant.'); + expect(sys).toBeDefined(); + expect(sys?.sdk).toBe('anthropic'); + expect(sys?.model).toBe('claude-opus-4-7'); + }); + + it('extracts per-message content literals from messages: []', () => { + const src = [ + 'await anthropic.messages.create({', + " model: 'claude-opus-4-7',", + ' messages: [', + " { role: 'user', content: 'Hello there' },", + " { role: 'assistant', content: 'Hi!' },", + ' ],', + '});', + ].join('\n'); + const result = detectSdkPrompts(src, 'src/b.ts'); + const texts = result.prompts.map((p) => p.text).sort(); + expect(texts).toContain('Hello there'); + expect(texts).toContain('Hi!'); + }); +}); + +describe('detectSdkPrompts — openai', () => { + it('extracts chat.completions.create system+content', () => { + const src = [ + 'const r = await openai.chat.completions.create({', + " model: 'gpt-4o',", + ' messages: [', + " { role: 'system', content: 'You are concise.' },", + " { role: 'user', content: 'Hi' },", + ' ],', + '});', + ].join('\n'); + const result = detectSdkPrompts(src, 'src/c.ts'); + const texts = result.prompts.map((p) => p.text).sort(); + expect(texts).toContain('You are concise.'); + expect(texts).toContain('Hi'); + }); + + it('extracts responses.create', () => { + const src = [ + 'await openai.responses.create({', + " model: 'gpt-4o',", + " input: 'ignored',", + " prompt: 'Tell me a joke.',", + '});', + ].join('\n'); + const result = detectSdkPrompts(src, 'src/d.ts'); + expect(result.prompts.some((p) => p.text === 'Tell me a joke.')).toBe(true); + }); +}); + +describe('detectSdkPrompts — google', () => { + it('extracts contents literal from generateContent', () => { + const src = ['await model.generateContent({', " contents: 'Write a haiku.',", '});'].join( + '\n', + ); + const result = detectSdkPrompts(src, 'src/e.ts'); + expect(result.prompts.some((p) => p.text === 'Write a haiku.')).toBe(true); + expect(result.prompts[0]?.sdk).toBe('google'); + }); +}); + +describe('detectSdkPrompts — mistral', () => { + it('extracts mistralClient.chat content', () => { + const src = [ + 'await mistralClient.chat({', + " model: 'mistral-large-latest',", + " messages: [{ role: 'user', content: 'Bonjour' }],", + '});', + ].join('\n'); + const result = detectSdkPrompts(src, 'src/f.ts'); + expect(result.prompts.some((p) => p.text === 'Bonjour')).toBe(true); + }); + + it('extracts mistral.chat.complete', () => { + const src = [ + 'await mistral.chat.complete({', + " model: 'mistral-large-latest',", + " messages: [{ role: 'user', content: 'Hola' }],", + '});', + ].join('\n'); + const result = detectSdkPrompts(src, 'src/g.ts'); + expect(result.prompts.some((p) => p.text === 'Hola')).toBe(true); + }); +}); + +describe('detectSdkPrompts — cohere', () => { + it('extracts cohere.chat message', () => { + const src = [ + 'await cohere.chat({', + " model: 'command-r-plus',", + " message: 'unused-field',", + " preamble: 'unused-field',", + " content: 'Tell me about Cohere.',", + '});', + ].join('\n'); + const result = detectSdkPrompts(src, 'src/h.ts'); + expect(result.prompts.some((p) => p.text === 'Tell me about Cohere.')).toBe(true); + expect(result.prompts[0]?.sdk).toBe('cohere'); + }); +}); + +describe('detectSdkPrompts — non-literal handling', () => { + it('flags non-literal system as nonLiteralLocations and skips it', () => { + const src = [ + 'const sys = buildSystemPrompt();', + 'await anthropic.messages.create({', + " model: 'claude-opus-4-7',", + ' system: sys,', + '});', + ].join('\n'); + const result = detectSdkPrompts(src, 'src/i.ts'); + expect(result.prompts).toHaveLength(0); + expect(result.nonLiteralLocations.length).toBeGreaterThan(0); + expect(result.nonLiteralLocations[0]?.sdk).toBe('anthropic'); + }); +}); + +describe('SDK_PATTERNS registry', () => { + it('has at least one pattern per supported SDK', () => { + const sdks = new Set(SDK_PATTERNS.map((p) => p.sdk)); + expect(sdks.has('anthropic')).toBe(true); + expect(sdks.has('openai')).toBe(true); + expect(sdks.has('google')).toBe(true); + expect(sdks.has('mistral')).toBe(true); + expect(sdks.has('cohere')).toBe(true); + }); +}); diff --git a/packages/action/src/detectors/sdk-regex-detector.ts b/packages/action/src/detectors/sdk-regex-detector.ts new file mode 100644 index 0000000..0254d9b --- /dev/null +++ b/packages/action/src/detectors/sdk-regex-detector.ts @@ -0,0 +1,201 @@ +import { computeMatchId, findEnclosingFunction } from './extracted-prompt.js'; +import type { ExtractedPrompt, Sdk } from './extracted-prompt.js'; + +export interface SdkPattern { + sdk: Sdk; + regex: RegExp; + modelHintRegex?: RegExp; +} + +export const SDK_PATTERNS: readonly SdkPattern[] = [ + { + sdk: 'anthropic', + regex: /\b(?:anthropic|client)\s*\.\s*messages\s*\.\s*create\s*\(\s*\{([\s\S]*?)\}\s*\)/g, + modelHintRegex: /model\s*[:=]\s*['"]([^'"]+)['"]/, + }, + { + sdk: 'openai', + regex: + /\b(?:openai|client)\s*\.\s*chat\s*\.\s*completions\s*\.\s*create\s*\(\s*\{([\s\S]*?)\}\s*\)/g, + modelHintRegex: /model\s*[:=]\s*['"]([^'"]+)['"]/, + }, + { + sdk: 'openai', + regex: /\b(?:openai|client)\s*\.\s*responses\s*\.\s*create\s*\(\s*\{([\s\S]*?)\}\s*\)/g, + modelHintRegex: /model\s*[:=]\s*['"]([^'"]+)['"]/, + }, + { + sdk: 'google', + regex: /\bmodel\s*\.\s*generateContent\s*\(\s*\{([\s\S]*?)\}\s*\)/g, + }, + { + sdk: 'mistral', + regex: + /\b(?:mistralClient|mistral|client)\s*\.\s*chat(?:\s*\.\s*complete)?\s*\(\s*\{([\s\S]*?)\}\s*\)/g, + modelHintRegex: /model\s*[:=]\s*['"]([^'"]+)['"]/, + }, + { + sdk: 'cohere', + regex: /\b(?:cohere|cohereClient|client)\s*\.\s*chat\s*\(\s*\{([\s\S]*?)\}\s*\)/g, + modelHintRegex: /model\s*[:=]\s*['"]([^'"]+)['"]/, + }, +]; + +const KEYS_FOR_PROMPT = ['system', 'prompt', 'content', 'text', 'contents'] as const; + +const INTERP_PLACEHOLDER = '__INTERP__'; + +interface ExtractedValue { + text: string; + isLiteral: boolean; +} + +const extractKeyLiteral = (body: string, key: string): ExtractedValue | null => { + const keyRe = new RegExp(`\\b${key}\\s*[:=]\\s*`, 'g'); + let match: RegExpExecArray | null; + // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex.exec loop + while ((match = keyRe.exec(body)) !== null) { + const start = match.index + match[0].length; + const rest = body.slice(start); + const literal = readStringLiteral(rest); + if (literal) return { text: literal, isLiteral: true }; + const next = rest.trim(); + if (next.length === 0) continue; + if (next.startsWith('[') || next.startsWith('{')) continue; + return { text: '', isLiteral: false }; + } + return null; +}; + +const readStringLiteral = (text: string): string | null => { + let i = 0; + while (i < text.length && /\s/.test(text[i] ?? '')) i++; + const quote = text[i]; + if (quote !== '"' && quote !== "'" && quote !== '`') return null; + let j = i + 1; + let escaped = false; + while (j < text.length) { + const c = text[j]; + if (escaped) { + escaped = false; + j++; + continue; + } + if (c === '\\') { + escaped = true; + j++; + continue; + } + if (c === quote) { + const raw = text.slice(i + 1, j); + if (quote === '`') return raw.replace(/\$\{[^}]*\}/g, INTERP_PLACEHOLDER); + return raw; + } + j++; + } + return null; +}; + +const extractMessagesContents = (body: string): { literals: string[]; nonLiteralCount: number } => { + const literals: string[] = []; + let nonLiteralCount = 0; + const re = /\bmessages\s*[:=]\s*\[([\s\S]*?)\]/g; + let match: RegExpExecArray | null; + // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex.exec loop + while ((match = re.exec(body)) !== null) { + const arr = match[1] ?? ''; + const contentRe = /\bcontent\s*[:=]\s*/g; + let cMatch: RegExpExecArray | null; + // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex.exec loop + while ((cMatch = contentRe.exec(arr)) !== null) { + const after = arr.slice(cMatch.index + cMatch[0].length); + const literal = readStringLiteral(after); + if (literal !== null) literals.push(literal); + else nonLiteralCount++; + } + } + return { literals, nonLiteralCount }; +}; + +const lineColForOffset = (content: string, offset: number): { line: number; col: number } => { + let line = 1; + let lastNewline = -1; + for (let i = 0; i < offset && i < content.length; i++) { + if (content[i] === '\n') { + line++; + lastNewline = i; + } + } + return { line, col: offset - lastNewline }; +}; + +export interface SdkRegexResult { + prompts: ExtractedPrompt[]; + nonLiteralLocations: Array<{ file: string; line: number; sdk: Sdk }>; +} + +export const detectSdkPrompts = (content: string, file: string): SdkRegexResult => { + const lines = content.split('\n'); + const prompts: ExtractedPrompt[] = []; + const nonLiteralLocations: Array<{ file: string; line: number; sdk: Sdk }> = []; + + for (const pattern of SDK_PATTERNS) { + pattern.regex.lastIndex = 0; + let match: RegExpExecArray | null; + // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex.exec loop + while ((match = pattern.regex.exec(content)) !== null) { + const body = match[1] ?? ''; + const { line, col } = lineColForOffset(content, match.index); + const enclosing = findEnclosingFunction(lines, line); + const matchId = computeMatchId(file, pattern.sdk, enclosing); + + const modelHint = pattern.modelHintRegex?.exec(body)?.[1]; + + let foundLiteral = false; + let sawNonLiteral = false; + + for (const key of KEYS_FOR_PROMPT) { + const extracted = extractKeyLiteral(body, key); + if (!extracted) continue; + if (!extracted.isLiteral) { + sawNonLiteral = true; + continue; + } + foundLiteral = true; + const entry: ExtractedPrompt = { + file, + line, + col, + text: extracted.text, + source: 'sdk-regex', + sdk: pattern.sdk, + matchId, + }; + if (modelHint !== undefined) entry.model = modelHint; + prompts.push(entry); + } + + const msgs = extractMessagesContents(body); + for (const text of msgs.literals) { + foundLiteral = true; + const entry: ExtractedPrompt = { + file, + line, + col, + text, + source: 'sdk-regex', + sdk: pattern.sdk, + matchId, + }; + if (modelHint !== undefined) entry.model = modelHint; + prompts.push(entry); + } + if (msgs.nonLiteralCount > 0) sawNonLiteral = true; + + if (!foundLiteral && sawNonLiteral) { + nonLiteralLocations.push({ file, line, sdk: pattern.sdk }); + } + } + } + return { prompts, nonLiteralLocations }; +}; diff --git a/packages/action/src/index.ts b/packages/action/src/index.ts index 007af3d..50412d0 100644 --- a/packages/action/src/index.ts +++ b/packages/action/src/index.ts @@ -12,19 +12,41 @@ import { tokenize, } from '@tokenometer/core'; import { minimatch } from 'minimatch'; +import { + type CodeDetectionMode, + type ExtractedPrompt, + detectPrompts, + shouldSkipFile, +} from './detectors/index.js'; +import { type CodePromptRow, measureExtractedPrompts } from './measure-code.js'; import { aggregatePerFileDiff, renderPerFileMarkdown } from './per-file-diff.js'; +import { renderCodeSection } from './render-code-section.js'; + +type CommentMode = 'single' | 'split'; interface Inputs { baseRef: string; budget: number | null; + codeDetection: CodeDetectionMode; + codePaths: string[]; commentMarker: string; + commentMode: CommentMode; formats: Format[]; githubToken: string; modelIds: string[]; paths: string[]; + promptMarkerComment: string; topNFiles: number; + topNPrompts: number; } +const CODE_DETECTION_MODES = ['off', 'annotations', 'sdk-regex', 'both'] as const; + +const isCodeDetectionMode = (s: string): s is CodeDetectionMode => + (CODE_DETECTION_MODES as readonly string[]).includes(s); + +const isCommentMode = (s: string): s is CommentMode => s === 'single' || s === 'split'; + interface FileCost { cost: number; model: string; @@ -75,15 +97,47 @@ const readInputs = (): Inputs => { throw new Error(`top-n-files must be an integer 1-20, got "${topNRaw}"`); } const topNFiles = Math.max(1, Math.min(20, topNParsed)); + + const codePaths = core + .getInput('code-paths') + .split(/[\n,]/) + .map((s) => s.trim()) + .filter(Boolean); + const codeDetectionRaw = (core.getInput('code-detection').trim() || 'off') as string; + if (!isCodeDetectionMode(codeDetectionRaw)) { + throw new Error( + `code-detection must be one of ${CODE_DETECTION_MODES.join(', ')}, got "${codeDetectionRaw}"`, + ); + } + const codeDetection = codeDetectionRaw; + const promptMarkerComment = + core.getInput('prompt-marker-comment').trim() || '@tokenometer-prompt'; + const commentModeRaw = (core.getInput('comment-mode').trim() || 'single') as string; + if (!isCommentMode(commentModeRaw)) { + throw new Error(`comment-mode must be 'single' or 'split', got "${commentModeRaw}"`); + } + const commentMode = commentModeRaw; + const topNPromptsRaw = core.getInput('top-n-prompts').trim(); + const topNPromptsParsed = topNPromptsRaw === '' ? 5 : Number.parseInt(topNPromptsRaw, 10); + if (!Number.isFinite(topNPromptsParsed)) { + throw new Error(`top-n-prompts must be an integer 1-20, got "${topNPromptsRaw}"`); + } + const topNPrompts = Math.max(1, Math.min(20, topNPromptsParsed)); + return { baseRef: core.getInput('base-ref').trim(), budget, + codeDetection, + codePaths, commentMarker: core.getInput('comment-marker'), + commentMode, formats: formats as Format[], githubToken: core.getInput('github-token'), modelIds, paths, + promptMarkerComment, topNFiles, + topNPrompts, }; }; @@ -184,7 +238,9 @@ const renderMarkdown = ( formats: readonly Format[], budget: number | null, topNFiles: number, + opts: { renderBudget?: boolean } = {}, ): { body: string; totalDelta: number } => { + const renderBudget = opts.renderBudget ?? true; const totalHead = results.reduce((acc, r) => acc + sumCost(r.head), 0); const totalBase = results.reduce((acc, r) => acc + (r.base ? sumCost(r.base) : 0), 0); const totalDelta = totalHead - totalBase; @@ -240,7 +296,7 @@ const renderMarkdown = ( } } - if (budget !== null) { + if (budget !== null && renderBudget) { const ok = totalDelta <= budget; lines.push(''); lines.push( @@ -295,6 +351,65 @@ const upsertStickyComment = async ( return created.data.html_url; }; +const collectCodePrompts = async ( + baseRef: string, + inputs: Inputs, +): Promise<{ rows: CodePromptRow[]; section: string; delta: number }> => { + if (inputs.codeDetection === 'off') { + return { rows: [], section: '', delta: 0 }; + } + const changedCode = await matchPaths(baseRef, inputs.codePaths); + core.info( + `Changed code files (for inline-prompt scan): ${ + changedCode.length === 0 ? '(none)' : changedCode.join(', ') + }`, + ); + const baseExtracted: ExtractedPrompt[] = []; + const headExtracted: ExtractedPrompt[] = []; + for (const path of changedCode) { + const headContent = await fs.readFile(resolve(path), 'utf8').catch(() => null); + if (headContent !== null && !shouldSkipFile(path, headContent)) { + const result = detectPrompts( + headContent, + path, + inputs.codeDetection, + inputs.promptMarkerComment, + ); + headExtracted.push(...result.prompts); + for (const loc of result.nonLiteralLocations) { + core.warning(`prompt at ${loc.file}:${loc.line} (${loc.sdk}) is non-literal — skipping`); + } + } + const baseContent = await readFileAt(baseRef, path); + if (baseContent !== null && !shouldSkipFile(path, baseContent)) { + const result = detectPrompts( + baseContent, + path, + inputs.codeDetection, + inputs.promptMarkerComment, + ); + baseExtracted.push(...result.prompts); + } + } + const rows = measureExtractedPrompts( + baseExtracted, + headExtracted, + inputs.modelIds, + inputs.formats, + ); + const delta = rows.reduce((acc, r) => acc + r.costDelta, 0); + const section = renderCodeSection(rows, inputs.topNPrompts); + return { rows, section, delta }; +}; + +const composeBudgetLine = (budget: number | null, totalDelta: number): string => { + if (budget === null) return ''; + const ok = totalDelta <= budget; + return `${ok ? '✅' : '❌'} Budget: ${formatCost(budget)} · Δ ${ok ? 'within' : 'exceeds'} budget.`; +}; + +const CODE_COMMENT_MARKER = ''; + const run = async (): Promise => { try { const inputs = readInputs(); @@ -319,20 +434,75 @@ const run = async (): Promise => { }); } - const { body, totalDelta } = renderMarkdown( + const codeResult = await collectCodePrompts(baseRef, inputs); + + const splitMode = inputs.commentMode === 'split' && inputs.codeDetection !== 'off'; + + // When splitting, the main comment renders the budget against file-only + // delta (existing semantics). When not splitting, suppress the per-renderer + // budget line so we can render a single combined-total line at the end. + const { body: fileBody, totalDelta: filesCostDelta } = renderMarkdown( inputs.commentMarker, results, inputs.modelIds, inputs.formats, inputs.budget, inputs.topNFiles, + { renderBudget: splitMode }, + ); + + const totalDelta = filesCostDelta + codeResult.delta; + + let mainBody = fileBody; + if (!splitMode) { + const trailer: string[] = []; + if (codeResult.section) { + trailer.push(''); + trailer.push(codeResult.section); + } + if (codeResult.rows.length > 0) { + trailer.push(''); + trailer.push( + `**Total Δ (files + code-embedded):** ${formatDelta(totalDelta)} (files ${formatDelta(filesCostDelta)}, code ${formatDelta(codeResult.delta)})`, + ); + } + const budgetLine = composeBudgetLine(inputs.budget, totalDelta); + if (budgetLine) { + trailer.push(''); + trailer.push(budgetLine); + } + mainBody = `${fileBody}${trailer.length > 0 ? `\n${trailer.join('\n')}` : ''}`; + } + + const commentUrl = await upsertStickyComment( + inputs.githubToken, + inputs.commentMarker, + mainBody, ); - const commentUrl = await upsertStickyComment(inputs.githubToken, inputs.commentMarker, body); + if (splitMode && codeResult.section) { + const codeBudgetLine = composeBudgetLine(inputs.budget, totalDelta); + const codeBodyLines: string[] = []; + codeBodyLines.push(CODE_COMMENT_MARKER); + codeBodyLines.push('## tokenometer · code-embedded prompts'); + codeBodyLines.push(''); + codeBodyLines.push(codeResult.section); + codeBodyLines.push(''); + codeBodyLines.push( + `**Total Δ (files + code-embedded):** ${formatDelta(totalDelta)} (files ${formatDelta(filesCostDelta)}, code ${formatDelta(codeResult.delta)})`, + ); + if (codeBudgetLine) { + codeBodyLines.push(''); + codeBodyLines.push(codeBudgetLine); + } + await upsertStickyComment(inputs.githubToken, CODE_COMMENT_MARKER, codeBodyLines.join('\n')); + } - core.setOutput('cost-delta', totalDelta.toFixed(8)); + core.setOutput('cost-delta', filesCostDelta.toFixed(8)); + core.setOutput('code-cost-delta', codeResult.delta.toFixed(8)); + core.setOutput('total-cost-delta', totalDelta.toFixed(8)); if (commentUrl) core.setOutput('comment-url', commentUrl); - core.summary.addRaw(body).write(); + core.summary.addRaw(mainBody).write(); if (inputs.budget !== null && totalDelta > inputs.budget) { core.setFailed( diff --git a/packages/action/src/measure-code.test.ts b/packages/action/src/measure-code.test.ts new file mode 100644 index 0000000..499c09f --- /dev/null +++ b/packages/action/src/measure-code.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import type { ExtractedPrompt } from './detectors/extracted-prompt.js'; +import { measureExtractedPrompts } from './measure-code.js'; + +const make = ( + overrides: Partial & Pick, +): ExtractedPrompt => ({ + file: 'src/a.ts', + line: 10, + col: 1, + source: 'annotation', + ...overrides, +}); + +describe('measureExtractedPrompts', () => { + it('matches by matchId and produces a single row per pair', () => { + const base: ExtractedPrompt[] = [make({ matchId: 'm1', text: 'hello world' })]; + const head: ExtractedPrompt[] = [make({ matchId: 'm1', text: 'hello there world' })]; + const rows = measureExtractedPrompts(base, head, ['gpt-4o'], ['text']); + expect(rows).toHaveLength(1); + const row = rows[0]; + if (!row) throw new Error('expected row'); + expect(row.model).toBe('gpt-4o'); + expect(row.baseTokens).toBeGreaterThan(0); + expect(row.headTokens).toBeGreaterThan(row.baseTokens); + expect(row.costDelta).toBeGreaterThan(0); + }); + + it('treats unmatched head as added (baseTokens=0)', () => { + const base: ExtractedPrompt[] = []; + const head: ExtractedPrompt[] = [make({ matchId: 'mAdd', text: 'new prompt added' })]; + const rows = measureExtractedPrompts(base, head, ['gpt-4o'], ['text']); + expect(rows).toHaveLength(1); + const row = rows[0]; + if (!row) throw new Error('expected row'); + expect(row.baseTokens).toBe(0); + expect(row.baseCost).toBe(0); + expect(row.headTokens).toBeGreaterThan(0); + expect(row.costDelta).toBeGreaterThan(0); + }); + + it('treats unmatched base as removed (headTokens=0)', () => { + const base: ExtractedPrompt[] = [make({ matchId: 'mDel', text: 'old prompt removed' })]; + const head: ExtractedPrompt[] = []; + const rows = measureExtractedPrompts(base, head, ['gpt-4o'], ['text']); + expect(rows).toHaveLength(1); + const row = rows[0]; + if (!row) throw new Error('expected row'); + expect(row.headTokens).toBe(0); + expect(row.headCost).toBe(0); + expect(row.baseTokens).toBeGreaterThan(0); + expect(row.costDelta).toBeLessThan(0); + }); + + it('falls back to Levenshtein when matchId changes within a file (ratio >= 0.6)', () => { + const base: ExtractedPrompt[] = [ + make({ matchId: 'oldId', text: 'You are a helpful assistant.' }), + ]; + const head: ExtractedPrompt[] = [ + make({ matchId: 'newId', text: 'You are a helpful assistant for code.' }), + ]; + const rows = measureExtractedPrompts(base, head, ['gpt-4o'], ['text']); + expect(rows).toHaveLength(1); + const row = rows[0]; + if (!row) throw new Error('expected row'); + expect(row.baseTokens).toBeGreaterThan(0); + expect(row.headTokens).toBeGreaterThan(0); + }); + + it('does not fuzzy-match prompts in different files', () => { + const base: ExtractedPrompt[] = [ + make({ file: 'src/x.ts', matchId: 'oldX', text: 'You are a helpful assistant.' }), + ]; + const head: ExtractedPrompt[] = [ + make({ file: 'src/y.ts', matchId: 'newY', text: 'You are a helpful assistant.' }), + ]; + const rows = measureExtractedPrompts(base, head, ['gpt-4o'], ['text']); + expect(rows).toHaveLength(2); + }); + + it('prefers the prompt-annotated model over defaultModels[0]', () => { + const base: ExtractedPrompt[] = [ + make({ matchId: 'm1', text: 'hello', model: 'claude-opus-4-7' }), + ]; + const head: ExtractedPrompt[] = [ + make({ matchId: 'm1', text: 'hello world', model: 'claude-opus-4-7' }), + ]; + const rows = measureExtractedPrompts(base, head, ['gpt-4o'], ['text']); + expect(rows[0]?.model).toBe('claude-opus-4-7'); + }); + + it('returns empty when defaultModels is empty', () => { + const rows = measureExtractedPrompts( + [make({ matchId: 'm1', text: 'hello' })], + [], + [], + ['text'], + ); + expect(rows).toEqual([]); + }); + + it('reports the location as file:line', () => { + const base: ExtractedPrompt[] = []; + const head: ExtractedPrompt[] = [ + make({ file: 'src/router.ts', line: 42, matchId: 'm1', text: 'hi' }), + ]; + const rows = measureExtractedPrompts(base, head, ['gpt-4o'], ['text']); + expect(rows[0]?.location).toBe('src/router.ts:42'); + }); +}); diff --git a/packages/action/src/measure-code.ts b/packages/action/src/measure-code.ts new file mode 100644 index 0000000..c7c2f3d --- /dev/null +++ b/packages/action/src/measure-code.ts @@ -0,0 +1,177 @@ +import { type Format, tokenize } from '@tokenometer/core'; +import type { ExtractedPrompt } from './detectors/extracted-prompt.js'; + +export interface CodePromptRow { + location: string; + model: string; + baseTokens: number; + headTokens: number; + baseCost: number; + headCost: number; + costDelta: number; +} + +const FALLBACK_MATCH_RATIO = 0.6; + +const levenshtein = (a: string, b: string): number => { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + const lenA = a.length; + const lenB = b.length; + let prev = new Array(lenB + 1); + let curr = new Array(lenB + 1); + for (let j = 0; j <= lenB; j++) prev[j] = j; + for (let i = 1; i <= lenA; i++) { + curr[0] = i; + for (let j = 1; j <= lenB; j++) { + const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1; + curr[j] = Math.min((curr[j - 1] ?? 0) + 1, (prev[j] ?? 0) + 1, (prev[j - 1] ?? 0) + cost); + } + [prev, curr] = [curr, prev]; + } + return prev[lenB] ?? 0; +}; + +const similarity = (a: string, b: string): number => { + if (a.length === 0 && b.length === 0) return 1; + const dist = levenshtein(a, b); + const max = Math.max(a.length, b.length); + if (max === 0) return 1; + return 1 - dist / max; +}; + +const pickModel = (prompt: ExtractedPrompt, defaultModels: readonly string[]): string => { + if (prompt.model) return prompt.model; + const first = defaultModels[0]; + if (!first) throw new Error('measureExtractedPrompts: defaultModels must have at least 1 entry'); + return first; +}; + +const measureCost = ( + text: string, + modelId: string, + format: Format, +): { tokens: number; cost: number } => { + if (text.length === 0) return { tokens: 0, cost: 0 }; + try { + const r = tokenize({ format, modelId, prompt: text }); + return { tokens: r.inputTokens, cost: r.inputCost }; + } catch { + return { tokens: 0, cost: 0 }; + } +}; + +const formatLocation = (p: ExtractedPrompt): string => `${p.file}:${p.line}`; + +interface MatchPair { + base: ExtractedPrompt | null; + head: ExtractedPrompt | null; +} + +const pairPrompts = ( + base: readonly ExtractedPrompt[], + head: readonly ExtractedPrompt[], +): MatchPair[] => { + const pairs: MatchPair[] = []; + const baseByMatchId = new Map(); + for (const p of base) { + const list = baseByMatchId.get(p.matchId) ?? []; + list.push(p); + baseByMatchId.set(p.matchId, list); + } + + const headUnmatched: ExtractedPrompt[] = []; + for (const h of head) { + const candidates = baseByMatchId.get(h.matchId); + if (candidates && candidates.length > 0) { + const b = candidates.shift(); + if (b) { + pairs.push({ base: b, head: h }); + continue; + } + } + headUnmatched.push(h); + } + + const baseUnmatched: ExtractedPrompt[] = []; + for (const list of baseByMatchId.values()) baseUnmatched.push(...list); + + const headByFile = new Map(); + for (const h of headUnmatched) { + const list = headByFile.get(h.file) ?? []; + list.push(h); + headByFile.set(h.file, list); + } + + for (const b of baseUnmatched) { + const candidates = headByFile.get(b.file); + if (!candidates || candidates.length === 0) { + pairs.push({ base: b, head: null }); + continue; + } + let bestIdx = -1; + let bestScore = FALLBACK_MATCH_RATIO; + for (let i = 0; i < candidates.length; i++) { + const h = candidates[i]; + if (!h) continue; + const score = similarity(b.text, h.text); + if (score >= bestScore) { + bestScore = score; + bestIdx = i; + } + } + if (bestIdx >= 0) { + const h = candidates.splice(bestIdx, 1)[0]; + if (h) { + pairs.push({ base: b, head: h }); + continue; + } + } + pairs.push({ base: b, head: null }); + } + + for (const list of headByFile.values()) { + for (const h of list) pairs.push({ base: null, head: h }); + } + + return pairs; +}; + +export const measureExtractedPrompts = ( + base: readonly ExtractedPrompt[], + head: readonly ExtractedPrompt[], + defaultModels: readonly string[], + _formats: readonly Format[], +): CodePromptRow[] => { + if (defaultModels.length === 0) return []; + const format: Format = 'text'; + + const pairs = pairPrompts(base, head); + const rows: CodePromptRow[] = []; + + for (const pair of pairs) { + const sample = pair.head ?? pair.base; + if (!sample) continue; + const model = pickModel(sample, defaultModels); + const headMeasure = pair.head + ? measureCost(pair.head.text, model, format) + : { tokens: 0, cost: 0 }; + const baseMeasure = pair.base + ? measureCost(pair.base.text, model, format) + : { tokens: 0, cost: 0 }; + + const location = formatLocation(sample); + rows.push({ + location, + model, + baseTokens: baseMeasure.tokens, + headTokens: headMeasure.tokens, + baseCost: baseMeasure.cost, + headCost: headMeasure.cost, + costDelta: headMeasure.cost - baseMeasure.cost, + }); + } + + return rows; +}; diff --git a/packages/action/src/per-file-diff.ts b/packages/action/src/per-file-diff.ts index aa91ad1..552208d 100644 --- a/packages/action/src/per-file-diff.ts +++ b/packages/action/src/per-file-diff.ts @@ -83,19 +83,19 @@ export const aggregatePerFileDiff = ( }; }; -const formatTokensDelta = (delta: number): string => { +export const formatTokensDelta = (delta: number): string => { if (delta === 0) return '0'; const sign = delta > 0 ? '+' : '−'; return `${sign}${Math.abs(delta).toLocaleString()}`; }; -const formatUsd = (usd: number): string => { +export const formatUsd = (usd: number): string => { if (Math.abs(usd) >= 0.01) return `$${usd.toFixed(4)}`; if (Math.abs(usd) >= 0.000001) return `$${usd.toFixed(6)}`; return `$${usd.toExponential(2)}`; }; -const formatUsdDelta = (delta: number): string => { +export const formatUsdDelta = (delta: number): string => { if (delta === 0) return '$0'; const sign = delta > 0 ? '+' : '−'; return `${sign}${formatUsd(Math.abs(delta))}`; diff --git a/packages/action/src/render-code-section.test.ts b/packages/action/src/render-code-section.test.ts new file mode 100644 index 0000000..22a4990 --- /dev/null +++ b/packages/action/src/render-code-section.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import type { CodePromptRow } from './measure-code.js'; +import { renderCodeSection } from './render-code-section.js'; + +const row = (overrides: Partial): CodePromptRow => ({ + location: 'src/a.ts:10', + model: 'gpt-4o', + baseTokens: 100, + headTokens: 120, + baseCost: 0.001, + headCost: 0.0012, + costDelta: 0.0002, + ...overrides, +}); + +describe('renderCodeSection', () => { + it('returns empty string for empty input', () => { + expect(renderCodeSection([], 5)).toBe(''); + }); + + it('renders a single-row table without
', () => { + const out = renderCodeSection([row({})], 5); + expect(out).toContain('### Code-Embedded Prompts (1)'); + expect(out).toContain('| `src/a.ts:10` | gpt-4o |'); + expect(out).not.toContain('
'); + }); + + it('caps the visible table to topN and folds the rest into
', () => { + const rows = Array.from({ length: 10 }, (_, i) => + row({ location: `src/f${i}.ts:1`, costDelta: 0.0001 * (i + 1) }), + ); + const out = renderCodeSection(rows, 3); + expect(out).toContain('### Code-Embedded Prompts (10)'); + expect(out).toContain('
All 10 prompts'); + const tableSection = out.split('
')[0] ?? ''; + expect(tableSection).toContain('src/f9.ts:1'); + expect(tableSection).toContain('src/f8.ts:1'); + expect(tableSection).toContain('src/f7.ts:1'); + }); + + it('sorts by |costDelta| desc then by |tokensDelta| desc then by location', () => { + const rows: CodePromptRow[] = [ + row({ location: 'src/small.ts:1', costDelta: 0.0001, baseTokens: 50, headTokens: 60 }), + row({ location: 'src/big.ts:1', costDelta: 0.001, baseTokens: 50, headTokens: 200 }), + row({ location: 'src/mid.ts:1', costDelta: 0.0005, baseTokens: 50, headTokens: 100 }), + ]; + const out = renderCodeSection(rows, 5); + const lines = out.split('\n'); + const bigIdx = lines.findIndex((l) => l.includes('big.ts')); + const midIdx = lines.findIndex((l) => l.includes('mid.ts')); + const smallIdx = lines.findIndex((l) => l.includes('small.ts')); + expect(bigIdx).toBeLessThan(midIdx); + expect(midIdx).toBeLessThan(smallIdx); + }); + + it('matches snapshot for canonical fixture', () => { + const rows: CodePromptRow[] = [ + row({ + location: 'src/router.ts:42', + model: 'claude-opus-4-7', + baseTokens: 100, + headTokens: 220, + costDelta: 0.0018, + }), + row({ + location: 'src/agent.ts:10', + model: 'gpt-4o', + baseTokens: 80, + headTokens: 0, + costDelta: -0.0008, + }), + ]; + expect(renderCodeSection(rows, 5)).toMatchSnapshot(); + }); +}); diff --git a/packages/action/src/render-code-section.ts b/packages/action/src/render-code-section.ts new file mode 100644 index 0000000..b107e20 --- /dev/null +++ b/packages/action/src/render-code-section.ts @@ -0,0 +1,43 @@ +import type { CodePromptRow } from './measure-code.js'; +import { formatTokensDelta, formatUsdDelta } from './per-file-diff.js'; + +const TABLE_HEADER = ['| Location | Model | Tokens Δ | USD Δ |', '|---|---|---:|---:|']; + +const renderRow = (row: CodePromptRow): string => { + const tokensDelta = row.headTokens - row.baseTokens; + return `| \`${row.location}\` | ${row.model} | ${formatTokensDelta(tokensDelta)} | ${formatUsdDelta(row.costDelta)} |`; +}; + +const sortRows = (rows: readonly CodePromptRow[]): CodePromptRow[] => + [...rows].sort((a, b) => { + const absCost = Math.abs(b.costDelta) - Math.abs(a.costDelta); + if (absCost !== 0) return absCost; + const absTok = Math.abs(b.headTokens - b.baseTokens) - Math.abs(a.headTokens - a.baseTokens); + if (absTok !== 0) return absTok; + return a.location.localeCompare(b.location); + }); + +export const renderCodeSection = (rows: readonly CodePromptRow[], topN: number): string => { + if (rows.length === 0) return ''; + const clampedTopN = Math.max(1, Math.min(20, Math.trunc(topN))); + const sorted = sortRows(rows); + const top = sorted.slice(0, clampedTopN); + + const lines: string[] = []; + lines.push(`### Code-Embedded Prompts (${sorted.length})`); + lines.push(''); + lines.push(...TABLE_HEADER); + for (const row of top) lines.push(renderRow(row)); + + if (sorted.length > top.length) { + lines.push(''); + lines.push(`
All ${sorted.length} prompts`); + lines.push(''); + lines.push(...TABLE_HEADER); + for (const row of sorted) lines.push(renderRow(row)); + lines.push(''); + lines.push('
'); + } + + return lines.join('\n'); +}; diff --git a/packages/action/tests/fixtures/annotation-basic.ts b/packages/action/tests/fixtures/annotation-basic.ts new file mode 100644 index 0000000..f126655 --- /dev/null +++ b/packages/action/tests/fixtures/annotation-basic.ts @@ -0,0 +1,11 @@ +// @tokenometer-prompt model=claude-opus-4-7 +const SYSTEM = 'You are an assistant that answers in fewer than 100 words.'; + +export function buildPrompt(): string { + return SYSTEM; +} + +/* @tokenometer-prompt model=gpt-4o */ +const USER = 'Summarise the changelog.'; + +export { USER }; diff --git a/packages/action/tests/fixtures/annotation-python.py b/packages/action/tests/fixtures/annotation-python.py new file mode 100644 index 0000000..ce7bc7a --- /dev/null +++ b/packages/action/tests/fixtures/annotation-python.py @@ -0,0 +1,7 @@ +# @tokenometer-prompt model=gpt-4o +SYSTEM = "You are an assistant that answers in fewer than 100 words." + + +# @tokenometer-prompt model=claude-opus-4-7 +USER = """Summarise the changelog +in two short paragraphs.""" diff --git a/packages/action/tests/fixtures/sdk-anthropic.ts b/packages/action/tests/fixtures/sdk-anthropic.ts new file mode 100644 index 0000000..e858ca4 --- /dev/null +++ b/packages/action/tests/fixtures/sdk-anthropic.ts @@ -0,0 +1,12 @@ +import Anthropic from '@anthropic-ai/sdk'; + +const anthropic = new Anthropic(); + +export const callMessages = async () => { + const r = await anthropic.messages.create({ + model: 'claude-opus-4-7', + system: 'You answer in fewer than 100 words.', + messages: [{ role: 'user', content: 'What is consistent hashing?' }], + }); + return r; +}; diff --git a/packages/action/tests/fixtures/sdk-cohere.ts b/packages/action/tests/fixtures/sdk-cohere.ts new file mode 100644 index 0000000..cf0ae10 --- /dev/null +++ b/packages/action/tests/fixtures/sdk-cohere.ts @@ -0,0 +1,11 @@ +import { CohereClient } from 'cohere-ai'; + +const cohere = new CohereClient({ token: 'FAKE_KEY' }); + +export const callCohere = async () => { + const r = await cohere.chat({ + model: 'command-r-plus', + content: 'Tell me about retrieval-augmented generation.', + }); + return r; +}; diff --git a/packages/action/tests/fixtures/sdk-google.ts b/packages/action/tests/fixtures/sdk-google.ts new file mode 100644 index 0000000..a06823c --- /dev/null +++ b/packages/action/tests/fixtures/sdk-google.ts @@ -0,0 +1,11 @@ +import { GoogleGenerativeAI } from '@google/generative-ai'; + +const genai = new GoogleGenerativeAI('FAKE_KEY'); +const model = genai.getGenerativeModel({ model: 'gemini-1.5-pro' }); + +export const callGemini = async () => { + const r = await model.generateContent({ + contents: 'Explain Raft in two paragraphs.', + }); + return r; +}; diff --git a/packages/action/tests/fixtures/sdk-mistral.ts b/packages/action/tests/fixtures/sdk-mistral.ts new file mode 100644 index 0000000..8a1bccb --- /dev/null +++ b/packages/action/tests/fixtures/sdk-mistral.ts @@ -0,0 +1,11 @@ +import MistralClient from '@mistralai/mistralai'; + +const mistralClient = new MistralClient('FAKE_KEY'); + +export const callMistral = async () => { + const r = await mistralClient.chat({ + model: 'mistral-large-latest', + messages: [{ role: 'user', content: 'Bonjour, qui es-tu ?' }], + }); + return r; +}; diff --git a/packages/action/tests/fixtures/sdk-openai.ts b/packages/action/tests/fixtures/sdk-openai.ts new file mode 100644 index 0000000..7f34fa6 --- /dev/null +++ b/packages/action/tests/fixtures/sdk-openai.ts @@ -0,0 +1,22 @@ +import OpenAI from 'openai'; + +const openai = new OpenAI(); + +export const callChat = async () => { + const r = await openai.chat.completions.create({ + model: 'gpt-4o', + messages: [ + { role: 'system', content: 'You are a precise summariser.' }, + { role: 'user', content: 'Summarise the GitHub Actions changelog for last month.' }, + ], + }); + return r; +}; + +export const callResponses = async () => { + const r = await openai.responses.create({ + model: 'gpt-4o', + prompt: 'List three classic distributed-systems concepts.', + }); + return r; +}; diff --git a/packages/action/tests/fixtures/sdk-python.py b/packages/action/tests/fixtures/sdk-python.py new file mode 100644 index 0000000..88d4eca --- /dev/null +++ b/packages/action/tests/fixtures/sdk-python.py @@ -0,0 +1,12 @@ +from anthropic import Anthropic + +client = Anthropic() + + +def call_messages(): + response = client.messages.create( + model='claude-opus-4-7', + system='You are a helpful assistant.', + messages=[{'role': 'user', 'content': 'Hello there.'}], + ) + return response diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000..1f07599 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,137 @@ +# @tokenometer/mcp + +[![npm @tokenometer/mcp](https://img.shields.io/npm/v/@tokenometer/mcp.svg?label=%40tokenometer%2Fmcp)](https://www.npmjs.com/package/@tokenometer/mcp) +[![License: MIT](https://img.shields.io/github/license/faraa2m/tokenometer.svg)](https://github.com/faraa2m/tokenometer/blob/main/LICENSE) + +> Model Context Protocol (MCP) server that wraps [`@tokenometer/core`](https://www.npmjs.com/package/@tokenometer/core). Lets AI agents — Claude Desktop, Cursor, or anything else that speaks MCP — estimate LLM token cost, run empirical token counts, check budgets, and measure real generation latency *before* dispatching a request. + +The server exposes 10 tools over stdio. It runs as a child process started by your MCP host (Claude Desktop, Cursor, etc.); the host calls `tools/list` to discover the schema and `tools/call` to invoke a tool. Cost estimation is offline by default; empirical and latency modes hit each provider's API (free `countTokens` for empirical, real metered streaming for latency). + +## What it is + +Tokenometer's core library knows how to: + +- Estimate token cost across **63 models / 5 providers** (Anthropic, OpenAI, Google, Mistral, Cohere) using each provider's tokenizer. +- Call provider `countTokens` endpoints (Anthropic, Google, OpenAI, Cohere) for exact counts. +- Measure real streaming TTFT / tokens-per-sec latency. +- Compute vision-token cost for image inputs. + +This package surfaces all of that as MCP tools so an agent can self-monitor spend, A/B model choices, or fail fast on a budget violation without leaving its tool-use loop. + +## Install + +`npx` runs the published bin directly — no global install needed: + +```bash +npx -y @tokenometer/mcp +``` + +The first time `npx` fetches it; later invocations reuse the cache. + +## Claude Desktop + +Add this block to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows): + +```json +{ + "mcpServers": { + "tokenometer": { + "command": "npx", + "args": ["-y", "@tokenometer/mcp"], + "env": { + "ANTHROPIC_API_KEY": "sk-ant-..." + } + } + } +} +``` + +Restart Claude Desktop. Tokenometer tools will appear in the tools picker. + +## Cursor + +Add the same block to `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "tokenometer": { + "command": "npx", + "args": ["-y", "@tokenometer/mcp"], + "env": { + "ANTHROPIC_API_KEY": "sk-ant-...", + "OPENAI_API_KEY": "sk-..." + } + } + } +} +``` + +Restart Cursor. The tools become available to the agent loop automatically. + +## Tool reference + +| Tool | Purpose | Needs API key? | +|---|---|---| +| `estimate_cost` | Estimate token cost for one (text, model) using the offline tokenizer. Optional `outputTokens` adds the completion cost. | No | +| `estimate_cost_matrix` | Same as above, but for the cross-product of `models x formats`. Includes cheapest / most expensive cells. | No | +| `count_tokens_empirical` | Exact token count via the provider's `countTokens` API (free, no completion charge). | Anthropic / Google / Cohere only; OpenAI uses local tiktoken | +| `count_tokens_empirical_matrix` | Empirical count across many models; per-cell errors stay inline so one missing key doesn't abort the matrix. | Per-cell | +| `get_model_info` | Provider, context window, max output tokens, USD per 1k input / output, and the rates dataset version. | No | +| `list_models` | Every registered model; optional `provider` filter. | No | +| `get_rates_version` | The `RATES_VERSION` date stamp from the bundled rates registry. | No | +| `estimate_vision_cost` | Per-image vision tokens for Anthropic / OpenAI / Google. Optional `model` adds per-image USD. | No | +| `budget_check` | Pre-flight: does this prompt fit a `maxCostUsd` or `maxTokens` ceiling on this model? Returns pass/fail plus headroom. | No | +| `measure_latency` | Real metered streaming generations (default 3 trials) per provider; reports TTFT, total ms, tokens/sec as p50 / p95 / mean. **Each trial is a paid chat completion.** | Yes (per provider) | + +### Environment variables + +| Variable | Used by | +|---|---| +| `ANTHROPIC_API_KEY` | empirical + latency on Anthropic | +| `OPENAI_API_KEY` | latency on OpenAI (empirical uses local tiktoken) | +| `GOOGLE_API_KEY` or `GEMINI_API_KEY` | empirical + latency on Google | +| `MISTRAL_API_KEY` | latency on Mistral (empirical is unsupported by upstream) | +| `COHERE_API_KEY` | empirical + latency on Cohere | + +Missing keys surface a structured error: `{ "code": "key_missing", "required": "ANTHROPIC_API_KEY", "docs": "..." }`. Tools that don't need keys never fail on missing env. + +### Error shape + +All tool errors return `isError: true` with a single JSON-encoded text content block: + +```json +{ "code": "user_error", "message": "Unknown model \"fake\". Known models: ..." } +``` + +Stable error codes: + +- `user_error` — a `UserFacingError` from `@tokenometer/core` (unknown model, bad format, etc.). +- `key_missing` — the required provider env var is not set. +- `invalid_args` — input failed zod validation (includes `issues` array). +- `unknown_tool` — the requested tool name is not registered. +- `unsupported_provider` — vision tokens on Mistral / Cohere. +- `provider_error` — empirical call to a provider failed (rate limit, network, etc.). +- `internal` — anything else; check the `message` field. + +## Limitations + +- **Request / response only.** No `prompts`, `resources`, or `roots` capabilities — this server is purely a tool surface. The MCP host drives every invocation. +- **Vision uses bundled math.** Vision-token counts come from the providers' published formulas, not a live API call. Numbers match the providers' documented behavior; they don't account for unannounced changes. +- **Mistral empirical is unsupported.** Mistral does not expose a public token-count endpoint; offline mode (`mistral-tokenizer-js` for V1/V2/V3, chars-over-4 for Tekken) is the only option. +- **Latency is metered.** Every `measure_latency` trial sends a real `max_tokens=200` chat completion. Default trials = 3; bound to 1..10. Budget accordingly. +- **stdio transport only.** Streamable HTTP is on the MCP SDK; this server ships with stdio because that's what Claude Desktop and Cursor use today. + +## Verifying the install + +After the host has started the server you should see this on stderr in the host's log: + +``` +tokenometer-mcp ready +``` + +Calling `tools/list` should return 10 tools. The smallest smoke test is `estimate_cost` with `text: "hello"` and `model: "gpt-4o"` — it runs offline and returns a token count within milliseconds. + +## License + +MIT diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000..36a396a --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,66 @@ +{ + "name": "@tokenometer/mcp", + "version": "1.0.1", + "description": "Model Context Protocol server for tokenometer — lets AI agents self-monitor LLM token spend, estimate costs, and budget-check before sending prompts.", + "license": "MIT", + "author": "Faraazuddin Mohammed ", + "homepage": "https://tokenometer.vercel.app", + "repository": { + "type": "git", + "url": "git+https://github.com/faraa2m/tokenometer.git", + "directory": "packages/mcp" + }, + "bugs": { + "url": "https://github.com/faraa2m/tokenometer/issues" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "ai", + "agent", + "llm", + "tokens", + "cost", + "budget", + "anthropic", + "openai", + "claude", + "gpt", + "gemini", + "mistral", + "cohere" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "tokenometer-mcp": "./dist/index.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist", "README.md"], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "tsc -b && chmod +x dist/index.js", + "prepack": "chmod +x dist/index.js", + "clean": "rm -rf dist" + }, + "dependencies": { + "@tokenometer/core": "1.0.1", + "@modelcontextprotocol/sdk": "^1.0.4", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^3.0.0" + } +} diff --git a/packages/mcp/src/e2e.test.ts b/packages/mcp/src/e2e.test.ts new file mode 100644 index 0000000..f1fecac --- /dev/null +++ b/packages/mcp/src/e2e.test.ts @@ -0,0 +1,148 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const DIST_ENTRY = join(here, '..', 'dist', 'index.js'); +const HAS_BUILD = existsSync(DIST_ENTRY); + +interface JsonRpcMessage { + jsonrpc: '2.0'; + id?: number | string; + method?: string; + params?: Record; + result?: unknown; + error?: { code: number; message: string }; +} + +/** + * Minimal stdio-mode MCP client for end-to-end testing. The protocol uses + * newline-delimited JSON over stdin/stdout, so we write `${JSON.stringify(msg)}\n` + * and split incoming stdout on `\n`. Each line is one JSON-RPC message. + */ +class StdioClient { + private proc: ChildProcess | null = null; + private buffer = ''; + private nextId = 1; + private pending = new Map void>(); + + async start(): Promise { + const proc = spawn('node', [DIST_ENTRY], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + }); + this.proc = proc; + proc.stdout?.on('data', (chunk: Buffer) => { + this.buffer += chunk.toString('utf8'); + while (true) { + const nl = this.buffer.indexOf('\n'); + if (nl === -1) break; + const line = this.buffer.slice(0, nl).trim(); + this.buffer = this.buffer.slice(nl + 1); + if (!line) continue; + let msg: JsonRpcMessage; + try { + msg = JSON.parse(line) as JsonRpcMessage; + } catch { + continue; + } + const id = msg.id; + if (id !== undefined) { + const handler = this.pending.get(id); + if (handler) { + this.pending.delete(id); + handler(msg); + } + } + } + }); + // Wait for the ready line on stderr before doing the initialize handshake. + await new Promise((resolve) => { + const onData = (chunk: Buffer): void => { + if (chunk.toString('utf8').includes('tokenometer-mcp ready')) { + proc.stderr?.off('data', onData); + resolve(); + } + }; + proc.stderr?.on('data', onData); + }); + + // initialize handshake + await this.request('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'e2e-test', version: '0.0.0' }, + }); + // initialized notification (no id, no response expected) + this.notify('notifications/initialized', {}); + } + + request(method: string, params: Record): Promise { + const id = this.nextId++; + const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }); + return new Promise((resolve) => { + this.pending.set(id, resolve); + this.proc?.stdin?.write(`${msg}\n`); + }); + } + + notify(method: string, params: Record): void { + const msg = JSON.stringify({ jsonrpc: '2.0', method, params }); + this.proc?.stdin?.write(`${msg}\n`); + } + + stop(): void { + this.proc?.kill('SIGTERM'); + } +} + +describe.skipIf(!HAS_BUILD)('e2e via spawned stdio server', () => { + const client = new StdioClient(); + + beforeAll(async () => { + await client.start(); + }, 30_000); + + afterAll(() => { + client.stop(); + }); + + it('lists the registered tools', async () => { + const response = await client.request('tools/list', {}); + const result = response.result as { tools: Array<{ name: string }> }; + const names = result.tools.map((t) => t.name); + expect(names).toContain('estimate_cost'); + expect(names).toContain('list_models'); + expect(names).toContain('get_rates_version'); + }); + + it('calls estimate_cost and returns a parseable payload', async () => { + const response = await client.request('tools/call', { + name: 'estimate_cost', + arguments: { text: 'hello world', model: 'gpt-4o' }, + }); + const result = response.result as { + content: Array<{ type: string; text: string }>; + isError?: boolean; + }; + expect(result.isError).toBeFalsy(); + const payload = JSON.parse(result.content[0]?.text ?? '{}') as { tokens: number }; + expect(payload.tokens).toBeGreaterThan(0); + }); + + it('returns isError for an unknown tool name', async () => { + const response = await client.request('tools/call', { + name: 'does_not_exist', + arguments: {}, + }); + const result = response.result as { + content: Array<{ type: string; text: string }>; + isError?: boolean; + }; + expect(result.isError).toBe(true); + const payload = JSON.parse(result.content[0]?.text ?? '{}') as { code: string }; + expect(payload.code).toBe('unknown_tool'); + }); +}); diff --git a/packages/mcp/src/env.ts b/packages/mcp/src/env.ts new file mode 100644 index 0000000..6190911 --- /dev/null +++ b/packages/mcp/src/env.ts @@ -0,0 +1,25 @@ +import type { EmpiricalEnv } from '@tokenometer/core'; + +/** + * Read provider API keys from process.env into the EmpiricalEnv shape + * consumed by `@tokenometer/core`. Mirrors the CLI's readEnv() verbatim so + * both surfaces resolve keys identically. + */ +export const readEnv = (): EmpiricalEnv => { + const env: EmpiricalEnv = {}; + const { + ANTHROPIC_API_KEY, + COHERE_API_KEY, + GEMINI_API_KEY, + GOOGLE_API_KEY, + MISTRAL_API_KEY, + OPENAI_API_KEY, + } = process.env; + if (ANTHROPIC_API_KEY) env.anthropicApiKey = ANTHROPIC_API_KEY; + if (COHERE_API_KEY) env.cohereApiKey = COHERE_API_KEY; + const googleKey = GOOGLE_API_KEY ?? GEMINI_API_KEY; + if (googleKey) env.googleApiKey = googleKey; + if (MISTRAL_API_KEY) env.mistralApiKey = MISTRAL_API_KEY; + if (OPENAI_API_KEY) env.openaiApiKey = OPENAI_API_KEY; + return env; +}; diff --git a/packages/mcp/src/errors.ts b/packages/mcp/src/errors.ts new file mode 100644 index 0000000..84e156a --- /dev/null +++ b/packages/mcp/src/errors.ts @@ -0,0 +1,53 @@ +import { UserFacingError } from '@tokenometer/core'; + +/** + * Standard error result shape returned from tool handlers when a call fails. + * Mirrors the MCP `tools/call` error convention: `isError: true` plus a single + * text content block containing a JSON-encoded payload the calling agent can + * parse for structured handling. + */ +export interface ToolErrorResult { + isError: true; + content: Array<{ type: 'text'; text: string }>; +} + +export interface ToolErrorPayload { + code: string; + message: string; + [key: string]: unknown; +} + +const wrap = (payload: ToolErrorPayload): ToolErrorResult => ({ + isError: true, + content: [{ type: 'text', text: JSON.stringify(payload) }], +}); + +/** + * Map an unknown error (most often a `UserFacingError` from core) onto the + * standard tool error result. Unknown error types are reported under the + * `internal` code with their `.message` (no stack — agents don't need it). + */ +export const toMcpError = (err: unknown): ToolErrorResult => { + if (err instanceof UserFacingError) { + return wrap({ code: 'user_error', message: err.message }); + } + const message = err instanceof Error ? err.message : String(err); + return wrap({ code: 'internal', message }); +}; + +/** + * Convenience constructor for the well-known `key_missing` error returned by + * empirical and latency tools when the required provider env var is unset. + */ +export const keyMissingError = ( + required: string, + docs = 'https://github.com/faraa2m/tokenometer#empirical-mode', +): ToolErrorResult => + wrap({ + code: 'key_missing', + message: `Missing required environment variable: ${required}`, + required, + docs, + }); + +export const errorResult = (payload: ToolErrorPayload): ToolErrorResult => wrap(payload); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 0000000..37e1628 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { createServer } from './server.js'; + +const main = async (): Promise => { + const server = createServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + // Logs to stderr only — stdout is reserved for the protocol. + process.stderr.write('tokenometer-mcp ready\n'); + const cleanup = (): void => { + void transport.close(); + process.exit(0); + }; + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); +}; + +main().catch((err: unknown) => { + process.stderr.write(`tokenometer-mcp fatal: ${(err as Error).message}\n`); + process.exit(1); +}); diff --git a/packages/mcp/src/schemas.ts b/packages/mcp/src/schemas.ts new file mode 100644 index 0000000..d5af84f --- /dev/null +++ b/packages/mcp/src/schemas.ts @@ -0,0 +1,92 @@ +import { z } from 'zod'; + +const FORMATS = ['json', 'yaml', 'xml', 'markdown', 'text'] as const; +const PROVIDERS = ['anthropic', 'cohere', 'google', 'mistral', 'openai'] as const; +const VISION_DETAIL = ['low', 'high', 'auto'] as const; + +const MAX_TEXT_LEN = 1_000_000; + +export const FormatEnum = z.enum(FORMATS); +export const ProviderEnum = z.enum(PROVIDERS); +export const VisionDetailEnum = z.enum(VISION_DETAIL); + +export const EstimateCostInput = z.object({ + text: z.string().min(1).max(MAX_TEXT_LEN), + model: z.string().min(1), + format: FormatEnum.optional(), + outputTokens: z.number().int().min(0).optional(), +}); +export type EstimateCostInput = z.infer; + +export const EstimateCostMatrixInput = z.object({ + text: z.string().min(1).max(MAX_TEXT_LEN), + models: z.array(z.string().min(1)).min(1), + formats: z.array(FormatEnum).min(1).optional(), +}); +export type EstimateCostMatrixInput = z.infer; + +export const CountTokensEmpiricalInput = z.object({ + text: z.string().min(1).max(MAX_TEXT_LEN), + model: z.string().min(1), + format: FormatEnum.optional(), +}); +export type CountTokensEmpiricalInput = z.infer; + +export const CountTokensEmpiricalMatrixInput = z.object({ + text: z.string().min(1).max(MAX_TEXT_LEN), + models: z.array(z.string().min(1)).min(1), + formats: z.array(FormatEnum).min(1).optional(), +}); +export type CountTokensEmpiricalMatrixInput = z.infer; + +export const GetModelInfoInput = z.object({ + model: z.string().min(1), +}); +export type GetModelInfoInput = z.infer; + +export const ListModelsInput = z.object({ + provider: ProviderEnum.optional(), + // Reserved for future capability filters (e.g., 'vision', 'streaming'). + // Accepted but currently ignored to keep the schema forward-compatible. + capability: z.string().min(1).optional(), +}); +export type ListModelsInput = z.infer; + +export const GetRatesVersionInput = z.object({}).strict(); +export type GetRatesVersionInput = z.infer; + +const VisionImage = z.object({ + width: z.number().positive().finite(), + height: z.number().positive().finite(), + detail: VisionDetailEnum.optional(), +}); + +export const EstimateVisionCostInput = z.object({ + provider: ProviderEnum, + images: z.array(VisionImage).min(1), + // Optional model — when provided, per-image USD is computed from the model's + // input-per-1k rate. Without it, only token counts come back. + model: z.string().min(1).optional(), +}); +export type EstimateVisionCostInput = z.infer; + +export const BudgetCheckInput = z + .object({ + text: z.string().min(1).max(MAX_TEXT_LEN), + model: z.string().min(1), + maxCostUsd: z.number().positive().finite().optional(), + maxTokens: z.number().int().positive().optional(), + format: FormatEnum.optional(), + }) + .refine((v) => v.maxCostUsd !== undefined || v.maxTokens !== undefined, { + message: 'budget_check requires at least one of maxCostUsd or maxTokens', + }); +export type BudgetCheckInput = z.infer; + +export const MeasureLatencyInput = z.object({ + model: z.string().min(1), + prompt: z.string().min(1).max(MAX_TEXT_LEN), + trials: z.number().int().min(1).max(10).optional(), + maxTokens: z.number().int().min(1).max(4096).optional(), +}); +export type MeasureLatencyInput = z.infer; diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts new file mode 100644 index 0000000..dd53652 --- /dev/null +++ b/packages/mcp/src/server.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, it } from 'vitest'; +import { TOOLS } from './tools/index.js'; + +const findTool = (name: string) => { + const t = TOOLS.find((x) => x.name === name); + if (!t) throw new Error(`tool not registered: ${name}`); + return t; +}; + +const parsePayload = (result: { content: Array<{ type: 'text'; text: string }> }): unknown => { + const first = result.content[0]; + if (!first) throw new Error('empty content'); + return JSON.parse(first.text); +}; + +describe('TOOLS registry', () => { + it('exposes the expected 10 tools', () => { + const names = TOOLS.map((t) => t.name).sort(); + expect(names).toEqual( + [ + 'budget_check', + 'count_tokens_empirical', + 'count_tokens_empirical_matrix', + 'estimate_cost', + 'estimate_cost_matrix', + 'estimate_vision_cost', + 'get_model_info', + 'get_rates_version', + 'list_models', + 'measure_latency', + ].sort(), + ); + }); + + it('each tool has a non-empty description', () => { + for (const t of TOOLS) { + expect(typeof t.description).toBe('string'); + expect(t.description.length).toBeGreaterThan(10); + } + }); +}); + +describe('estimate_cost', () => { + const tool = findTool('estimate_cost'); + + it('returns tokens and inputCost for a known offline model', async () => { + const result = await tool.handler({ text: 'hello world', model: 'gpt-4o', format: 'text' }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { + tokens: number; + inputCost: number; + totalCost: number; + model: string; + }; + expect(payload.tokens).toBeGreaterThan(0); + expect(payload.inputCost).toBeGreaterThan(0); + expect(payload.totalCost).toBeCloseTo(payload.inputCost, 10); + expect(payload.model).toBe('gpt-4o'); + }); + + it('adds outputCost when outputTokens is provided', async () => { + const result = await tool.handler({ + text: 'hi', + model: 'gpt-4o', + outputTokens: 100, + }); + const payload = parsePayload(result) as { + outputCost?: number; + totalCost: number; + inputCost: number; + }; + expect(payload.outputCost).toBeGreaterThan(0); + expect(payload.totalCost).toBeGreaterThan(payload.inputCost); + }); + + it('returns isError for an unknown model', async () => { + const result = await tool.handler({ text: 'hi', model: 'fake-model-xyz' }); + expect(result.isError).toBe(true); + const payload = parsePayload(result) as { code: string }; + expect(payload.code).toBe('user_error'); + }); +}); + +describe('estimate_cost_matrix', () => { + const tool = findTool('estimate_cost_matrix'); + + it('returns one cell per (model, format) pair plus cheapest/mostExpensive', async () => { + const result = await tool.handler({ + text: 'hello world', + models: ['gpt-4o', 'gpt-4o-mini'], + formats: ['text', 'json'], + }); + const payload = parsePayload(result) as { + results: Array<{ model: string; format: string }>; + cheapest: { model: string }; + mostExpensive: { model: string }; + }; + expect(payload.results.length).toBe(4); + expect(payload.cheapest).toBeDefined(); + expect(payload.mostExpensive).toBeDefined(); + }); + + it('defaults to format=text when formats is omitted', async () => { + const result = await tool.handler({ text: 'hi', models: ['gpt-4o'] }); + const payload = parsePayload(result) as { results: Array<{ format: string }> }; + expect(payload.results).toHaveLength(1); + expect(payload.results[0]?.format).toBe('text'); + }); +}); + +describe('count_tokens_empirical', () => { + const tool = findTool('count_tokens_empirical'); + + it('runs locally for openai (no API key needed)', async () => { + const result = await tool.handler({ text: 'hello world', model: 'gpt-4o' }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { inputTokens: number }; + expect(payload.inputTokens).toBeGreaterThan(0); + }); + + it.skipIf(!process.env.ANTHROPIC_API_KEY)('hits the live Anthropic count endpoint', async () => { + const result = await tool.handler({ text: 'hello', model: 'claude-opus-4-7' }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { inputTokens: number }; + expect(payload.inputTokens).toBeGreaterThan(0); + }); + + it('returns key_missing when the required env var is absent', async () => { + const saved = process.env.ANTHROPIC_API_KEY; + Reflect.deleteProperty(process.env, 'ANTHROPIC_API_KEY'); + try { + const result = await tool.handler({ text: 'hi', model: 'claude-opus-4-7' }); + expect(result.isError).toBe(true); + const payload = parsePayload(result) as { code: string; required: string }; + expect(payload.code).toBe('key_missing'); + expect(payload.required).toBe('ANTHROPIC_API_KEY'); + } finally { + if (saved !== undefined) process.env.ANTHROPIC_API_KEY = saved; + } + }); +}); + +describe('count_tokens_empirical_matrix', () => { + const tool = findTool('count_tokens_empirical_matrix'); + + it('returns per-cell results with inline errors rather than failing wholesale', async () => { + const saved = process.env.ANTHROPIC_API_KEY; + Reflect.deleteProperty(process.env, 'ANTHROPIC_API_KEY'); + try { + const result = await tool.handler({ + text: 'hi', + models: ['gpt-4o', 'claude-opus-4-7'], + }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { + results: Array<{ isError?: boolean; model: string; inputTokens?: number; code?: string }>; + }; + expect(payload.results).toHaveLength(2); + const openai = payload.results.find((r) => r.model === 'gpt-4o'); + const anth = payload.results.find((r) => r.model === 'claude-opus-4-7'); + expect(openai?.isError).toBeFalsy(); + expect((openai as { inputTokens: number }).inputTokens).toBeGreaterThan(0); + expect(anth?.isError).toBe(true); + expect(anth?.code).toBe('key_missing'); + } finally { + if (saved !== undefined) process.env.ANTHROPIC_API_KEY = saved; + } + }); +}); + +describe('get_model_info', () => { + const tool = findTool('get_model_info'); + + it('returns metadata for a known model', async () => { + const result = await tool.handler({ model: 'gpt-4o' }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { + id: string; + provider: string; + ratePer1k: { input: number; output: number }; + ratesVersion: string; + }; + expect(payload.id).toBe('gpt-4o'); + expect(payload.provider).toBe('openai'); + expect(payload.ratePer1k.input).toBeGreaterThan(0); + expect(payload.ratePer1k.output).toBeGreaterThan(0); + expect(payload.ratesVersion).toMatch(/\d{4}-\d{2}-\d{2}/); + }); + + it('errors on unknown model', async () => { + const result = await tool.handler({ model: 'nope-not-real' }); + expect(result.isError).toBe(true); + }); +}); + +describe('list_models', () => { + const tool = findTool('list_models'); + + it('returns every model when no filter is set', async () => { + const result = await tool.handler({}); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { models: Array<{ provider: string }> }; + expect(payload.models.length).toBeGreaterThan(0); + }); + + it('filters by provider', async () => { + const result = await tool.handler({ provider: 'openai' }); + const payload = parsePayload(result) as { models: Array<{ provider: string }> }; + expect(payload.models.length).toBeGreaterThan(0); + for (const m of payload.models) { + expect(m.provider).toBe('openai'); + } + }); +}); + +describe('get_rates_version', () => { + const tool = findTool('get_rates_version'); + + it('returns the rates version', async () => { + const result = await tool.handler({}); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { ratesVersion: string }; + expect(payload.ratesVersion).toMatch(/\d{4}-\d{2}-\d{2}/); + }); +}); + +describe('estimate_vision_cost', () => { + const tool = findTool('estimate_vision_cost'); + + it('computes tokens for an Anthropic image', async () => { + const result = await tool.handler({ + provider: 'anthropic', + images: [{ width: 800, height: 600 }], + }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { + totalTokens: number; + images: Array<{ tokens: number }>; + }; + expect(payload.totalTokens).toBeGreaterThan(0); + expect(payload.images[0]?.tokens).toBe(640); // ceil(480000/750) + }); + + it('includes per-image USD when model is provided', async () => { + const result = await tool.handler({ + provider: 'openai', + images: [{ width: 800, height: 600 }], + model: 'gpt-4o', + }); + const payload = parsePayload(result) as { + images: Array<{ costUsd?: number }>; + totalCostUsd?: number; + }; + expect(payload.images[0]?.costUsd).toBeGreaterThan(0); + expect(payload.totalCostUsd).toBeGreaterThan(0); + }); + + it('returns unsupported_provider for mistral', async () => { + const result = await tool.handler({ + provider: 'mistral', + images: [{ width: 100, height: 100 }], + }); + expect(result.isError).toBe(true); + const payload = parsePayload(result) as { code: string }; + expect(payload.code).toBe('unsupported_provider'); + }); +}); + +describe('budget_check', () => { + const tool = findTool('budget_check'); + + it('passes when prompt fits both budgets', async () => { + const result = await tool.handler({ + text: 'hello', + model: 'gpt-4o', + maxCostUsd: 1, + maxTokens: 10_000, + }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { pass: boolean; headroom: number }; + expect(payload.pass).toBe(true); + expect(payload.headroom).toBeGreaterThan(0); + }); + + it('fails with a reason when token budget is too small', async () => { + const result = await tool.handler({ + text: 'hello world this is a longer prompt to exceed the small budget', + model: 'gpt-4o', + maxTokens: 1, + }); + const payload = parsePayload(result) as { pass: boolean; reason?: string }; + expect(payload.pass).toBe(false); + expect(payload.reason).toMatch(/exceeds/); + }); + + it('rejects calls with neither budget set', () => { + // Schema enforces this at parse time, not in the handler. Verify via safeParse. + const parsed = tool.schema.safeParse({ text: 'hi', model: 'gpt-4o' }); + expect(parsed.success).toBe(false); + }); +}); + +describe('measure_latency', () => { + const tool = findTool('measure_latency'); + + it('returns key_missing without a provider API key', async () => { + const saved = process.env.ANTHROPIC_API_KEY; + Reflect.deleteProperty(process.env, 'ANTHROPIC_API_KEY'); + try { + const result = await tool.handler({ + model: 'claude-opus-4-7', + prompt: 'hi', + trials: 1, + }); + expect(result.isError).toBe(true); + const payload = parsePayload(result) as { code: string }; + expect(payload.code).toBe('key_missing'); + } finally { + if (saved !== undefined) process.env.ANTHROPIC_API_KEY = saved; + } + }); +}); + +describe('schema validation', () => { + it('estimate_cost rejects empty text', () => { + const tool = findTool('estimate_cost'); + const parsed = tool.schema.safeParse({ text: '', model: 'gpt-4o' }); + expect(parsed.success).toBe(false); + }); + + it('estimate_cost_matrix rejects empty models array', () => { + const tool = findTool('estimate_cost_matrix'); + const parsed = tool.schema.safeParse({ text: 'hi', models: [] }); + expect(parsed.success).toBe(false); + }); + + it('measure_latency clamps trials to 10', () => { + const tool = findTool('measure_latency'); + const parsed = tool.schema.safeParse({ + model: 'gpt-4o', + prompt: 'hi', + trials: 20, + }); + expect(parsed.success).toBe(false); + }); +}); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 0000000..f4190d9 --- /dev/null +++ b/packages/mcp/src/server.ts @@ -0,0 +1,69 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { TOOLS } from './tools/index.js'; +import type { ToolResult } from './tools/types.js'; + +const SERVER_NAME = 'tokenometer'; +const SERVER_VERSION = '1.0.1'; + +/** + * Build an MCP `Server` instance with the tokenometer tool set wired up. + * Transport layering (stdio, sse, etc.) is handled separately by the + * caller; this function just registers request handlers. + */ +export const createServer = (): Server => { + const server = new Server( + { name: SERVER_NAME, version: SERVER_VERSION }, + { capabilities: { tools: {} } }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: TOOLS.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: zodToJsonSchema(t.schema, { target: 'jsonSchema7' }), + })), + })); + + const handleCallTool = async (req: { + params: { name: string; arguments?: unknown }; + }): Promise => { + const tool = TOOLS.find((t) => t.name === req.params.name); + if (!tool) { + return { + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify({ code: 'unknown_tool', name: req.params.name }), + }, + ], + }; + } + const parsed = tool.schema.safeParse(req.params.arguments ?? {}); + if (!parsed.success) { + return { + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify({ code: 'invalid_args', issues: parsed.error.issues }), + }, + ], + }; + } + return tool.handler(parsed.data); + }; + + // The SDK's request-handler signature has tightened across protocol versions + // (newer types include an optional `task` field for long-running operations). + // Our handler always returns a plain ToolResult; cast through `unknown` to + // accommodate either signature without losing internal type safety. + server.setRequestHandler( + CallToolRequestSchema, + handleCallTool as unknown as Parameters[1], + ); + + return server; +}; diff --git a/packages/mcp/src/tools/budget-check.ts b/packages/mcp/src/tools/budget-check.ts new file mode 100644 index 0000000..e8b181d --- /dev/null +++ b/packages/mcp/src/tools/budget-check.ts @@ -0,0 +1,78 @@ +import { tokenize } from '@tokenometer/core'; +import type { Format } from '@tokenometer/core'; +import { toMcpError } from '../errors.js'; +import { BudgetCheckInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const DEFAULT_FORMAT: Format = 'text'; + +interface BudgetCheckEntry { + kind: 'cost' | 'tokens'; + pass: boolean; + headroom: number; + reason?: string; +} + +export const budgetCheck: ToolDef = { + name: 'budget_check', + description: + 'Pre-flight check: would sending this prompt to this model fit within a maxCostUsd or maxTokens budget? Returns pass/fail, the actual token + cost, headroom remaining, and a reason on failure. Agents can call this before dispatching the real LLM request.', + schema: BudgetCheckInput, + handler: async (input) => { + try { + const format = input.format ?? DEFAULT_FORMAT; + const cell = tokenize({ format, modelId: input.model, prompt: input.text }); + + const checks: BudgetCheckEntry[] = []; + + if (input.maxCostUsd !== undefined) { + const headroom = input.maxCostUsd - cell.inputCost; + const pass = headroom >= 0; + const entry: BudgetCheckEntry = { kind: 'cost', pass, headroom }; + if (!pass) { + entry.reason = `Input cost $${cell.inputCost.toFixed(6)} exceeds maxCostUsd $${input.maxCostUsd.toFixed(6)}`; + } + checks.push(entry); + } + if (input.maxTokens !== undefined) { + const headroom = input.maxTokens - cell.inputTokens; + const pass = headroom >= 0; + const entry: BudgetCheckEntry = { kind: 'tokens', pass, headroom }; + if (!pass) { + entry.reason = `Input tokens ${cell.inputTokens} exceeds maxTokens ${input.maxTokens}`; + } + checks.push(entry); + } + + const failed = checks.find((c) => !c.pass); + const passed = failed === undefined; + + // Headroom is reported as the *tighter* (smaller) constraint's headroom + // when both limits are passed; agents care about the binding limit. + const firstHeadroom = checks[0]?.headroom ?? 0; + const headroom = + checks.length === 0 + ? 0 + : checks.reduce((min, c) => (c.headroom < min ? c.headroom : min), firstHeadroom); + + const payload = { + pass: passed, + actualTokens: cell.inputTokens, + actualCost: cell.inputCost, + headroom, + checks, + ...(failed?.reason ? { reason: failed.reason } : {}), + model: cell.model, + format: cell.format, + approximate: cell.approximate, + }; + + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/count-tokens-empirical-matrix.ts b/packages/mcp/src/tools/count-tokens-empirical-matrix.ts new file mode 100644 index 0000000..34f421c --- /dev/null +++ b/packages/mcp/src/tools/count-tokens-empirical-matrix.ts @@ -0,0 +1,87 @@ +import { getModel, tokenizeEmpirical } from '@tokenometer/core'; +import type { Format, TokenizeResult } from '@tokenometer/core'; +import { readEnv } from '../env.js'; +import { toMcpError } from '../errors.js'; +import { CountTokensEmpiricalMatrixInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const DEFAULT_FORMATS: readonly Format[] = ['text']; + +const REQUIRED_KEY: Record = { + anthropic: 'ANTHROPIC_API_KEY', + cohere: 'COHERE_API_KEY', + google: 'GOOGLE_API_KEY', + openai: '', + mistral: '', +}; + +const ENV_FIELD: Record = { + anthropic: 'anthropicApiKey', + cohere: 'cohereApiKey', + google: 'googleApiKey', +}; + +type CellOk = TokenizeResult; +type CellErr = { isError: true; model: string; format: Format; code: string; message: string }; + +export const countTokensEmpiricalMatrix: ToolDef = { + name: 'count_tokens_empirical_matrix', + description: + 'Run empirical token counts across the cross-product of models and formats. Per-cell errors (e.g. missing API key, unsupported provider) are returned inline rather than aborting the whole matrix.', + schema: CountTokensEmpiricalMatrixInput, + handler: async (input) => { + try { + const formats: readonly Format[] = input.formats ?? DEFAULT_FORMATS; + const env = readEnv(); + + const tasks: Promise[] = []; + for (const modelId of input.models) { + for (const format of formats) { + tasks.push( + (async (): Promise => { + try { + const model = getModel(modelId); + const requiredVar = REQUIRED_KEY[model.provider]; + if (requiredVar) { + const fieldKey = ENV_FIELD[model.provider]; + if (!fieldKey || !env[fieldKey]) { + return { + isError: true, + model: modelId, + format, + code: 'key_missing', + message: `Missing ${requiredVar}`, + }; + } + } + return await tokenizeEmpirical({ + env, + format, + modelId, + prompt: input.text, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + model: modelId, + format, + code: 'provider_error', + message, + }; + } + })(), + ); + } + } + const results = await Promise.all(tasks); + const payload = { results }; + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/count-tokens-empirical.ts b/packages/mcp/src/tools/count-tokens-empirical.ts new file mode 100644 index 0000000..2d2df6a --- /dev/null +++ b/packages/mcp/src/tools/count-tokens-empirical.ts @@ -0,0 +1,55 @@ +import { getModel, tokenizeEmpirical } from '@tokenometer/core'; +import type { Format } from '@tokenometer/core'; +import { readEnv } from '../env.js'; +import { keyMissingError, toMcpError } from '../errors.js'; +import { CountTokensEmpiricalInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const DEFAULT_FORMAT: Format = 'text'; + +const REQUIRED_KEY: Record = { + anthropic: 'ANTHROPIC_API_KEY', + cohere: 'COHERE_API_KEY', + google: 'GOOGLE_API_KEY', + openai: '', // OpenAI uses local tiktoken — no key needed. + mistral: '', // Mistral has no public token-count API — empirical mode unsupported. +}; + +const ENV_FIELD: Record = { + anthropic: 'anthropicApiKey', + cohere: 'cohereApiKey', + google: 'googleApiKey', +}; + +export const countTokensEmpirical: ToolDef = { + name: 'count_tokens_empirical', + description: + "Count tokens using each provider's official countTokens API (free, exact). Anthropic via messages.countTokens, Google via model.countTokens, OpenAI via local tiktoken o200k_base, Cohere via POST /v1/tokenize. Mistral is unsupported (no public endpoint).", + schema: CountTokensEmpiricalInput, + handler: async (input) => { + try { + const model = getModel(input.model); + const requiredVar = REQUIRED_KEY[model.provider]; + if (requiredVar) { + const env = readEnv(); + const fieldKey = ENV_FIELD[model.provider]; + if (!fieldKey || !env[fieldKey]) { + return keyMissingError(requiredVar); + } + } + const format = input.format ?? DEFAULT_FORMAT; + const cell = await tokenizeEmpirical({ + env: readEnv(), + format, + modelId: input.model, + prompt: input.text, + }); + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(cell) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/estimate-cost-matrix.ts b/packages/mcp/src/tools/estimate-cost-matrix.ts new file mode 100644 index 0000000..b872029 --- /dev/null +++ b/packages/mcp/src/tools/estimate-cost-matrix.ts @@ -0,0 +1,67 @@ +import { tokenizeMatrix } from '@tokenometer/core'; +import type { Format, TokenizeResult } from '@tokenometer/core'; +import { toMcpError } from '../errors.js'; +import { EstimateCostMatrixInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const DEFAULT_FORMATS: readonly Format[] = ['text']; + +interface MatrixCell { + model: string; + format: Format; + tokens: number; + inputCost: number; + approximate: boolean; + tokenizer: TokenizeResult['tokenizer']; +} + +const pickCheapest = (cells: readonly MatrixCell[]): MatrixCell | undefined => + cells.reduce( + (acc, c) => (acc === undefined || c.inputCost < acc.inputCost ? c : acc), + undefined, + ); + +const pickPriciest = (cells: readonly MatrixCell[]): MatrixCell | undefined => + cells.reduce( + (acc, c) => (acc === undefined || c.inputCost > acc.inputCost ? c : acc), + undefined, + ); + +export const estimateCostMatrix: ToolDef = { + name: 'estimate_cost_matrix', + description: + 'Estimate token cost across the cross-product of models and formats. Returns one cell per (model, format) plus pointers to the cheapest and most expensive cells.', + schema: EstimateCostMatrixInput, + handler: async (input) => { + try { + const formats: readonly Format[] = input.formats ?? DEFAULT_FORMATS; + const cells = tokenizeMatrix({ + formats, + modelIds: input.models, + prompt: input.text, + }); + + const results: MatrixCell[] = cells.map((c) => ({ + model: c.model, + format: c.format, + tokens: c.inputTokens, + inputCost: c.inputCost, + approximate: c.approximate, + tokenizer: c.tokenizer, + })); + + const payload = { + results, + cheapest: pickCheapest(results), + mostExpensive: pickPriciest(results), + }; + + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/estimate-cost.ts b/packages/mcp/src/tools/estimate-cost.ts new file mode 100644 index 0000000..cdd93e6 --- /dev/null +++ b/packages/mcp/src/tools/estimate-cost.ts @@ -0,0 +1,47 @@ +import { getModel, getRate, tokenize } from '@tokenometer/core'; +import type { Format } from '@tokenometer/core'; +import { toMcpError } from '../errors.js'; +import { EstimateCostInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const DEFAULT_FORMAT: Format = 'text'; + +export const estimateCost: ToolDef = { + name: 'estimate_cost', + description: + 'Estimate input (and optional output) token cost for a single prompt + model. Uses the offline tokenizer for the provider (cl100k for Anthropic, o200k for OpenAI, chars/4 heuristic for Google/Cohere, SentencePiece for Mistral V1/V2/V3).', + schema: EstimateCostInput, + handler: async (input) => { + try { + const format = input.format ?? DEFAULT_FORMAT; + const cell = tokenize({ format, modelId: input.model, prompt: input.text }); + const rate = getRate(input.model); + // Touch getModel to surface UserFacingError early for unknown models even + // when tokenize() happens to succeed first. (Defensive; tokenize already + // calls it, but this keeps the error path consistent.) + getModel(input.model); + + const outputTokens = input.outputTokens; + const outputCost = + outputTokens !== undefined ? (outputTokens / 1000) * rate.outputPer1k : undefined; + const totalCost = cell.inputCost + (outputCost ?? 0); + + const payload = { + tokens: cell.inputTokens, + inputCost: cell.inputCost, + ...(outputCost !== undefined ? { outputCost } : {}), + totalCost, + model: cell.model, + format: cell.format, + approximate: cell.approximate, + tokenizer: cell.tokenizer, + }; + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/estimate-vision-cost.ts b/packages/mcp/src/tools/estimate-vision-cost.ts new file mode 100644 index 0000000..93d53f4 --- /dev/null +++ b/packages/mcp/src/tools/estimate-vision-cost.ts @@ -0,0 +1,100 @@ +import { + anthropicVisionTokens, + getModel, + getRate, + googleVisionTokens, + openaiVisionTokens, +} from '@tokenometer/core'; +import type { Provider } from '@tokenometer/core'; +import { errorResult, toMcpError } from '../errors.js'; +import { EstimateVisionCostInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const computeTokens = ( + provider: Provider, + img: { width: number; height: number; detail?: 'low' | 'high' | 'auto' | undefined }, +): number => { + switch (provider) { + case 'anthropic': + return anthropicVisionTokens( + img.detail !== undefined + ? { width: img.width, height: img.height, detail: img.detail } + : { width: img.width, height: img.height }, + ); + case 'openai': + return openaiVisionTokens( + img.detail !== undefined + ? { width: img.width, height: img.height, detail: img.detail } + : { width: img.width, height: img.height }, + ); + case 'google': + return googleVisionTokens( + img.detail !== undefined + ? { width: img.width, height: img.height, detail: img.detail } + : { width: img.width, height: img.height }, + ); + default: + throw new Error(`Vision tokens not supported for provider "${provider}"`); + } +}; + +export const estimateVisionCost: ToolDef = { + name: 'estimate_vision_cost', + description: + "Estimate vision-token cost for one or more images using the provider's published formula (Anthropic, OpenAI, or Google). Optionally include `model` to get per-image USD using that model's input rate. Mistral and Cohere are not supported.", + schema: EstimateVisionCostInput, + handler: async (input) => { + try { + if (input.provider === 'mistral' || input.provider === 'cohere') { + return errorResult({ + code: 'unsupported_provider', + message: `Vision tokens are not published for provider "${input.provider}". Use anthropic, openai, or google.`, + }); + } + + const inputPer1k = (() => { + if (input.model === undefined) return undefined; + const model = getModel(input.model); + if (model.provider !== input.provider) { + throw new Error( + `Model "${input.model}" belongs to provider "${model.provider}" but request specifies "${input.provider}".`, + ); + } + return getRate(input.model).inputPer1k; + })(); + + const images = input.images.map((img) => { + const tokens = computeTokens(input.provider, img); + const costUsd = inputPer1k !== undefined ? (tokens / 1000) * inputPer1k : undefined; + return { + width: img.width, + height: img.height, + ...(img.detail !== undefined ? { detail: img.detail } : {}), + tokens, + ...(costUsd !== undefined ? { costUsd } : {}), + }; + }); + + const totalTokens = images.reduce((sum, i) => sum + i.tokens, 0); + const totalCostUsd = images.reduce((sum, i) => { + if (i.costUsd === undefined) return sum; + return (sum ?? 0) + i.costUsd; + }, undefined); + + const payload = { + provider: input.provider, + ...(input.model !== undefined ? { model: input.model } : {}), + images, + totalTokens, + ...(totalCostUsd !== undefined ? { totalCostUsd } : {}), + }; + + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/get-model-info.ts b/packages/mcp/src/tools/get-model-info.ts new file mode 100644 index 0000000..0177715 --- /dev/null +++ b/packages/mcp/src/tools/get-model-info.ts @@ -0,0 +1,35 @@ +import { RATES_VERSION, getModel, getRate } from '@tokenometer/core'; +import { toMcpError } from '../errors.js'; +import { GetModelInfoInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +export const getModelInfo: ToolDef = { + name: 'get_model_info', + description: + 'Return registry metadata for a model: provider, context window, max output tokens, input/output USD per 1k, and the rates dataset version.', + schema: GetModelInfoInput, + handler: async (input) => { + try { + const descriptor = getModel(input.model); + const rate = getRate(input.model); + const payload = { + id: descriptor.id, + provider: descriptor.provider, + contextWindow: descriptor.contextWindow, + maxOutput: descriptor.maxOutputTokens, + ratePer1k: { + input: rate.inputPer1k, + output: rate.outputPer1k, + ...(rate.cachedInputPer1k !== undefined ? { cachedInput: rate.cachedInputPer1k } : {}), + }, + ratesVersion: RATES_VERSION, + }; + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/get-rates-version.ts b/packages/mcp/src/tools/get-rates-version.ts new file mode 100644 index 0000000..e23b08e --- /dev/null +++ b/packages/mcp/src/tools/get-rates-version.ts @@ -0,0 +1,22 @@ +import { RATES_VERSION } from '@tokenometer/core'; +import { toMcpError } from '../errors.js'; +import { GetRatesVersionInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +export const getRatesVersion: ToolDef = { + name: 'get_rates_version', + description: + 'Return the version stamp of the rates dataset bundled with this server. Use this to detect when pricing data may be stale relative to a published date.', + schema: GetRatesVersionInput, + handler: async () => { + try { + const payload = { ratesVersion: RATES_VERSION }; + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts new file mode 100644 index 0000000..6d80530 --- /dev/null +++ b/packages/mcp/src/tools/index.ts @@ -0,0 +1,31 @@ +import { budgetCheck } from './budget-check.js'; +import { countTokensEmpiricalMatrix } from './count-tokens-empirical-matrix.js'; +import { countTokensEmpirical } from './count-tokens-empirical.js'; +import { estimateCostMatrix } from './estimate-cost-matrix.js'; +import { estimateCost } from './estimate-cost.js'; +import { estimateVisionCost } from './estimate-vision-cost.js'; +import { getModelInfo } from './get-model-info.js'; +import { getRatesVersion } from './get-rates-version.js'; +import { listModels } from './list-models.js'; +import { measureLatencyTool } from './measure-latency.js'; +import type { AnyToolDef } from './types.js'; + +// Each concrete ToolDef is widened to `AnyToolDef` via `as unknown as` so the +// heterogeneous array type-checks under TypeScript's strict function-parameter +// variance. The server safeParses each tool's schema before invoking handler, +// so the runtime input shape is always validated even though the array has +// lost the specific input type at compile time. +export const TOOLS: ReadonlyArray = [ + estimateCost, + estimateCostMatrix, + countTokensEmpirical, + countTokensEmpiricalMatrix, + getModelInfo, + listModels, + getRatesVersion, + estimateVisionCost, + budgetCheck, + measureLatencyTool, +] as unknown as ReadonlyArray; + +export type { AnyToolDef, ToolDef, ToolResult } from './types.js'; diff --git a/packages/mcp/src/tools/list-models.ts b/packages/mcp/src/tools/list-models.ts new file mode 100644 index 0000000..9a4c48b --- /dev/null +++ b/packages/mcp/src/tools/list-models.ts @@ -0,0 +1,26 @@ +import { MODELS } from '@tokenometer/core'; +import type { ModelDescriptor } from '@tokenometer/core'; +import { toMcpError } from '../errors.js'; +import { ListModelsInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +export const listModels: ToolDef = { + name: 'list_models', + description: + 'List every registered model in the rates registry, optionally filtered by provider. Each entry includes id, provider, context window, max output tokens, and pricing source.', + schema: ListModelsInput, + handler: async (input) => { + try { + const all: ModelDescriptor[] = Object.values(MODELS); + const filtered = + input.provider !== undefined ? all.filter((m) => m.provider === input.provider) : all; + const payload = { models: filtered }; + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/measure-latency.ts b/packages/mcp/src/tools/measure-latency.ts new file mode 100644 index 0000000..206f5a6 --- /dev/null +++ b/packages/mcp/src/tools/measure-latency.ts @@ -0,0 +1,58 @@ +import { getModel, measureLatency } from '@tokenometer/core'; +import { readEnv } from '../env.js'; +import { keyMissingError, toMcpError } from '../errors.js'; +import { MeasureLatencyInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const REQUIRED_KEY: Record = { + anthropic: 'ANTHROPIC_API_KEY', + cohere: 'COHERE_API_KEY', + google: 'GOOGLE_API_KEY', + mistral: 'MISTRAL_API_KEY', + openai: 'OPENAI_API_KEY', +}; + +const ENV_FIELD: Record< + string, + 'anthropicApiKey' | 'cohereApiKey' | 'googleApiKey' | 'mistralApiKey' | 'openaiApiKey' | undefined +> = { + anthropic: 'anthropicApiKey', + cohere: 'cohereApiKey', + google: 'googleApiKey', + mistral: 'mistralApiKey', + openai: 'openaiApiKey', +}; + +const DEFAULT_TRIALS = 3; + +export const measureLatencyTool: ToolDef = { + name: 'measure_latency', + description: + 'Run real metered streaming generations against the provider and report TTFT, total ms, and tokens/sec as p50/p95/mean. Each trial is a real (paid) chat completion. Requires the provider API key.', + schema: MeasureLatencyInput, + handler: async (input) => { + try { + const model = getModel(input.model); + const requiredVar = REQUIRED_KEY[model.provider]; + const fieldKey = ENV_FIELD[model.provider]; + const env = readEnv(); + if (!fieldKey || !env[fieldKey]) { + return keyMissingError(requiredVar ?? 'PROVIDER_API_KEY'); + } + const trials = input.trials ?? DEFAULT_TRIALS; + const latency = await measureLatency({ + env, + modelId: input.model, + prompt: input.prompt, + trials, + ...(input.maxTokens !== undefined ? { maxTokens: input.maxTokens } : {}), + }); + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(latency) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/types.ts b/packages/mcp/src/tools/types.ts new file mode 100644 index 0000000..86e1be8 --- /dev/null +++ b/packages/mcp/src/tools/types.ts @@ -0,0 +1,36 @@ +import type { z } from 'zod'; + +export interface ToolResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} + +/** + * One tool entry. `S` is the zod input schema type (inferred from each tool's + * file). The handler receives the parsed input narrowed by `z.infer`. + * + * Note on variance: arrays of `ToolDef` with heterogeneous schemas need to be + * typed as `ReadonlyArray` to satisfy TypeScript's strict variance + * check on the handler's parameter type. `AnyToolDef` erases the schema's + * input shape to `unknown`; the server narrows on the parsed value before + * dispatch, so the runtime contract is preserved. + */ +export interface ToolDef { + name: string; + description: string; + schema: S; + handler: (input: z.infer) => Promise; +} + +/** + * Erased-shape tool def for storing heterogeneous handlers in a single array. + * The handler accepts `unknown` because at the array level we've lost the + * specific input type. Callers must safeParse the schema before invoking + * `handler` (which the server already does). + */ +export interface AnyToolDef { + name: string; + description: string; + schema: z.ZodTypeAny; + handler: (input: unknown) => Promise; +} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 0000000..c44ff6a --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist"], + "references": [{ "path": "../core" }] +} diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 0000000..a80b633 --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,146 @@ +# @tokenometer/react + +React hooks and unstyled components for building LLM token-cost dashboards on +top of [`@tokenometer/core`](https://www.npmjs.com/package/@tokenometer/core). +Counters, cost matrices, budget meters, vision-token estimators and a live +tokenizer textarea for Claude, GPT-4o, Gemini, Mistral and Cohere — with a +headless API so you can bring your own UI. + +## Install + +```bash +npm i @tokenometer/react @tokenometer/core react react-dom +``` + +`react`, `react-dom` and `@tokenometer/core` are peer dependencies, so the +package adds nothing extra to your bundle beyond its own code. + +## Quickstart + +### Token counter + +```tsx +import { TokenCounter } from '@tokenometer/react'; + +export function Header() { + return ; +} +``` + +### Cost matrix across models and formats + +```tsx +import { ModelCostMatrix } from '@tokenometer/react'; + +export function Compare({ prompt }: { prompt: string }) { + return ( + + ); +} +``` + +### Live tokenizer textarea + +```tsx +import { LiveTokenizer } from '@tokenometer/react'; + +export function Playground() { + return ; +} +``` + +## Hooks API + +All hooks are tree-shakeable and exported from both the root entry and +`@tokenometer/react/hooks`. + +| Hook | Signature | Notes | +| --- | --- | --- | +| `useTokenCount` | `({ prompt, model, format? }) => { tokens, cost, tokenizer, approximate, error? }` | Synchronous. Memoized on inputs. | +| `useTokenCountEmpirical` | `({ prompt, model, env, format? }) => { data?, error?, isLoading }` | Async; provider API keys via `env`. | +| `useCostMatrix` | `({ prompt, models, formats? }) => TokenizeResult[]` | Flat cartesian product. | +| `useBudget` | `({ usedUsd, budgetUsd, warnAt? }) => { percent, remaining, state, formatted }` | `state` is `'ok' \| 'warn' \| 'over'`. | +| `useDebouncedTokenCount` | `({ prompt, model, delayMs?, format? }) => UseTokenCountResult & { isPending }` | Debounces the prompt before tokenizing. | +| `useModelList` | `({ providers? }) => ModelDescriptor[]` | Reads `MODELS` from core. | +| `usePricing` | `({ models?, providers? }) => { model, rate }[]` | Projects `RATES` into rows. | + +## Components API + +| Component | Required props | Optional props | +| --- | --- | --- | +| `TokenCounter` | `prompt`, `model` | `format`, `className`, `render` | +| `ModelCostMatrix` | `prompt`, `models` | `formats`, `className` | +| `BudgetMeter` | `usedUsd`, `budgetUsd` | `warnAt`, `label`, `className` | +| `CostBreakdown` | `items` | `showTotal`, `className` | +| `ModelSelector` | `value`, `onChange` | `providers`, `id`, `placeholder`, `className` | +| `LiveTokenizer` | `model` | `defaultPrompt`, `debounceMs`, `placeholder`, `onChange`, `className` | +| `PricingTable` | — | `models`, `providers`, `currency`, `className` | +| `VisionCostEstimator` | `provider`, `images` | `model`, `className` | + +Every component accepts `className` and forwards a ref to its root element. +Components emit `data-tk=""` attributes so consumers can target them +with their own CSS without ID gymnastics. + +## Headless pattern + +Every component is built on top of a hook. If you want full control over +the markup, skip the components and call the hook directly: + +```tsx +import { useTokenCount, formatUsd } from '@tokenometer/react'; + +export function CustomBadge({ prompt, model }: { prompt: string; model: string }) { + const { tokens, cost, approximate } = useTokenCount({ prompt, model }); + return ( +
+ {tokens} tokens · {formatUsd(cost)} + {approximate ? (approx.) : null} +
+ ); +} +``` + +## Styled variants + +If you want batteries-included visuals without writing CSS, import from +`@tokenometer/react/styled`: + +```tsx +import { StyledTokenCounter, StyledPricingTable } from '@tokenometer/react/styled'; +``` + +Styled wrappers apply minimal inline styles using CSS custom properties +(`--tk-bg`, `--tk-fg`, `--tk-border`, `--tk-warn`, `--tk-danger`, +`--tk-spacing`, `--tk-radius`, `--tk-font`). Override any of them on +`:root` or a parent element to theme. + +## SSR and React Server Components + +The build emits a `"use client";` banner so the package works inside React +Server Components without wrapping every import in `'use client'`. Hooks +that touch DOM APIs (debounce timers, `useState`) are intentionally lazy: +they only run on the client. Server-rendered output is deterministic and +matches the first client render for synchronous hooks. + +## Micro-frontend notes + +The package is ESM-first with a CommonJS fallback and ships separate +entry points for hooks, components, and styled wrappers. Federated builds +can import only what they use: + +```ts +import { useTokenCount } from '@tokenometer/react/hooks'; +import { TokenCounter } from '@tokenometer/react/components'; +``` + +`sideEffects: false` keeps bundlers happy with tree-shaking. React, +react-dom and `@tokenometer/core` are peer dependencies, so federated +hosts can dedupe them. + +## License + +MIT. diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000..4cd856d --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,85 @@ +{ + "name": "@tokenometer/react", + "version": "0.1.0", + "description": "React hooks and components for LLM token cost dashboards — drop-in counters, cost matrices, budget meters for Claude, GPT-4o, Gemini, Mistral, Cohere.", + "license": "MIT", + "author": "Faraazuddin Mohammed ", + "homepage": "https://tokenometer.vercel.app", + "repository": { + "type": "git", + "url": "git+https://github.com/faraa2m/tokenometer.git", + "directory": "packages/react" + }, + "bugs": { + "url": "https://github.com/faraa2m/tokenometer/issues" + }, + "keywords": [ + "react", + "hooks", + "llm", + "tokens", + "cost", + "dashboard", + "ai", + "claude", + "gpt", + "gemini" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./hooks": { + "types": "./dist/hooks/index.d.ts", + "import": "./dist/hooks/index.js", + "require": "./dist/hooks/index.cjs" + }, + "./components": { + "types": "./dist/components/index.d.ts", + "import": "./dist/components/index.js", + "require": "./dist/components/index.cjs" + }, + "./styled": { + "types": "./dist/styled/index.d.ts", + "import": "./dist/styled/index.js", + "require": "./dist/styled/index.cjs" + }, + "./package.json": "./package.json" + }, + "files": ["dist", "README.md"], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "tsup", + "clean": "rm -rf dist" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0", + "@tokenometer/core": ">=1.0.1" + }, + "devDependencies": { + "@tokenometer/core": "1.0.1", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", + "@types/node": "^22.10.5", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "jsdom": "^25.0.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^3.0.0" + } +} diff --git a/packages/react/src/components/BudgetMeter.test.tsx b/packages/react/src/components/BudgetMeter.test.tsx new file mode 100644 index 0000000..2dc6ffe --- /dev/null +++ b/packages/react/src/components/BudgetMeter.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { BudgetMeter } from './BudgetMeter.js'; + +describe('BudgetMeter', () => { + it('renders a progress bar with the right max and value', () => { + render(); + const bar = screen.getByRole('progressbar', { name: 'month' }); + expect(bar).toHaveAttribute('max', '10'); + expect(bar).toHaveAttribute('value', '2.5'); + }); + + it('sets state attribute according to spend', () => { + const { container, rerender } = render(); + expect(container.firstChild).toHaveAttribute('data-tk-state', 'ok'); + rerender(); + expect(container.firstChild).toHaveAttribute('data-tk-state', 'warn'); + rerender(); + expect(container.firstChild).toHaveAttribute('data-tk-state', 'over'); + }); + + it('clamps the progress value to the max so the bar does not overflow', () => { + render(); + const bar = screen.getByRole('progressbar', { name: 'm' }); + expect(bar).toHaveAttribute('value', '10'); + }); +}); diff --git a/packages/react/src/components/BudgetMeter.tsx b/packages/react/src/components/BudgetMeter.tsx new file mode 100644 index 0000000..8cb906e --- /dev/null +++ b/packages/react/src/components/BudgetMeter.tsx @@ -0,0 +1,39 @@ +import { forwardRef } from 'react'; +import { useBudget } from '../hooks/useBudget.js'; + +export interface BudgetMeterProps { + usedUsd: number; + budgetUsd: number; + warnAt?: number; + className?: string; + label?: string; +} + +/** + * Progress bar visualizing spend against a USD budget. + * `` is intentionally unstyled — wrap with CSS or use the + * styled variant for opinionated visuals. + */ +export const BudgetMeter = forwardRef( + function BudgetMeter(props, ref) { + const { usedUsd, budgetUsd, warnAt, className, label = 'budget' } = props; + const budget = useBudget({ + usedUsd, + budgetUsd, + ...(warnAt !== undefined ? { warnAt } : {}), + }); + const max = budgetUsd > 0 ? budgetUsd : 1; + const value = Math.max(0, Math.min(usedUsd, max)); + return ( +
+
+ {label} + + {budget.formatted.used} / {budget.formatted.budget} ({budget.formatted.percent}) + +
+ +
+ ); + }, +); diff --git a/packages/react/src/components/CostBreakdown.tsx b/packages/react/src/components/CostBreakdown.tsx new file mode 100644 index 0000000..4510d36 --- /dev/null +++ b/packages/react/src/components/CostBreakdown.tsx @@ -0,0 +1,56 @@ +import { forwardRef } from 'react'; +import { formatUsd } from '../utils/format.js'; + +export interface CostBreakdownItem { + label: string; + tokens: number; + cost: number; +} + +export interface CostBreakdownProps { + items: readonly CostBreakdownItem[]; + showTotal?: boolean; + className?: string; +} + +/** + * Table of {label, tokens, cost} rows with an optional total row. + */ +export const CostBreakdown = forwardRef( + function CostBreakdown(props, ref) { + const { items, showTotal = true, className } = props; + const totals = items.reduce( + (acc, item) => ({ cost: acc.cost + item.cost, tokens: acc.tokens + item.tokens }), + { cost: 0, tokens: 0 }, + ); + return ( + + + + + + + + + + {items.map((item) => ( + + + + + + ))} + + {showTotal ? ( + + + + + + + + ) : null} +
labeltokenscost
{item.label}{item.tokens}{formatUsd(item.cost)}
total{totals.tokens}{formatUsd(totals.cost)}
+ ); + }, +); diff --git a/packages/react/src/components/LiveTokenizer.tsx b/packages/react/src/components/LiveTokenizer.tsx new file mode 100644 index 0000000..e1803cc --- /dev/null +++ b/packages/react/src/components/LiveTokenizer.tsx @@ -0,0 +1,60 @@ +import { type ChangeEvent, forwardRef, useCallback, useState } from 'react'; +import { useDebouncedTokenCount } from '../hooks/useDebouncedTokenCount.js'; +import { formatUsd } from '../utils/format.js'; + +export interface LiveTokenizerProps { + model: string; + defaultPrompt?: string; + debounceMs?: number; + placeholder?: string; + className?: string; + onChange?: (prompt: string) => void; +} + +/** + * Controlled textarea + debounced token count. Renders a live readout of + * tokens and input cost. + */ +export const LiveTokenizer = forwardRef( + function LiveTokenizer(props, ref) { + const { + model, + defaultPrompt = '', + debounceMs = 200, + placeholder = 'paste a prompt...', + className, + onChange, + } = props; + const [prompt, setPrompt] = useState(defaultPrompt); + const onTextareaChange = useCallback( + (e: ChangeEvent) => { + const next = e.target.value; + setPrompt(next); + onChange?.(next); + }, + [onChange], + ); + const result = useDebouncedTokenCount({ prompt, model, delayMs: debounceMs }); + return ( +
+