diff --git a/.github/workflows/publish-sdk.yml b/.github/workflows/publish-sdk.yml new file mode 100644 index 0000000000..e47ce863ba --- /dev/null +++ b/.github/workflows/publish-sdk.yml @@ -0,0 +1,63 @@ +name: Publish SDK + +on: + workflow_dispatch: + inputs: + bump: + description: "Semver bump for the release" + type: choice + options: [patch, minor, major] + default: patch + schedule: + # Daily data release, after the hourly model syncs have merged. + - cron: "23 5 * * *" + +concurrency: publish-sdk + +jobs: + publish: + if: github.repository == 'anomalyco/models.dev' + runs-on: ubuntu-latest + permissions: + contents: write # push sdk-v* tags on manual releases + id-token: write # npm trusted publishing (OIDC) + provenance + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: dev + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: bun install + + - name: Validate models + run: bun validate + + - name: SDK tests + run: bun run test + working-directory: packages/sdk + + - name: Publish + id: publish + run: > + bun script/publish.ts + --bump=${{ inputs.bump || 'patch' }} + ${{ github.event_name == 'schedule' && '--if-changed' || '' }} + working-directory: packages/sdk + + - name: Tag release + if: github.event_name == 'workflow_dispatch' && steps.publish.outputs.version != '' + run: | + git tag "sdk-v${{ steps.publish.outputs.version }}" + git push origin "sdk-v${{ steps.publish.outputs.version }}" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 86bf9bfbcc..d96ecdd4f8 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -23,3 +23,7 @@ jobs: - name: Run validation script run: bun validate + + - name: SDK tests + run: bun run test + working-directory: packages/sdk diff --git a/.gitignore b/.gitignore index ed5663cc85..517093ff52 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist .sync/ node_modules .opencode/package-lock.json +packages/sdk/src/snapshot.js diff --git a/bun.lock b/bun.lock index bccc3b80b7..9184da8c97 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ }, }, "packages/core": { - "name": "models.dev", + "name": "@models.dev/core", "version": "0.0.0", "dependencies": { "remeda": "^2.33.7", @@ -29,12 +29,30 @@ "@tsconfig/bun": "catalog:", }, }, + "packages/sdk": { + "name": "models.dev", + "version": "0.0.0", + "devDependencies": { + "@models.dev/core": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "effect": "4.0.0-beta.83", + "typescript": "catalog:", + "zod": "catalog:", + }, + "peerDependencies": { + "effect": "4.0.0-beta.83", + }, + "optionalPeers": [ + "effect", + ], + }, "packages/web": { "name": "@models.dev/web", "dependencies": { + "@models.dev/core": "workspace:*", "@tanstack/virtual-core": "^3.14.0", "hono": "^4.8.0", - "models.dev": "workspace:*", }, "devDependencies": { "@types/bun": "^1.2.16", @@ -54,10 +72,26 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], + "@models.dev/core": ["@models.dev/core@workspace:packages/core"], + "@models.dev/function": ["@models.dev/function@workspace:packages/function"], "@models.dev/web": ["@models.dev/web@workspace:packages/web"], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="], "@tsconfig/bun": ["@tsconfig/bun@1.0.8", "", {}, "sha512-JlJaRaS4hBTypxtFe8WhnwV8blf0R+3yehLk8XuyxUYNx6VXsKCjACSCvOYEFUiqlhlBWxtYCn/zRlOb8BzBQg=="], @@ -110,10 +144,14 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -136,8 +174,12 @@ "express-rate-limit": ["express-rate-limit@7.5.0", "", { "peerDependencies": { "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg=="], + "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], + "finalhandler": ["finalhandler@2.1.0", "", { "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" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="], + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], @@ -170,6 +212,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], @@ -190,6 +234,8 @@ "jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -202,12 +248,20 @@ "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], - "models.dev": ["models.dev@workspace:packages/core"], + "models.dev": ["models.dev@workspace:packages/sdk"], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="], @@ -236,6 +290,8 @@ "punycode": ["punycode@1.3.2", "", {}, "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="], + "pure-rand": ["pure-rand@8.4.1", "", {}, "sha512-c58R2+SPFcSIPXoU834QN/KPDDOSd8sXcSrqf6e83Me6Rrp1EYkxukkjXMVrKvKaADs1SOyNkWdfvLf6zY8qLQ=="], + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], "querystring": ["querystring@0.2.0", "", {}, "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g=="], @@ -294,8 +350,12 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "toml": ["toml@4.1.2", "", {}, "sha512-m0vXfHODcw3gk+KONAOlVQ5yNHc3yS3B1ybM3HS1vqDoS0RWTDDVBVVTYi8hH0k+2OM1vmo9fb1WX9EVqjqfHA=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], + "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], @@ -304,7 +364,7 @@ "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="], - "uuid": ["uuid@8.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw=="], + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -318,12 +378,16 @@ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.24.3", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A=="], "@models.dev/function/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250522.0", "", {}, "sha512-9RIffHobc35JWeddzBguGgPa4wLDr5x5F94+0/qy7LiV6pTBQ/M5qGEN9VA16IDT3EUpYI0WKh6VpcmeVEtVtw=="], + "aws-sdk/uuid": ["uuid@8.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw=="], + "bun-types/@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="], "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], diff --git a/packages/core/package.json b/packages/core/package.json index 9f0fc0faf0..5068f8b13d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,7 @@ { - "name": "models.dev", + "name": "@models.dev/core", "version": "0.0.0", + "private": true, "$schema": "https://json.schemastore.org/package.json", "type": "module", "dependencies": { diff --git a/packages/core/script/generate-ollama-cloud.ts b/packages/core/script/generate-ollama-cloud.ts index 58f0a434de..a3a433344c 100755 --- a/packages/core/script/generate-ollama-cloud.ts +++ b/packages/core/script/generate-ollama-cloud.ts @@ -31,8 +31,10 @@ function modelFileName(modelName: string): string { return modelName + ".toml"; } -type OllamaModel = Omit & { - limit: Model["limit"] & { output?: number }; +type OllamaModel = Omit & { + description?: Model["description"]; + release_date?: Model["release_date"]; + limit: Omit & { output?: number }; }; type ComparableModel = Pick; }; -function normalizeForComparison(model: Omit): ComparableModel { +function normalizeForComparison(model: OllamaModel | Omit): ComparableModel { return { name: model.name, attachment: model.attachment, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c313977de2..69f4e893dc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ export * from "./schema.js"; export * from "./generate.js"; export * from "./describe.js"; +export * from "./family.js"; diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index c449e94a1e..81d77b5616 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -272,7 +272,11 @@ const ModelBase = z.object({ .optional(), }); -function refineModel(schema: T) { +function refineModel< + Output extends z.infer | z.infer, + Def extends z.ZodTypeDef, + Input, +>(schema: z.ZodType) { return schema .refine( (data) => { diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 50c5bcdd62..0c6446d763 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -735,8 +735,10 @@ function sortReasoningValues(values: Array) { export function formatToml(model: z.infer) { const lines: string[] = []; - if (model.base_model !== undefined) lines.push(`base_model = ${quote(model.base_model)}`); - if (model.base_model_omit !== undefined) { + if ("base_model" in model && model.base_model !== undefined) { + lines.push(`base_model = ${quote(model.base_model)}`); + } + if ("base_model_omit" in model && model.base_model_omit !== undefined) { lines.push(`base_model_omit = [${model.base_model_omit.map(quote).join(", ")}]`); } if (model.name !== undefined) lines.push(`name = ${quote(model.name)}`); @@ -781,8 +783,8 @@ export function formatToml(model: z.infer) { if (model.cost !== undefined) { lines.push("", "[cost]"); - lines.push(`input = ${formatNumber(model.cost.input)}`); - lines.push(`output = ${formatNumber(model.cost.output)}`); + if (model.cost.input !== undefined) lines.push(`input = ${formatNumber(model.cost.input)}`); + if (model.cost.output !== undefined) lines.push(`output = ${formatNumber(model.cost.output)}`); if (model.cost.reasoning !== undefined) { lines.push(`reasoning = ${formatNumber(model.cost.reasoning)}`); } @@ -801,9 +803,11 @@ export function formatToml(model: z.infer) { for (const tier of model.cost.tiers ?? []) { lines.push("", "[[cost.tiers]]"); - lines.push(`tier = { type = ${quote(tier.tier.type ?? "context")}, size = ${formatInteger(tier.tier.size)} }`); - lines.push(`input = ${formatNumber(tier.input)}`); - lines.push(`output = ${formatNumber(tier.output)}`); + if (tier.tier?.size !== undefined) { + lines.push(`tier = { type = ${quote(tier.tier.type ?? "context")}, size = ${formatInteger(tier.tier.size)} }`); + } + if (tier.input !== undefined) lines.push(`input = ${formatNumber(tier.input)}`); + if (tier.output !== undefined) lines.push(`output = ${formatNumber(tier.output)}`); if (tier.reasoning !== undefined) lines.push(`reasoning = ${formatNumber(tier.reasoning)}`); if (tier.cache_read !== undefined) lines.push(`cache_read = ${formatNumber(tier.cache_read)}`); if (tier.cache_write !== undefined) lines.push(`cache_write = ${formatNumber(tier.cache_write)}`); diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 0000000000..e941b4a189 --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,108 @@ +# models.dev + +Official typed client for the [models.dev](https://models.dev) API — an open-source database of AI model capabilities, pricing, and limits. + +```sh +npm install models.dev +``` + +- **Zero dependencies.** The root client is a small `fetch` wrapper; works on Node ≥ 18, Bun, Deno, browsers, and edge runtimes. +- **Fully typed.** Hand-written types, verified in CI to be exactly equivalent to the schemas that generate the data. +- **Three entrypoints.** Promise client, [Effect](https://effect.website) client, and a bundled offline snapshot. + +## Usage + +```ts +import { Models } from "models.dev" + +const client = Models.make() + +const providers = await client.providers() // GET /api.json +providers["anthropic"]?.models["claude-opus-4-6"]?.cost?.input // USD per 1M tokens + +const models = await client.models() // GET /models.json +models["anthropic/claude-opus-4-6"]?.knowledge // provider-agnostic metadata + +const catalog = await client.catalog() // GET /catalog.json — both in one request +``` + +| Method | Endpoint | Contents | +| --- | --- | --- | +| `providers()` | `/api.json` | Providers with their models, pricing, and limits | +| `models()` | `/models.json` | Provider-agnostic model metadata, keyed by `/` | +| `catalog()` | `/catalog.json` | `{ providers, models }` in a single payload | + +The client is **stateless**: every call performs exactly one GET, nothing is cached, and lookups are plain object access on the returned data. Cache however you like: + +```ts +let cached: Promise | undefined +const providers = () => (cached ??= client.providers()) +``` + +Options: + +```ts +const client = Models.make({ + baseUrl: "https://models.dev", // default + fetch: myFetch, // proxies, polyfills, test doubles + headers: { "x-extra": "1" }, // sent with every request +}) + +await client.providers({ signal: AbortSignal.timeout(5000) }) +``` + +Errors are a single `ModelsDevError` with `reason: "Transport" | "UnexpectedStatus" | "MalformedResponse"` and the underlying `cause`. + +## Offline snapshot + +A full copy of the database ships inside the package as a separate, tree-shakable entrypoint — nothing from it is loaded or bundled unless you import it: + +```ts +import snapshot, { providers, models, generatedAt } from "models.dev/snapshot" + +providers["anthropic"]?.models["claude-opus-4-6"]?.limit.context +``` + +Use it for no-network runtimes, tests, cold-start-sensitive paths, or as an explicit fallback: + +```ts +const providers = await client.providers().catch(async () => (await import("models.dev/snapshot")).providers) +``` + +Freshness: the published snapshot is at most ~24h behind the live API (data releases are automated). The client is the freshness path; the snapshot is the availability path. + +## Effect + +An Effect-native client lives at `models.dev/effect` (requires the optional peer dependency `effect`): + +```ts +import { Models } from "models.dev/effect" +import { FetchHttpClient } from "effect/unstable/http" +import { Effect } from "effect" + +const program = Effect.gen(function* () { + const client = yield* Models.make() + return yield* client.providers() // Effect +}) + +await program.pipe(Effect.provide(FetchHttpClient.layer), Effect.runPromise) +``` + +Transport comes from the environment's `HttpClient` service, so proxies, retries, tracing, and test transports compose the usual Effect way. For DI, `Models.Service` and `Models.layer(options?)` are provided: + +```ts +const program = Effect.gen(function* () { + const client = yield* Models.Service + return yield* client.models() +}) + +program.pipe(Effect.provide(Models.layer().pipe(Layer.provide(FetchHttpClient.layer)))) +``` + +## Types + +All data types are exported from the root (and re-exported from `/effect`): `Provider`, `Model`, `ModelMetadata`, `Catalog`, `Cost`, `Limit`, `ReasoningOption`, `KnownProviderID`, and friends. `KNOWN_PROVIDER_IDS` is a runtime list of provider IDs known at release time. + +## Contributing + +The data lives as TOML files in [anomalyco/models.dev](https://github.com/anomalyco/models.dev) — corrections and new models/providers are welcome there. This package is generated and published from that repository. diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 0000000000..2cbd97bfbd --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "models.dev", + "version": "0.0.0", + "description": "Official typed client for the models.dev API \u2014 an open database of AI model capabilities, pricing, and limits", + "type": "module", + "sideEffects": false, + "license": "MIT", + "homepage": "https://models.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/anomalyco/models.dev.git", + "directory": "packages/sdk" + }, + "keywords": [ + "ai", + "llm", + "models", + "pricing", + "context-window", + "openai", + "anthropic", + "effect" + ], + "engines": { + "node": ">=18" + }, + "exports": { + ".": "./src/index.ts", + "./effect": "./src/effect.ts", + "./snapshot": "./src/snapshot.js" + }, + "files": [ + "dist" + ], + "scripts": { + "generate": "bun script/generate.ts", + "build": "bun script/build.ts", + "typecheck": "tsc --noEmit", + "test": "bun run generate && bun run typecheck && bun test" + }, + "peerDependencies": { + "effect": "4.0.0-beta.83" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + } + }, + "devDependencies": { + "@models.dev/core": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "effect": "4.0.0-beta.83", + "typescript": "catalog:", + "zod": "catalog:" + } +} diff --git a/packages/sdk/script/build.ts b/packages/sdk/script/build.ts new file mode 100644 index 0000000000..6df9813233 --- /dev/null +++ b/packages/sdk/script/build.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env bun +// Builds dist/: regenerates snapshot + generated types, compiles with tsc, +// and copies the snapshot module (which tsc does not process) into dist. + +import path from "node:path" +import { rm } from "node:fs/promises" +import { $ } from "bun" +import { generate } from "./generate.ts" + +const pkg = path.join(import.meta.dirname, "..") +const dist = path.join(pkg, "dist") + +export async function build() { + await generate() + await rm(dist, { recursive: true, force: true }) + await $`bunx tsc -p tsconfig.build.json`.cwd(pkg) + await Bun.write(path.join(dist, "snapshot.js"), Bun.file(path.join(pkg, "src", "snapshot.js"))) + await Bun.write(path.join(dist, "snapshot.d.ts"), Bun.file(path.join(pkg, "src", "snapshot.d.ts"))) +} + +if (import.meta.main) { + await build() + console.log("built dist/") +} diff --git a/packages/sdk/script/generate.ts b/packages/sdk/script/generate.ts new file mode 100644 index 0000000000..b3d537383f --- /dev/null +++ b/packages/sdk/script/generate.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env bun +// Generates src/generated.ts (known provider IDs + model family union) and +// src/snapshot.js (the bundled data snapshot) from this repository's TOMLs. + +import path from "node:path" +import { generateCatalog, ModelFamilyValues } from "@models.dev/core" + +const root = path.join(import.meta.dirname, "..", "..", "..") +const src = path.join(import.meta.dirname, "..", "src") + +function sortRecord(record: Record): Record { + return Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) +} + +/** Deterministic catalog: provider, per-provider model, and metadata keys sorted. */ +export async function loadCatalog() { + const catalog = await generateCatalog(root) + const providers = sortRecord( + Object.fromEntries( + Object.entries(catalog.providers).map(([id, provider]) => [id, { ...provider, models: sortRecord(provider.models) }]), + ), + ) + return { providers, models: sortRecord(catalog.models) } +} + +/** The exact JSON payload embedded in src/snapshot.js. Used by publish to diff against npm. */ +export function snapshotPayload(catalog: Awaited>) { + return JSON.stringify(catalog) +} + +function union(values: string[]) { + return values.map((value) => ` | ${JSON.stringify(value)}`).join("\n") +} + +export async function generate() { + const catalog = await loadCatalog() + + const providerIDs = Object.keys(catalog.providers) + const families = [...new Set(ModelFamilyValues)].sort() + await Bun.write( + path.join(src, "generated.ts"), + `// Generated by script/generate.ts. Do not edit; run \`bun run generate\` in packages/sdk. + +/** Provider IDs known when this SDK version was generated. Newer providers may exist; the API is the source of truth. */ +export const KNOWN_PROVIDER_IDS = [ +${providerIDs.map((id) => ` ${JSON.stringify(id)},`).join("\n")} +] as const + +export type KnownProviderID = (typeof KNOWN_PROVIDER_IDS)[number] + +/** Model family identifiers used to group related models. */ +export type ModelFamily = +${union(families)} +`, + ) + + await Bun.write( + path.join(src, "snapshot.js"), + `// Generated by script/generate.ts. Do not edit; run \`bun run generate\` in packages/sdk. +const data = /* @__PURE__ */ JSON.parse(${JSON.stringify(snapshotPayload(catalog))}) +export const providers = data.providers +export const models = data.models +export const generatedAt = ${JSON.stringify(new Date().toISOString())} +export default data +`, + ) + + return catalog +} + +/** Generates once if outputs are missing; used by tests that import the snapshot. */ +export async function ensureGenerated() { + if (await Bun.file(path.join(src, "snapshot.js")).exists()) return + await generate() +} + +if (import.meta.main) { + await generate() + console.log("generated src/generated.ts and src/snapshot.js") +} diff --git a/packages/sdk/script/publish.ts b/packages/sdk/script/publish.ts new file mode 100644 index 0000000000..d086f1f815 --- /dev/null +++ b/packages/sdk/script/publish.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env bun +// Publishes models.dev to npm, opencode-style: +// - the version is never stored in git: it is `npm view models.dev version` +// plus a semver bump computed here (patch by default); +// - `--if-changed` (scheduled data releases) skips publishing when the +// freshly generated snapshot payload is byte-identical to the one inside +// the currently published tarball; +// - package.json exports are rewritten src -> dist for the tarball and +// restored afterwards. +// +// Auth: npm Trusted Publishing (OIDC) in CI — no token needed once the +// package is linked to this repo+workflow on npmjs.com. `--provenance` is +// added automatically when running in GitHub Actions. + +import path from "node:path" +import { appendFile, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { $ } from "bun" +import { loadCatalog, snapshotPayload } from "./generate.ts" +import { build } from "./build.ts" + +const pkg = path.join(import.meta.dirname, "..") +const packageJsonPath = path.join(pkg, "package.json") +const versionTsPath = path.join(pkg, "src", "version.ts") + +const bumpArg = process.argv.find((argument) => argument.startsWith("--bump="))?.slice("--bump=".length) ?? "patch" +const ifChanged = process.argv.includes("--if-changed") + +if (!["patch", "minor", "major"].includes(bumpArg)) { + console.error(`Invalid --bump=${bumpArg}; expected patch, minor, or major`) + process.exit(1) +} + +async function currentVersion(): Promise { + try { + return (await $`npm view models.dev version`.text()).trim() + } catch { + return "0.0.0" + } +} + +function bump(version: string, kind: string): string { + const [major = 0, minor = 0, patch = 0] = version.split(".").map((part) => Number.parseInt(part, 10)) + if (kind === "major") return `${major + 1}.0.0` + if (kind === "minor") return `${major}.${minor + 1}.0` + return `${major}.${minor}.${patch + 1}` +} + +/** The `const data = ...` line of the published dist/snapshot.js, or undefined. */ +async function publishedSnapshotLine(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "models-dev-publish-")) + try { + const tarball = (await $`npm pack models.dev@latest --pack-destination ${directory}`.cwd(directory).text()) + .trim() + .split("\n") + .at(-1)! + await $`tar -xzf ${path.join(directory, tarball)} -C ${directory}` + const file = Bun.file(path.join(directory, "package", "dist", "snapshot.js")) + if (!(await file.exists())) return undefined + const text = await file.text() + return text.split("\n").find((line) => line.startsWith("const data = ")) + } catch { + return undefined + } finally { + await rm(directory, { recursive: true, force: true }) + } +} + +const catalog = await loadCatalog() + +if (ifChanged) { + const fresh = `const data = /* @__PURE__ */ JSON.parse(${JSON.stringify(snapshotPayload(catalog))})` + const published = await publishedSnapshotLine() + if (published === fresh) { + console.log("Snapshot unchanged since the published version; skipping publish") + process.exit(0) + } +} + +const current = await currentVersion() +const next = bump(current, bumpArg) + +const alreadyPublished = await $`npm view models.dev@${next} version`.quiet().nothrow().text() +if (alreadyPublished.trim() === next) { + console.log(`models.dev@${next} already published; skipping`) + process.exit(0) +} + +console.log(`Publishing models.dev@${next} (${bumpArg} bump from ${current})`) + +const packageJson = await Bun.file(packageJsonPath).json() +const versionTs = await Bun.file(versionTsPath).text() + +try { + await Bun.write(versionTsPath, versionTs.replace('"0.0.0"', JSON.stringify(next))) + await build() + + packageJson.version = next + packageJson.exports = { + ".": { types: "./dist/index.d.ts", default: "./dist/index.js" }, + "./effect": { types: "./dist/effect.d.ts", default: "./dist/effect.js" }, + "./snapshot": { types: "./dist/snapshot.d.ts", default: "./dist/snapshot.js" }, + } + await Bun.write(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n") + + const provenance = process.env["GITHUB_ACTIONS"] === "true" ? ["--provenance"] : [] + await $`npm publish --access public ${provenance}`.cwd(pkg) + + const output = process.env["GITHUB_OUTPUT"] + if (output !== undefined) await appendFile(output, `version=${next}\n`) + console.log(`Published models.dev@${next}`) +} finally { + await $`git checkout -- ${packageJsonPath} ${versionTsPath}`.nothrow() +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts new file mode 100644 index 0000000000..e0fab8e82b --- /dev/null +++ b/packages/sdk/src/client.ts @@ -0,0 +1,83 @@ +import { ModelsDevError } from "./error.js" +import type { Catalog, ModelMetadataMap, ProviderMap } from "./types.js" +import { VERSION } from "./version.js" + +/** Accepted anywhere headers can be passed. Same shapes as the standard `HeadersInit`. */ +export type HeadersInput = Headers | Record | Array<[string, string]> + +export interface ClientOptions { + /** Base URL of the models.dev deployment. Defaults to `https://models.dev`. */ + readonly baseUrl?: string + /** + * Custom `fetch` implementation (proxies, polyfills, test doubles). + * Resolved lazily at request time, so late-installed polyfills work. + * Defaults to `globalThis.fetch`. + */ + readonly fetch?: typeof globalThis.fetch + /** Extra headers sent with every request. */ + readonly headers?: HeadersInput +} + +export interface RequestOptions { + readonly signal?: AbortSignal + /** Extra headers for this request. Overrides client-level headers. */ + readonly headers?: HeadersInput +} + +/** + * Creates a stateless models.dev client. Every method performs exactly one + * `GET` and nothing is ever cached — callers who want caching should wrap + * calls with their own policy. For a no-network alternative, see the + * `models.dev/snapshot` entrypoint. + */ +export function make(options: ClientOptions = {}) { + const baseUrl = options.baseUrl ?? "https://models.dev" + const base = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/" + + const request = async (path: string, requestOptions?: RequestOptions): Promise => { + const fetch = options.fetch ?? globalThis.fetch + const headers = new Headers({ "user-agent": `models.dev/${VERSION}` }) + for (const [key, value] of new Headers(options.headers)) headers.set(key, value) + for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value) + + let response: Response + try { + response = await fetch(new URL(path, base), { + method: "GET", + headers, + signal: requestOptions?.signal, + }) + } catch (cause) { + throw new ModelsDevError("Transport", { cause }) + } + if (!response.ok) { + try { + await response.body?.cancel() + } catch {} + throw new ModelsDevError("UnexpectedStatus", { cause: { status: response.status } }) + } + let text: string + try { + text = await response.text() + } catch (cause) { + throw new ModelsDevError("Transport", { cause }) + } + if (text === "") throw new ModelsDevError("MalformedResponse") + try { + return JSON.parse(text) as A + } catch (cause) { + throw new ModelsDevError("MalformedResponse", { cause }) + } + } + + return { + /** All providers with their models, pricing, and limits (`/api.json`). */ + providers: (requestOptions?: RequestOptions) => request("api.json", requestOptions), + /** Provider-agnostic model metadata (`/models.json`). */ + models: (requestOptions?: RequestOptions) => request("models.json", requestOptions), + /** Providers and model metadata in a single request (`/catalog.json`). */ + catalog: (requestOptions?: RequestOptions) => request("catalog.json", requestOptions), + } +} + +export type ModelsClient = ReturnType diff --git a/packages/sdk/src/effect.ts b/packages/sdk/src/effect.ts new file mode 100644 index 0000000000..8323c11371 --- /dev/null +++ b/packages/sdk/src/effect.ts @@ -0,0 +1,5 @@ +// Effect-native client. Requires the optional peer dependency `effect`. +export * as Models from "./effect/client.js" +export { ModelsDevError, type ClientOptions, type ModelsClient } from "./effect/client.js" +export { KNOWN_PROVIDER_IDS } from "./generated.js" +export type * from "./types.js" diff --git a/packages/sdk/src/effect/client.ts b/packages/sdk/src/effect/client.ts new file mode 100644 index 0000000000..b356a28134 --- /dev/null +++ b/packages/sdk/src/effect/client.ts @@ -0,0 +1,58 @@ +import { Context, Effect, Layer, Schema } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import type { Catalog, ModelMetadataMap, ProviderMap } from "../types.js" +import { VERSION } from "../version.js" + +/** The only error in the failure channel of client methods. Wraps the underlying `HttpClientError` as `cause`. */ +export class ModelsDevError extends Schema.TaggedErrorClass()("ModelsDevError", { + cause: Schema.Defect(), +}) {} + +export interface ClientOptions { + /** Base URL of the models.dev deployment. Defaults to `https://models.dev`. */ + readonly baseUrl?: string + /** Extra headers sent with every request. */ + readonly headers?: Record +} + +/** + * Creates a stateless models.dev client on top of the `HttpClient` service + * from the environment (`FetchHttpClient.layer`, `NodeHttpClient.layer`, or a + * custom transport). Nothing is ever cached — compose `Effect.cached` / + * `Effect.cachedWithTTL` around calls for caching. + */ +export const make = (options?: ClientOptions) => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const baseUrl = options?.baseUrl ?? "https://models.dev" + const base = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/" + + const get = (path: string): Effect.Effect => + http + .get(new URL(path, base), { + headers: { "user-agent": `models.dev/${VERSION}`, ...options?.headers }, + }) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.map((data) => data as A), + Effect.mapError((cause) => new ModelsDevError({ cause })), + ) + + return { + /** All providers with their models, pricing, and limits (`/api.json`). */ + providers: () => get("api.json"), + /** Provider-agnostic model metadata (`/models.json`). */ + models: () => get("models.json"), + /** Providers and model metadata in a single request (`/catalog.json`). */ + catalog: () => get("catalog.json"), + } + }) + +export type ModelsClient = Effect.Success> + +/** Service key for dependency-injecting a shared client: `yield* Models.Service`. */ +export class Service extends Context.Service()("models.dev/Models") {} + +/** Layer providing `Models.Service`; requires an `HttpClient` in the environment. */ +export const layer = (options?: ClientOptions) => Layer.effect(Service)(make(options)) diff --git a/packages/sdk/src/error.ts b/packages/sdk/src/error.ts new file mode 100644 index 0000000000..187143448c --- /dev/null +++ b/packages/sdk/src/error.ts @@ -0,0 +1,18 @@ +export type ModelsDevErrorReason = "Transport" | "UnexpectedStatus" | "MalformedResponse" + +/** + * The only error thrown by the models.dev client. + * + * - `Transport` — the fetch itself failed (network, DNS, abort). `cause` is the underlying error. + * - `UnexpectedStatus` — non-2xx response. `cause` is `{ status: number }`. + * - `MalformedResponse` — the body was empty or not valid JSON. `cause` is the parse error, if any. + */ +export class ModelsDevError extends Error { + override readonly name = "ModelsDevError" + constructor( + readonly reason: ModelsDevErrorReason, + options?: ErrorOptions, + ) { + super(reason, options) + } +} diff --git a/packages/sdk/src/generated.ts b/packages/sdk/src/generated.ts new file mode 100644 index 0000000000..3fb68c2614 --- /dev/null +++ b/packages/sdk/src/generated.ts @@ -0,0 +1,367 @@ +// Generated by script/generate.ts. Do not edit; run `bun run generate` in packages/sdk. + +/** Provider IDs known when this SDK version was generated. Newer providers may exist; the API is the source of truth. */ +export const KNOWN_PROVIDER_IDS = [ + "302ai", + "abacus", + "abliteration-ai", + "aihubmix", + "alibaba", + "alibaba-cn", + "alibaba-coding-plan", + "alibaba-coding-plan-cn", + "alibaba-token-plan", + "alibaba-token-plan-cn", + "amazon-bedrock", + "ambient", + "anthropic", + "anyapi", + "atomic-chat", + "auriko", + "azure", + "azure-cognitive-services", + "bailing", + "baseten", + "berget", + "cerebras", + "chutes", + "clarifai", + "claudinio", + "cloudferro-sherlock", + "cloudflare-ai-gateway", + "cloudflare-workers-ai", + "cohere", + "cortecs", + "crof", + "databricks", + "deepinfra", + "deepseek", + "digitalocean", + "dinference", + "drun", + "evroc", + "fastrouter", + "fireworks-ai", + "freemodel", + "friendli", + "frogbot", + "github-copilot", + "github-models", + "gitlab", + "gmicloud", + "google", + "google-vertex", + "google-vertex-anthropic", + "groq", + "helicone", + "hpc-ai", + "huggingface", + "iflowcn", + "inception", + "inceptron", + "inference", + "io-net", + "jiekou", + "kilo", + "kimi-for-coding", + "kuae-cloud-coding-plan", + "lilac", + "llama", + "llmgateway", + "llmtr", + "lmstudio", + "lucidquery", + "meganova", + "merge-gateway", + "minimax", + "minimax-cn", + "minimax-cn-coding-plan", + "minimax-coding-plan", + "mistral", + "mixlayer", + "moark", + "modelscope", + "moonshotai", + "moonshotai-cn", + "morph", + "nano-gpt", + "nearai", + "nebius", + "neon", + "neuralwatt", + "nova", + "novita-ai", + "nvidia", + "ollama-cloud", + "openai", + "opencode", + "opencode-go", + "openrouter", + "orcarouter", + "ovhcloud", + "perplexity", + "perplexity-agent", + "poe", + "poolside", + "privatemode-ai", + "qihang-ai", + "qiniu-ai", + "regolo-ai", + "requesty", + "routing-run", + "sakana", + "sap-ai-core", + "sarvam", + "scaleway", + "siliconflow", + "siliconflow-cn", + "snowflake-cortex", + "stackit", + "stepfun", + "stepfun-ai", + "subconscious", + "submodel", + "synthetic", + "tencent-coding-plan", + "tencent-tokenhub", + "the-grid-ai", + "tinfoil", + "togetherai", + "umans-ai", + "umans-ai-coding-plan", + "upstage", + "v0", + "venice", + "vercel", + "vivgrid", + "vultr", + "wafer.ai", + "wandb", + "xai", + "xiaomi", + "xiaomi-token-plan-ams", + "xiaomi-token-plan-cn", + "xiaomi-token-plan-sgp", + "xpersona", + "zai", + "zai-coding-plan", + "zeldoc", + "zenmux", + "zhipuai", + "zhipuai-coding-plan", +] as const + +export type KnownProviderID = (typeof KNOWN_PROVIDER_IDS)[number] + +/** Model family identifiers used to group related models. */ +export type ModelFamily = + | "Hy" + | "agi" + | "allam" + | "allenai" + | "alpha" + | "aura" + | "auto" + | "baichuan" + | "bart" + | "bge" + | "big-pickle" + | "canopylabs" + | "chutesai" + | "claude" + | "claude-fable" + | "claude-haiku" + | "claude-opus" + | "claude-sonnet" + | "codestral" + | "codestral-embed" + | "cogito" + | "cohere-embed" + | "command" + | "command-a" + | "command-light" + | "command-r" + | "dall-e" + | "deepseek" + | "deepseek-flash" + | "deepseek-flash-free" + | "deepseek-flash-think" + | "deepseek-thinking" + | "devstral" + | "discolm" + | "distilbert" + | "dream-machine" + | "dreamshaper" + | "elephant" + | "elevenlabs" + | "ernie" + | "falcon" + | "flux" + | "fugu" + | "gemini" + | "gemini-embedding" + | "gemini-flash" + | "gemini-flash-lite" + | "gemini-pro" + | "gemma" + | "glm" + | "glm-air" + | "glm-flash" + | "glm-free" + | "glm-z" + | "glmv" + | "gpt" + | "gpt-codex" + | "gpt-codex-mini" + | "gpt-codex-spark" + | "gpt-image" + | "gpt-mini" + | "gpt-nano" + | "gpt-oss" + | "gpt-pro" + | "granite" + | "grok" + | "grok-beta" + | "grok-build" + | "grok-vision" + | "groq" + | "hermes" + | "hunyuan" + | "hy3" + | "hy3-free" + | "ideogram" + | "imagen" + | "indictrans" + | "intellect" + | "jais" + | "jamba" + | "kat-coder" + | "kimi" + | "kimi-free" + | "kimi-k2" + | "kimi-thinking" + | "ling" + | "ling-flash-free" + | "liquid" + | "llama" + | "llava" + | "longcat" + | "lucid" + | "lyria" + | "m2m" + | "magistral" + | "magistral-medium" + | "magistral-small" + | "mai" + | "melotts" + | "mercury" + | "mimo" + | "mimo-flash-free" + | "mimo-omni" + | "mimo-omni-free" + | "mimo-pro" + | "mimo-pro-free" + | "mimo-v2-omni" + | "mimo-v2-pro" + | "mimo-v2.5" + | "mimo-v2.5-free" + | "mimo-v2.5-pro" + | "minimax" + | "minimax-free" + | "minimax-m2.5" + | "minimax-m2.7" + | "minimax-m3" + | "minimax-m3-free" + | "ministral" + | "mistral" + | "mistral-embed" + | "mistral-large" + | "mistral-medium" + | "mistral-nemo" + | "mistral-small" + | "mixtral" + | "mm-poly" + | "model-router" + | "morph" + | "nano-banana" + | "nemoretriever" + | "nemotron" + | "nemotron-free" + | "neural-chat" + | "north" + | "north-free" + | "nousresearch" + | "nova" + | "nova-lite" + | "nova-micro" + | "nova-pro" + | "o" + | "o-mini" + | "o-pro" + | "openchat" + | "opengvlab" + | "ornith" + | "osmosis" + | "oswe" + | "palmyra" + | "pangu" + | "parakeet" + | "phi" + | "phoenix" + | "pixtral" + | "plamo" + | "pony" + | "qvq" + | "qwen" + | "qwen-free" + | "qwen3.5" + | "qwen3.6" + | "qwen3.7-max" + | "qwen3.7-plus" + | "qwerky" + | "ray" + | "recraft" + | "rednote" + | "reka" + | "resnet" + | "ring" + | "ring-1t-free" + | "rnj" + | "runway" + | "sarvam" + | "seed" + | "sherlock" + | "skywork" + | "smart-turn" + | "solar" + | "solar-mini" + | "solar-pro" + | "sonar" + | "sonar-deep-research" + | "sonar-pro" + | "sonar-reasoning" + | "sora" + | "sourceful" + | "sqlcoder" + | "stable-diffusion" + | "starling" + | "step" + | "tako" + | "text-embedding" + | "titan" + | "titan-embed" + | "tngtech" + | "topazlabs" + | "trinity" + | "trinity-mini" + | "tstars" + | "una-cybertron" + | "unsloth" + | "v0" + | "venice" + | "veo" + | "voxtral" + | "voyage" + | "whisper" + | "yi" + | "zephyr" diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 0000000000..475ee64fea --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,6 @@ +export * as Models from "./client.js" +export type { ClientOptions, HeadersInput, ModelsClient, RequestOptions } from "./client.js" +export { ModelsDevError, type ModelsDevErrorReason } from "./error.js" +export { KNOWN_PROVIDER_IDS } from "./generated.js" +export type * from "./types.js" +export { VERSION } from "./version.js" diff --git a/packages/sdk/src/snapshot.d.ts b/packages/sdk/src/snapshot.d.ts new file mode 100644 index 0000000000..a8948d3355 --- /dev/null +++ b/packages/sdk/src/snapshot.d.ts @@ -0,0 +1,14 @@ +import type { Catalog, ModelMetadataMap, ProviderMap } from "./index.js" + +/** All providers with their models, pricing, and limits. Same shape as `client.providers()`. */ +export declare const providers: ProviderMap + +/** Provider-agnostic model metadata keyed by canonical model ID. Same shape as `client.models()`. */ +export declare const models: ModelMetadataMap + +/** ISO timestamp of when this snapshot was generated from the models.dev repository. */ +export declare const generatedAt: string + +/** The full catalog: `{ providers, models }`. Same shape as `client.catalog()`. */ +declare const snapshot: Catalog +export default snapshot diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts new file mode 100644 index 0000000000..e7c7d6e38f --- /dev/null +++ b/packages/sdk/src/types.ts @@ -0,0 +1,273 @@ +// Hand-written mirrors of the Zod schemas in @models.dev/core (src/schema.ts). +// Kept intentionally free of zod so the published .d.ts has zero dependencies. +// Drift against the schemas is caught by test/types.test.ts, which asserts +// exact mutual assignability with the z.infer types from @models.dev/core. + +export type { KnownProviderID, ModelFamily } from "./generated.js" +import type { ModelFamily } from "./generated.js" + +/** Any JSON-serializable value. */ +export type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[] + +/** + * Reasoning effort levels accepted by a model's `effort` reasoning option. + * `null` means the provider accepts disabling reasoning explicitly. + */ +export type ReasoningEffort = null | "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "default" + +/** Reasoning enabled/disabled via a simple boolean toggle. */ +export interface ReasoningOptionToggle { + type: "toggle" +} + +/** Reasoning controlled by a named effort level. */ +export interface ReasoningOptionEffort { + type: "effort" + /** Effort values the provider accepts for this model. */ + values: ReasoningEffort[] +} + +/** Reasoning controlled by a token budget. */ +export interface ReasoningOptionBudgetTokens { + type: "budget_tokens" + /** Minimum reasoning budget in tokens. `-1` means dynamic/unbounded. */ + min?: number + /** Maximum reasoning budget in tokens. */ + max?: number +} + +/** How reasoning can be configured for a model. */ +export type ReasoningOption = ReasoningOptionToggle | ReasoningOptionEffort | ReasoningOptionBudgetTokens + +/** Pricing in USD per million tokens. */ +export interface Cost { + /** Input (prompt) price, USD per 1M tokens. */ + input: number + /** Output (completion) price, USD per 1M tokens. */ + output: number + /** Reasoning token price, USD per 1M tokens. */ + reasoning?: number + /** Cache read price, USD per 1M tokens. */ + cache_read?: number + /** Cache write price, USD per 1M tokens. */ + cache_write?: number + /** Audio input price, USD per 1M tokens. */ + input_audio?: number + /** Audio output price, USD per 1M tokens. */ + output_audio?: number +} + +/** Pricing that applies from a given context size upward. */ +export interface CostTier extends Cost { + tier: { + type: "context" + /** Context size (in tokens) at which this tier starts to apply. */ + size: number + } +} + +/** Pricing for a provider's model, including context-size tiers. */ +export interface ModelCost extends Cost { + /** Legacy compatibility field: pricing applied beyond 200K context. Prefer `tiers`. */ + context_over_200k?: Cost + /** Context-size-based pricing tiers. */ + tiers?: CostTier[] +} + +/** Input/output data types a model supports. */ +export type Modality = "text" | "audio" | "image" | "video" | "pdf" + +export interface Modalities { + input: Modality[] + output: Modality[] +} + +/** Token limits for a provider's model. */ +export interface Limit { + /** Context window size in tokens. */ + context: number + /** Maximum input tokens. */ + input?: number + /** Maximum output tokens. */ + output: number +} + +/** Token limits in provider-agnostic model metadata. */ +export interface MetadataLimit { + /** Context window size in tokens. */ + context: number + /** Maximum input tokens. */ + input?: number + /** Maximum output tokens. */ + output?: number +} + +/** A link related to a model (announcement, paper, weights, ...). */ +export interface ModelLink { + label?: string + url: string + type?: "announcement" | "blog" | "docs" | "license" | "model_card" | "paper" | "weights" | "other" +} + +/** Downloadable weights for an open-weights model. */ +export interface ModelWeights { + label?: string + url: string + /** Weights format, e.g. "safetensors" or "gguf". */ + format?: string + quantization?: string +} + +/** A reported benchmark result. */ +export interface BenchmarkResult { + name: string + score: number | string + metric?: string + harness?: string + variant?: string + dataset?: string + version?: string + source?: string + /** YYYY-MM or YYYY-MM-DD. */ + date?: string +} + +/** + * Provider-agnostic model metadata as published by the lab. + * Served by `GET https://models.dev/models.json`, keyed by `/` ID. + * Carries no provider-specific pricing or limits; see {@link Model} for those. + */ +export interface ModelMetadata { + /** Canonical model ID, e.g. "anthropic/claude-opus-4-6". */ + id: string + name: string + description: string + family?: ModelFamily + /** Supports file attachments. */ + attachment?: boolean + /** Is a reasoning model. */ + reasoning?: boolean + /** Supports tool/function calling. */ + tool_call?: boolean + /** Supports structured output (JSON schema). */ + structured_output?: boolean + /** Supports the temperature parameter. */ + temperature?: boolean + /** Knowledge cutoff, YYYY-MM or YYYY-MM-DD. */ + knowledge?: string + /** YYYY-MM or YYYY-MM-DD. */ + release_date?: string + /** YYYY-MM or YYYY-MM-DD. */ + last_updated?: string + modalities?: Modalities + open_weights?: boolean + limit?: MetadataLimit + /** License identifier for open-weights models. */ + license?: string + links?: ModelLink[] + weights?: ModelWeights[] + benchmarks?: BenchmarkResult[] +} + +/** Per-mode overrides for experimental model modes. */ +export interface ExperimentalMode { + cost?: Cost + provider?: { + /** Extra request body fields enabling this mode. */ + body?: Record + /** Extra request headers enabling this mode. */ + headers?: Record + } +} + +export interface ModelExperimental { + modes?: Record +} + +/** Provider-specific wiring for SDK routing. */ +export interface ModelProviderConfig { + /** Override of the provider-level npm package for this model. */ + npm?: string + /** Override of the API endpoint for this model. */ + api?: string + /** API shape when the npm package supports multiple. */ + shape?: "responses" | "completions" + /** Extra request body fields required by this model. */ + body?: Record + /** Extra request headers required by this model. */ + headers?: Record +} + +/** + * A model as offered by a specific provider, including that provider's + * pricing and limits. Part of `GET https://models.dev/api.json`. + */ +export interface Model { + /** Provider-scoped model ID, e.g. "claude-opus-4-6". */ + id: string + name: string + description: string + family?: ModelFamily + /** Supports file attachments. */ + attachment: boolean + /** Is a reasoning model. */ + reasoning: boolean + /** Present exactly when `reasoning` is true. */ + reasoning_options?: ReasoningOption[] + /** Supports tool/function calling. */ + tool_call: boolean + /** Supports interleaved thinking between tool calls. */ + interleaved?: true | { field: "reasoning_content" | "reasoning_details" } + /** Supports structured output (JSON schema). */ + structured_output?: boolean + /** Supports the temperature parameter. */ + temperature?: boolean + /** Knowledge cutoff, YYYY-MM or YYYY-MM-DD. */ + knowledge?: string + /** YYYY-MM or YYYY-MM-DD. */ + release_date: string + /** YYYY-MM or YYYY-MM-DD. */ + last_updated: string + modalities: Modalities + open_weights: boolean + limit: Limit + /** Lifecycle status; absent means generally available. */ + status?: "alpha" | "beta" | "deprecated" + experimental?: ModelExperimental + provider?: ModelProviderConfig + /** Absent for models with no published pricing (e.g. subscription-only). */ + cost?: ModelCost +} + +/** + * An inference provider and the models it offers. + * Served by `GET https://models.dev/api.json`, keyed by provider ID. + */ +export interface Provider { + /** Provider ID, e.g. "anthropic". */ + id: string + /** Environment variables used for authentication, e.g. ["ANTHROPIC_API_KEY"]. */ + env: string[] + /** AI SDK npm package implementing this provider. */ + npm: string + /** Base API URL for openai-compatible providers. */ + api?: string + /** Human-readable provider name. */ + name: string + /** URL of the provider's model documentation. */ + doc: string + /** Models offered by this provider, keyed by provider-scoped model ID. */ + models: Record +} + +/** Response of `GET https://models.dev/api.json`: all providers keyed by provider ID. */ +export type ProviderMap = Record + +/** Response of `GET https://models.dev/models.json`: provider-agnostic metadata keyed by canonical model ID. */ +export type ModelMetadataMap = Record + +/** Response of `GET https://models.dev/catalog.json`: providers and model metadata in one payload. */ +export interface Catalog { + providers: ProviderMap + models: ModelMetadataMap +} diff --git a/packages/sdk/src/version.ts b/packages/sdk/src/version.ts new file mode 100644 index 0000000000..82d5d1e59d --- /dev/null +++ b/packages/sdk/src/version.ts @@ -0,0 +1,2 @@ +// Rewritten to the computed release version by script/publish.ts at publish time. +export const VERSION = "0.0.0" diff --git a/packages/sdk/test/client.test.ts b/packages/sdk/test/client.test.ts new file mode 100644 index 0000000000..993ef3f254 --- /dev/null +++ b/packages/sdk/test/client.test.ts @@ -0,0 +1,127 @@ +import { expect, test } from "bun:test" +import { Models, ModelsDevError, VERSION } from "../src/index.js" + +interface Call { + url: URL + init: RequestInit +} + +function stub(data: unknown, init?: ResponseInit) { + const calls: Call[] = [] + const fetch = (async (input: unknown, requestInit?: RequestInit) => { + calls.push({ url: input as URL, init: requestInit ?? {} }) + return new Response(JSON.stringify(data), { + headers: { "content-type": "application/json" }, + ...init, + }) + }) as typeof globalThis.fetch + return { calls, fetch } +} + +function headers(call: Call) { + return new Headers(call.init.headers) +} + +test("providers() GETs /api.json with the default base URL", async () => { + const providers = { anthropic: { id: "anthropic" } } + const { calls, fetch } = stub(providers) + const client = Models.make({ fetch }) + const result = await client.providers() + expect(result).toEqual(providers as never) + expect(calls[0]?.url.href).toBe("https://models.dev/api.json") + expect(calls[0]?.init.method).toBe("GET") +}) + +test("models() and catalog() hit their endpoints", async () => { + const { calls, fetch } = stub({}) + const client = Models.make({ fetch }) + await client.models() + await client.catalog() + expect(calls.map((call) => call.url.href)).toEqual(["https://models.dev/models.json", "https://models.dev/catalog.json"]) +}) + +test("baseUrl with subpath is preserved, with or without trailing slash", async () => { + const { calls, fetch } = stub({}) + await Models.make({ fetch, baseUrl: "https://example.com/mirror" }).providers() + await Models.make({ fetch, baseUrl: "https://example.com/mirror/" }).providers() + expect(calls.map((call) => call.url.href)).toEqual([ + "https://example.com/mirror/api.json", + "https://example.com/mirror/api.json", + ]) +}) + +test("identifies itself with a versioned user-agent", async () => { + const { calls, fetch } = stub({}) + await Models.make({ fetch }).providers() + expect(headers(calls[0]!).get("user-agent")).toBe(`models.dev/${VERSION}`) +}) + +test("client headers override defaults, request headers override client headers", async () => { + const { calls, fetch } = stub({}) + const client = Models.make({ fetch, headers: { "user-agent": "custom", "x-one": "client", "x-two": "client" } }) + await client.providers({ headers: { "x-two": "request" } }) + const sent = headers(calls[0]!) + expect(sent.get("user-agent")).toBe("custom") + expect(sent.get("x-one")).toBe("client") + expect(sent.get("x-two")).toBe("request") +}) + +test("abort signal is passed through", async () => { + const { calls, fetch } = stub({}) + const controller = new AbortController() + await Models.make({ fetch }).providers({ signal: controller.signal }) + expect(calls[0]?.init.signal).toBe(controller.signal) +}) + +test("stateless: every call fetches again", async () => { + const { calls, fetch } = stub({}) + const client = Models.make({ fetch }) + await client.providers() + await client.providers() + expect(calls.length).toBe(2) +}) + +test("network failure throws Transport with cause", async () => { + const failure = new Error("boom") + const client = Models.make({ + fetch: (() => Promise.reject(failure)) as unknown as typeof globalThis.fetch, + }) + const error = await client.providers().catch((error: unknown) => error) + expect(error).toBeInstanceOf(ModelsDevError) + expect((error as ModelsDevError).reason).toBe("Transport") + expect((error as ModelsDevError).cause).toBe(failure) +}) + +test("non-2xx throws UnexpectedStatus with the status in cause", async () => { + const { fetch } = stub({ message: "not found" }, { status: 404 }) + const error = await Models.make({ fetch }).providers().catch((error: unknown) => error) + expect(error).toBeInstanceOf(ModelsDevError) + expect((error as ModelsDevError).reason).toBe("UnexpectedStatus") + expect((error as ModelsDevError).cause).toEqual({ status: 404 }) +}) + +test("invalid JSON throws MalformedResponse", async () => { + const fetch = (async () => new Response("not json")) as unknown as typeof globalThis.fetch + const error = await Models.make({ fetch }).providers().catch((error: unknown) => error) + expect((error as ModelsDevError).reason).toBe("MalformedResponse") +}) + +test("empty body throws MalformedResponse", async () => { + const fetch = (async () => new Response("")) as unknown as typeof globalThis.fetch + const error = await Models.make({ fetch }).providers().catch((error: unknown) => error) + expect((error as ModelsDevError).reason).toBe("MalformedResponse") +}) + +test("global fetch is resolved lazily so late polyfills work", async () => { + const original = globalThis.fetch + const client = Models.make() + try { + const { calls, fetch } = stub({ late: true }) + globalThis.fetch = fetch + const result = await client.providers() + expect(result).toEqual({ late: true } as never) + expect(calls.length).toBe(1) + } finally { + globalThis.fetch = original + } +}) diff --git a/packages/sdk/test/effect.test.ts b/packages/sdk/test/effect.test.ts new file mode 100644 index 0000000000..b15120e1e5 --- /dev/null +++ b/packages/sdk/test/effect.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test" +import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { Models, ModelsDevError } from "../src/effect.js" + +function stub(data: unknown, init?: ResponseInit) { + const requests: Request[] = [] + const fetch = (async (input: Parameters[0], requestInit?: RequestInit) => { + requests.push(new Request(input instanceof URL ? input.href : (input as string), requestInit)) + return new Response(JSON.stringify(data), { + headers: { "content-type": "application/json" }, + ...init, + }) + }) as typeof globalThis.fetch + const layer = FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetch))) + return { requests, layer } +} + +test("providers() succeeds through an injected transport", async () => { + const { requests, layer } = stub({ anthropic: { id: "anthropic" } }) + const program = Effect.gen(function* () { + const client = yield* Models.make() + return yield* client.providers() + }) + const result = await program.pipe(Effect.provide(layer), Effect.runPromise) + expect(result["anthropic"]?.id).toBe("anthropic") + expect(requests[0]?.url).toBe("https://models.dev/api.json") + expect(requests[0]?.headers.get("user-agent")).toMatch(/^models\.dev\//) +}) + +test("models() and catalog() hit their endpoints, baseUrl subpath preserved", async () => { + const { requests, layer } = stub({}) + const program = Effect.gen(function* () { + const client = yield* Models.make({ baseUrl: "https://example.com/mirror" }) + yield* client.models() + yield* client.catalog() + }) + await program.pipe(Effect.provide(layer), Effect.runPromise) + expect(requests.map((request) => request.url)).toEqual([ + "https://example.com/mirror/models.json", + "https://example.com/mirror/catalog.json", + ]) +}) + +test("custom headers are sent", async () => { + const { requests, layer } = stub({}) + const program = Effect.gen(function* () { + const client = yield* Models.make({ headers: { "x-custom": "yes" } }) + yield* client.providers() + }) + await program.pipe(Effect.provide(layer), Effect.runPromise) + expect(requests[0]?.headers.get("x-custom")).toBe("yes") +}) + +test("non-2xx fails with ModelsDevError in the error channel", async () => { + const { layer } = stub({ error: "down" }, { status: 503 }) + const program = Effect.gen(function* () { + const client = yield* Models.make() + return yield* client.providers() + }) + const error = await program.pipe(Effect.flip, Effect.provide(layer), Effect.runPromise) + expect(error).toBeInstanceOf(ModelsDevError) + expect(error._tag).toBe("ModelsDevError") +}) + +test("Service and layer provide a shared client", async () => { + const { requests, layer } = stub({ "openai/gpt-oss-120b": { id: "openai/gpt-oss-120b" } }) + const program = Effect.gen(function* () { + const client = yield* Models.Service + return yield* client.models() + }) + const result = await program.pipe( + Effect.provide(Models.layer().pipe(Layer.provide(layer))), + Effect.runPromise, + ) + expect(result["openai/gpt-oss-120b"]?.id).toBe("openai/gpt-oss-120b") + expect(requests.length).toBe(1) +}) diff --git a/packages/sdk/test/import-boundaries.test.ts b/packages/sdk/test/import-boundaries.test.ts new file mode 100644 index 0000000000..51f26b97d1 --- /dev/null +++ b/packages/sdk/test/import-boundaries.test.ts @@ -0,0 +1,73 @@ +// Enforces the package's structural promises: +// - the root client has zero dependencies (no effect, no zod, no core) and +// never touches the snapshot; +// - the snapshot entrypoint is fully self-contained (imports nothing); +// - the effect client pulls in effect but nothing else. +// +// Implementation modules are bundled with local files inlined and packages +// kept external, so any package dependency must surface as an import +// statement in the output. The barrel entrypoints are checked statically +// (bun currently over-shakes re-export-only entrypoints of sideEffects:false +// packages, so bundling them directly would test nothing). + +import { expect, test } from "bun:test" +import path from "node:path" +import { ensureGenerated } from "../script/generate.ts" + +const src = path.join(import.meta.dirname, "..", "src") + +// A string that only ever appears in the snapshot payload. +const SNAPSHOT_SENTINEL = '\\"302ai\\"' + +async function bundle(entrypoint: string) { + const result = await Bun.build({ + entrypoints: [entrypoint], + target: "bun", + packages: "external", + throw: true, + }) + const output = await result.outputs[0]!.text() + const imports = [...output.matchAll(/^(?:import|export)[^"'\n]*["']([^"'\n]+)["'];?\s*$/gm)].map( + (match) => match[1]!, + ) + return { output, imports } +} + +async function specifiers(file: string) { + const source = await Bun.file(path.join(src, file)).text() + return [...source.matchAll(/from\s+["']([^"']+)["']/g)].map((match) => match[1]!) +} + +test("root client bundles with no package imports and no snapshot", async () => { + const { output, imports } = await bundle(path.join(src, "client.ts")) + expect(imports).toEqual([]) + expect(output.includes(SNAPSHOT_SENTINEL)).toBe(false) + expect(output.length).toBeLessThan(100_000) +}) + +test("root barrel only re-exports zero-dependency local modules", async () => { + const allowed = ["./client.js", "./error.js", "./generated.js", "./types.js", "./version.js"] + for (const specifier of await specifiers("index.ts")) { + expect(allowed).toContain(specifier) + } +}) + +test("snapshot entrypoint is self-contained", async () => { + await ensureGenerated() + const { imports } = await bundle(path.join(src, "snapshot.js")) + expect(imports).toEqual([]) +}) + +test("effect client bundles with only effect imports", async () => { + const { output, imports } = await bundle(path.join(src, "effect", "client.ts")) + expect(imports.length).toBeGreaterThan(0) + expect(imports.every((specifier) => specifier === "effect" || specifier.startsWith("effect/"))).toBe(true) + expect(output.includes(SNAPSHOT_SENTINEL)).toBe(false) +}) + +test("effect barrel only re-exports the effect client and local types", async () => { + const allowed = ["./effect/client.js", "./generated.js", "./types.js"] + for (const specifier of await specifiers("effect.ts")) { + expect(allowed).toContain(specifier) + } +}) diff --git a/packages/sdk/test/snapshot.test.ts b/packages/sdk/test/snapshot.test.ts new file mode 100644 index 0000000000..041e2092cc --- /dev/null +++ b/packages/sdk/test/snapshot.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test" +import { ensureGenerated } from "../script/generate.ts" + +test("snapshot exports providers, models, generatedAt, and a default catalog", async () => { + await ensureGenerated() + const snapshot = await import("../src/snapshot.js") + expect(Object.keys(snapshot.providers).length).toBeGreaterThan(100) + expect(Object.keys(snapshot.models).length).toBeGreaterThan(100) + expect(snapshot.default.providers).toBe(snapshot.providers) + expect(snapshot.default.models).toBe(snapshot.models) + expect(Number.isNaN(Date.parse(snapshot.generatedAt))).toBe(false) + + const anthropic = snapshot.providers["anthropic"] + expect(anthropic?.env.length).toBeGreaterThan(0) + const model = Object.values(anthropic!.models)[0] + expect(typeof model?.name).toBe("string") + expect(typeof model?.limit.context).toBe("number") +}) diff --git a/packages/sdk/test/types.test.ts b/packages/sdk/test/types.test.ts new file mode 100644 index 0000000000..87da941e20 --- /dev/null +++ b/packages/sdk/test/types.test.ts @@ -0,0 +1,38 @@ +// Drift protection between @models.dev/core's Zod schemas (the source of +// truth) and this package's hand-written interfaces. The type-level +// assertions fail `tsc --noEmit` (part of the test script) whenever the +// schemas and the published types stop being exactly mutually assignable. + +import { expect, test } from "bun:test" +import type { z } from "zod" +import * as Core from "@models.dev/core" +import type { Catalog, Model, ModelFamily, ModelMetadata, Provider } from "../src/index.js" +import { KNOWN_PROVIDER_IDS } from "../src/index.js" +import { loadCatalog } from "../script/generate.ts" + +type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false +type Expect = T + +// If one of these lines errors, a schema in packages/core changed shape: +// update src/types.ts (or src/generated.ts via `bun run generate`) to match. +type _provider = Expect, Provider>> +type _model = Expect, Model>> +type _metadata = Expect, ModelMetadata>> +type _family = Expect> +type _catalog = Expect>, Catalog>> + +test("generated provider IDs match the providers directory", async () => { + const catalog = await loadCatalog() + expect(Object.keys(catalog.providers)).toEqual([...KNOWN_PROVIDER_IDS]) +}) + +test("a freshly generated catalog satisfies the published types", async () => { + // The annotation is the assertion: core's inferred output must be assignable + // to the published Catalog type. + const catalog: Catalog = await loadCatalog() + expect(Object.keys(catalog.providers).length).toBeGreaterThan(100) + expect(Object.keys(catalog.models).length).toBeGreaterThan(100) + const anthropic = catalog.providers["anthropic"] + expect(anthropic).toBeDefined() + expect(Object.keys(anthropic!.models).length).toBeGreaterThan(0) +}) diff --git a/packages/sdk/tsconfig.build.json b/packages/sdk/tsconfig.build.json new file mode 100644 index 0000000000..50e83fe2db --- /dev/null +++ b/packages/sdk/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "verbatimModuleSyntax": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["src/snapshot.js", "src/snapshot.d.ts"] +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 0000000000..2349a96a29 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "types": ["bun", "node"], + "noEmit": true + }, + "include": ["src", "script", "test"], + "exclude": ["src/snapshot.js"] +} diff --git a/packages/web/package.json b/packages/web/package.json index db5ee052b8..b32fcce534 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -8,7 +8,7 @@ "dependencies": { "@tanstack/virtual-core": "^3.14.0", "hono": "^4.8.0", - "models.dev": "workspace:*" + "@models.dev/core": "workspace:*" }, "devDependencies": { "@types/bun": "^1.2.16" diff --git a/packages/web/src/render.tsx b/packages/web/src/render.tsx index f71d42b7aa..9599aa35e5 100644 --- a/packages/web/src/render.tsx +++ b/packages/web/src/render.tsx @@ -1,8 +1,8 @@ /** @jsx jsx */ /** @jsxImportSource hono/jsx */ -import { generateCatalog } from "models.dev"; -import type { Model, ModelMetadata, Provider } from "models.dev"; +import { generateCatalog } from "@models.dev/core"; +import type { Model, ModelMetadata, Provider } from "@models.dev/core"; import { Fragment } from "hono/jsx"; import { renderToString } from "hono/jsx/dom/server"; import { existsSync, readFileSync, readdirSync } from "fs";