Compare commits
676 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a74991397 | |||
| 5fdcea6b36 | |||
| 31e56480b4 | |||
| 92a621594e | |||
| d2db353ceb | |||
| 900ae509d2 | |||
| cd3e99025f | |||
| 1098981eb6 | |||
| 27a151cf53 | |||
| 41ff42ab7f | |||
| adf1cbdecd | |||
| 10ddc78ce0 | |||
| 7ae897e440 | |||
| 02e452c2e8 | |||
| f05b63fff5 | |||
| e277d60236 | |||
| b01277a737 | |||
| a5da5aa429 | |||
| 5ceac8a58b | |||
| 11e1d5623a | |||
| f107afc57c | |||
| e789d7c1f2 | |||
| 899668ad49 | |||
| 8c677f0134 | |||
| 462c7877d9 | |||
| 55871f9dca | |||
| 4f7194a3c8 | |||
| f65f0148da | |||
| 14952f8855 | |||
| d7c6d3ad12 | |||
| 356bc79d08 | |||
| a89b1ed726 | |||
| a998576773 | |||
| fbe842bbea | |||
| 6c0c3d1b10 | |||
| db0a7cf611 | |||
| d775e37e3b | |||
| 36753063d9 | |||
| 5ee955297a | |||
| 1b77511903 | |||
| 8896ead7bf | |||
| eb96594d47 | |||
| 327332efe3 | |||
| 5020951745 | |||
| cb6f97774e | |||
| 7f8b493b0c | |||
| d65a862533 | |||
| 8e710e19ea | |||
| 36c6896e97 | |||
| a8be548a5d | |||
| 8c2fae4ab0 | |||
| 5e7fad350d | |||
| feb85ef2c9 | |||
| d4161ebf24 | |||
| b91ab02e2b | |||
| dd09d07f75 | |||
| 4515f85d47 | |||
| 97572240e1 | |||
| 80cc04e9e5 | |||
| d532ebb89b | |||
| 9bae3e887f | |||
| 0be69bf872 | |||
| 0ed38cbecf | |||
| e65703382a | |||
| 748754c99b | |||
| ec9c12d0fc | |||
| ac81822c89 | |||
| d32ed764bd | |||
| 45fb951c42 | |||
| 260d79b2d5 | |||
| 746b9caf79 | |||
| 8362b55503 | |||
| dde3953a9f | |||
| 0a1695212c | |||
| 89fbb6bb69 | |||
| 005fe0fb5a | |||
| e283875ce7 | |||
| 3598019251 | |||
| e9ad8b0a3f | |||
| 0ee78eeda5 | |||
| 0a5b33e518 | |||
| 22416dda64 | |||
| eba2702e3a | |||
| c2c5cc8f21 | |||
| 022b1b9946 | |||
| 8269e04222 | |||
| a75cf2ed1c | |||
| 7b00aafa79 | |||
| e54e0fc8b5 | |||
| 38611e75fa | |||
| e62c1e973e | |||
| c2b3c601e4 | |||
| 9ff1d36a21 | |||
| e0f4042ad1 | |||
| 14736ba4b6 | |||
| 9351d68731 | |||
| 7a5a1d2aff | |||
| 699284ce91 | |||
| 21ce5c3ac8 | |||
| 3485cf52d0 | |||
| 99e8f25c78 | |||
| 7102978cb4 | |||
| 530f60c69c | |||
| 2415c5be21 | |||
| 85aba468cd | |||
| d1ec1ba777 | |||
| 0f94bf16ec | |||
| 506e8f48a9 | |||
| 3480bc5992 | |||
| fde97814ef | |||
| ff7eddcb70 | |||
| 5cbab85b8d | |||
| 6f9820de9f | |||
| cdfb429098 | |||
| 1c2546af8a | |||
| 2288a1626b | |||
| b3ecfc3d70 | |||
| 30b3e677fd | |||
| 3b37eee86e | |||
| 71f069670e | |||
| f401672689 | |||
| 2a0d86a034 | |||
| d9439cdf2f | |||
| 3d443d568d | |||
| a76c8fe9dd | |||
| d08e8d6cc1 | |||
| 4c06e44047 | |||
| 5e344ded49 | |||
| 458b7f4d1a | |||
| baf4432140 | |||
| bbf72ea4e0 | |||
| 8f9adc7567 | |||
| addaf1c036 | |||
| 55d16a58b6 | |||
| 72a4deab66 | |||
| 8dd829a187 | |||
| a671cc05d5 | |||
| 656c6f08a7 | |||
| 8979741a32 | |||
| 82851b9a3d | |||
| ae511892d7 | |||
| 9312418242 | |||
| 9828a0177d | |||
| 122627a852 | |||
| 4dee8d0d34 | |||
| 6883e793ce | |||
| e69064709b | |||
| bd8e582b96 | |||
| 21945db90f | |||
| 1771e02be8 | |||
| bb08fc26e9 | |||
| 2e015de42d | |||
| 914a3d9d18 | |||
| bab01dd9ab | |||
| bdaae956af | |||
| 6f118145c0 | |||
| 386eaed119 | |||
| 151e9c9071 | |||
| ba99f1edce | |||
| b96b074a3b | |||
| 2593e131a1 | |||
| 4aebbe5ca3 | |||
| b98495e9c8 | |||
| bc95b42ccd | |||
| 6139fb8c69 | |||
| 3070758007 | |||
| 5525e83de4 | |||
| 359fd879b8 | |||
| 01b5a1a656 | |||
| b1958be099 | |||
| 08aa068523 | |||
| f31ad0b02f | |||
| 585aa7fa1b | |||
| c42a327b3e | |||
| 92ebbfb5c4 | |||
| 535fe8c971 | |||
| a3b4bfc16c | |||
| d0fcd6f11f | |||
| e55cd54218 | |||
| 0d73b82b9f | |||
| 83c7e2b63f | |||
| 83ae4cf813 | |||
| 77eae6eef7 | |||
| 8cbf6ed10e | |||
| 06d87e4411 | |||
| 2cb3832618 | |||
| 950a0446d4 | |||
| 41fbfb1a17 | |||
| cf5045a90b | |||
| ef739220de | |||
| df960d1a90 | |||
| 7294efee15 | |||
| 1e537147eb | |||
| 8f2f83ef61 | |||
| b133426465 | |||
| dafff5a770 | |||
| dd894f077f | |||
| 91590874e7 | |||
| e9f4cecc54 | |||
| fb1ac09883 | |||
| a436236146 | |||
| 1415b4be97 | |||
| 1437da86e7 | |||
| e7d57885d1 | |||
| 8157916515 | |||
| 2e5b87a9c2 | |||
| 92e19432d3 | |||
| 70ac6fccda | |||
| dc3283417d | |||
| 34fd6673e5 | |||
| 5df9293314 | |||
| a81a9559d7 | |||
| 4197bf57a6 | |||
| d0ac772507 | |||
| 1afaa053e7 | |||
| 9b77ce1c9e | |||
| 68dc6d1820 | |||
| a1eb5eece3 | |||
| f7ec2c517f | |||
| f98e8ec793 | |||
| d210066793 | |||
| 06908cbf36 | |||
| b0614d2088 | |||
| 32c1c45c52 | |||
| 7c033f27e6 | |||
| d648e63499 | |||
| 1353f965b9 | |||
| 175082d43f | |||
| bba0a9c3f4 | |||
| 4bdb195178 | |||
| 12706b7652 | |||
| d8c76d0c67 | |||
| 3963dd13d5 | |||
| 8749a56efa | |||
| 25de2ee27d | |||
| 438df7f03c | |||
| 7d18558aa3 | |||
| c82f736fcc | |||
| 6a436805b3 | |||
| ce7823f073 | |||
| 033efdb7d4 | |||
| 0f1855c0a7 | |||
| bd9e0c2677 | |||
| 38545d63a5 | |||
| 014be328d6 | |||
| c139147540 | |||
| 59cd93cafc | |||
| e91db96d83 | |||
| d70dd8dcdc | |||
| b18e681457 | |||
| b73a6a2130 | |||
| 153c1cc420 | |||
| ca0b30569e | |||
| 8345bfbd06 | |||
| 28c0d9ce23 | |||
| c16f3da694 | |||
| 6aa6e55d60 | |||
| 9efaf754a5 | |||
| 104e4bdc1f | |||
| db16c113f7 | |||
| 64a122a13d | |||
| fcc6521d1d | |||
| c623b4a55f | |||
| 1d730fea16 | |||
| 5457215e29 | |||
| b3ab45990f | |||
| d906a07e31 | |||
| 4b1f6edd52 | |||
| a92a2cfe3d | |||
| 70891f58e5 | |||
| 1600c827fc | |||
| 5851cdc136 | |||
| 4fd0e38c58 | |||
| dd685ea42c | |||
| 7835298241 | |||
| c5fbcc2c9b | |||
| ab2eb51b4e | |||
| 8e19ec580c | |||
| 3aecc94c46 | |||
| 2ac7912ee7 | |||
| 9ac5b5f625 | |||
| 8c4d9f4696 | |||
| c4bb0b4b11 | |||
| db4d03c171 | |||
| 60d1d4df77 | |||
| 31654fc2ef | |||
| 01e56b1e1e | |||
| 4a7d9275d6 | |||
| 978fe11e0c | |||
| 0b4198955f | |||
| 66b568b681 | |||
| 589fddeed7 | |||
| 6f75266d42 | |||
| 0f03115ee0 | |||
| ae2564edc2 | |||
| 629856cf41 | |||
| 567bc13ab4 | |||
| 5eac173bc5 | |||
| cb0d640afd | |||
| b5e27e652f | |||
| 650c27411f | |||
| 98c55325bf | |||
| 2fdd33b8a7 | |||
| e4dea0a3d8 | |||
| b4b3622bfd | |||
| 3b35b5598a | |||
| d2e16bab34 | |||
| 051dc6236a | |||
| 5b9dee1f3d | |||
| 426dfe7be5 | |||
| fddfb083d1 | |||
| 4eccfaba87 | |||
| ea46d016d3 | |||
| 1bd1449ec5 | |||
| 7d334a51c9 | |||
| 10ae0b5a1d | |||
| d25cce170d | |||
| 3e097a5d89 | |||
| 074b38eb98 | |||
| d474922588 | |||
| 56ddb9017c | |||
| a565bbebc0 | |||
| 5b97fca592 | |||
| aec3f94081 | |||
| e5c63d671e | |||
| 22e87ab31b | |||
| 90515f1913 | |||
| 97d93676bc | |||
| 6c3c4a721c | |||
| 692fbd0f19 | |||
| c3bd263a67 | |||
| 9b9685c297 | |||
| b3f063da79 | |||
| a33d0aea81 | |||
| ca380a136e | |||
| dfa186a768 | |||
| d63ffa53e8 | |||
| 284def86ef | |||
| c19936565d | |||
| 73b872e141 | |||
| 7373bb5878 | |||
| d11c151c25 | |||
| f3f4fea66c | |||
| 90116256a6 | |||
| 5008df8bcc | |||
| 696aa80dec | |||
| 7d00d863dc | |||
| ad9eb83b8c | |||
| 467da9a82c | |||
| 22786bcf4b | |||
| c8d258b7cb | |||
| 70309829ca | |||
| 71e00f193b | |||
| d44e724170 | |||
| 414695db9f | |||
| e8b5a27723 | |||
| 6dc9d805db | |||
| bdf56c9111 | |||
| 293cc3e82f | |||
| 7f5bd4f231 | |||
| c4826babc5 | |||
| 91e8bb985e | |||
| 56723051d4 | |||
| 0bb3e55c08 | |||
| 080b7a328b | |||
| c59c4aae6c | |||
| c5d696583e | |||
| 2cb5a99b98 | |||
| 70490d892f | |||
| f858a85ba6 | |||
| 44e4c92882 | |||
| 43eafcb258 | |||
| b382ac7af9 | |||
| ca21110644 | |||
| 0c2e47e8ba | |||
| 6a0704574b | |||
| 1cb1341516 | |||
| bc47e95427 | |||
| b071e8add8 | |||
| 23e527753e | |||
| e81c045ed0 | |||
| 0c602ca936 | |||
| 9866f84989 | |||
| 6b397ffe37 | |||
| bc21596889 | |||
| 20abba3190 | |||
| 5881bf98a0 | |||
| 595f7d028c | |||
| c81dec9c5d | |||
| 4d45ed25d2 | |||
| 03cf48de53 | |||
| 0d3a284395 | |||
| 5b1bb0fc80 | |||
| 332ebb8811 | |||
| 2111813bd4 | |||
| f5b8521af6 | |||
| 79481cff40 | |||
| 40dc356d4c | |||
| f929fe89e7 | |||
| cbd245d454 | |||
| e5289e9b3a | |||
| bb623f3ff9 | |||
| 37fffafafc | |||
| d329310745 | |||
| 8016a6c45a | |||
| 1eeaa0b756 | |||
| dd3533b4e0 | |||
| 3a5867834f | |||
| 1e83bca7a3 | |||
| cd8853f88b | |||
| 23b290c6a8 | |||
| 4e7849cee7 | |||
| e3e63a7247 | |||
| fb297153e4 | |||
| e8dd06e0ce | |||
| bdee3d438b | |||
| b410bc3ef2 | |||
| 870e3d1d26 | |||
| 5f21f3b603 | |||
| 27dad05e25 | |||
| 2ed88cbcc0 | |||
| 4d199c932e | |||
| 2d142f920c | |||
| 47dea9e551 | |||
| 0d25c3dcac | |||
| 3d7f9256cb | |||
| 6aa1ebd4be | |||
| f9ebebaffd | |||
| 7b3fe83c09 | |||
| 0a06b3efc2 | |||
| 90dcbbbcc5 | |||
| b17f5fd8ae | |||
| 68691ac3f9 | |||
| 78fe905fcb | |||
| 6b488802cf | |||
| 4d0505b70e | |||
| b729923bd9 | |||
| 34c7aa7dfe | |||
| 09b4d3548b | |||
| f1cad8fdc0 | |||
| b2f7f57f26 | |||
| 925d4eba1f | |||
| bc1e4b870b | |||
| ef913f9645 | |||
| cec747ade3 | |||
| 2ced32d52f | |||
| 92cc5a79df | |||
| 4cf8661f92 | |||
| ebe431c0dc | |||
| 8b2c5f30a0 | |||
| 0fc92e1c47 | |||
| d73ca9024a | |||
| bff48f9fde | |||
| c5c803f415 | |||
| cd69509b48 | |||
| 83d15fd756 | |||
| b3c09451d9 | |||
| d98bdb5eff | |||
| ea205913ce | |||
| 3532801639 | |||
| 1bb50141c2 | |||
| fa8bdd63fb | |||
| 56f64577ce | |||
| f726af5767 | |||
| 4838e3cb9b | |||
| dbe92646c3 | |||
| 4717c67054 | |||
| d4c77c14fd | |||
| 96b3d65307 | |||
| b491c29cf9 | |||
| d937abd849 | |||
| df52175b0c | |||
| bee8339c07 | |||
| 181bf96fa3 | |||
| 9d49d2fd52 | |||
| 648776aa85 | |||
| 421cb099b0 | |||
| f458b19994 | |||
| d347093b03 | |||
| 3328712262 | |||
| 60edc1b52d | |||
| eee44cd080 | |||
| 048a3235e8 | |||
| 1db03ec1e6 | |||
| 6d283349ad | |||
| 7e07302ecd | |||
| 8bc407a617 | |||
| 5305d2bae2 | |||
| fee96c27b9 | |||
| 66520adbc6 | |||
| 40714995cc | |||
| 66c4896003 | |||
| 31091f3d4e | |||
| 5fe512c1b1 | |||
| 63efa131e7 | |||
| 29cd503070 | |||
| a9b704c656 | |||
| 0a88e412e5 | |||
| b46e29ccd5 | |||
| a181b770d6 | |||
| fa71201f20 | |||
| 3ac17aefb6 | |||
| 1f2ceb91a5 | |||
| d82681900c | |||
| 7e296f4cbd | |||
| f202ff51e4 | |||
| 4085a0b536 | |||
| 935fdeca65 | |||
| cef8828fbe | |||
| 9277a23a29 | |||
| d7bfb16b0f | |||
| 37ae6fe7c8 | |||
| 87ce527476 | |||
| 34bed30fa1 | |||
| 8a0c3cb75f | |||
| f65460ac16 | |||
| 8ea92aed9a | |||
| f340d82fc3 | |||
| c7431ae24c | |||
| 3d1888b7b5 | |||
| 16a8fa5c20 | |||
| dcd37ccdbb | |||
| d18c3f910c | |||
| 2cec5a492c | |||
| 61dd0ec489 | |||
| c7758204b5 | |||
| ed91520aa2 | |||
| 3e82669a82 | |||
| 1cc0c9c074 | |||
| d73d7f6453 | |||
| afb59f86ee | |||
| 05242f68d4 | |||
| c1b029dcc1 | |||
| bc2dd5137a | |||
| 99ec4900c7 | |||
| a8c124ac9e | |||
| 0d20a363a9 | |||
| 3ac613678b | |||
| e2ead1b4e6 | |||
| be53c33588 | |||
| 55cf5fa310 | |||
| 3e0fe362f2 | |||
| 89d06ae31f | |||
| c994b116ae | |||
| 833e8f7a66 | |||
| 32bd1427fb | |||
| ae7672b87e | |||
| 9b27cc5a76 | |||
| 7014e3e318 | |||
| e0e153e8d6 | |||
| a0fbef93e4 | |||
| e4a89229be | |||
| 9ad15e97cd | |||
| e3b4df4e87 | |||
| e3e2066c83 | |||
| 208dcd12a2 | |||
| d166444aa1 | |||
| cd35f96e73 | |||
| 2b0c21e0b4 | |||
| 787b0bf9e9 | |||
| 77fbf02ff6 | |||
| 89973fd122 | |||
| e458a9f5b8 | |||
| 61d95afae2 | |||
| 4a2df5e008 | |||
| 3db907f3ce | |||
| 70d8f9cc6e | |||
| b9b354ada0 | |||
| 95632ad376 | |||
| 7153506989 | |||
| c73bab2e7e | |||
| a783dc808d | |||
| 58e72802bc | |||
| 19233d93c4 | |||
| 7eea45e078 | |||
| 001ec226f6 | |||
| 32461d5b44 | |||
| 9f078294c0 | |||
| c885ed49cd | |||
| 2fc434062f | |||
| dbcb7e6d69 | |||
| b58392fc62 | |||
| a4818c90ca | |||
| 49fbdba49f | |||
| f2dd4da7f9 | |||
| a3ed215038 | |||
| d45df0530b | |||
| 990531258b | |||
| cd2c9e3b62 | |||
| a114991278 | |||
| 5ff2035cee | |||
| a5993cc140 | |||
| aebe4b6cd0 | |||
| 214adb1154 | |||
| b301c1f8b6 | |||
| b81c8b385b | |||
| 1caa438b3e | |||
| 673bc92f4d | |||
| 0159eaa158 | |||
| efdc7b9a54 | |||
| b08721206d | |||
| fe0d4cd9fa | |||
| 738ad6ed70 | |||
| 19b66fbaf2 | |||
| c416836497 | |||
| 059cc4b91f | |||
| 116bd328a6 | |||
| aa30ce3ef2 | |||
| 9533a47906 | |||
| ad18f780f0 | |||
| a48519e557 | |||
| 23f5e74392 | |||
| 7a50ea28a1 | |||
| 5944f94197 | |||
| 98732b7d76 | |||
| c6412d7e59 | |||
| b5a7a6e974 | |||
| ba7c3d7b0b | |||
| 53a9a2a36d | |||
| 3b6bca9b90 | |||
| f5a048060f | |||
| 9e6178a5e3 | |||
| e4150361b3 | |||
| 8de0fc059d | |||
| 054733884f | |||
| 3d09981eda | |||
| 6877af7770 | |||
| 802985f76c | |||
| 794993fd48 | |||
| 00b53a422a | |||
| 2ccecb6011 | |||
| 9b7e3abf00 | |||
| 27d6a3d503 | |||
| 6beb1f8be2 | |||
| e0c1124fe2 | |||
| bf4ceb7baa | |||
| 1a41934e55 | |||
| aeb4caec9f | |||
| ccb8dcc65f | |||
| 435ec1df7b | |||
| 7c9d609143 | |||
| add7947164 | |||
| dd0c1af12a | |||
| 0c93cc03be | |||
| 62e25b73f8 | |||
| 17093e0031 | |||
| ae542978f0 | |||
| 7f16117bba | |||
| 2b96a2d3d6 | |||
| 9799a841c6 | |||
| 71c59b4235 | |||
| 0568b412aa | |||
| 812612465a | |||
| ac8a79dd74 | |||
| 9460d981d4 | |||
| 91db9d81ec | |||
| d8d5f08df6 | |||
| 11d5443ab1 | |||
| 90558e9eed | |||
| cb7d258e33 | |||
| 2af43dc4f8 | |||
| 93ddb6b131 | |||
| 04bf671f18 | |||
| 1f3ba4ba21 | |||
| bb1c08dd41 | |||
| 86d0f14397 | |||
| 7336b3619c | |||
| 73b81ac027 | |||
| ac7e35af4e | |||
| 8f340e1eb2 | |||
| 62da5b0cbd | |||
| 4e49abc10c | |||
| 6ecd9ec509 | |||
| bb42d0b855 | |||
| a46966efef | |||
| 19ace4436f |
@@ -0,0 +1,89 @@
|
||||
name: Sync Model Catalogs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 8 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- group: aggregators
|
||||
title: "chore(sync): update aggregator model catalogs"
|
||||
branch: automation/sync-models-aggregators
|
||||
labels: automation,model-sync,sync-group:aggregators,provider:openrouter
|
||||
- group: cloudflare
|
||||
title: "chore(sync): update cloudflare model catalogs"
|
||||
branch: automation/sync-models-cloudflare
|
||||
labels: automation,model-sync,sync-group:cloudflare,provider:cloudflare-workers-ai
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
ref: dev
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@f4d14e03ff726c06358e5557344e1da148b56cf7
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Sync model catalogs
|
||||
run: bun models:sync ${{ matrix.group }}
|
||||
env:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
CLOUDFLARE_WORKERS_AI_SYNC_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_WORKERS_AI_SYNC_ACCOUNT_ID }}
|
||||
CLOUDFLARE_WORKERS_AI_SYNC_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_AI_SYNC_API_TOKEN }}
|
||||
|
||||
- name: Validate models
|
||||
run: bun validate
|
||||
|
||||
- name: Create pull request
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BRANCH: ${{ matrix.branch }}
|
||||
LABELS: ${{ matrix.labels }}
|
||||
TITLE: ${{ matrix.title }}
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain -- providers)" ]; then
|
||||
echo "No model catalog changes found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "$BRANCH"
|
||||
git add providers
|
||||
git commit -m "$TITLE"
|
||||
git push --force-with-lease origin "$BRANCH"
|
||||
|
||||
label_args=()
|
||||
IFS=',' read -ra labels <<< "$LABELS"
|
||||
for label in "${labels[@]}"; do
|
||||
gh label create "$label" --color "0E8A16" --description "Automated model catalog sync" >/dev/null 2>&1 || true
|
||||
label_args+=(--label "$label")
|
||||
done
|
||||
|
||||
pr_number="$(gh pr list --head "$BRANCH" --base dev --json number --jq '.[0].number')"
|
||||
if [ -n "$pr_number" ]; then
|
||||
gh pr edit "$pr_number" --title "$TITLE" --body-file .sync/model-sync-report.md
|
||||
for label in "${labels[@]}"; do
|
||||
gh pr edit "$pr_number" --add-label "$label"
|
||||
done
|
||||
else
|
||||
gh pr create --base dev --head "$BRANCH" --title "$TITLE" --body-file .sync/model-sync-report.md "${label_args[@]}"
|
||||
fi
|
||||
@@ -3,6 +3,7 @@
|
||||
.idea
|
||||
dist
|
||||
.DS_Store
|
||||
.sync/
|
||||
node_modules
|
||||
data/tokenspeed-monitor.sqlite
|
||||
data/tokenspeed-monitor.sqlite-shm
|
||||
|
||||
@@ -120,6 +120,31 @@ output = ["text"] # Supported output modalities
|
||||
field = "reasoning_content" # Name of the interleaved field "reasoning_content" or "reasoning_details"
|
||||
```
|
||||
|
||||
#### 3a. Reuse an Existing Model with `extends`
|
||||
|
||||
For wrapper providers that mirror a model from another provider, prefer reusing the canonical model definition instead of duplicating the whole file.
|
||||
|
||||
Use `extends` only for non-first-party wrappers and mirrors. Do not use it inside the actual lab provider directories that act as the canonical source for a model family, for example `providers/anthropic/`, `providers/openai/`, `providers/google/`, `providers/xai/`, `providers/minimax/`, or `providers/moonshot/`.
|
||||
|
||||
```toml
|
||||
[extends]
|
||||
from = "anthropic/claude-opus-4-6"
|
||||
omit = ["experimental.modes.fast"]
|
||||
|
||||
[provider]
|
||||
npm = "@ai-sdk/anthropic"
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `from` must point to another model using `<provider>/<model-id>`.
|
||||
- `omit` is optional and removes fields after the inherited model and local overrides are merged.
|
||||
- You can override any top-level model field locally.
|
||||
- If you override a nested table like `[cost]`, `[limit]`, or `[modalities]`, include the full values needed for that table.
|
||||
- `id` still comes from the filename; do not add it to the TOML.
|
||||
|
||||
Use `extends` when the wrapper model is materially the same as the source model and only differs by a small set of overrides or omitted fields.
|
||||
|
||||
#### 4. Submit a Pull Request
|
||||
|
||||
1. Fork this repo
|
||||
@@ -136,9 +161,17 @@ There's a GitHub Action that will automatically validate your submission against
|
||||
- Values are within acceptable ranges
|
||||
- TOML syntax is valid
|
||||
|
||||
When converting existing wrapper models to `extends`, compare generated output before and after the change:
|
||||
|
||||
```bash
|
||||
bun run compare:migrations
|
||||
```
|
||||
|
||||
This prints a diff for each changed model TOML so you can confirm the generated JSON only changed where you intended.
|
||||
|
||||
### Schema Reference
|
||||
|
||||
Models must conform to the following schema, as defined in `app/schemas.ts`.
|
||||
Models must conform to the following schema, as defined in `packages/core/src/schema.ts`.
|
||||
|
||||
**Provider Schema:**
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "models.dev",
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250801.0",
|
||||
"@cloudflare/workers-types": "^4.20260424.1",
|
||||
"sst": "3.17.23",
|
||||
},
|
||||
},
|
||||
@@ -32,6 +32,7 @@
|
||||
"packages/web": {
|
||||
"name": "@models.dev/web",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "^3.14.0",
|
||||
"hono": "^4.8.0",
|
||||
"models.dev": "workspace:*",
|
||||
},
|
||||
@@ -49,7 +50,7 @@
|
||||
"zod": "3.24.2",
|
||||
},
|
||||
"packages": {
|
||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250801.0", "", {}, "sha512-BQmMdoOGClY23TesgkR1PeGrPvPsSFD/zW7pDzWZHkOEsqkPk2A91h52bP8GbtKYTl1vdaYjQgJlGsP6Ih4G0w=="],
|
||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260424.1", "", {}, "sha512-0DLJ9yEk1KKzPbqop80Gw/P1wkKKzawmipULiJWdBXIBCoMvE0OVWms3IrL/Q/G7tfmPop9yF4XlZ69k9JLYng=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -57,9 +58,11 @@
|
||||
|
||||
"@models.dev/web": ["@models.dev/web@workspace:packages/web"],
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="],
|
||||
|
||||
"@tsconfig/bun": ["@tsconfig/bun@1.0.8", "", {}, "sha512-JlJaRaS4hBTypxtFe8WhnwV8blf0R+3yehLk8XuyxUYNx6VXsKCjACSCvOYEFUiqlhlBWxtYCn/zRlOb8BzBQg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="],
|
||||
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
|
||||
|
||||
"@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
@@ -79,7 +82,7 @@
|
||||
|
||||
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="],
|
||||
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
@@ -325,8 +328,6 @@
|
||||
|
||||
"http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
|
||||
|
||||
"models.dev/@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
|
||||
|
||||
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
|
||||
|
||||
"opencontrol/hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="],
|
||||
@@ -334,11 +335,5 @@
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
||||
|
||||
"models.dev/@types/bun/bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
|
||||
|
||||
"models.dev/@types/bun/bun-types/@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="],
|
||||
|
||||
"models.dev/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+8
-2
@@ -17,13 +17,19 @@
|
||||
"scripts": {
|
||||
"validate": "bun ./packages/core/script/validate.ts",
|
||||
"compare:migrations": "bun ./packages/core/script/compare-model-migrations.ts",
|
||||
"cloudflare:sync": "bun ./packages/core/script/sync-models.ts cloudflare-workers-ai",
|
||||
"chutes:generate": "bun ./packages/core/script/generate-chutes.ts",
|
||||
"databricks:generate": "bun ./packages/core/script/generate-databricks.ts",
|
||||
"helicone:generate": "bun ./packages/core/script/generate-helicone.ts",
|
||||
"venice:generate": "bun ./packages/core/script/generate-venice.ts",
|
||||
"vercel:generate": "bun ./packages/core/script/generate-vercel.ts",
|
||||
"wandb:generate": "bun ./packages/core/script/generate-wandb.ts"
|
||||
"wandb:generate": "bun ./packages/core/script/generate-wandb.ts",
|
||||
"digitalocean:generate": "bun ./packages/core/script/generate-digitalocean.ts",
|
||||
"ambient:generate": "bun ./packages/core/script/generate-ambient.ts",
|
||||
"models:sync": "bun ./packages/core/script/sync-models.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250801.0",
|
||||
"@cloudflare/workers-types": "^4.20260424.1",
|
||||
"sst": "3.17.23"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Generates Ambient model TOML files from https://api.ambient.xyz/v1/models.
|
||||
*
|
||||
* Emits `[extends]`-format TOMLs that inherit upstream metadata
|
||||
* (family, release_date, knowledge, capabilities) from the canonical
|
||||
* provider model, and override only the fields Ambient's API reports:
|
||||
* cost, limit, modalities.
|
||||
*
|
||||
* Flags:
|
||||
* --dry-run Preview generated TOMLs without writing files.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import path from "node:path";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
const API_ENDPOINT = "https://api.ambient.xyz/v1/models";
|
||||
|
||||
// Allowlist for the initial rollout.
|
||||
const ALLOWLIST = new Set<string>([
|
||||
"zai-org/GLM-5.1-FP8",
|
||||
"moonshotai/kimi-k2.6",
|
||||
]);
|
||||
|
||||
// Maps Ambient model IDs to canonical <provider>/<model> in this repo.
|
||||
// The generated TOML uses this path as `[extends].from` so capabilities
|
||||
// and metadata propagate from the upstream provider automatically.
|
||||
const EXTENDS_MAP: Record<string, string> = {
|
||||
"zai-org/GLM-5.1-FP8": "zai/glm-5.1",
|
||||
"moonshotai/kimi-k2.6": "moonshotai/kimi-k2.6",
|
||||
};
|
||||
|
||||
const Pricing = z
|
||||
.object({
|
||||
prompt: z.string(),
|
||||
completion: z.string(),
|
||||
input_cache_read: z.string().optional(),
|
||||
input_cache_write: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const AmbientModel = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
context_length: z.number(),
|
||||
max_output_length: z.number(),
|
||||
input_modalities: z.array(z.string()),
|
||||
output_modalities: z.array(z.string()),
|
||||
pricing: Pricing,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const AmbientResponse = z
|
||||
.object({
|
||||
object: z.literal("list"),
|
||||
data: z.array(AmbientModel),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ALLOWED_MODALITIES = new Set(["text", "audio", "image", "video", "pdf"]);
|
||||
|
||||
function modalities(values: string[]): string[] {
|
||||
return values
|
||||
.map((v) => v.toLowerCase())
|
||||
.filter((v) => ALLOWED_MODALITIES.has(v));
|
||||
}
|
||||
|
||||
function perMTok(price: string): number {
|
||||
const n = parseFloat(price);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new Error(`Invalid price: ${price}`);
|
||||
}
|
||||
// Round to 6 decimals to absorb float noise from per-token strings.
|
||||
return Math.round(n * 1_000_000 * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function formatToml(
|
||||
model: z.infer<typeof AmbientModel>,
|
||||
extendsFrom: string,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("[extends]");
|
||||
lines.push(`from = "${extendsFrom}"`);
|
||||
lines.push("");
|
||||
|
||||
lines.push("[cost]");
|
||||
lines.push(`input = ${perMTok(model.pricing.prompt)}`);
|
||||
lines.push(`output = ${perMTok(model.pricing.completion)}`);
|
||||
if (model.pricing.input_cache_read !== undefined) {
|
||||
lines.push(`cache_read = ${perMTok(model.pricing.input_cache_read)}`);
|
||||
}
|
||||
if (model.pricing.input_cache_write !== undefined) {
|
||||
lines.push(`cache_write = ${perMTok(model.pricing.input_cache_write)}`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push("[limit]");
|
||||
lines.push(`context = ${model.context_length}`);
|
||||
lines.push(`output = ${model.max_output_length}`);
|
||||
lines.push("");
|
||||
|
||||
const input = modalities(model.input_modalities);
|
||||
const output = modalities(model.output_modalities);
|
||||
lines.push("[modalities]");
|
||||
lines.push(`input = [${input.map((m) => `"${m}"`).join(", ")}]`);
|
||||
lines.push(`output = [${output.map((m) => `"${m}"`).join(", ")}]`);
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dryRun = process.argv.includes("--dry-run");
|
||||
|
||||
const outDir = path.join(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"providers",
|
||||
"ambient",
|
||||
"models",
|
||||
);
|
||||
|
||||
const res = await fetch(API_ENDPOINT);
|
||||
if (!res.ok) {
|
||||
console.error(`Fetch failed: ${res.status} ${res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const parsed = AmbientResponse.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
console.error("Invalid Ambient response:", parsed.error.issues);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const selected = parsed.data.data.filter((m) => ALLOWLIST.has(m.id));
|
||||
const missing = [...ALLOWLIST].filter(
|
||||
(id) => !selected.some((m) => m.id === id),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
console.error(`Allowlisted models missing from API: ${missing.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const model of selected) {
|
||||
const extendsFrom = EXTENDS_MAP[model.id];
|
||||
if (!extendsFrom) {
|
||||
console.error(`No EXTENDS_MAP entry for ${model.id}; skipping`);
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(outDir, `${model.id}.toml`);
|
||||
const toml = formatToml(model, extendsFrom);
|
||||
if (dryRun) {
|
||||
console.log(`--- ${path.relative(process.cwd(), filePath)} ---`);
|
||||
console.log(toml);
|
||||
} else {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await Bun.write(filePath, toml);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${dryRun ? "Previewed" : "Wrote"} ${count} model file(s) under providers/ambient/models/`,
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,589 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Generates Chutes model TOML files from the Chutes LLM API.
|
||||
*
|
||||
* Flags:
|
||||
* --dry-run: Preview changes without writing files
|
||||
* --new-only: Only create new models, skip updating existing ones
|
||||
* --keep-orphans: Don't delete TOML files for models no longer in the API
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import path from "node:path";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { ModelFamilyValues } from "../src/family.js";
|
||||
|
||||
const API_ENDPOINT = "https://llm.chutes.ai/v1/models";
|
||||
|
||||
enum SkipZeroFields {
|
||||
LimitContext = "limit.context",
|
||||
LimitOutput = "limit.output",
|
||||
}
|
||||
|
||||
const Pricing = z.object({
|
||||
prompt: z.number().optional(),
|
||||
completion: z.number().optional(),
|
||||
input_cache_read: z.number().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const ChutesModel = z.object({
|
||||
id: z.string(),
|
||||
created: z.number(),
|
||||
pricing: Pricing.optional(),
|
||||
context_length: z.number().optional(),
|
||||
max_output_length: z.number().optional(),
|
||||
max_model_len: z.number().optional(),
|
||||
input_modalities: z.array(z.string()).optional(),
|
||||
output_modalities: z.array(z.string()).optional(),
|
||||
supported_features: z.array(z.string()).optional(),
|
||||
supported_sampling_parameters: z.array(z.string()).optional(),
|
||||
quantization: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const ChutesResponse = z.object({
|
||||
data: z.array(ChutesModel),
|
||||
}).passthrough();
|
||||
|
||||
interface ExistingModel {
|
||||
name?: string;
|
||||
family?: string;
|
||||
attachment?: boolean;
|
||||
reasoning?: boolean;
|
||||
tool_call?: boolean;
|
||||
structured_output?: boolean;
|
||||
temperature?: boolean;
|
||||
knowledge?: string;
|
||||
release_date?: string;
|
||||
last_updated?: string;
|
||||
open_weights?: boolean;
|
||||
interleaved?: boolean | { field: string };
|
||||
status?: string;
|
||||
cost?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache_read?: number;
|
||||
};
|
||||
limit?: {
|
||||
context?: number;
|
||||
output?: number;
|
||||
};
|
||||
modalities?: {
|
||||
input?: string[];
|
||||
output?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface MergedModel {
|
||||
name: string;
|
||||
family?: string;
|
||||
attachment: boolean;
|
||||
reasoning: boolean;
|
||||
tool_call: boolean;
|
||||
structured_output?: boolean;
|
||||
temperature: boolean;
|
||||
knowledge?: string;
|
||||
release_date: string;
|
||||
last_updated: string;
|
||||
open_weights: boolean;
|
||||
interleaved?: boolean | { field: string };
|
||||
status?: string;
|
||||
cost?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cache_read?: number;
|
||||
};
|
||||
limit: {
|
||||
context: number;
|
||||
output: number;
|
||||
};
|
||||
modalities: {
|
||||
input: string[];
|
||||
output: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface Changes {
|
||||
field: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
}
|
||||
|
||||
// ── Utility functions ────────────────────────────────────────────────
|
||||
|
||||
function timestampToDate(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getTodayDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1000) {
|
||||
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "_");
|
||||
}
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Humanize a model ID into a readable name.
|
||||
* Strips the org prefix and replaces hyphens with spaces.
|
||||
* e.g. "Qwen/Qwen3-32B-TEE" → "Qwen3 32B TEE"
|
||||
*/
|
||||
function humanizeModelName(modelId: string): string {
|
||||
const parts = modelId.split("/");
|
||||
const modelPart = parts[parts.length - 1];
|
||||
return modelPart.replace(/-/g, " ");
|
||||
}
|
||||
|
||||
// ── Family inference ───────────
|
||||
|
||||
function isSubstring(target: string, family: string): boolean {
|
||||
return target.toLowerCase().includes(family.toLowerCase());
|
||||
}
|
||||
|
||||
function matchesFamily(target: string, family: string): boolean {
|
||||
const targetLower = target.toLowerCase();
|
||||
const familyLower = family.toLowerCase();
|
||||
let familyIdx = 0;
|
||||
|
||||
for (let i = 0; i < targetLower.length && familyIdx < familyLower.length; i++) {
|
||||
if (targetLower[i] === familyLower[familyIdx]) {
|
||||
familyIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
return familyIdx === familyLower.length;
|
||||
}
|
||||
|
||||
function inferFamily(modelId: string, modelName: string): string | undefined {
|
||||
const sortedFamilies = [...ModelFamilyValues].sort((a, b) => b.length - a.length);
|
||||
|
||||
// First pass: try exact substring matches
|
||||
for (const family of sortedFamilies) {
|
||||
if (isSubstring(modelId, family)) {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
for (const family of sortedFamilies) {
|
||||
if (isSubstring(modelName, family)) {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: fall back to subsequence matching
|
||||
for (const family of sortedFamilies) {
|
||||
if (matchesFamily(modelId, family)) {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
for (const family of sortedFamilies) {
|
||||
if (matchesFamily(modelName, family)) {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Load existing TOML ───────────────────────────────────────────────
|
||||
|
||||
async function loadExistingModel(filePath: string): Promise<ExistingModel | null> {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return null;
|
||||
}
|
||||
const toml = await import(filePath, { with: { type: "toml" } }).then(
|
||||
(mod) => mod.default,
|
||||
);
|
||||
return toml as ExistingModel;
|
||||
} catch (e) {
|
||||
console.warn(`Warning: Failed to parse existing file ${filePath}:`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Merge API data with existing TOML ────────────────────────────────
|
||||
|
||||
function mergeModel(
|
||||
apiModel: z.infer<typeof ChutesModel>,
|
||||
existing: ExistingModel | null,
|
||||
): MergedModel {
|
||||
const features = new Set(apiModel.supported_features ?? []);
|
||||
const samplingParams = new Set(apiModel.supported_sampling_parameters ?? []);
|
||||
const inputMods = apiModel.input_modalities ?? ["text"];
|
||||
const outputMods = apiModel.output_modalities ?? ["text"];
|
||||
|
||||
// Capabilities from API features
|
||||
const hasAttachment = inputMods.some((m) =>
|
||||
m === "image" || m === "video" || m === "pdf",
|
||||
);
|
||||
const hasReasoning = features.has("reasoning");
|
||||
const hasToolCall = features.has("tools");
|
||||
const hasStructuredOutput = features.has("structured_outputs");
|
||||
const hasTemperature = samplingParams.size > 0
|
||||
? samplingParams.has("temperature")
|
||||
: true; // default true if no sampling params info
|
||||
|
||||
// Preserve existing values when available (manually specified)
|
||||
const modelName = existing?.name ?? humanizeModelName(apiModel.id);
|
||||
const family = existing?.family ?? inferFamily(apiModel.id, modelName);
|
||||
const knowledge = existing?.knowledge;
|
||||
const interleaved = existing?.interleaved;
|
||||
const status = existing?.status;
|
||||
|
||||
// Release date: existing > API created timestamp > today
|
||||
const releaseDate = existing?.release_date
|
||||
?? timestampToDate(apiModel.created)
|
||||
?? getTodayDate();
|
||||
|
||||
// Context limit: prefer context_length, fallback to max_model_len
|
||||
const apiContext = apiModel.context_length ?? apiModel.max_model_len ?? 0;
|
||||
const contextLimit = apiContext > 0
|
||||
? apiContext
|
||||
: (existing?.limit?.context ?? 0);
|
||||
|
||||
// Output limit: prefer max_output_length, fallback to existing
|
||||
const apiOutput = apiModel.max_output_length ?? 0;
|
||||
const outputLimit = apiOutput > 0
|
||||
? apiOutput
|
||||
: (existing?.limit?.output ?? 0);
|
||||
|
||||
const merged: MergedModel = {
|
||||
name: modelName,
|
||||
family,
|
||||
attachment: hasAttachment,
|
||||
reasoning: hasReasoning,
|
||||
tool_call: hasToolCall,
|
||||
temperature: hasTemperature,
|
||||
release_date: releaseDate,
|
||||
last_updated: getTodayDate(),
|
||||
open_weights: true, // Chutes hosts open-weight models
|
||||
...(hasStructuredOutput && { structured_output: hasStructuredOutput }),
|
||||
...(knowledge && { knowledge }),
|
||||
...(interleaved !== undefined && { interleaved }),
|
||||
...(status && { status }),
|
||||
limit: {
|
||||
context: contextLimit,
|
||||
output: outputLimit,
|
||||
},
|
||||
modalities: {
|
||||
input: inputMods,
|
||||
output: outputMods,
|
||||
},
|
||||
};
|
||||
|
||||
// Cost: API values are already in USD per 1M tokens — use directly
|
||||
if (apiModel.pricing) {
|
||||
const inputPrice = apiModel.pricing.prompt;
|
||||
const outputPrice = apiModel.pricing.completion;
|
||||
const cacheReadPrice = apiModel.pricing.input_cache_read;
|
||||
|
||||
if (inputPrice !== undefined && outputPrice !== undefined) {
|
||||
merged.cost = {
|
||||
input: inputPrice,
|
||||
output: outputPrice,
|
||||
...(cacheReadPrice !== undefined && { cache_read: cacheReadPrice }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── TOML formatting ──────────────────────────────────────────────────
|
||||
|
||||
function formatToml(model: MergedModel): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`# Auto-generated by generate-chutes.ts — do not edit pricing, limits, or capabilities.`);
|
||||
lines.push(`# Manual overrides preserved on re-run: name, family, knowledge, interleaved, status`);
|
||||
lines.push(`name = "${model.name.replace(/"/g, '\\"')}"`);
|
||||
if (model.family) {
|
||||
lines.push(`family = "${model.family}"`);
|
||||
}
|
||||
lines.push(`release_date = "${model.release_date}"`);
|
||||
lines.push(`last_updated = "${model.last_updated}"`);
|
||||
lines.push(`attachment = ${model.attachment}`);
|
||||
lines.push(`reasoning = ${model.reasoning}`);
|
||||
lines.push(`temperature = ${model.temperature}`);
|
||||
lines.push(`tool_call = ${model.tool_call}`);
|
||||
if (model.structured_output !== undefined) {
|
||||
lines.push(`structured_output = ${model.structured_output}`);
|
||||
}
|
||||
lines.push(`open_weights = ${model.open_weights}`);
|
||||
if (model.knowledge) {
|
||||
lines.push(`knowledge = "${model.knowledge}"`);
|
||||
}
|
||||
if (model.status) {
|
||||
lines.push(`status = "${model.status}"`);
|
||||
}
|
||||
|
||||
if (model.cost) {
|
||||
lines.push("");
|
||||
lines.push(`[cost]`);
|
||||
lines.push(`input = ${model.cost.input}`);
|
||||
lines.push(`output = ${model.cost.output}`);
|
||||
if (model.cost.cache_read !== undefined) {
|
||||
lines.push(`cache_read = ${model.cost.cache_read}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(`[limit]`);
|
||||
lines.push(`context = ${formatNumber(model.limit.context)}`);
|
||||
lines.push(`output = ${formatNumber(model.limit.output)}`);
|
||||
|
||||
lines.push("");
|
||||
lines.push(`[modalities]`);
|
||||
lines.push(`input = [${model.modalities.input.map((m) => `"${m}"`).join(", ")}]`);
|
||||
lines.push(`output = [${model.modalities.output.map((m) => `"${m}"`).join(", ")}]`);
|
||||
|
||||
if (model.interleaved !== undefined) {
|
||||
lines.push("");
|
||||
if (model.interleaved === true) {
|
||||
lines.push(`interleaved = true`);
|
||||
} else if (typeof model.interleaved === "object") {
|
||||
lines.push(`[interleaved]`);
|
||||
lines.push(`field = "${model.interleaved.field}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ── Change detection ─────────────────────────────────────────────────
|
||||
|
||||
function detectChanges(
|
||||
existing: ExistingModel | null,
|
||||
merged: MergedModel,
|
||||
): Changes[] {
|
||||
if (!existing) return [];
|
||||
|
||||
const changes: Changes[] = [];
|
||||
const EPSILON = 0.001;
|
||||
|
||||
const shouldSkipZero = (field: string, oldVal: unknown, newVal: unknown): boolean => {
|
||||
if (!Object.values(SkipZeroFields).includes(field as SkipZeroFields)) {
|
||||
return false;
|
||||
}
|
||||
return (typeof oldVal === "number" && oldVal === 0) || (typeof newVal === "number" && newVal === 0);
|
||||
};
|
||||
|
||||
const formatValue = (val: unknown): string => {
|
||||
if (typeof val === "number") return formatNumber(val);
|
||||
if (Array.isArray(val)) return `[${val.join(", ")}]`;
|
||||
if (val === undefined) return "(none)";
|
||||
return String(val);
|
||||
};
|
||||
|
||||
const isMaterialPriceDiff = (oldPrice: unknown, newPrice: unknown): boolean => {
|
||||
if (oldPrice === 0 && newPrice === undefined) return false;
|
||||
if (oldPrice !== undefined && newPrice !== undefined) {
|
||||
return Math.abs((oldPrice as number) - (newPrice as number)) > EPSILON;
|
||||
}
|
||||
return oldPrice !== newPrice;
|
||||
};
|
||||
|
||||
const compare = (field: string, oldVal: unknown, newVal: unknown) => {
|
||||
if (shouldSkipZero(field, oldVal, newVal)) return;
|
||||
|
||||
const isDiff = field.startsWith("cost.")
|
||||
? isMaterialPriceDiff(oldVal, newVal)
|
||||
: JSON.stringify(oldVal) !== JSON.stringify(newVal);
|
||||
|
||||
if (isDiff) {
|
||||
changes.push({
|
||||
field,
|
||||
oldValue: formatValue(oldVal),
|
||||
newValue: formatValue(newVal),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
compare("name", existing.name, merged.name);
|
||||
compare("family", existing.family, merged.family);
|
||||
compare("attachment", existing.attachment, merged.attachment);
|
||||
compare("reasoning", existing.reasoning, merged.reasoning);
|
||||
compare("tool_call", existing.tool_call, merged.tool_call);
|
||||
compare("structured_output", existing.structured_output, merged.structured_output);
|
||||
compare("open_weights", existing.open_weights, merged.open_weights);
|
||||
compare("release_date", existing.release_date, merged.release_date);
|
||||
compare("cost.input", existing.cost?.input, merged.cost?.input);
|
||||
compare("cost.output", existing.cost?.output, merged.cost?.output);
|
||||
compare("cost.cache_read", existing.cost?.cache_read, merged.cost?.cache_read);
|
||||
compare("limit.context", existing.limit?.context, merged.limit.context);
|
||||
compare("limit.output", existing.limit?.output, merged.limit.output);
|
||||
compare("modalities.input", existing.modalities?.input, merged.modalities.input);
|
||||
compare("modalities.output", existing.modalities?.output, merged.modalities.output);
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const newOnly = args.includes("--new-only");
|
||||
const keepOrphans = args.includes("--keep-orphans");
|
||||
|
||||
const modelsDir = path.join(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"providers",
|
||||
"chutes",
|
||||
"models",
|
||||
);
|
||||
|
||||
console.log(`${dryRun ? "[DRY RUN] " : ""}${newOnly ? "[NEW ONLY] " : ""}${keepOrphans ? "[KEEP ORPHANS] " : ""}Fetching Chutes models from API...`);
|
||||
|
||||
const res = await fetch(API_ENDPOINT);
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to fetch API: ${res.status} ${res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = ChutesResponse.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.error("Invalid API response:", parsed.error.errors);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const apiModels = parsed.data.data;
|
||||
|
||||
// Scan existing TOML files
|
||||
const existingFiles = new Set<string>();
|
||||
try {
|
||||
for await (const file of new Bun.Glob("**/*.toml").scan({
|
||||
cwd: modelsDir,
|
||||
absolute: false,
|
||||
})) {
|
||||
existingFiles.add(file);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
console.log(`Found ${apiModels.length} models in API, ${existingFiles.size} existing files\n`);
|
||||
|
||||
const apiModelIds = new Set<string>();
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let unchanged = 0;
|
||||
|
||||
for (const apiModel of apiModels) {
|
||||
const relativePath = `${apiModel.id}.toml`;
|
||||
const filePath = path.join(modelsDir, relativePath);
|
||||
const dirPath = path.dirname(filePath);
|
||||
|
||||
apiModelIds.add(relativePath);
|
||||
|
||||
const existing = await loadExistingModel(filePath);
|
||||
const merged = mergeModel(apiModel, existing);
|
||||
const tomlContent = formatToml(merged);
|
||||
|
||||
if (existing === null) {
|
||||
created++;
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Would create: ${relativePath}`);
|
||||
console.log(` name = "${merged.name}"`);
|
||||
if (merged.family) {
|
||||
console.log(` family = "${merged.family}" (inferred)`);
|
||||
}
|
||||
console.log("");
|
||||
} else {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
await Bun.write(filePath, tomlContent);
|
||||
console.log(`Created: ${relativePath}`);
|
||||
}
|
||||
} else {
|
||||
if (newOnly) {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const changes = detectChanges(existing, merged);
|
||||
const existingContent = await Bun.file(filePath).text();
|
||||
const formatChanged = existingContent !== tomlContent;
|
||||
|
||||
if (changes.length > 0 || formatChanged) {
|
||||
updated++;
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Would update: ${relativePath}`);
|
||||
} else {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
await Bun.write(filePath, tomlContent);
|
||||
console.log(`Updated: ${relativePath}`);
|
||||
}
|
||||
for (const change of changes) {
|
||||
console.log(` ${change.field}: ${change.oldValue} → ${change.newValue}`);
|
||||
}
|
||||
if (changes.length === 0 && formatChanged) {
|
||||
console.log(` (format-only change)`);
|
||||
}
|
||||
console.log("");
|
||||
} else {
|
||||
unchanged++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle orphaned files (on disk but not in API)
|
||||
const orphaned: string[] = [];
|
||||
for (const file of existingFiles) {
|
||||
if (!apiModelIds.has(file)) {
|
||||
orphaned.push(file);
|
||||
const orphanPath = path.join(modelsDir, file);
|
||||
if (keepOrphans) {
|
||||
console.log(`Orphaned (kept): ${file}`);
|
||||
} else if (dryRun) {
|
||||
console.log(`[DRY RUN] Would delete: ${file}`);
|
||||
} else {
|
||||
await Bun.file(orphanPath).delete();
|
||||
console.log(`Deleted: ${file}`);
|
||||
|
||||
// Clean up empty parent directories
|
||||
const parentDir = path.dirname(orphanPath);
|
||||
try {
|
||||
const remaining = [];
|
||||
for await (const entry of new Bun.Glob("*").scan({ cwd: parentDir })) {
|
||||
remaining.push(entry);
|
||||
}
|
||||
if (remaining.length === 0) {
|
||||
const { rmdir } = await import("node:fs/promises");
|
||||
await rmdir(parentDir);
|
||||
console.log(` Removed empty directory: ${path.basename(parentDir)}/`);
|
||||
}
|
||||
} catch {
|
||||
// Directory not empty or other error, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
if (dryRun) {
|
||||
console.log(
|
||||
`Summary: ${created} would be created, ${updated} would be updated, ${unchanged} unchanged, ${orphaned.length} would be deleted`,
|
||||
);
|
||||
} else if (keepOrphans) {
|
||||
console.log(
|
||||
`Summary: ${created} created, ${updated} updated, ${unchanged} unchanged, ${orphaned.length} orphaned (kept)`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Summary: ${created} created, ${updated} updated, ${unchanged} unchanged, ${orphaned.length} deleted`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Generates Databricks model TOML files from the Foundation Model API endpoint.
|
||||
*
|
||||
* Each Databricks endpoint exposes a model from another provider (Anthropic,
|
||||
* OpenAI, Google, etc.), so the generated TOML uses [extends] to inherit
|
||||
* canonical metadata from that upstream provider's TOML in models.dev.
|
||||
*
|
||||
* Usage:
|
||||
* DATABRICKS_HOST=<host> DATABRICKS_TOKEN=<pat> bun run databricks:generate
|
||||
* bun run databricks:generate --workspace <host> --token <pat>
|
||||
*
|
||||
* Flags:
|
||||
* --dry-run: Preview changes without writing files
|
||||
* --new-only: Only create new models, skip updating existing ones
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import path from "node:path";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name: string) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i !== -1 ? args[i + 1] : undefined;
|
||||
};
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const newOnly = args.includes("--new-only");
|
||||
|
||||
const host = flag("workspace") ?? process.env.DATABRICKS_HOST;
|
||||
const token = flag("token") ?? process.env.DATABRICKS_TOKEN;
|
||||
|
||||
if (!host || !token) {
|
||||
console.error(
|
||||
"Usage: DATABRICKS_HOST=<host> DATABRICKS_TOKEN=<pat> bun run databricks:generate",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workspace = host.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
const PROVIDERS_DIR = path.join(import.meta.dirname, "..", "..", "..", "providers");
|
||||
const MODELS_DIR = path.join(PROVIDERS_DIR, "databricks", "models");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FoundationModel = z
|
||||
.object({
|
||||
ai_gateway_v2_supported: z.boolean().optional(),
|
||||
api_types: z.array(z.string()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ServedEntity = z
|
||||
.object({
|
||||
foundation_model: FoundationModel.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const Endpoint = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
config: z
|
||||
.object({
|
||||
served_entities: z.array(ServedEntity).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const FoundationModelsResponse = z
|
||||
.object({
|
||||
endpoints: z.array(Endpoint),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canonical resolution: map a Databricks endpoint name to a models.dev entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PREFIX_TO_PROVIDER: [string, string][] = [
|
||||
["claude-", "anthropic"],
|
||||
["gpt-", "openai"],
|
||||
["gemini-", "google"],
|
||||
["mistral-", "mistral"],
|
||||
["mixtral-", "mistral"],
|
||||
];
|
||||
|
||||
type Resolution =
|
||||
| { type: "extends"; from: string }
|
||||
| { type: "inline"; content: string }
|
||||
| null;
|
||||
|
||||
async function resolveCanonical(endpointName: string): Promise<Resolution> {
|
||||
const bare = endpointName.replace(/^databricks-/, "");
|
||||
|
||||
// Models in provider subdirectories (e.g. openrouter/openai/gpt-oss-*)
|
||||
// can't use [extends] (schema requires provider/model format), so inline.
|
||||
if (bare.startsWith("gpt-oss-")) {
|
||||
const p = path.join(PROVIDERS_DIR, "openrouter", "models", "openai", `${bare}.toml`);
|
||||
if (existsSync(p)) {
|
||||
return { type: "inline", content: await readFile(p, "utf8") };
|
||||
}
|
||||
}
|
||||
|
||||
// Meta Llama: "meta-llama-3-3-70b-instruct" → "llama-3.3-70b-instruct"
|
||||
if (bare.startsWith("meta-llama-") || bare.startsWith("llama-")) {
|
||||
const llamaId = bare
|
||||
.replace(/^meta-llama-/, "llama-")
|
||||
.replace(/^(llama-\d+)-(\d+)-/, "$1.$2-");
|
||||
const p = path.join(PROVIDERS_DIR, "llama", "models", `${llamaId}.toml`);
|
||||
if (existsSync(p)) return { type: "extends", from: `llama/${llamaId}` };
|
||||
}
|
||||
|
||||
for (const [prefix, provider] of PREFIX_TO_PROVIDER) {
|
||||
if (!bare.startsWith(prefix)) continue;
|
||||
|
||||
const exact = path.join(PROVIDERS_DIR, provider, "models", `${bare}.toml`);
|
||||
if (existsSync(exact)) return { type: "extends", from: `${provider}/${bare}` };
|
||||
|
||||
// Try with hyphens-as-dots in version (e.g. gpt-5-4 → gpt-5.4)
|
||||
const dotted = bare.replace(/^((?:[a-z]+-)+\d+)-(\d)/, "$1.$2");
|
||||
if (dotted !== bare) {
|
||||
const dottedExact = path.join(PROVIDERS_DIR, provider, "models", `${dotted}.toml`);
|
||||
if (existsSync(dottedExact)) return { type: "extends", from: `${provider}/${dotted}` };
|
||||
}
|
||||
|
||||
// Fuzzy: longest filename that shares a prefix with bare or its dotted form
|
||||
const candidates = [bare, ...(dotted !== bare ? [dotted] : [])];
|
||||
const files: string[] = [];
|
||||
try {
|
||||
for await (const f of new Bun.Glob("*.toml").scan({
|
||||
cwd: path.join(PROVIDERS_DIR, provider, "models"),
|
||||
})) {
|
||||
files.push(f);
|
||||
}
|
||||
} catch {
|
||||
// provider directory may not exist
|
||||
}
|
||||
const match = files
|
||||
.map((f) => f.replace(/\.toml$/, ""))
|
||||
.filter((id) => candidates.some((c) => id.startsWith(c) || c.startsWith(id)))
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
if (match) return { type: "extends", from: `${provider}/${match}` };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatToml(resolution: Resolution, endpointName: string): string {
|
||||
if (resolution?.type === "extends") {
|
||||
return `[extends]\nfrom = "${resolution.from}"\n`;
|
||||
}
|
||||
if (resolution?.type === "inline") {
|
||||
return resolution.content;
|
||||
}
|
||||
return `# TODO: fill in details for ${endpointName}\nname = "${endpointName}"\n`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IGNORE_PREFIXES = [
|
||||
"databricks-llama-",
|
||||
"databricks-meta-llama-",
|
||||
"databricks-qwen",
|
||||
"databricks-gemma-",
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log(
|
||||
`${dryRun ? "[DRY RUN] " : ""}${newOnly ? "[NEW ONLY] " : ""}Fetching Databricks foundation-models...`,
|
||||
);
|
||||
|
||||
const url = `https://${workspace}/api/2.0/serving-endpoints:foundation-models`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to fetch API: ${res.status} ${res.statusText}`);
|
||||
console.error(await res.text().catch(() => ""));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = FoundationModelsResponse.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.error("Invalid API response:", parsed.error.errors);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const endpoints = parsed.data.endpoints.filter(
|
||||
(e) =>
|
||||
!IGNORE_PREFIXES.some((p) => e.name.startsWith(p)) &&
|
||||
e.config?.served_entities?.some(
|
||||
(se) =>
|
||||
se.foundation_model?.ai_gateway_v2_supported === true &&
|
||||
se.foundation_model?.api_types?.includes("mlflow/v1/chat/completions"),
|
||||
),
|
||||
);
|
||||
|
||||
const existingFiles = new Set<string>();
|
||||
try {
|
||||
for await (const f of new Bun.Glob("*.toml").scan({ cwd: MODELS_DIR })) {
|
||||
existingFiles.add(f);
|
||||
}
|
||||
} catch {
|
||||
// directory may not exist yet
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Found ${endpoints.length} models in API, ${existingFiles.size} existing files\n`,
|
||||
);
|
||||
|
||||
const apiModelIds = new Set<string>();
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let unchanged = 0;
|
||||
|
||||
for (const ep of endpoints) {
|
||||
const filename = `${ep.name}.toml`;
|
||||
apiModelIds.add(filename);
|
||||
const filePath = path.join(MODELS_DIR, filename);
|
||||
|
||||
const resolution = await resolveCanonical(ep.name);
|
||||
const newContent = formatToml(resolution, ep.name);
|
||||
const tag = resolution?.type === "extends" ? `extends ${resolution.from}` : resolution?.type ?? "stub";
|
||||
|
||||
const existed = existsSync(filePath);
|
||||
if (!existed) {
|
||||
created++;
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Would create: ${filename} → ${tag}`);
|
||||
} else {
|
||||
await mkdir(MODELS_DIR, { recursive: true });
|
||||
await Bun.write(filePath, newContent);
|
||||
console.log(`Created: ${filename} → ${tag}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (newOnly) {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingContent = await readFile(filePath, "utf8");
|
||||
if (existingContent === newContent) {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
updated++;
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Would update: ${filename} → ${tag}`);
|
||||
} else {
|
||||
await Bun.write(filePath, newContent);
|
||||
console.log(`Updated: ${filename} → ${tag}`);
|
||||
}
|
||||
}
|
||||
|
||||
const orphaned: string[] = [];
|
||||
for (const file of existingFiles) {
|
||||
if (!apiModelIds.has(file)) {
|
||||
orphaned.push(file);
|
||||
console.log(`Warning: Orphaned file (not in API): ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
if (dryRun) {
|
||||
console.log(
|
||||
`Summary: ${created} would be created, ${updated} would be updated, ${unchanged} unchanged, ${orphaned.length} orphaned`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Summary: ${created} created, ${updated} updated, ${unchanged} unchanged, ${orphaned.length} orphaned`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,732 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Generates DigitalOcean model TOML files from two public APIs:
|
||||
*
|
||||
* - https://api.digitalocean.com/v2/gen-ai/models (model metadata, lifecycle, modalities, limits)
|
||||
* - https://www.digitalocean.com/api/static-content/v1/products (pricing, including >200k tiers)
|
||||
*
|
||||
* The v2 models API requires a DigitalOcean personal access token or model access key,
|
||||
* read from the DIGITALOCEAN_API_TOKEN environment variable (or --api-key flag).
|
||||
* The static-content pricing API is public and requires no auth.
|
||||
*
|
||||
* Cache pricing (cache_read, cache_write) is NOT available from any DO API and is
|
||||
* preserved from existing TOML files when present.
|
||||
*
|
||||
* Fields the APIs cannot provide (preserved from existing TOMLs, never overwritten):
|
||||
* family, knowledge, open_weights, interleaved, attachment, release_date,
|
||||
* cache_read, cache_write
|
||||
*
|
||||
* Flags:
|
||||
* --dry-run Preview changes without writing files
|
||||
* --new-only Only create new models, skip updating existing ones
|
||||
* --api-key=<key> DigitalOcean API key (overrides DIGITALOCEAN_API_TOKEN env var)
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import path from "node:path";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { ModelFamilyValues } from "../src/family.js";
|
||||
|
||||
const MODELS_API = "https://api.digitalocean.com/v2/gen-ai/models";
|
||||
const PRICING_API = "https://www.digitalocean.com/api/static-content/v1/products";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v2 models API schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DoModel = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
lifecycle_status: z.string(),
|
||||
type: z.string().optional(),
|
||||
thinking: z.boolean().optional(),
|
||||
context_window: z.union([z.number(), z.string()]).optional(),
|
||||
modalities: z
|
||||
.object({
|
||||
input: z.array(z.string()).optional(),
|
||||
output: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
settings: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
max: z.number().optional(),
|
||||
default_value: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
created_at: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const DoModelsResponse = z
|
||||
.object({
|
||||
models: z.array(DoModel),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// static-content pricing API schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PricingEntry = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
model: z.string(),
|
||||
prompt_tokens: z.string().optional(), // "≤200k" | ">200k" | undefined
|
||||
price: z.object({ rate: z.number() }),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const StaticContentResponse = z
|
||||
.object({
|
||||
gradient: z.object({
|
||||
models: z.array(PricingEntry),
|
||||
}),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived pricing map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ModelPricing {
|
||||
input: number;
|
||||
output: number;
|
||||
inputOver200k?: number;
|
||||
outputOver200k?: number;
|
||||
}
|
||||
|
||||
// Map marketing names from /v1/products to API model IDs from /v2/gen-ai/models.
|
||||
// The pricing API uses display names, not the machine IDs, so this table is the
|
||||
// join key. Add entries here when DO adds new models with tiered pricing.
|
||||
const PRICING_NAME_MAP: Record<string, string> = {
|
||||
// Anthropic
|
||||
"claude sonnet 4.6": "anthropic-claude-4.6-sonnet",
|
||||
"claude sonnet 4.5": "anthropic-claude-4.5-sonnet",
|
||||
"claude sonnet 4": "anthropic-claude-sonnet-4",
|
||||
"claude haiku 4.5": "anthropic-claude-haiku-4.5",
|
||||
"claude opus 4.6": "anthropic-claude-opus-4.6",
|
||||
"claude opus 4.5": "anthropic-claude-opus-4.5",
|
||||
"claude opus 4.1": "anthropic-claude-4.1-opus",
|
||||
"claude opus 4": "anthropic-claude-opus-4",
|
||||
// OpenAI
|
||||
"gpt-5.4": "openai-gpt-5.4",
|
||||
"gpt-5.4 mini": "openai-gpt-5.4-mini",
|
||||
"gpt-5.4 nano": "openai-gpt-5.4-nano",
|
||||
"gpt-5.4 pro": "openai-gpt-5.4-pro",
|
||||
"gpt-5.3-codex": "openai-gpt-5.3-codex",
|
||||
"gpt-5.2": "openai-gpt-5.2",
|
||||
"gpt-5.2 pro": "openai-gpt-5.2-pro",
|
||||
"gpt-5.1-codex-max": "openai-gpt-5.1-codex-max",
|
||||
"gpt-5": "openai-gpt-5",
|
||||
"gpt-5 mini": "openai-gpt-5-mini",
|
||||
"gpt-5 nano": "openai-gpt-5-nano",
|
||||
"gpt-4.1": "openai-gpt-4.1",
|
||||
"gpt image 1": "openai-gpt-image-1",
|
||||
"gpt image 1.5": "openai-gpt-image-1.5",
|
||||
"gpt-oss-120b": "openai-gpt-oss-120b",
|
||||
"gpt-oss-20b": "openai-gpt-oss-20b",
|
||||
"gpt-4o": "openai-gpt-4o",
|
||||
"gpt-4o mini": "openai-gpt-4o-mini",
|
||||
"o1": "openai-o1",
|
||||
"o3-mini": "openai-o3-mini",
|
||||
// DeepSeek
|
||||
"deepseek r1 distill llama 70b": "deepseek-r1-distill-llama-70b",
|
||||
// Llama
|
||||
"llama 3.3 70b": "llama3.3-70b-instruct",
|
||||
// DO-hosted
|
||||
"qwen3-32b": "alibaba-qwen3-32b",
|
||||
"minimax m2.5 (public preview)": "minimax-m2.5",
|
||||
"kimi k2.5": "kimi-k2.5",
|
||||
"nvidia nemotron 3 super 120b (public preview)": "nvidia-nemotron-3-super-120b",
|
||||
"glm 5": "glm-5",
|
||||
};
|
||||
|
||||
function normalizeDisplayName(raw: string): string {
|
||||
// Strip " Input Tokens" / " Output Tokens" suffix and lowercase
|
||||
return raw
|
||||
.replace(/\s+(input|output)\s+tokens$/i, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function buildPricingMap(entries: z.infer<typeof PricingEntry>[]): Map<string, ModelPricing> {
|
||||
const map = new Map<string, ModelPricing>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const displayName = normalizeDisplayName(entry.name);
|
||||
const modelId = PRICING_NAME_MAP[displayName];
|
||||
if (!modelId) continue;
|
||||
|
||||
const isInput = entry.name.toLowerCase().includes("input tokens");
|
||||
const isOver200k = entry.prompt_tokens === ">200k";
|
||||
// Round to avoid float noise (e.g. 0.9900000000000001)
|
||||
const rate = Math.round(entry.price.rate * 10000) / 10000;
|
||||
|
||||
const existing = map.get(modelId) ?? ({} as ModelPricing);
|
||||
|
||||
if (isInput && isOver200k) existing.inputOver200k = rate;
|
||||
else if (!isInput && isOver200k) existing.outputOver200k = rate;
|
||||
else if (isInput) existing.input = rate;
|
||||
else existing.output = rate;
|
||||
|
||||
map.set(modelId, existing);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Existing TOML shape (fields we read and may preserve)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ExistingModel {
|
||||
name?: string;
|
||||
family?: string;
|
||||
attachment?: boolean;
|
||||
reasoning?: boolean;
|
||||
tool_call?: boolean;
|
||||
structured_output?: boolean;
|
||||
temperature?: boolean;
|
||||
knowledge?: string;
|
||||
release_date?: string;
|
||||
last_updated?: string;
|
||||
open_weights?: boolean;
|
||||
interleaved?: boolean | { field: string };
|
||||
status?: string;
|
||||
cost?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
context_over_200k?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
context_min?: number;
|
||||
};
|
||||
tiers?: Array<{
|
||||
tier: {
|
||||
type?: "context";
|
||||
size: number;
|
||||
};
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
}>;
|
||||
};
|
||||
limit?: {
|
||||
context?: number;
|
||||
input?: number;
|
||||
output?: number;
|
||||
};
|
||||
modalities?: {
|
||||
input?: string[];
|
||||
output?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
async function loadExisting(filePath: string): Promise<ExistingModel | null> {
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) return null;
|
||||
try {
|
||||
const mod = await import(filePath, { with: { type: "toml" } });
|
||||
return mod.default as ExistingModel;
|
||||
} catch (e) {
|
||||
console.warn(`Warning: failed to parse ${filePath}:`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merged model shape (what we write)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MergedModel {
|
||||
name: string;
|
||||
family?: string;
|
||||
attachment: boolean;
|
||||
reasoning: boolean;
|
||||
tool_call: boolean;
|
||||
structured_output?: boolean;
|
||||
temperature: boolean;
|
||||
knowledge?: string;
|
||||
release_date: string;
|
||||
last_updated: string;
|
||||
open_weights: boolean;
|
||||
interleaved?: boolean | { field: string };
|
||||
status?: string;
|
||||
cost?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
context_over_200k?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
context_min?: number;
|
||||
};
|
||||
};
|
||||
limit: {
|
||||
context: number;
|
||||
output: number;
|
||||
};
|
||||
modalities: {
|
||||
input: string[];
|
||||
output: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VALID_INPUT_MODALITIES = new Set(["text", "audio", "image", "video", "pdf"]);
|
||||
const VALID_OUTPUT_MODALITIES = new Set(["text", "audio", "image", "video", "pdf"]);
|
||||
|
||||
function filterInputModalities(raw: string[]): string[] {
|
||||
return raw.filter((m) => VALID_INPUT_MODALITIES.has(m));
|
||||
}
|
||||
|
||||
function filterOutputModalities(raw: string[]): string[] {
|
||||
// "code" is not a valid modality in the schema — map to "text"
|
||||
return [...new Set(raw.map((m) => (m === "code" ? "text" : m)).filter((m) => VALID_OUTPUT_MODALITIES.has(m)))];
|
||||
}
|
||||
|
||||
function getTodayDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n >= 1000 ? n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "_") : n.toString();
|
||||
}
|
||||
|
||||
function inferFamily(modelId: string, modelName: string): string | undefined {
|
||||
const sorted = [...ModelFamilyValues].sort((a, b) => b.length - a.length);
|
||||
const targets = [modelId.toLowerCase(), modelName.toLowerCase()];
|
||||
for (const family of sorted) {
|
||||
const f = family.toLowerCase();
|
||||
for (const t of targets) {
|
||||
if (t.includes(f)) return family;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getExistingLongContextCost(existing: ExistingModel | null) {
|
||||
const tier = existing?.cost?.tiers?.find(
|
||||
(tier) =>
|
||||
(tier.tier.type === undefined || tier.tier.type === "context") &&
|
||||
tier.tier.size >= 200_000,
|
||||
);
|
||||
if (tier) {
|
||||
return {
|
||||
...tier,
|
||||
context_min: tier.tier.size,
|
||||
};
|
||||
}
|
||||
|
||||
return existing?.cost?.context_over_200k === undefined
|
||||
? undefined
|
||||
: {
|
||||
...existing.cost.context_over_200k,
|
||||
context_min: 200_000,
|
||||
};
|
||||
}
|
||||
|
||||
function getLongContextMin(cost: { context_min?: number }) {
|
||||
return cost.context_min ?? 200_000;
|
||||
}
|
||||
|
||||
function formatInlineNumber(n: number): string {
|
||||
return n >= 1000 ? n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "_") : n.toString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merge API data with existing TOML
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mergeModel(
|
||||
apiModel: z.infer<typeof DoModel>,
|
||||
pricing: ModelPricing | undefined,
|
||||
existing: ExistingModel | null,
|
||||
): MergedModel {
|
||||
const rawInput = apiModel.modalities?.input ?? [];
|
||||
const rawOutput = apiModel.modalities?.output ?? [];
|
||||
const inputMods = filterInputModalities(rawInput.length > 0 ? rawInput : existing?.modalities?.input ?? ["text"]);
|
||||
const outputMods = filterOutputModalities(rawOutput.length > 0 ? rawOutput : existing?.modalities?.output ?? ["text"]);
|
||||
|
||||
const maxTokensSetting = apiModel.settings?.find((s) => s.name === "max_tokens");
|
||||
const maxTokens = maxTokensSetting?.max ?? existing?.limit?.output ?? 0;
|
||||
|
||||
const rawContext = apiModel.context_window;
|
||||
const contextWindow =
|
||||
rawContext !== undefined
|
||||
? typeof rawContext === "string"
|
||||
? parseInt(rawContext, 10)
|
||||
: rawContext
|
||||
: (existing?.limit?.context ?? 0);
|
||||
|
||||
const isDeprecated = apiModel.lifecycle_status === "end_of_life";
|
||||
|
||||
// Fields preserved from existing TOML (APIs don't provide these)
|
||||
const family = existing?.family ?? inferFamily(apiModel.id, apiModel.name);
|
||||
const knowledge = existing?.knowledge;
|
||||
const openWeights = existing?.open_weights ?? false;
|
||||
const interleaved = existing?.interleaved;
|
||||
const attachment = existing?.attachment ?? inputMods.some((m) => m !== "text");
|
||||
|
||||
// reasoning: trust existing if set, else use API thinking flag as a hint
|
||||
// (thinking flag is unreliable for non-LLM models so gate on output modality)
|
||||
const isTextOutput = outputMods.includes("text") && !outputMods.includes("image") && !outputMods.includes("video");
|
||||
const reasoning = existing?.reasoning ?? (isTextOutput && (apiModel.thinking ?? false));
|
||||
|
||||
// tool_call: no API signal, preserve existing or default true for text models
|
||||
const toolCall = existing?.tool_call ?? isTextOutput;
|
||||
|
||||
// temperature: no API signal, preserve or default true
|
||||
const temperature = existing?.temperature ?? true;
|
||||
|
||||
// structured_output: no API signal, preserve only
|
||||
const structuredOutput = existing?.structured_output;
|
||||
|
||||
const releaseDate = existing?.release_date ?? apiModel.created_at?.slice(0, 10) ?? getTodayDate();
|
||||
|
||||
const merged: MergedModel = {
|
||||
name: apiModel.name,
|
||||
family,
|
||||
attachment,
|
||||
reasoning,
|
||||
tool_call: toolCall,
|
||||
temperature,
|
||||
release_date: releaseDate,
|
||||
last_updated: getTodayDate(),
|
||||
open_weights: openWeights,
|
||||
...(structuredOutput !== undefined && { structured_output: structuredOutput }),
|
||||
...(knowledge && { knowledge }),
|
||||
...(interleaved !== undefined && { interleaved }),
|
||||
...(isDeprecated && { status: "deprecated" }),
|
||||
limit: { context: contextWindow, output: maxTokens },
|
||||
modalities: { input: inputMods, output: outputMods },
|
||||
};
|
||||
|
||||
// Pricing: static-content API is the sole source of truth for prices.
|
||||
// The v2 models API pricing is intentionally ignored. If a model has no
|
||||
// entry in the static-content API, preserve existing TOML prices.
|
||||
const inputPrice = pricing?.input ?? existing?.cost?.input;
|
||||
const outputPrice = pricing?.output ?? existing?.cost?.output;
|
||||
|
||||
if (inputPrice !== undefined && outputPrice !== undefined) {
|
||||
merged.cost = {
|
||||
input: inputPrice,
|
||||
output: outputPrice,
|
||||
// Always preserve cache pricing — not available from any DO API
|
||||
...(existing?.cost?.cache_read !== undefined && { cache_read: existing.cost.cache_read }),
|
||||
...(existing?.cost?.cache_write !== undefined && { cache_write: existing.cost.cache_write }),
|
||||
};
|
||||
|
||||
// Context-tiered pricing (>200k) from the static-content API
|
||||
const existingLongContextCost = getExistingLongContextCost(existing);
|
||||
if (pricing?.inputOver200k !== undefined && pricing?.outputOver200k !== undefined) {
|
||||
merged.cost.context_over_200k = {
|
||||
input: pricing.inputOver200k,
|
||||
output: pricing.outputOver200k,
|
||||
context_min: existingLongContextCost?.context_min ?? 200_000,
|
||||
...(existingLongContextCost?.cache_read !== undefined && {
|
||||
cache_read: existingLongContextCost.cache_read,
|
||||
}),
|
||||
...(existingLongContextCost?.cache_write !== undefined && {
|
||||
cache_write: existingLongContextCost.cache_write,
|
||||
}),
|
||||
};
|
||||
} else if (existingLongContextCost) {
|
||||
// Preserve manually-entered tiered pricing if API has no data
|
||||
merged.cost.context_over_200k = {
|
||||
input: existingLongContextCost.input ?? inputPrice,
|
||||
output: existingLongContextCost.output ?? outputPrice,
|
||||
context_min: existingLongContextCost.context_min,
|
||||
...(existingLongContextCost.cache_read !== undefined && {
|
||||
cache_read: existingLongContextCost.cache_read,
|
||||
}),
|
||||
...(existingLongContextCost.cache_write !== undefined && {
|
||||
cache_write: existingLongContextCost.cache_write,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TOML serialiser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatToml(model: MergedModel): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`name = "${model.name.replace(/"/g, '\\"')}"`);
|
||||
if (model.family) lines.push(`family = "${model.family}"`);
|
||||
lines.push(`release_date = "${model.release_date}"`);
|
||||
lines.push(`last_updated = "${model.last_updated}"`);
|
||||
lines.push(`attachment = ${model.attachment}`);
|
||||
lines.push(`reasoning = ${model.reasoning}`);
|
||||
lines.push(`temperature = ${model.temperature}`);
|
||||
lines.push(`tool_call = ${model.tool_call}`);
|
||||
if (model.structured_output !== undefined) lines.push(`structured_output = ${model.structured_output}`);
|
||||
if (model.knowledge) lines.push(`knowledge = "${model.knowledge}"`);
|
||||
lines.push(`open_weights = ${model.open_weights}`);
|
||||
if (model.status) lines.push(`status = "${model.status}"`);
|
||||
|
||||
if (model.interleaved !== undefined) {
|
||||
lines.push("");
|
||||
if (model.interleaved === true) {
|
||||
lines.push(`interleaved = true`);
|
||||
} else if (typeof model.interleaved === "object") {
|
||||
lines.push(`[interleaved]`);
|
||||
lines.push(`field = "${model.interleaved.field}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (model.cost) {
|
||||
lines.push("");
|
||||
lines.push(`[cost]`);
|
||||
lines.push(`input = ${model.cost.input}`);
|
||||
lines.push(`output = ${model.cost.output}`);
|
||||
if (model.cost.cache_read !== undefined) lines.push(`cache_read = ${model.cost.cache_read}`);
|
||||
if (model.cost.cache_write !== undefined) lines.push(`cache_write = ${model.cost.cache_write}`);
|
||||
|
||||
if (model.cost.context_over_200k) {
|
||||
lines.push("");
|
||||
lines.push(`[[cost.tiers]]`);
|
||||
lines.push(`tier = { size = ${formatInlineNumber(getLongContextMin(model.cost.context_over_200k))} }`);
|
||||
lines.push(`input = ${model.cost.context_over_200k.input}`);
|
||||
lines.push(`output = ${model.cost.context_over_200k.output}`);
|
||||
if (model.cost.context_over_200k.cache_read !== undefined)
|
||||
lines.push(`cache_read = ${model.cost.context_over_200k.cache_read}`);
|
||||
if (model.cost.context_over_200k.cache_write !== undefined)
|
||||
lines.push(`cache_write = ${model.cost.context_over_200k.cache_write}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(`[limit]`);
|
||||
lines.push(`context = ${formatNumber(model.limit.context)}`);
|
||||
lines.push(`output = ${formatNumber(model.limit.output)}`);
|
||||
|
||||
lines.push("");
|
||||
lines.push(`[modalities]`);
|
||||
lines.push(`input = [${model.modalities.input.map((m) => `"${m}"`).join(", ")}]`);
|
||||
lines.push(`output = [${model.modalities.output.map((m) => `"${m}"`).join(", ")}]`);
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Change detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Change {
|
||||
field: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
}
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
if (val === undefined) return "(none)";
|
||||
if (Array.isArray(val)) return `[${val.join(", ")}]`;
|
||||
if (typeof val === "number") return formatNumber(val);
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function detectChanges(existing: ExistingModel | null, merged: MergedModel): Change[] {
|
||||
if (!existing) return [];
|
||||
|
||||
const changes: Change[] = [];
|
||||
const EPSILON = 0.001;
|
||||
|
||||
const compare = (field: string, oldVal: unknown, newVal: unknown) => {
|
||||
if (oldVal === undefined && newVal === undefined) return;
|
||||
const isDiff = field.startsWith("cost.")
|
||||
? Math.abs((oldVal as number ?? 0) - (newVal as number ?? 0)) > EPSILON
|
||||
: JSON.stringify(oldVal) !== JSON.stringify(newVal);
|
||||
if (isDiff) changes.push({ field, oldValue: formatValue(oldVal), newValue: formatValue(newVal) });
|
||||
};
|
||||
|
||||
compare("name", existing.name, merged.name);
|
||||
compare("reasoning", existing.reasoning, merged.reasoning);
|
||||
compare("tool_call", existing.tool_call, merged.tool_call);
|
||||
compare("attachment", existing.attachment, merged.attachment);
|
||||
compare("status", existing.status, merged.status);
|
||||
compare("cost.input", existing.cost?.input, merged.cost?.input);
|
||||
compare("cost.output", existing.cost?.output, merged.cost?.output);
|
||||
const existingLongContextCost = getExistingLongContextCost(existing);
|
||||
compare("cost.context_over_200k.input", existingLongContextCost?.input, merged.cost?.context_over_200k?.input);
|
||||
compare("cost.context_over_200k.output", existingLongContextCost?.output, merged.cost?.context_over_200k?.output);
|
||||
compare("limit.context", existing.limit?.context, merged.limit.context);
|
||||
compare("limit.output", existing.limit?.output, merged.limit.output);
|
||||
compare("modalities.input", existing.modalities?.input, merged.modalities.input);
|
||||
compare("modalities.output", existing.modalities?.output, merged.modalities.output);
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const newOnly = args.includes("--new-only");
|
||||
|
||||
// Resolve API key
|
||||
const apiKeyArg = args.find((a) => a.startsWith("--api-key"));
|
||||
const apiKey =
|
||||
(apiKeyArg?.includes("=") ? apiKeyArg.split("=")[1] : args[args.indexOf(apiKeyArg!) + 1]) ??
|
||||
process.env.DIGITALOCEAN_API_TOKEN;
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("Error: DIGITALOCEAN_API_TOKEN is required (or pass --api-key=<key>)");
|
||||
console.error("Get one from: https://cloud.digitalocean.com/account/api/tokens");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const modelsDir = path.join(import.meta.dirname, "..", "..", "..", "providers", "digitalocean", "models");
|
||||
|
||||
const prefix = dryRun ? "[DRY RUN] " : "";
|
||||
console.log(`${prefix}Fetching DigitalOcean models from API...`);
|
||||
|
||||
// Fetch both APIs in parallel
|
||||
const [modelsRes, pricingRes] = await Promise.all([
|
||||
fetch(MODELS_API, { headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" } }),
|
||||
fetch(PRICING_API, { headers: { "User-Agent": "models.dev/digitalocean-sync" } }),
|
||||
]);
|
||||
|
||||
if (!modelsRes.ok) {
|
||||
console.error(`Failed to fetch models API: ${modelsRes.status} ${modelsRes.statusText}`);
|
||||
if (modelsRes.status === 401 || modelsRes.status === 403)
|
||||
console.error("Check your DIGITALOCEAN_API_TOKEN has read access.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!pricingRes.ok) {
|
||||
console.error(`Failed to fetch pricing API: ${pricingRes.status} ${pricingRes.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const modelsParsed = DoModelsResponse.safeParse(await modelsRes.json());
|
||||
if (!modelsParsed.success) {
|
||||
console.error("Unexpected models API response:", modelsParsed.error.errors);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pricingParsed = StaticContentResponse.safeParse(await pricingRes.json());
|
||||
if (!pricingParsed.success) {
|
||||
console.error("Unexpected pricing API response:", pricingParsed.error.errors);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const apiModels = modelsParsed.data.models;
|
||||
const pricingMap = buildPricingMap(pricingParsed.data.gradient.models);
|
||||
|
||||
// Collect existing TOML filenames for orphan detection
|
||||
const existingFiles = new Set<string>();
|
||||
for await (const file of new Bun.Glob("**/*.toml").scan({ cwd: modelsDir, absolute: false })) {
|
||||
existingFiles.add(file);
|
||||
}
|
||||
|
||||
console.log(`Found ${apiModels.length} models in API, ${existingFiles.size} existing TOML files\n`);
|
||||
|
||||
const apiModelFiles = new Set<string>();
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let unchanged = 0;
|
||||
|
||||
for (const apiModel of apiModels) {
|
||||
// Skip non-text models that opencode can't use: image, video, audio, embedding, reranking
|
||||
const outputMods = filterOutputModalities(apiModel.modalities?.output ?? []);
|
||||
const isTextModel = outputMods.includes("text");
|
||||
const isEmbedding = apiModel.type === "embedding";
|
||||
const isReranking = apiModel.type === "reranking";
|
||||
if (!isTextModel || isEmbedding || isReranking) continue;
|
||||
|
||||
// Model IDs may contain slashes (e.g. fal-ai/flux/schnell) — use as subpath
|
||||
const relativePath = `${apiModel.id}.toml`;
|
||||
const filePath = path.join(modelsDir, relativePath);
|
||||
const dirPath = path.dirname(filePath);
|
||||
|
||||
apiModelFiles.add(relativePath);
|
||||
|
||||
const existing = await loadExisting(filePath);
|
||||
const pricing = pricingMap.get(apiModel.id);
|
||||
const merged = mergeModel(apiModel, pricing, existing);
|
||||
const toml = formatToml(merged);
|
||||
|
||||
if (existing === null) {
|
||||
created++;
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Would create: ${relativePath}`);
|
||||
console.log(` name = "${merged.name}"`);
|
||||
if (pricing) console.log(` pricing: $${merged.cost?.input}/$${merged.cost?.output} per M tokens`);
|
||||
if (merged.family) console.log(` family = "${merged.family}" (inferred)`);
|
||||
console.log("");
|
||||
} else {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
await Bun.write(filePath, toml);
|
||||
console.log(`Created: ${relativePath}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (newOnly) {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const changes = detectChanges(existing, merged);
|
||||
if (changes.length > 0) {
|
||||
updated++;
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Would update: ${relativePath}`);
|
||||
} else {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
await Bun.write(filePath, toml);
|
||||
console.log(`Updated: ${relativePath}`);
|
||||
}
|
||||
for (const c of changes) console.log(` ${c.field}: ${c.oldValue} → ${c.newValue}`);
|
||||
console.log("");
|
||||
} else {
|
||||
unchanged++;
|
||||
}
|
||||
}
|
||||
|
||||
// Orphan detection: files in the TOML directory but not in the API
|
||||
const orphaned: string[] = [];
|
||||
for (const file of existingFiles) {
|
||||
if (!apiModelFiles.has(file)) {
|
||||
orphaned.push(file);
|
||||
console.log(`Warning: orphaned file (not in API): ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
if (dryRun) {
|
||||
console.log(
|
||||
`Summary: ${created} would be created, ${updated} would be updated, ${unchanged} unchanged, ${orphaned.length} orphaned`,
|
||||
);
|
||||
} else {
|
||||
console.log(`Summary: ${created} created, ${updated} updated, ${unchanged} unchanged, ${orphaned.length} orphaned`);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -162,7 +162,18 @@ interface ExistingModel {
|
||||
output?: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
context_min?: number;
|
||||
};
|
||||
tiers?: Array<{
|
||||
tier: {
|
||||
type?: "context";
|
||||
size: number;
|
||||
};
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
}>;
|
||||
};
|
||||
limit?: {
|
||||
context?: number;
|
||||
@@ -195,6 +206,30 @@ async function loadExistingModel(filePath: string): Promise<ExistingModel | null
|
||||
}
|
||||
}
|
||||
|
||||
function getExistingLongContextMin(existing: ExistingModel | null) {
|
||||
return (
|
||||
existing?.cost?.tiers?.find(
|
||||
(tier) =>
|
||||
(tier.tier.type === undefined || tier.tier.type === "context") &&
|
||||
tier.tier.size >= 200_000,
|
||||
)?.tier.size ?? 200_000
|
||||
);
|
||||
}
|
||||
|
||||
function getExistingLongContextCost(existing: ExistingModel | null) {
|
||||
return (
|
||||
existing?.cost?.tiers?.find(
|
||||
(tier) =>
|
||||
(tier.tier.type === undefined || tier.tier.type === "context") &&
|
||||
tier.tier.size >= 200_000,
|
||||
) ?? existing?.cost?.context_over_200k
|
||||
);
|
||||
}
|
||||
|
||||
function getLongContextMin(cost: { context_min?: number }) {
|
||||
return cost.context_min ?? 200_000;
|
||||
}
|
||||
|
||||
interface MergedModel {
|
||||
name: string;
|
||||
family?: string;
|
||||
@@ -219,6 +254,7 @@ interface MergedModel {
|
||||
output: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
context_min?: number;
|
||||
};
|
||||
};
|
||||
limit: {
|
||||
@@ -292,6 +328,7 @@ function mergeModel(
|
||||
merged.cost.context_over_200k = {
|
||||
input: spec.pricing.extended.input.usd,
|
||||
output: spec.pricing.extended.output.usd,
|
||||
context_min: spec.pricing.extended.context_token_threshold,
|
||||
...(spec.pricing.extended.cache_input && { cache_read: spec.pricing.extended.cache_input.usd }),
|
||||
...(spec.pricing.extended.cache_write && { cache_write: spec.pricing.extended.cache_write.usd }),
|
||||
};
|
||||
@@ -366,7 +403,8 @@ function formatToml(model: MergedModel): string {
|
||||
|
||||
if (model.cost.context_over_200k) {
|
||||
lines.push("");
|
||||
lines.push(`[cost.context_over_200k]`);
|
||||
lines.push(`[[cost.tiers]]`);
|
||||
lines.push(`tier = { size = ${formatNumber(getLongContextMin(model.cost.context_over_200k))} }`);
|
||||
lines.push(`input = ${model.cost.context_over_200k.input}`);
|
||||
lines.push(`output = ${model.cost.context_over_200k.output}`);
|
||||
if (model.cost.context_over_200k.cache_read !== undefined) {
|
||||
@@ -438,10 +476,11 @@ function detectChanges(
|
||||
compare("cost.output", existing.cost?.output, merged.cost?.output);
|
||||
compare("cost.cache_read", existing.cost?.cache_read, merged.cost?.cache_read);
|
||||
compare("cost.cache_write", existing.cost?.cache_write, merged.cost?.cache_write);
|
||||
compare("cost.context_over_200k.input", existing.cost?.context_over_200k?.input, merged.cost?.context_over_200k?.input);
|
||||
compare("cost.context_over_200k.output", existing.cost?.context_over_200k?.output, merged.cost?.context_over_200k?.output);
|
||||
compare("cost.context_over_200k.cache_read", existing.cost?.context_over_200k?.cache_read, merged.cost?.context_over_200k?.cache_read);
|
||||
compare("cost.context_over_200k.cache_write", existing.cost?.context_over_200k?.cache_write, merged.cost?.context_over_200k?.cache_write);
|
||||
const existingLongContextCost = getExistingLongContextCost(existing);
|
||||
compare("cost.context_over_200k.input", existingLongContextCost?.input, merged.cost?.context_over_200k?.input);
|
||||
compare("cost.context_over_200k.output", existingLongContextCost?.output, merged.cost?.context_over_200k?.output);
|
||||
compare("cost.context_over_200k.cache_read", existingLongContextCost?.cache_read, merged.cost?.context_over_200k?.cache_read);
|
||||
compare("cost.context_over_200k.cache_write", existingLongContextCost?.cache_write, merged.cost?.context_over_200k?.cache_write);
|
||||
compare("limit.context", existing.limit?.context, merged.limit.context);
|
||||
compare("limit.output", existing.limit?.output, merged.limit.output);
|
||||
compare("modalities.input", existing.modalities?.input, merged.modalities.input);
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import path from "node:path";
|
||||
import { mkdir, readdir, rm } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AuthoredModel, AuthoredModelShape } from "../src/schema.js";
|
||||
import { cloudflareWorkersAi } from "./sync/cloudflare-workers-ai.js";
|
||||
import { google } from "./sync/google.js";
|
||||
import { openrouter } from "./sync/openrouter.js";
|
||||
import { xai } from "./sync/xai.js";
|
||||
|
||||
const ExistingModel = AuthoredModelShape.partial()
|
||||
.extend({
|
||||
extends: z
|
||||
.object({
|
||||
from: z.string(),
|
||||
omit: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ExistingModel = z.infer<typeof ExistingModel>;
|
||||
export type SyncedModel = Omit<z.infer<typeof AuthoredModelShape>, "id">;
|
||||
|
||||
export interface SyncProvider<SourceModel> {
|
||||
id: string;
|
||||
name: string;
|
||||
modelsDir: string;
|
||||
skipCreates?: boolean;
|
||||
sourceID?(model: SourceModel): string;
|
||||
skippedNotice?(ids: string[]): string[];
|
||||
fetchModels(): Promise<unknown>;
|
||||
parseModels(raw: unknown): SourceModel[];
|
||||
translateModel(
|
||||
model: SourceModel,
|
||||
context: { existing(id: string): ExistingModel | undefined },
|
||||
): { id: string; model: SyncedModel } | undefined;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "changed" | "unchanged";
|
||||
created: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
unchanged: number;
|
||||
notices: string[];
|
||||
files: Array<{ status: "created" | "updated" | "deleted"; path: string }>;
|
||||
}
|
||||
|
||||
export const providers: {
|
||||
"cloudflare-workers-ai": SyncProvider<any>;
|
||||
google: SyncProvider<any>;
|
||||
openrouter: SyncProvider<any>;
|
||||
xai: SyncProvider<any>;
|
||||
} = {
|
||||
"cloudflare-workers-ai": cloudflareWorkersAi,
|
||||
google,
|
||||
openrouter,
|
||||
xai,
|
||||
};
|
||||
|
||||
export const groups = {
|
||||
aggregators: ["openrouter"],
|
||||
cloudflare: ["cloudflare-workers-ai"],
|
||||
direct: ["google", "xai"],
|
||||
} as const;
|
||||
|
||||
type ProviderID = keyof typeof providers;
|
||||
|
||||
interface SyncOptions {
|
||||
dryRun?: boolean;
|
||||
newOnly?: boolean;
|
||||
}
|
||||
|
||||
export async function syncProviderByID(id: ProviderID, options: SyncOptions = {}) {
|
||||
return syncProvider(providers[id], options);
|
||||
}
|
||||
|
||||
export async function syncProvider<SourceModel>(
|
||||
provider: SyncProvider<SourceModel>,
|
||||
options: SyncOptions = {},
|
||||
): Promise<SyncResult> {
|
||||
console.log(`\nSyncing ${provider.name}...`);
|
||||
|
||||
const existing = await readExisting(provider.modelsDir);
|
||||
const sourceModels = provider.parseModels(await provider.fetchModels());
|
||||
const desired = new Map<string, { model: z.infer<typeof AuthoredModel>; content: string }>();
|
||||
const skippedRemote: string[] = [];
|
||||
|
||||
for (const sourceModel of sourceModels) {
|
||||
const translated = provider.translateModel(sourceModel, {
|
||||
existing(id) {
|
||||
return existing.get(`${id}.toml`)?.toml;
|
||||
},
|
||||
});
|
||||
if (translated === undefined) {
|
||||
if (provider.skipCreates) skippedRemote.push(provider.sourceID?.(sourceModel) ?? "unknown");
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = `${translated.id}.toml`;
|
||||
if (provider.skipCreates && !existing.has(relativePath)) {
|
||||
skippedRemote.push(translated.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (desired.has(relativePath)) {
|
||||
throw new Error(`Duplicate synced model path: ${provider.id}/${relativePath}`);
|
||||
}
|
||||
|
||||
const parsed = AuthoredModel.safeParse({
|
||||
id: translated.id,
|
||||
...translated.model,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
parsed.error.cause = { provider: provider.id, path: relativePath };
|
||||
throw parsed.error;
|
||||
}
|
||||
|
||||
desired.set(relativePath, {
|
||||
model: parsed.data,
|
||||
content: formatToml(parsed.data),
|
||||
});
|
||||
}
|
||||
|
||||
const files: SyncResult["files"] = [];
|
||||
let unchanged = 0;
|
||||
|
||||
for (const [relativePath, file] of desired) {
|
||||
const filePath = path.join(provider.modelsDir, relativePath);
|
||||
const current = existing.get(relativePath);
|
||||
|
||||
if (current === undefined) {
|
||||
files.push({ status: "created", path: filePath });
|
||||
if (options.dryRun) {
|
||||
console.log(`Would create ${relativePath}`);
|
||||
} else {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await Bun.write(filePath, file.content);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sameModel(relativePath, current.toml, file.model)) {
|
||||
if (options.newOnly) {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push({ status: "updated", path: filePath });
|
||||
if (options.dryRun) {
|
||||
console.log(`Would update ${relativePath}`);
|
||||
} else {
|
||||
if (current.symlink) await rm(filePath, { force: true });
|
||||
await Bun.write(filePath, file.content);
|
||||
}
|
||||
} else {
|
||||
unchanged++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const relativePath of existing.keys()) {
|
||||
if (desired.has(relativePath)) continue;
|
||||
if (options.newOnly) {
|
||||
console.log(`Skipping removal in new-only mode: ${relativePath}`);
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(provider.modelsDir, relativePath);
|
||||
files.push({ status: "deleted", path: filePath });
|
||||
if (options.dryRun) {
|
||||
console.log(`Would remove ${relativePath}`);
|
||||
} else {
|
||||
await rm(filePath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const result = summarize(provider, files, unchanged, provider.skippedNotice?.(skippedRemote) ?? []);
|
||||
console.log(
|
||||
`${options.dryRun ? "Dry run: " : ""}${result.created} created, ${result.updated} updated, ${result.deleted} removed, ${result.unchanged} unchanged`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function syncTargets(target: string, options: SyncOptions = {}) {
|
||||
const ids = target in groups
|
||||
? groups[target as keyof typeof groups]
|
||||
: target in providers
|
||||
? [target as ProviderID]
|
||||
: undefined;
|
||||
|
||||
if (ids === undefined) {
|
||||
throw new Error(`Unknown sync target: ${target}`);
|
||||
}
|
||||
|
||||
const results: SyncResult[] = [];
|
||||
for (const id of ids) {
|
||||
results.push(await syncProviderByID(id as ProviderID, options));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function readExisting(modelsDir: string) {
|
||||
const existing = new Map<string, { text: string; toml: ExistingModel; symlink: boolean }>();
|
||||
|
||||
for (const { file, symlink } of await tomlFiles(modelsDir)) {
|
||||
const text = await Bun.file(path.join(modelsDir, file)).text();
|
||||
const parsed = ExistingModel.safeParse(Bun.TOML.parse(text));
|
||||
if (!parsed.success) {
|
||||
parsed.error.cause = { path: path.join(modelsDir, file) };
|
||||
throw parsed.error;
|
||||
}
|
||||
existing.set(file, { text, toml: parsed.data, symlink });
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function tomlFiles(root: string, dir = "") {
|
||||
const result: Array<{ file: string; symlink: boolean }> = [];
|
||||
|
||||
for (const entry of await readdir(path.join(root, dir), { withFileTypes: true })) {
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...await tomlFiles(root, file));
|
||||
} else if (entry.name.endsWith(".toml") && (entry.isFile() || entry.isSymbolicLink())) {
|
||||
result.push({ file, symlink: entry.isSymbolicLink() });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function summarize(
|
||||
provider: { id: string; name: string },
|
||||
files: SyncResult["files"],
|
||||
unchanged: number,
|
||||
notices: string[],
|
||||
): SyncResult {
|
||||
return {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
status: files.length > 0 ? "changed" : "unchanged",
|
||||
created: files.filter((file) => file.status === "created").length,
|
||||
updated: files.filter((file) => file.status === "updated").length,
|
||||
deleted: files.filter((file) => file.status === "deleted").length,
|
||||
unchanged,
|
||||
notices,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function sameModel(
|
||||
relativePath: string,
|
||||
current: ExistingModel,
|
||||
desired: z.infer<typeof AuthoredModel>,
|
||||
) {
|
||||
const parsed = AuthoredModel.safeParse({
|
||||
id: relativePath.slice(0, -5),
|
||||
...current,
|
||||
});
|
||||
return parsed.success && stable(parsed.data) === stable(desired);
|
||||
}
|
||||
|
||||
function stable(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(stable);
|
||||
const ordered = value.every((item) => item === null || typeof item !== "object")
|
||||
? items.sort()
|
||||
: items;
|
||||
return `[${ordered.join(",")}]`;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return `{${Object.entries(value)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
async function writeReport(target: string, results: SyncResult[]) {
|
||||
await mkdir(".sync", { recursive: true });
|
||||
|
||||
const lines = [
|
||||
`Updates model TOMLs for the \`${target}\` sync target.`,
|
||||
"",
|
||||
"| Provider | Status | Created | Updated | Deleted |",
|
||||
"| --- | --- | ---: | ---: | ---: |",
|
||||
];
|
||||
|
||||
for (const result of results) {
|
||||
lines.push(
|
||||
`| ${result.name} | ${result.status} | ${result.created} | ${result.updated} | ${result.deleted} |`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const result of results.filter((item) => item.files.length > 0)) {
|
||||
lines.push("", `<details><summary>${result.name} changed files</summary>`, "");
|
||||
for (const file of result.files) {
|
||||
lines.push(`- ${file.status}: \`${file.path}\``);
|
||||
}
|
||||
lines.push("", "</details>");
|
||||
}
|
||||
|
||||
const noticeResults = results.filter((item) => item.notices.length > 0);
|
||||
if (noticeResults.length > 0) {
|
||||
lines.push("", "## Notices");
|
||||
for (const result of noticeResults) {
|
||||
lines.push("", `### ${result.name}`);
|
||||
for (const notice of result.notices) {
|
||||
lines.push(`- ${notice}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("", "This PR was created automatically by the daily model sync workflow.");
|
||||
await Bun.write(".sync/model-sync-report.md", `${lines.join("\n")}\n`);
|
||||
}
|
||||
|
||||
function quote(value: string) {
|
||||
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
||||
function formatInteger(n: number) {
|
||||
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, "_");
|
||||
}
|
||||
|
||||
function formatNumber(n: number) {
|
||||
return Number.isInteger(n) ? formatInteger(n) : String(n);
|
||||
}
|
||||
|
||||
function formatToml(model: z.infer<typeof AuthoredModel>) {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`name = ${quote(model.name)}`);
|
||||
if (model.family !== undefined) lines.push(`family = ${quote(model.family)}`);
|
||||
lines.push(`release_date = ${quote(model.release_date)}`);
|
||||
lines.push(`last_updated = ${quote(model.last_updated)}`);
|
||||
lines.push(`attachment = ${model.attachment}`);
|
||||
lines.push(`reasoning = ${model.reasoning}`);
|
||||
if (model.temperature !== undefined) lines.push(`temperature = ${model.temperature}`);
|
||||
lines.push(`tool_call = ${model.tool_call}`);
|
||||
if (model.structured_output !== undefined) {
|
||||
lines.push(`structured_output = ${model.structured_output}`);
|
||||
}
|
||||
if (model.knowledge !== undefined) lines.push(`knowledge = ${quote(model.knowledge)}`);
|
||||
lines.push(`open_weights = ${model.open_weights}`);
|
||||
if (model.status !== undefined) lines.push(`status = ${quote(model.status)}`);
|
||||
|
||||
if (model.interleaved !== undefined) {
|
||||
lines.push("");
|
||||
if (model.interleaved === true) {
|
||||
lines.push("interleaved = true");
|
||||
} else {
|
||||
lines.push("[interleaved]");
|
||||
lines.push(`field = ${quote(model.interleaved.field)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (model.cost !== undefined) {
|
||||
lines.push("", "[cost]");
|
||||
lines.push(`input = ${formatNumber(model.cost.input)}`);
|
||||
lines.push(`output = ${formatNumber(model.cost.output)}`);
|
||||
if (model.cost.reasoning !== undefined) {
|
||||
lines.push(`reasoning = ${formatNumber(model.cost.reasoning)}`);
|
||||
}
|
||||
if (model.cost.cache_read !== undefined) {
|
||||
lines.push(`cache_read = ${formatNumber(model.cost.cache_read)}`);
|
||||
}
|
||||
if (model.cost.cache_write !== undefined) {
|
||||
lines.push(`cache_write = ${formatNumber(model.cost.cache_write)}`);
|
||||
}
|
||||
if (model.cost.input_audio !== undefined) {
|
||||
lines.push(`input_audio = ${formatNumber(model.cost.input_audio)}`);
|
||||
}
|
||||
if (model.cost.output_audio !== undefined) {
|
||||
lines.push(`output_audio = ${formatNumber(model.cost.output_audio)}`);
|
||||
}
|
||||
|
||||
for (const tier of model.cost.tiers ?? []) {
|
||||
lines.push("", "[[cost.tiers]]");
|
||||
lines.push(`tier = { size = ${formatInteger(tier.tier.size)} }`);
|
||||
lines.push(`input = ${formatNumber(tier.input)}`);
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("", "[limit]");
|
||||
lines.push(`context = ${formatInteger(model.limit.context)}`);
|
||||
if (model.limit.input !== undefined) lines.push(`input = ${formatInteger(model.limit.input)}`);
|
||||
lines.push(`output = ${formatInteger(model.limit.output)}`);
|
||||
|
||||
lines.push("", "[modalities]");
|
||||
lines.push(`input = [${model.modalities.input.map(quote).join(", ")}]`);
|
||||
lines.push(`output = [${model.modalities.output.map(quote).join(", ")}]`);
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export async function main(args = process.argv.slice(2)) {
|
||||
const target = args.find((arg) => !arg.startsWith("-")) ?? "aggregators";
|
||||
const results = await syncTargets(target, {
|
||||
dryRun: args.includes("--dry-run"),
|
||||
newOnly: args.includes("--new-only"),
|
||||
});
|
||||
|
||||
await writeReport(target, results);
|
||||
|
||||
console.log("\nSync summary");
|
||||
for (const result of results) {
|
||||
console.log(
|
||||
`${result.name}: ${result.created} created, ${result.updated} updated, ${result.deleted} deleted`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) await main();
|
||||
@@ -0,0 +1,176 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ExistingModel, SyncProvider } from "../sync-models.js";
|
||||
import {
|
||||
buildOpenRouterModel,
|
||||
OpenRouterModel,
|
||||
OpenRouterResponse,
|
||||
} from "./openrouter.js";
|
||||
|
||||
const API_BASE = "https://api.cloudflare.com/client/v4/accounts";
|
||||
|
||||
const CloudflareOpenRouterResponse = z.object({
|
||||
result: z.union([OpenRouterResponse, z.array(OpenRouterModel)]).optional(),
|
||||
result_info: z.object({
|
||||
page: z.number().optional(),
|
||||
total_pages: z.number().optional(),
|
||||
}).passthrough().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const CloudflareModel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created: z.number(),
|
||||
hugging_face_id: z.string().nullable().optional(),
|
||||
context_length: z.number(),
|
||||
max_output_length: z.number().nullable().optional(),
|
||||
input_modalities: z.array(z.string()).optional(),
|
||||
output_modalities: z.array(z.string()).optional(),
|
||||
pricing: z.object({
|
||||
prompt: z.string(),
|
||||
completion: z.string(),
|
||||
internal_reasoning: z.string().optional(),
|
||||
input_cache_read: z.string().optional(),
|
||||
input_cache_write: z.string().optional(),
|
||||
}),
|
||||
supported_features: z.array(z.string()).optional(),
|
||||
supported_sampling_parameters: z.array(z.string()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const CloudflareResponse = z.object({
|
||||
data: z.array(CloudflareModel),
|
||||
}).passthrough();
|
||||
|
||||
type CloudflareModel = z.infer<typeof CloudflareModel>;
|
||||
|
||||
export const cloudflareWorkersAi = {
|
||||
id: "cloudflare-workers-ai",
|
||||
name: "Cloudflare Workers AI",
|
||||
modelsDir: "providers/cloudflare-workers-ai/models",
|
||||
async fetchModels() {
|
||||
const accountID = process.env.CLOUDFLARE_WORKERS_AI_SYNC_ACCOUNT_ID;
|
||||
const token = process.env.CLOUDFLARE_WORKERS_AI_SYNC_API_TOKEN;
|
||||
if (accountID === undefined || token === undefined) {
|
||||
throw new Error(
|
||||
"Cloudflare Workers AI sync requires CLOUDFLARE_WORKERS_AI_SYNC_ACCOUNT_ID and CLOUDFLARE_WORKERS_AI_SYNC_API_TOKEN",
|
||||
);
|
||||
}
|
||||
|
||||
const first = await fetchPage(accountID, token, 1);
|
||||
const models = parseCloudflareModels(first);
|
||||
const pageInfo = CloudflareOpenRouterResponse.safeParse(first).success
|
||||
? CloudflareOpenRouterResponse.parse(first).result_info
|
||||
: undefined;
|
||||
|
||||
for (let page = 2; page <= (pageInfo?.total_pages ?? 1); page++) {
|
||||
models.push(...parseCloudflareModels(await fetchPage(accountID, token, page)));
|
||||
}
|
||||
|
||||
return { data: models };
|
||||
},
|
||||
parseModels(raw) {
|
||||
return parseCloudflareModels(raw);
|
||||
},
|
||||
translateModel(model, context) {
|
||||
const normalized = normalizeModel(model);
|
||||
const id = normalized.id.replace(/^workers-ai\//, "");
|
||||
return {
|
||||
id,
|
||||
model: buildWorkersAiModel(normalized, context.existing(id)),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<CloudflareModel>;
|
||||
|
||||
function buildWorkersAiModel(model: z.infer<typeof OpenRouterModel>, existing: ExistingModel | undefined) {
|
||||
const synced = buildOpenRouterModel(model, existing);
|
||||
return {
|
||||
...synced,
|
||||
name: existing?.name ?? synced.name,
|
||||
release_date: existing?.release_date ?? synced.release_date,
|
||||
last_updated: existing?.last_updated ?? synced.last_updated,
|
||||
limit: {
|
||||
...synced.limit,
|
||||
output: existing?.limit?.output ?? synced.limit.output,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPage(accountID: string, token: string, page: number) {
|
||||
const url = new URL(`${API_BASE}/${accountID}/ai/models/search`);
|
||||
url.searchParams.set("format", "openrouter");
|
||||
url.searchParams.set("per_page", "1000");
|
||||
url.searchParams.set("page", String(page));
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Cloudflare Workers AI models request failed: ${response.status} ${response.statusText}${await responseDetails(response)}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function parseCloudflareModels(raw: unknown) {
|
||||
const cloudflare = CloudflareResponse.safeParse(raw);
|
||||
if (cloudflare.success) return cloudflare.data.data;
|
||||
|
||||
const direct = OpenRouterResponse.safeParse(raw);
|
||||
if (direct.success) return direct.data.data;
|
||||
|
||||
const wrapped = CloudflareOpenRouterResponse.parse(raw);
|
||||
if (wrapped.result === undefined) {
|
||||
throw new Error("Cloudflare Workers AI response did not include model data");
|
||||
}
|
||||
return Array.isArray(wrapped.result) ? wrapped.result : wrapped.result.data;
|
||||
}
|
||||
|
||||
function normalizeModel(model: CloudflareModel) {
|
||||
if ("architecture" in model && "top_provider" in model && "supported_parameters" in model) {
|
||||
return OpenRouterModel.parse(model);
|
||||
}
|
||||
|
||||
return OpenRouterModel.parse({
|
||||
id: model.id.startsWith("@cf/") ? model.id : `@cf/${model.id.replace(/^@cf\//, "")}`,
|
||||
name: model.name,
|
||||
created: model.created,
|
||||
hugging_face_id: model.hugging_face_id ?? null,
|
||||
knowledge_cutoff: null,
|
||||
context_length: model.context_length,
|
||||
architecture: {
|
||||
input_modalities: model.input_modalities ?? ["text"],
|
||||
output_modalities: model.output_modalities ?? ["text"],
|
||||
},
|
||||
pricing: model.pricing,
|
||||
top_provider: {
|
||||
context_length: model.context_length,
|
||||
max_completion_tokens: model.max_output_length ?? null,
|
||||
},
|
||||
supported_parameters: [
|
||||
...model.supported_sampling_parameters ?? [],
|
||||
...model.supported_features ?? [],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function responseDetails(response: Response) {
|
||||
const text = await response.text();
|
||||
if (text.length === 0) return "";
|
||||
|
||||
try {
|
||||
const body = z.object({
|
||||
errors: z.array(z.object({
|
||||
code: z.union([z.string(), z.number()]).optional(),
|
||||
message: z.string().optional(),
|
||||
}).passthrough()).optional(),
|
||||
}).passthrough().parse(JSON.parse(text));
|
||||
const details = body.errors
|
||||
?.map((error) => [error.code, error.message].filter(Boolean).join(": "))
|
||||
.filter((message) => message.length > 0)
|
||||
.join("; ");
|
||||
return details === undefined || details.length === 0 ? "" : ` (${details})`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ExistingModel, SyncProvider, SyncedModel } from "../sync-models.js";
|
||||
|
||||
const API_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models";
|
||||
|
||||
const GoogleModel = z.object({
|
||||
name: z.string(),
|
||||
baseModelId: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
inputTokenLimit: z.number().int().nonnegative(),
|
||||
outputTokenLimit: z.number().int().nonnegative(),
|
||||
supportedGenerationMethods: z.array(z.string()).optional(),
|
||||
temperature: z.number().optional(),
|
||||
topP: z.number().optional(),
|
||||
topK: z.number().optional(),
|
||||
maxTemperature: z.number().optional(),
|
||||
thinking: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const GoogleResponse = z.object({
|
||||
models: z.array(GoogleModel).optional(),
|
||||
nextPageToken: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
type GoogleModel = z.infer<typeof GoogleModel>;
|
||||
|
||||
export const google = {
|
||||
id: "google",
|
||||
name: "Google",
|
||||
modelsDir: "providers/google/models",
|
||||
skipCreates: true,
|
||||
sourceID(model) {
|
||||
return model.name.replace(/^models\//, "");
|
||||
},
|
||||
skippedNotice(ids) {
|
||||
if (ids.length === 0) return [];
|
||||
return [
|
||||
`${ids.length} Google models returned by the API were not created because the Models API does not provide authoritative modalities, pricing, knowledge cutoff, release date, tool calling, or structured output metadata. Existing models are still updated from API-authoritative fields.`,
|
||||
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
async fetchModels() {
|
||||
const key = process.env.GOOGLE_API_KEY
|
||||
?? process.env.GEMINI_API_KEY
|
||||
?? process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||
if (key === undefined) {
|
||||
throw new Error("Google sync requires GOOGLE_API_KEY, GEMINI_API_KEY, or GOOGLE_GENERATIVE_AI_API_KEY");
|
||||
}
|
||||
|
||||
const models: GoogleModel[] = [];
|
||||
let pageToken: string | undefined;
|
||||
|
||||
do {
|
||||
const url = new URL(API_ENDPOINT);
|
||||
url.searchParams.set("key", key);
|
||||
url.searchParams.set("pageSize", "1000");
|
||||
if (pageToken !== undefined) url.searchParams.set("pageToken", pageToken);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Google models request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const page = GoogleResponse.parse(await response.json());
|
||||
models.push(...page.models ?? []);
|
||||
pageToken = page.nextPageToken;
|
||||
} while (pageToken !== undefined);
|
||||
|
||||
return { models };
|
||||
},
|
||||
parseModels(raw) {
|
||||
return GoogleResponse.parse(raw).models ?? [];
|
||||
},
|
||||
translateModel(model, context) {
|
||||
const id = model.name.replace(/^models\//, "");
|
||||
const existing = context.existing(id);
|
||||
if (existing === undefined) return undefined;
|
||||
|
||||
return {
|
||||
id,
|
||||
model: buildModel(model, existing),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<GoogleModel>;
|
||||
|
||||
function buildModel(model: GoogleModel, existing: ExistingModel): SyncedModel {
|
||||
const name = existing.name;
|
||||
const releaseDate = existing.release_date;
|
||||
const lastUpdated = existing.last_updated;
|
||||
const attachment = existing.attachment;
|
||||
const reasoning = existing.reasoning;
|
||||
const toolCall = existing.tool_call;
|
||||
const openWeights = existing.open_weights;
|
||||
const limit = existing.limit;
|
||||
const modalities = existing.modalities;
|
||||
|
||||
if (
|
||||
name === undefined
|
||||
|| releaseDate === undefined
|
||||
|| lastUpdated === undefined
|
||||
|| attachment === undefined
|
||||
|| reasoning === undefined
|
||||
|| toolCall === undefined
|
||||
|| openWeights === undefined
|
||||
|| limit === undefined
|
||||
|| modalities === undefined
|
||||
) {
|
||||
throw new Error(`Google model ${model.name} has incomplete local TOML metadata required for sync`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: model.displayName ?? name,
|
||||
family: existing.family,
|
||||
release_date: releaseDate,
|
||||
last_updated: lastUpdated,
|
||||
attachment,
|
||||
reasoning: model.thinking ?? reasoning,
|
||||
temperature: model.temperature !== undefined || model.maxTemperature !== undefined
|
||||
? true
|
||||
: existing.temperature,
|
||||
tool_call: toolCall,
|
||||
structured_output: existing.structured_output,
|
||||
knowledge: existing.knowledge,
|
||||
open_weights: openWeights,
|
||||
status: existing.status,
|
||||
interleaved: existing.interleaved,
|
||||
cost: existing.cost,
|
||||
limit: {
|
||||
input: limit.input,
|
||||
context: model.inputTokenLimit,
|
||||
output: model.outputTokenLimit,
|
||||
},
|
||||
modalities,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ModelFamilyValues } from "../../src/family.js";
|
||||
import type { ExistingModel, SyncProvider } from "../sync-models.js";
|
||||
|
||||
const API_ENDPOINT = "https://openrouter.ai/api/v1/models";
|
||||
|
||||
export const OpenRouterModel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created: z.number(),
|
||||
hugging_face_id: z.string().nullable(),
|
||||
knowledge_cutoff: z.string().nullable(),
|
||||
context_length: z.number(),
|
||||
architecture: z.object({
|
||||
input_modalities: z.array(z.string()),
|
||||
output_modalities: z.array(z.string()),
|
||||
}),
|
||||
pricing: z.object({
|
||||
prompt: z.string(),
|
||||
completion: z.string(),
|
||||
internal_reasoning: z.string().optional(),
|
||||
input_cache_read: z.string().optional(),
|
||||
input_cache_write: z.string().optional(),
|
||||
}),
|
||||
top_provider: z.object({
|
||||
context_length: z.number().nullable(),
|
||||
max_completion_tokens: z.number().nullable(),
|
||||
}),
|
||||
supported_parameters: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const OpenRouterResponse = z.object({
|
||||
data: z.array(OpenRouterModel),
|
||||
}).passthrough();
|
||||
|
||||
export type OpenRouterModel = z.infer<typeof OpenRouterModel>;
|
||||
|
||||
export const openrouter = {
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
modelsDir: "providers/openrouter/models",
|
||||
async fetchModels() {
|
||||
const headers = process.env.OPENROUTER_API_KEY
|
||||
? { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}` }
|
||||
: undefined;
|
||||
const response = await fetch(API_ENDPOINT, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenRouter request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
return OpenRouterResponse.parse(raw).data;
|
||||
},
|
||||
translateModel(model, context) {
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildOpenRouterModel(model, context.existing(model.id)),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<OpenRouterModel>;
|
||||
|
||||
function dateFromTimestamp(timestamp: number) {
|
||||
return new Date(timestamp * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function price(value: string | undefined) {
|
||||
if (value === undefined) return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0
|
||||
? Math.round(number * 1_000_000_000_000) / 1_000_000
|
||||
: undefined;
|
||||
}
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
function modalities(values: string[], fallback: Modality[]): Modality[] {
|
||||
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
const result = values
|
||||
.map((value) => value.toLowerCase())
|
||||
.map((value) => value === "file" ? "pdf" : value)
|
||||
.filter((value): value is Modality => allowed.has(value as Modality));
|
||||
return [...new Set(result.length > 0 ? result : fallback)];
|
||||
}
|
||||
|
||||
function inferFamily(model: OpenRouterModel, name: string) {
|
||||
const target = `${model.id} ${name}`.toLowerCase();
|
||||
return [...ModelFamilyValues]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((family) => {
|
||||
const value = family.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (family === "o") {
|
||||
return new RegExp(`(^|[^a-z0-9])${value}(?=\\d|$|[^a-z0-9])`).test(target);
|
||||
}
|
||||
return new RegExp(`(^|[^a-z0-9])${value}(?=$|[^a-z0-9])`).test(target);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildOpenRouterModel(model: OpenRouterModel, existing: ExistingModel | undefined) {
|
||||
const params = new Set(model.supported_parameters);
|
||||
const name = model.name.replace(/^[^:]+:\s+/, "");
|
||||
const input = modalities(model.architecture.input_modalities, ["text"]);
|
||||
const output = modalities(model.architecture.output_modalities, ["text"]);
|
||||
const prompt = price(model.pricing.prompt);
|
||||
const completion = price(model.pricing.completion);
|
||||
const reasoning = params.has("reasoning") || params.has("include_reasoning");
|
||||
const context = model.top_provider.context_length ?? model.context_length;
|
||||
const family = inferFamily(model, name);
|
||||
|
||||
return {
|
||||
name,
|
||||
family: existing?.family === "o" && family !== "o"
|
||||
? family
|
||||
: (existing?.family ?? family),
|
||||
release_date: dateFromTimestamp(model.created),
|
||||
last_updated: dateFromTimestamp(model.created),
|
||||
attachment: input.some((value) => value !== "text"),
|
||||
reasoning,
|
||||
temperature: params.has("temperature"),
|
||||
tool_call: params.has("tools") || params.has("tool_choice"),
|
||||
structured_output: params.has("structured_outputs"),
|
||||
knowledge: model.knowledge_cutoff?.slice(0, 10) ?? existing?.knowledge,
|
||||
open_weights: Boolean(model.hugging_face_id),
|
||||
status: existing?.status,
|
||||
interleaved: existing?.interleaved,
|
||||
cost: prompt !== undefined && completion !== undefined
|
||||
? {
|
||||
input: prompt,
|
||||
output: completion,
|
||||
reasoning: reasoning ? price(model.pricing.internal_reasoning) : undefined,
|
||||
cache_read: price(model.pricing.input_cache_read),
|
||||
cache_write: price(model.pricing.input_cache_write),
|
||||
tiers: existing?.cost?.tiers,
|
||||
}
|
||||
: existing?.cost,
|
||||
limit: {
|
||||
context,
|
||||
input: existing?.limit?.input,
|
||||
output: model.top_provider.max_completion_tokens ?? existing?.limit?.output ?? context,
|
||||
},
|
||||
modalities: { input, output },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ExistingModel, SyncProvider, SyncedModel } from "../sync-models.js";
|
||||
|
||||
const API_BASE = "https://api.x.ai/v1";
|
||||
|
||||
const XAIModel = z.object({
|
||||
id: z.string(),
|
||||
canonical_id: z.string().optional(),
|
||||
created: z.number().int().nonnegative(),
|
||||
aliases: z.array(z.string()).optional(),
|
||||
input_modalities: z.array(z.string()).optional(),
|
||||
output_modalities: z.array(z.string()).optional(),
|
||||
prompt_text_token_price: z.number().int().nonnegative().optional(),
|
||||
cached_prompt_text_token_price: z.number().int().nonnegative().optional(),
|
||||
completion_text_token_price: z.number().int().nonnegative().optional(),
|
||||
max_prompt_length: z.number().int().nonnegative().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const XAIModelList = z.object({
|
||||
models: z.array(XAIModel),
|
||||
}).passthrough();
|
||||
|
||||
const XAIResponse = z.object({
|
||||
models: z.array(XAIModel),
|
||||
});
|
||||
|
||||
const XAIAPIKey = z.object({
|
||||
acls: z.array(z.string()),
|
||||
}).passthrough();
|
||||
|
||||
type XAIModel = z.infer<typeof XAIModel>;
|
||||
|
||||
export const xai = {
|
||||
id: "xai",
|
||||
name: "xAI",
|
||||
modelsDir: "providers/xai/models",
|
||||
skipCreates: true,
|
||||
sourceID(model) {
|
||||
return model.id;
|
||||
},
|
||||
skippedNotice(ids) {
|
||||
if (ids.length === 0) return [];
|
||||
return [
|
||||
`${ids.length} xAI models returned by the API were not created because the Models API does not provide enough authoritative metadata for the catalog, especially output token limits and some feature/capability flags. Existing models are still updated from API-authoritative fields.`,
|
||||
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
async fetchModels() {
|
||||
const key = process.env.XAI_API_KEY;
|
||||
if (key === undefined) throw new Error("xAI sync requires XAI_API_KEY");
|
||||
await assertFullModelAccess(key);
|
||||
|
||||
const models = await Promise.all([
|
||||
fetchTypedModels(key, "language-models"),
|
||||
fetchTypedModels(key, "image-generation-models"),
|
||||
fetchTypedModels(key, "video-generation-models"),
|
||||
]);
|
||||
|
||||
return { models: models.flat() };
|
||||
},
|
||||
parseModels(raw) {
|
||||
const models = XAIResponse.parse(raw).models;
|
||||
const seen = new Set<string>();
|
||||
const expanded: XAIModel[] = [];
|
||||
|
||||
for (const model of models) {
|
||||
if (!seen.has(model.id)) {
|
||||
seen.add(model.id);
|
||||
expanded.push(model);
|
||||
}
|
||||
}
|
||||
|
||||
for (const model of models) {
|
||||
for (const alias of model.aliases ?? []) {
|
||||
if (seen.has(alias)) continue;
|
||||
seen.add(alias);
|
||||
expanded.push({ ...model, id: alias, canonical_id: model.id });
|
||||
}
|
||||
}
|
||||
|
||||
return expanded;
|
||||
},
|
||||
translateModel(model, context) {
|
||||
const existing = context.existing(model.id);
|
||||
if (existing === undefined) return undefined;
|
||||
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildModel(model, existing),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<XAIModel>;
|
||||
|
||||
async function assertFullModelAccess(key: string) {
|
||||
const response = await fetch(`${API_BASE}/api-key`, {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`xAI API key metadata request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const apiKey = XAIAPIKey.parse(await response.json());
|
||||
if (!apiKey.acls.includes("api-key:model:*")) {
|
||||
throw new Error("xAI sync requires XAI_API_KEY to include api-key:model:* so the model list is not ACL-filtered");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTypedModels(key: string, endpoint: string) {
|
||||
const response = await fetch(`${API_BASE}/${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`xAI ${endpoint} request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return XAIModelList.parse(await response.json()).models;
|
||||
}
|
||||
|
||||
function dateFromTimestamp(timestamp: number) {
|
||||
return new Date(timestamp * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
function modalities(values: string[] | undefined, fallback: Modality[]) {
|
||||
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
const result = (values ?? [])
|
||||
.map((value) => value.toLowerCase())
|
||||
.filter((value): value is Modality => allowed.has(value as Modality));
|
||||
return [...new Set(result.length > 0 ? result : fallback)];
|
||||
}
|
||||
|
||||
function tokenPrice(value: number | undefined) {
|
||||
if (value === undefined) return undefined;
|
||||
return value / 10_000;
|
||||
}
|
||||
|
||||
function cost(model: XAIModel, existing: ExistingModel) {
|
||||
const input = tokenPrice(model.prompt_text_token_price);
|
||||
const output = tokenPrice(model.completion_text_token_price);
|
||||
if (input === undefined || output === undefined) return existing.cost;
|
||||
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
reasoning: existing.cost?.reasoning,
|
||||
cache_read: tokenPrice(model.cached_prompt_text_token_price),
|
||||
cache_write: existing.cost?.cache_write,
|
||||
input_audio: existing.cost?.input_audio,
|
||||
output_audio: existing.cost?.output_audio,
|
||||
tiers: existing.cost?.tiers,
|
||||
};
|
||||
}
|
||||
|
||||
function buildModel(model: XAIModel, existing: ExistingModel): SyncedModel {
|
||||
const name = existing.name;
|
||||
const attachment = existing.attachment;
|
||||
const reasoning = existing.reasoning;
|
||||
const toolCall = existing.tool_call;
|
||||
const openWeights = existing.open_weights;
|
||||
const limit = existing.limit;
|
||||
const releaseDate = existing.release_date;
|
||||
const lastUpdated = existing.last_updated;
|
||||
|
||||
if (
|
||||
name === undefined
|
||||
|| attachment === undefined
|
||||
|| reasoning === undefined
|
||||
|| toolCall === undefined
|
||||
|| openWeights === undefined
|
||||
|| limit === undefined
|
||||
|| (model.canonical_id !== undefined && releaseDate === undefined)
|
||||
|| (model.canonical_id !== undefined && lastUpdated === undefined)
|
||||
) {
|
||||
throw new Error(`xAI model ${model.id} has incomplete local TOML metadata required for sync`);
|
||||
}
|
||||
|
||||
const input = modalities(model.input_modalities, existing.modalities?.input ?? ["text"]);
|
||||
const output = modalities(model.output_modalities, existing.modalities?.output ?? ["text"]);
|
||||
const created = dateFromTimestamp(model.created);
|
||||
|
||||
return {
|
||||
name,
|
||||
family: existing.family,
|
||||
release_date: model.canonical_id === undefined ? created : releaseDate!,
|
||||
last_updated: model.canonical_id === undefined ? created : lastUpdated!,
|
||||
attachment: input.some((value) => value !== "text"),
|
||||
reasoning,
|
||||
temperature: existing.temperature,
|
||||
tool_call: toolCall,
|
||||
structured_output: existing.structured_output,
|
||||
knowledge: existing.knowledge,
|
||||
open_weights: openWeights,
|
||||
status: existing.status,
|
||||
interleaved: existing.interleaved,
|
||||
cost: cost(model, existing),
|
||||
limit: {
|
||||
input: limit.input,
|
||||
context: model.max_prompt_length ?? limit.context,
|
||||
output: limit.output,
|
||||
},
|
||||
modalities: { input, output },
|
||||
};
|
||||
}
|
||||
@@ -54,12 +54,17 @@ export const ModelFamilyValues = [
|
||||
// DeepSeek
|
||||
"deepseek",
|
||||
"deepseek-thinking",
|
||||
"deepseek-flash",
|
||||
"deepseek-flash-free",
|
||||
"deepseek-flash-think",
|
||||
|
||||
// Microsoft Phi
|
||||
"phi",
|
||||
|
||||
// Moonshot Kimi
|
||||
"kimi",
|
||||
"kimi-k2.5",
|
||||
"kimi-k2.6",
|
||||
"kimi-free",
|
||||
"kimi-thinking",
|
||||
|
||||
@@ -77,6 +82,7 @@ export const ModelFamilyValues = [
|
||||
|
||||
// xAI Grok
|
||||
"grok",
|
||||
"grok-build",
|
||||
"grok-vision",
|
||||
"grok-beta",
|
||||
|
||||
@@ -115,6 +121,9 @@ export const ModelFamilyValues = [
|
||||
// Hunyuan
|
||||
"hunyuan",
|
||||
|
||||
// Hy
|
||||
"Hy",
|
||||
|
||||
// Yi
|
||||
"yi",
|
||||
|
||||
@@ -202,6 +211,10 @@ export const ModelFamilyValues = [
|
||||
"mimo",
|
||||
"mimo-pro",
|
||||
"mimo-omni",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
"mimo-v2.5-pro",
|
||||
"mimo-v2.5",
|
||||
"mimo-pro-free",
|
||||
"mimo-omni-free",
|
||||
"mimo-flash-free",
|
||||
@@ -277,9 +290,15 @@ export const ModelFamilyValues = [
|
||||
// RNJ
|
||||
"rnj",
|
||||
|
||||
// Tecent Hy
|
||||
"hy3",
|
||||
"hy3-free",
|
||||
|
||||
// Ling & Ring (InclusionAI)
|
||||
"ling",
|
||||
"ling-flash-free",
|
||||
"ring",
|
||||
"ring-1t-free",
|
||||
|
||||
// Kat Coder
|
||||
"kat-coder",
|
||||
|
||||
@@ -2,9 +2,9 @@ import path from "path";
|
||||
import { mergeDeep } from "remeda";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Provider, Model } from "./schema.js";
|
||||
import { Provider, Model, AuthoredModel, AuthoredModelShape } from "./schema.js";
|
||||
|
||||
const ExtendsModel = Model.sourceType()
|
||||
const ExtendsModel = AuthoredModelShape
|
||||
.partial()
|
||||
.extend({
|
||||
extends: z
|
||||
@@ -71,12 +71,12 @@ export async function generate(directory: string) {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const model = Model.safeParse(toml);
|
||||
const model = AuthoredModel.safeParse(toml);
|
||||
if (!model.success) {
|
||||
model.error.cause = { modelPath, toml };
|
||||
throw model.error;
|
||||
}
|
||||
provider.data.models[modelID] = model.data;
|
||||
provider.data.models[modelID] = normalizeModelCost(model.data);
|
||||
}
|
||||
result[providerID] = provider.data;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export async function generate(directory: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const model = Model.safeParse(merged);
|
||||
const model = Model.safeParse(normalizeCost(merged));
|
||||
if (!model.success) {
|
||||
model.error.cause = { modelPath: pendingModel.modelPath, toml: merged };
|
||||
throw model.error;
|
||||
@@ -155,3 +155,51 @@ export async function generate(directory: string) {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeModelCost(model: z.infer<typeof AuthoredModel>): Model {
|
||||
return normalizeCost(model) as Model;
|
||||
}
|
||||
|
||||
function normalizeCost(model: Record<string, unknown>) {
|
||||
const cost = model.cost;
|
||||
if (cost === undefined || cost === null || typeof cost !== "object" || Array.isArray(cost)) {
|
||||
return model;
|
||||
}
|
||||
|
||||
const tiers = (cost as { tiers?: unknown }).tiers;
|
||||
if (!Array.isArray(tiers)) {
|
||||
return model;
|
||||
}
|
||||
|
||||
if (tiers.length !== 1) {
|
||||
return model;
|
||||
}
|
||||
|
||||
const contextOver200k = tiers.find((tier) => {
|
||||
if (tier === null || typeof tier !== "object" || Array.isArray(tier)) return false;
|
||||
const tierConfig = (tier as { tier?: unknown }).tier;
|
||||
if (tierConfig === null || typeof tierConfig !== "object" || Array.isArray(tierConfig)) return false;
|
||||
const type = (tierConfig as { type?: unknown }).type;
|
||||
const size = (tierConfig as { size?: unknown }).size;
|
||||
// context_over_200k is a legacy compatibility field. It intentionally
|
||||
// includes higher thresholds; cost.tiers carries the exact threshold.
|
||||
return (
|
||||
(type === undefined || type === "context") &&
|
||||
typeof size === "number" &&
|
||||
size >= 200_000
|
||||
);
|
||||
});
|
||||
|
||||
if (contextOver200k === undefined) {
|
||||
return model;
|
||||
}
|
||||
|
||||
const { tier: _tier, ...legacyCost } = contextOver200k as Record<string, unknown>;
|
||||
return {
|
||||
...model,
|
||||
cost: {
|
||||
...(cost as Record<string, unknown>),
|
||||
context_over_200k: legacyCost,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+156
-98
@@ -21,110 +21,164 @@ const JsonValue: z.ZodType<JsonValue> = z.lazy(() =>
|
||||
]),
|
||||
);
|
||||
|
||||
const Cost = z.object({
|
||||
input: z.number().min(0, "Input price cannot be negative"),
|
||||
output: z.number().min(0, "Output price cannot be negative"),
|
||||
reasoning: z.number().min(0, "Input price cannot be negative").optional(),
|
||||
cache_read: z
|
||||
.number()
|
||||
.min(0, "Cache read price cannot be negative")
|
||||
const Cost = z
|
||||
.object({
|
||||
input: z.number().min(0, "Input price cannot be negative"),
|
||||
output: z.number().min(0, "Output price cannot be negative"),
|
||||
reasoning: z
|
||||
.number()
|
||||
.min(0, "Reasoning price cannot be negative")
|
||||
.optional(),
|
||||
cache_read: z
|
||||
.number()
|
||||
.min(0, "Cache read price cannot be negative")
|
||||
.optional(),
|
||||
cache_write: z
|
||||
.number()
|
||||
.min(0, "Cache write price cannot be negative")
|
||||
.optional(),
|
||||
input_audio: z
|
||||
.number()
|
||||
.min(0, "Audio input price cannot be negative")
|
||||
.optional(),
|
||||
output_audio: z
|
||||
.number()
|
||||
.min(0, "Audio output price cannot be negative")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const CostTier = Cost.extend({
|
||||
tier: z
|
||||
.object({
|
||||
type: z.literal("context").default("context"),
|
||||
size: z.number().int().min(0, "Context tier size cannot be negative"),
|
||||
})
|
||||
.strict(),
|
||||
}).strict();
|
||||
|
||||
const AuthoredCost = Cost.extend({
|
||||
context_over_200k: z.never().optional(),
|
||||
tiers: z.array(CostTier).optional(),
|
||||
});
|
||||
|
||||
const OutputCost = Cost.extend({
|
||||
context_over_200k: Cost.optional(),
|
||||
tiers: z.array(CostTier).optional(),
|
||||
});
|
||||
|
||||
const ModelBase = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1, "Model name cannot be empty"),
|
||||
family: ModelFamily.optional(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean(),
|
||||
tool_call: z.boolean(),
|
||||
interleaved: z
|
||||
.union([
|
||||
z.literal(true),
|
||||
z
|
||||
.object({
|
||||
field: z.enum(["reasoning_content", "reasoning_details"]),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
.optional(),
|
||||
cache_write: z
|
||||
.number()
|
||||
.min(0, "Cache write price cannot be negative")
|
||||
structured_output: z.boolean().optional(),
|
||||
temperature: z.boolean().optional(),
|
||||
knowledge: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}(-\d{2})?$/, {
|
||||
message: "Must be in YYYY-MM or YYYY-MM-DD format",
|
||||
})
|
||||
.optional(),
|
||||
input_audio: z
|
||||
.number()
|
||||
.min(0, "Audio input price cannot be negative")
|
||||
release_date: z.string().regex(/^\d{4}-\d{2}(-\d{2})?$/, {
|
||||
message: "Must be in YYYY-MM or YYYY-MM-DD format",
|
||||
}),
|
||||
last_updated: z.string().regex(/^\d{4}-\d{2}(-\d{2})?$/, {
|
||||
message: "Must be in YYYY-MM or YYYY-MM-DD format",
|
||||
}),
|
||||
modalities: z.object({
|
||||
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
||||
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
||||
}),
|
||||
open_weights: z.boolean(),
|
||||
limit: z.object({
|
||||
context: z.number().min(0, "Context window must be positive"),
|
||||
input: z.number().min(0, "Input tokens must be positive").optional(),
|
||||
output: z.number().min(0, "Output tokens must be positive"),
|
||||
}),
|
||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||
experimental: z
|
||||
.object({
|
||||
modes: z
|
||||
.record(
|
||||
z.object({
|
||||
cost: Cost.optional(),
|
||||
provider: z
|
||||
.object({
|
||||
body: z.record(JsonValue).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
output_audio: z
|
||||
.number()
|
||||
.min(0, "Audio output price cannot be negative")
|
||||
provider: z
|
||||
.object({
|
||||
npm: z.string().optional(),
|
||||
api: z.string().optional(),
|
||||
shape: z.enum(["responses", "completions"]).optional(),
|
||||
body: z.record(JsonValue).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export const Model = z
|
||||
|
||||
function refineModel<T extends z.ZodTypeAny>(schema: T) {
|
||||
return schema
|
||||
.refine(
|
||||
(data) => {
|
||||
return !(data.reasoning === false && data.cost?.reasoning !== undefined);
|
||||
},
|
||||
{
|
||||
message: "Cannot set cost.reasoning when reasoning is false",
|
||||
path: ["cost", "reasoning"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
const tiers = data.cost?.tiers;
|
||||
if (tiers === undefined) return true;
|
||||
|
||||
const sizes = tiers.map((tier: { tier: { size: number } }) => tier.tier.size);
|
||||
return new Set(sizes).size === sizes.length;
|
||||
},
|
||||
{
|
||||
message: "Cost context tiers must not have duplicate sizes",
|
||||
path: ["cost", "tiers"],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const ModelShape = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1, "Model name cannot be empty"),
|
||||
family: ModelFamily.optional(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean(),
|
||||
tool_call: z.boolean(),
|
||||
interleaved: z
|
||||
.union([
|
||||
z.literal(true),
|
||||
z
|
||||
.object({
|
||||
field: z.enum(["reasoning_content", "reasoning_details"]),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
.optional(),
|
||||
structured_output: z.boolean().optional(),
|
||||
temperature: z.boolean().optional(),
|
||||
knowledge: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}(-\d{2})?$/, {
|
||||
message: "Must be in YYYY-MM or YYYY-MM-DD format",
|
||||
})
|
||||
.optional(),
|
||||
release_date: z.string().regex(/^\d{4}-\d{2}(-\d{2})?$/, {
|
||||
message: "Must be in YYYY-MM or YYYY-MM-DD format",
|
||||
}),
|
||||
last_updated: z.string().regex(/^\d{4}-\d{2}(-\d{2})?$/, {
|
||||
message: "Must be in YYYY-MM or YYYY-MM-DD format",
|
||||
}),
|
||||
modalities: z.object({
|
||||
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
||||
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
||||
}),
|
||||
open_weights: z.boolean(),
|
||||
cost: Cost.extend({
|
||||
context_over_200k: Cost.optional(),
|
||||
}).optional(),
|
||||
limit: z.object({
|
||||
context: z.number().min(0, "Context window must be positive"),
|
||||
input: z.number().min(0, "Input tokens must be positive").optional(),
|
||||
output: z.number().min(0, "Output tokens must be positive"),
|
||||
}),
|
||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||
experimental: z
|
||||
.object({
|
||||
modes: z
|
||||
.record(
|
||||
z.object({
|
||||
cost: Cost.optional(),
|
||||
provider: z
|
||||
.object({
|
||||
body: z.record(JsonValue).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
provider: z
|
||||
.object({
|
||||
npm: z.string().optional(),
|
||||
api: z.string().optional(),
|
||||
shape: z.enum(["responses", "completions"]).optional(),
|
||||
body: z.record(JsonValue).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
...ModelBase.shape,
|
||||
cost: OutputCost.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(data) => {
|
||||
return !(data.reasoning === false && data.cost?.reasoning !== undefined);
|
||||
},
|
||||
{
|
||||
message: "Cannot set cost.reasoning when reasoning is false",
|
||||
path: ["cost", "reasoning"],
|
||||
},
|
||||
);
|
||||
.strict();
|
||||
|
||||
export const AuthoredModelShape = z
|
||||
.object({
|
||||
...ModelBase.shape,
|
||||
cost: AuthoredCost.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const Model = refineModel(ModelShape);
|
||||
|
||||
export const AuthoredModel = refineModel(AuthoredModelShape);
|
||||
|
||||
export type Model = z.infer<typeof Model>;
|
||||
|
||||
@@ -150,6 +204,7 @@ export const Provider = z
|
||||
const isOpenAIcompatible = data.npm === "@ai-sdk/openai-compatible";
|
||||
const isOpenrouter = data.npm === "@openrouter/ai-sdk-provider";
|
||||
const isAnthropic = data.npm === "@ai-sdk/anthropic";
|
||||
const isKiro = data.npm === "kiro-acp-ai-provider";
|
||||
const hasApi = data.api !== undefined;
|
||||
|
||||
return (
|
||||
@@ -161,17 +216,20 @@ export const Provider = z
|
||||
isAnthropic ||
|
||||
// openai: api optional (always allowed)
|
||||
isOpenAI ||
|
||||
// kiro: api optional (always allowed)
|
||||
isKiro ||
|
||||
// all others: must NOT have api
|
||||
(!isOpenAI &&
|
||||
!isOpenAIcompatible &&
|
||||
!isOpenrouter &&
|
||||
!isAnthropic &&
|
||||
!isKiro &&
|
||||
!hasApi)
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"'api' is required for openai-compatible and openrouter, optional for anthropic and openai, forbidden otherwise",
|
||||
"'api' is required for openai-compatible and openrouter, optional for anthropic, openai, and kiro, forbidden otherwise",
|
||||
path: ["api"],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"build": "./script/build.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "^3.14.0",
|
||||
"hono": "^4.8.0",
|
||||
"models.dev": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: 'Rubik', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
@@ -205,12 +207,17 @@ header {
|
||||
}
|
||||
}
|
||||
|
||||
.table-viewport {
|
||||
height: calc(100svh - var(--header-height));
|
||||
margin-top: var(--header-height);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 0.875rem;
|
||||
width: 100%;
|
||||
margin-top: var(--header-height);
|
||||
}
|
||||
|
||||
thead,
|
||||
@@ -218,7 +225,7 @@ tbody {}
|
||||
|
||||
table thead th {
|
||||
position: sticky;
|
||||
top: var(--header-height);
|
||||
top: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.75rem;
|
||||
@@ -264,6 +271,7 @@ td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
tbody {
|
||||
@@ -319,21 +327,37 @@ tbody {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.provider-cell span:first-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.provider-cell img,
|
||||
.provider-cell svg {
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.virtual-spacer td {
|
||||
height: inherit;
|
||||
padding: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.empty-row td {
|
||||
padding: 2rem 0.75rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.empty-row div {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
width: calc(100vw - 1.5rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.model-id-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
@@ -546,4 +570,4 @@ dialog {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+295
-94
@@ -1,7 +1,53 @@
|
||||
import {
|
||||
Virtualizer,
|
||||
elementScroll,
|
||||
observeElementOffset,
|
||||
observeElementRect,
|
||||
} from "@tanstack/virtual-core";
|
||||
import {
|
||||
type TableRow,
|
||||
renderRow,
|
||||
escapeHtml,
|
||||
booleanText,
|
||||
knowledgeText,
|
||||
weightsText,
|
||||
} from "./shared.js";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TABLE_DATA__: TableRow[];
|
||||
}
|
||||
}
|
||||
|
||||
interface VirtualizedRow extends TableRow {
|
||||
key: string;
|
||||
searchText: string;
|
||||
sortValues: Array<string | number | undefined>;
|
||||
}
|
||||
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
const ESTIMATED_ROW_HEIGHT = 48;
|
||||
const VIRTUAL_OVERSCAN = 5;
|
||||
|
||||
const modal = document.getElementById("modal") as HTMLDialogElement;
|
||||
const modalClose = document.getElementById("close")!;
|
||||
const help = document.getElementById("help")!;
|
||||
const search = document.getElementById("search")! as HTMLInputElement;
|
||||
const viewport = document.getElementById("table-viewport") as HTMLElement;
|
||||
const tbody = document.getElementById(
|
||||
"models-table-body"
|
||||
) as HTMLTableSectionElement;
|
||||
const headers = Array.from(document.querySelectorAll("th.sortable"));
|
||||
const columnCount = document.querySelectorAll("thead th").length;
|
||||
|
||||
let isLoaded = false;
|
||||
let allRows: VirtualizedRow[] = [];
|
||||
let visibleRows: VirtualizedRow[] = [];
|
||||
let currentSort: { column: number; direction: SortDirection } = {
|
||||
column: -1,
|
||||
direction: "asc",
|
||||
};
|
||||
|
||||
/////////////////////////
|
||||
// URL State Management
|
||||
@@ -31,10 +77,7 @@ function getColumnNameForURL(headerEl: Element): string {
|
||||
}
|
||||
|
||||
function getColumnIndexByUrlName(name: string): number {
|
||||
const headers = document.querySelectorAll("th.sortable");
|
||||
return Array.from(headers).findIndex(
|
||||
(header) => getColumnNameForURL(header) === name
|
||||
);
|
||||
return headers.findIndex((header) => getColumnNameForURL(header) === name);
|
||||
}
|
||||
|
||||
/////////////////////////
|
||||
@@ -63,32 +106,181 @@ modal.addEventListener("click", (e) => {
|
||||
});
|
||||
|
||||
////////////////////
|
||||
// Handle Sorting
|
||||
// Row Data
|
||||
////////////////////
|
||||
let currentSort = { column: -1, direction: "asc" };
|
||||
function lockColumnWidths() {
|
||||
const ths = document.querySelectorAll("#models-table thead th");
|
||||
const widths = Array.from(ths).map((th) => th.getBoundingClientRect().width);
|
||||
|
||||
function sortTable(column: number, direction: "asc" | "desc") {
|
||||
const header = document.querySelectorAll("th.sortable")[column];
|
||||
const columnType = header.getAttribute("data-type");
|
||||
if (!columnType) return;
|
||||
const measurementRow = tbody.querySelector('tr[aria-hidden="true"]');
|
||||
if (measurementRow) measurementRow.remove();
|
||||
|
||||
// update state
|
||||
currentSort = { column, direction };
|
||||
updateQueryParams({
|
||||
sort: getColumnNameForURL(header),
|
||||
order: direction,
|
||||
});
|
||||
const table = document.getElementById("models-table")!;
|
||||
table.style.tableLayout = "fixed";
|
||||
|
||||
// sort rows
|
||||
const tbody = document.querySelector("table tbody")!;
|
||||
const rows = Array.from(
|
||||
tbody.querySelectorAll("tr")
|
||||
) as HTMLTableRowElement[];
|
||||
rows.sort((a, b) => {
|
||||
const aValue = getCellValue(a.cells[column], columnType);
|
||||
const bValue = getCellValue(b.cells[column], columnType);
|
||||
const colgroup = document.createElement("colgroup");
|
||||
for (const width of widths) {
|
||||
const col = document.createElement("col");
|
||||
col.style.width = `${width}px`;
|
||||
colgroup.appendChild(col);
|
||||
}
|
||||
table.insertBefore(colgroup, table.firstChild);
|
||||
}
|
||||
|
||||
function prepareRow(row: TableRow): VirtualizedRow {
|
||||
const sortValues: VirtualizedRow["sortValues"] = [
|
||||
row.providerName,
|
||||
row.modelName,
|
||||
row.family,
|
||||
row.providerId,
|
||||
row.modelId,
|
||||
booleanText(row.toolCall),
|
||||
booleanText(row.reasoning),
|
||||
row.input.length,
|
||||
row.output.length,
|
||||
row.inputCost,
|
||||
row.outputCost,
|
||||
row.reasoningCost,
|
||||
row.cacheReadCost,
|
||||
row.cacheWriteCost,
|
||||
row.audioInputCost,
|
||||
row.audioOutputCost,
|
||||
row.contextLimit,
|
||||
row.inputLimit,
|
||||
row.outputLimit,
|
||||
row.structuredOutput === undefined
|
||||
? undefined
|
||||
: booleanText(row.structuredOutput),
|
||||
booleanText(row.temperature),
|
||||
weightsText(row.openWeights),
|
||||
row.knowledge ? knowledgeText(row.knowledge) : undefined,
|
||||
row.releaseDate,
|
||||
row.lastUpdated,
|
||||
];
|
||||
|
||||
const searchableValues = [
|
||||
row.providerName,
|
||||
row.modelName,
|
||||
row.family ?? "",
|
||||
row.providerId,
|
||||
row.modelId,
|
||||
row.releaseDate,
|
||||
row.lastUpdated,
|
||||
];
|
||||
|
||||
return {
|
||||
...row,
|
||||
key: `${row.providerId}/${row.modelId}`,
|
||||
searchText: searchableValues.join(" ").toLowerCase(),
|
||||
sortValues,
|
||||
};
|
||||
}
|
||||
|
||||
////////////////////
|
||||
// Virtual Table
|
||||
////////////////////
|
||||
function getVirtualizerOptions(count: number) {
|
||||
return {
|
||||
count,
|
||||
getScrollElement: () => viewport,
|
||||
estimateSize: () => ESTIMATED_ROW_HEIGHT,
|
||||
getItemKey: (index: number) => visibleRows[index]?.key ?? index,
|
||||
initialRect: {
|
||||
width: viewport.clientWidth || window.innerWidth,
|
||||
height: viewport.clientHeight || window.innerHeight,
|
||||
},
|
||||
overscan: VIRTUAL_OVERSCAN,
|
||||
observeElementRect,
|
||||
observeElementOffset,
|
||||
scrollToFn: elementScroll,
|
||||
onChange: () => renderVirtualRows(),
|
||||
};
|
||||
}
|
||||
|
||||
const virtualizer = new Virtualizer<HTMLElement, HTMLTableRowElement>(
|
||||
getVirtualizerOptions(0)
|
||||
);
|
||||
const cleanupVirtualizer = virtualizer._didMount();
|
||||
virtualizer._willUpdate();
|
||||
window.addEventListener("pagehide", () => cleanupVirtualizer());
|
||||
|
||||
function renderStatusRow(message: string) {
|
||||
tbody.innerHTML = `<tr class="empty-row"><td colspan="${columnCount}"><div>${escapeHtml(
|
||||
message
|
||||
)}</div></td></tr>`;
|
||||
}
|
||||
|
||||
function setVirtualizerCount(count: number, resetScroll: boolean) {
|
||||
virtualizer.setOptions(getVirtualizerOptions(count));
|
||||
virtualizer._willUpdate();
|
||||
if (resetScroll) virtualizer.scrollToOffset(0);
|
||||
renderVirtualRows();
|
||||
}
|
||||
|
||||
function renderVirtualRows() {
|
||||
if (!isLoaded) return;
|
||||
if (visibleRows.length === 0) {
|
||||
renderStatusRow("No models found");
|
||||
return;
|
||||
}
|
||||
|
||||
const virtualRows = virtualizer.getVirtualItems();
|
||||
if (virtualRows.length === 0) return;
|
||||
|
||||
const firstRow = virtualRows[0]!;
|
||||
const lastRow = virtualRows[virtualRows.length - 1]!;
|
||||
const paddingTop = firstRow.start;
|
||||
const paddingBottom = Math.max(virtualizer.getTotalSize() - lastRow.end, 0);
|
||||
const html: string[] = [];
|
||||
|
||||
if (paddingTop > 0) html.push(renderSpacerRow(paddingTop));
|
||||
for (const virtualRow of virtualRows) {
|
||||
const row = visibleRows[virtualRow.index];
|
||||
if (row) html.push(renderRow(row, virtualRow.index));
|
||||
}
|
||||
if (paddingBottom > 0) html.push(renderSpacerRow(paddingBottom));
|
||||
|
||||
tbody.innerHTML = html.join("");
|
||||
tbody.querySelectorAll<HTMLTableRowElement>("tr[data-index]").forEach((row) =>
|
||||
virtualizer.measureElement(row)
|
||||
);
|
||||
}
|
||||
|
||||
function renderSpacerRow(height: number) {
|
||||
return `<tr class="virtual-spacer" style="height: ${height}px"><td colspan="${columnCount}"></td></tr>`;
|
||||
}
|
||||
|
||||
function applyRows(resetScroll = true) {
|
||||
if (!isLoaded) return;
|
||||
visibleRows = getRowsForDisplay();
|
||||
setVirtualizerCount(visibleRows.length, resetScroll);
|
||||
}
|
||||
|
||||
////////////////////
|
||||
// Sorting
|
||||
////////////////////
|
||||
function getRowsForDisplay() {
|
||||
const terms = search.value
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.map((term) => term.trim())
|
||||
.filter(Boolean);
|
||||
const filteredRows =
|
||||
terms.length === 0
|
||||
? allRows
|
||||
: allRows.filter((row) =>
|
||||
terms.some((term) => row.searchText.includes(term))
|
||||
);
|
||||
|
||||
if (currentSort.column === -1) return filteredRows;
|
||||
|
||||
const columnType = headers[currentSort.column]?.getAttribute("data-type");
|
||||
if (!columnType) return filteredRows;
|
||||
|
||||
return [...filteredRows].sort((a, b) => {
|
||||
const aValue = a.sortValues[currentSort.column];
|
||||
const bValue = b.sortValues[currentSort.column];
|
||||
|
||||
// Handle undefined values - always sort to bottom
|
||||
if (aValue === undefined && bValue === undefined) return 0;
|
||||
if (aValue === undefined) return 1;
|
||||
if (bValue === undefined) return -1;
|
||||
@@ -96,45 +288,48 @@ function sortTable(column: number, direction: "asc" | "desc") {
|
||||
let comparison = 0;
|
||||
if (columnType === "number" || columnType === "modalities") {
|
||||
comparison = (aValue as number) - (bValue as number);
|
||||
} else if (columnType === "boolean") {
|
||||
comparison = (aValue as string).localeCompare(bValue as string);
|
||||
} else {
|
||||
comparison = (aValue as string).localeCompare(bValue as string);
|
||||
comparison = String(aValue).localeCompare(String(bValue));
|
||||
}
|
||||
|
||||
return direction === "asc" ? comparison : -comparison;
|
||||
return currentSort.direction === "asc" ? comparison : -comparison;
|
||||
});
|
||||
rows.forEach((row) => tbody.appendChild(row));
|
||||
}
|
||||
|
||||
// update sort indicators
|
||||
const headers = document.querySelectorAll("th.sortable");
|
||||
function sortTable(
|
||||
column: number,
|
||||
direction: SortDirection,
|
||||
updateURL = true
|
||||
) {
|
||||
const header = headers[column];
|
||||
if (!header?.getAttribute("data-type")) return;
|
||||
|
||||
currentSort = { column, direction };
|
||||
if (updateURL) {
|
||||
updateQueryParams({
|
||||
sort: getColumnNameForURL(header),
|
||||
order: direction,
|
||||
});
|
||||
}
|
||||
|
||||
updateSortIndicators();
|
||||
applyRows();
|
||||
}
|
||||
|
||||
function updateSortIndicators() {
|
||||
headers.forEach((header, i) => {
|
||||
const indicator = header.querySelector(".sort-indicator")!;
|
||||
|
||||
if (i === column) {
|
||||
indicator.textContent = direction === "asc" ? "↑" : "↓";
|
||||
} else {
|
||||
indicator.textContent = "";
|
||||
}
|
||||
indicator.textContent =
|
||||
i === currentSort.column
|
||||
? currentSort.direction === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: "";
|
||||
});
|
||||
}
|
||||
|
||||
function getCellValue(
|
||||
cell: HTMLTableCellElement,
|
||||
type: string
|
||||
): string | number | undefined {
|
||||
if (type === "modalities")
|
||||
return cell.querySelectorAll(".modality-icon").length;
|
||||
|
||||
const text = cell.textContent?.trim() || "";
|
||||
if (text === "-") return;
|
||||
if (type === "number") return parseFloat(text.replace(/[$,]/g, "")) || 0;
|
||||
return text;
|
||||
}
|
||||
|
||||
document.querySelectorAll("th.sortable").forEach((header) => {
|
||||
headers.forEach((header, column) => {
|
||||
header.addEventListener("click", () => {
|
||||
const column = Array.from(header.parentElement!.children).indexOf(header);
|
||||
const direction =
|
||||
currentSort.column === column && currentSort.direction === "asc"
|
||||
? "desc"
|
||||
@@ -144,34 +339,19 @@ document.querySelectorAll("th.sortable").forEach((header) => {
|
||||
});
|
||||
|
||||
///////////////////
|
||||
// Handle Search
|
||||
// Search
|
||||
///////////////////
|
||||
function filterTable(value: string) {
|
||||
const lowerCaseValues = value.toLowerCase().split(",").filter(str => str.trim() !== "");
|
||||
const rows = document.querySelectorAll(
|
||||
"table tbody tr"
|
||||
) as NodeListOf<HTMLTableRowElement>;
|
||||
|
||||
rows.forEach((row) => {
|
||||
const cellTexts = Array.from(row.cells).map((cell) =>
|
||||
cell.textContent!.toLowerCase()
|
||||
);
|
||||
const isVisible = lowerCaseValues.length === 0 ||
|
||||
lowerCaseValues.some((lowerCaseValue) => cellTexts.some((text) => text.includes(lowerCaseValue)));
|
||||
row.style.display = isVisible ? "" : "none";
|
||||
});
|
||||
|
||||
updateQueryParams({ search: value || null });
|
||||
}
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
filterTable(search.value);
|
||||
updateQueryParams({ search: search.value || null });
|
||||
applyRows();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
const key = e.key.toLowerCase();
|
||||
if ((e.metaKey || e.ctrlKey) && (key === "k" || key === "f")) {
|
||||
e.preventDefault();
|
||||
search.focus();
|
||||
search.select();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -185,10 +365,7 @@ search.addEventListener("keydown", (e) => {
|
||||
///////////////////////////////////
|
||||
// Handle Copy model ID function
|
||||
///////////////////////////////////
|
||||
(window as any).copyModelId = async (
|
||||
button: HTMLButtonElement,
|
||||
modelId: string
|
||||
) => {
|
||||
async function copyModelId(button: HTMLButtonElement, modelId: string) {
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(modelId);
|
||||
@@ -209,7 +386,18 @@ search.addEventListener("keydown", (e) => {
|
||||
} catch (err) {
|
||||
console.error("Failed to copy text: ", err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!(event.target instanceof Element)) return;
|
||||
const button = event.target.closest<HTMLButtonElement>(
|
||||
".copy-button[data-model-id]"
|
||||
);
|
||||
if (!button) return;
|
||||
|
||||
const modelId = button.dataset.modelId;
|
||||
if (modelId) void copyModelId(button, modelId);
|
||||
});
|
||||
|
||||
///////////////////////////////////
|
||||
// Initialize State from URL
|
||||
@@ -217,24 +405,37 @@ search.addEventListener("keydown", (e) => {
|
||||
function initializeFromURL() {
|
||||
const params = getQueryParams();
|
||||
|
||||
(() => {
|
||||
const searchQuery = params.get("search");
|
||||
if (!searchQuery) return;
|
||||
search.value = searchQuery;
|
||||
filterTable(searchQuery);
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const columnName = params.get("sort");
|
||||
if (!columnName) return;
|
||||
search.value = params.get("search") ?? "";
|
||||
|
||||
currentSort = { column: -1, direction: "asc" };
|
||||
const columnName = params.get("sort");
|
||||
if (columnName) {
|
||||
const columnIndex = getColumnIndexByUrlName(columnName);
|
||||
if (columnIndex === -1) return;
|
||||
if (columnIndex !== -1) {
|
||||
currentSort = {
|
||||
column: columnIndex,
|
||||
direction: params.get("order") === "desc" ? "desc" : "asc",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const direction = (params.get("order") as "asc" | "desc") || "asc";
|
||||
sortTable(columnIndex, direction);
|
||||
})();
|
||||
updateSortIndicators();
|
||||
applyRows(false);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initializeFromURL);
|
||||
function loadRows() {
|
||||
try {
|
||||
allRows = window.__TABLE_DATA__.map(prepareRow);
|
||||
lockColumnWidths();
|
||||
isLoaded = true;
|
||||
initializeFromURL();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
isLoaded = true;
|
||||
visibleRows = [];
|
||||
renderStatusRow("Failed to load model data");
|
||||
}
|
||||
}
|
||||
|
||||
loadRows();
|
||||
window.addEventListener("popstate", initializeFromURL);
|
||||
|
||||
+55
-232
@@ -6,6 +6,7 @@ import { Fragment } from "hono/jsx";
|
||||
import { renderToString } from "hono/jsx/dom/server";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { type TableRow, renderRow, getLargestRow } from "./shared.js";
|
||||
|
||||
export const Providers = await generate(
|
||||
path.join(import.meta.dir, "..", "..", "..", "providers")
|
||||
@@ -38,7 +39,6 @@ const loadProviderSvg = async (providerId: string): Promise<string | null> => {
|
||||
const file = Bun.file(providerLogoPath);
|
||||
return await file.text();
|
||||
}
|
||||
//
|
||||
// Fall back to default logo
|
||||
if (existsSync(defaultLogoPath)) {
|
||||
const file = Bun.file(defaultLogoPath);
|
||||
@@ -62,122 +62,47 @@ for (const [providerId] of Object.entries(Providers)) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderProviderLogo(providerId: string) {
|
||||
const svgContent = providerLogos.get(providerId) || "";
|
||||
export const INITIAL_ROW_COUNT = 50;
|
||||
|
||||
return <span dangerouslySetInnerHTML={{ __html: svgContent }} />;
|
||||
}
|
||||
export const TableRows: TableRow[] = Object.entries(Providers)
|
||||
.sort(([, providerA], [, providerB]) =>
|
||||
providerA.name.localeCompare(providerB.name)
|
||||
)
|
||||
.flatMap(([providerId, provider]) =>
|
||||
Object.entries(provider.models)
|
||||
.filter(([, model]) => model.status !== "alpha")
|
||||
.sort(([, modelA], [, modelB]) => modelA.name.localeCompare(modelB.name))
|
||||
.map(([modelId, model]) => ({
|
||||
providerId,
|
||||
providerName: provider.name,
|
||||
providerLogoSvg: providerLogos.get(providerId) || "",
|
||||
modelId,
|
||||
modelName: model.name,
|
||||
family: model.family,
|
||||
toolCall: model.tool_call,
|
||||
reasoning: model.reasoning,
|
||||
input: model.modalities.input,
|
||||
output: model.modalities.output,
|
||||
inputCost: model.cost?.input,
|
||||
outputCost: model.cost?.output,
|
||||
reasoningCost: model.cost?.reasoning,
|
||||
cacheReadCost: model.cost?.cache_read,
|
||||
cacheWriteCost: model.cost?.cache_write,
|
||||
audioInputCost: model.cost?.input_audio,
|
||||
audioOutputCost: model.cost?.output_audio,
|
||||
contextLimit: model.limit.context,
|
||||
inputLimit: model.limit.input,
|
||||
outputLimit: model.limit.output,
|
||||
structuredOutput: model.structured_output,
|
||||
temperature: model.temperature ?? false,
|
||||
openWeights: model.open_weights,
|
||||
knowledge: model.knowledge,
|
||||
releaseDate: model.release_date,
|
||||
lastUpdated: model.last_updated,
|
||||
}))
|
||||
);
|
||||
|
||||
const getModalityIcon = (modality: string) => {
|
||||
switch (modality) {
|
||||
case "text":
|
||||
return (
|
||||
<span class="modality-icon" data-tooltip="Text">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="4,7 4,4 20,4 20,7"></polyline>
|
||||
<line x1="9" y1="20" x2="15" y2="20"></line>
|
||||
<line x1="12" y1="4" x2="12" y2="20"></line>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<span class="modality-icon" data-tooltip="Image">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect>
|
||||
<circle cx="9" cy="9" r="2"></circle>
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<span class="modality-icon" data-tooltip="Audio">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
||||
<path d="m19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<span class="modality-icon" data-tooltip="Video">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m22 8-6 4 6 4V8Z"></path>
|
||||
<rect width="14" height="12" x="2" y="6" rx="2" ry="2"></rect>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
case "pdf":
|
||||
return (
|
||||
<span class="modality-icon" data-tooltip="PDF">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14,2 14,8 20,8"></polyline>
|
||||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||
<polyline points="10,9 9,9 8,9"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderCost = (cost?: number) => {
|
||||
return cost === undefined ? "-" : `$${cost.toFixed(2)}`;
|
||||
};
|
||||
const largestRow = getLargestRow(TableRows);
|
||||
|
||||
export const Rendered = renderToString(
|
||||
<Fragment>
|
||||
@@ -213,7 +138,8 @@ export const Rendered = renderToString(
|
||||
<button id="help">How to use</button>
|
||||
</div>
|
||||
</header>
|
||||
<table>
|
||||
<div id="table-viewport" class="table-viewport">
|
||||
<table id="models-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" data-type="text">
|
||||
@@ -342,120 +268,12 @@ export const Rendered = renderToString(
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(Providers)
|
||||
.sort(([, providerA], [, providerB]) =>
|
||||
providerA.name.localeCompare(providerB.name)
|
||||
)
|
||||
.flatMap(([providerId, provider]) =>
|
||||
Object.entries(provider.models)
|
||||
.filter(([, model]) => model.status !== "alpha")
|
||||
.sort(([, modelA], [, modelB]) =>
|
||||
modelA.name.localeCompare(modelB.name)
|
||||
)
|
||||
.map(([modelId, model]) => (
|
||||
<tr key={`${providerId}-${modelId}`}>
|
||||
<td>
|
||||
<div class="provider-cell">
|
||||
{renderProviderLogo(providerId)}
|
||||
<span>{provider.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{model.name}</td>
|
||||
<td>{model.family ?? "-"}</td>
|
||||
<td>{providerId}</td>
|
||||
<td>
|
||||
<div class="model-id-cell">
|
||||
<span class="model-id-text">{modelId}</span>
|
||||
<button
|
||||
class="copy-button"
|
||||
onclick={`copyModelId(this, '${modelId}')`}
|
||||
>
|
||||
<svg
|
||||
class="copy-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect
|
||||
width="14"
|
||||
height="14"
|
||||
x="8"
|
||||
y="8"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<path d="m4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
<svg
|
||||
class="check-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
style="display: none;"
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>{model.tool_call ? "Yes" : "No"}</td>
|
||||
<td>{model.reasoning ? "Yes" : "No"}</td>
|
||||
<td>
|
||||
<div class="modalities">
|
||||
{model.modalities.input.map((modality) =>
|
||||
getModalityIcon(modality)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="modalities">
|
||||
{model.modalities.output.map((modality) =>
|
||||
getModalityIcon(modality)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{renderCost(model.cost?.input)}</td>
|
||||
<td>{renderCost(model.cost?.output)}</td>
|
||||
<td>{renderCost(model.cost?.reasoning)}</td>
|
||||
<td>{renderCost(model.cost?.cache_read)}</td>
|
||||
<td>{renderCost(model.cost?.cache_write)}</td>
|
||||
<td>{renderCost(model.cost?.input_audio)}</td>
|
||||
<td>{renderCost(model.cost?.output_audio)}</td>
|
||||
<td>{model.limit.context.toLocaleString()}</td>
|
||||
<td>{model.limit.input?.toLocaleString() ?? "-"}</td>
|
||||
<td>{model.limit.output.toLocaleString()}</td>
|
||||
<td>
|
||||
{model.structured_output === undefined
|
||||
? "-"
|
||||
: model.structured_output
|
||||
? "Yes"
|
||||
: "No"}
|
||||
</td>
|
||||
<td>{model.temperature ? "Yes" : "No"}</td>
|
||||
<td>{model.open_weights ? "Open" : "Closed"}</td>
|
||||
<td>
|
||||
{model.knowledge ? model.knowledge.substring(0, 7) : "-"}
|
||||
</td>
|
||||
<td>{model.release_date}</td>
|
||||
<td>{model.last_updated}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<tbody id="models-table-body" dangerouslySetInnerHTML={{
|
||||
__html: TableRows.slice(0, INITIAL_ROW_COUNT).map((row, i) => renderRow(row, i)).join('')
|
||||
+ renderRow(largestRow, -1).replace('<tr', '<tr style="visibility:hidden" aria-hidden="true"')
|
||||
}} />
|
||||
</table>
|
||||
</div>
|
||||
<dialog id="modal">
|
||||
<div class="header">
|
||||
<h2>How to use</h2>
|
||||
@@ -565,10 +383,15 @@ export const Rendered = renderToString(
|
||||
>
|
||||
Edit on GitHub
|
||||
</a>
|
||||
<a href="https://sst.dev" target="_blank" rel="noopener noreferrer">
|
||||
Created by SST
|
||||
<a href="https://opencode.ai" target="_blank" rel="noopener noreferrer">
|
||||
Created by OpenCode
|
||||
</a>
|
||||
</div>
|
||||
</dialog>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.__TABLE_DATA__ = ${JSON.stringify(TableRows)}`,
|
||||
}}
|
||||
></script>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
export interface TableRow {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
providerLogoSvg: string;
|
||||
modelId: string;
|
||||
modelName: string;
|
||||
family?: string;
|
||||
toolCall: boolean;
|
||||
reasoning: boolean;
|
||||
input: string[];
|
||||
output: string[];
|
||||
inputCost?: number;
|
||||
outputCost?: number;
|
||||
reasoningCost?: number;
|
||||
cacheReadCost?: number;
|
||||
cacheWriteCost?: number;
|
||||
audioInputCost?: number;
|
||||
audioOutputCost?: number;
|
||||
contextLimit: number;
|
||||
inputLimit?: number;
|
||||
outputLimit: number;
|
||||
structuredOutput?: boolean;
|
||||
temperature: boolean;
|
||||
openWeights: boolean;
|
||||
knowledge?: string;
|
||||
releaseDate: string;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
const MODALITY_ICONS: Record<string, string> = {
|
||||
text: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4,7 4,4 20,4 20,7"></polyline><line x1="9" y1="20" x2="15" y2="20"></line><line x1="12" y1="4" x2="12" y2="20"></line></svg>`,
|
||||
image: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><circle cx="9" cy="9" r="2"></circle><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path></svg>`,
|
||||
audio: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="m19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg>`,
|
||||
video: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8Z"></path><rect width="14" height="12" x="2" y="6" rx="2" ry="2"></rect></svg>`,
|
||||
pdf: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14,2 14,8 20,8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10,9 9,9 8,9"></polyline></svg>`,
|
||||
};
|
||||
|
||||
export function escapeHtml(value: string | number) {
|
||||
return String(value).replace(/[&<>'"]/g, (char) => {
|
||||
switch (char) {
|
||||
case "&":
|
||||
return "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case "'":
|
||||
return "'";
|
||||
case '"':
|
||||
return """;
|
||||
default:
|
||||
return char;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function booleanText(value: boolean) {
|
||||
return value ? "Yes" : "No";
|
||||
}
|
||||
|
||||
export function optionalBooleanText(value?: boolean) {
|
||||
return value === undefined ? "-" : booleanText(value);
|
||||
}
|
||||
|
||||
export function formatCost(cost?: number) {
|
||||
return cost === undefined ? "-" : `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function formatNumber(value?: number) {
|
||||
return value === undefined ? "-" : value.toLocaleString();
|
||||
}
|
||||
|
||||
export function knowledgeText(value?: string) {
|
||||
return value ? value.substring(0, 7) : "-";
|
||||
}
|
||||
|
||||
export function weightsText(value: boolean) {
|
||||
return value ? "Open" : "Closed";
|
||||
}
|
||||
|
||||
export function renderModalityIcon(modality: string) {
|
||||
const label =
|
||||
modality === "pdf"
|
||||
? "PDF"
|
||||
: modality[0]!.toUpperCase() + modality.slice(1);
|
||||
const icon = MODALITY_ICONS[modality];
|
||||
if (!icon) return "";
|
||||
return `<span class="modality-icon" data-tooltip="${label}">${icon}</span>`;
|
||||
}
|
||||
|
||||
export function renderModalities(modalities: string[]) {
|
||||
return `<div class="modalities">${modalities
|
||||
.map(renderModalityIcon)
|
||||
.join("")}</div>`;
|
||||
}
|
||||
|
||||
export function renderCopyButton(modelId: string) {
|
||||
const escapedModelId = escapeHtml(modelId);
|
||||
return `<button type="button" class="copy-button" data-model-id="${escapedModelId}" aria-label="Copy model ID"><svg class="copy-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="m4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg><svg class="check-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;"><polyline points="20,6 9,17 4,12"></polyline></svg></button>`;
|
||||
}
|
||||
|
||||
export function renderRow(row: TableRow, index: number) {
|
||||
return `<tr data-index="${index}">
|
||||
<td><div class="provider-cell">${row.providerLogoSvg}<span>${escapeHtml(
|
||||
row.providerName
|
||||
)}</span></div></td>
|
||||
<td>${escapeHtml(row.modelName)}</td>
|
||||
<td>${escapeHtml(row.family ?? "-")}</td>
|
||||
<td>${escapeHtml(row.providerId)}</td>
|
||||
<td><div class="model-id-cell"><span class="model-id-text">${escapeHtml(
|
||||
row.modelId
|
||||
)}</span>${renderCopyButton(row.modelId)}</div></td>
|
||||
<td>${booleanText(row.toolCall)}</td>
|
||||
<td>${booleanText(row.reasoning)}</td>
|
||||
<td>${renderModalities(row.input)}</td>
|
||||
<td>${renderModalities(row.output)}</td>
|
||||
<td>${formatCost(row.inputCost)}</td>
|
||||
<td>${formatCost(row.outputCost)}</td>
|
||||
<td>${formatCost(row.reasoningCost)}</td>
|
||||
<td>${formatCost(row.cacheReadCost)}</td>
|
||||
<td>${formatCost(row.cacheWriteCost)}</td>
|
||||
<td>${formatCost(row.audioInputCost)}</td>
|
||||
<td>${formatCost(row.audioOutputCost)}</td>
|
||||
<td>${formatNumber(row.contextLimit)}</td>
|
||||
<td>${formatNumber(row.inputLimit)}</td>
|
||||
<td>${formatNumber(row.outputLimit)}</td>
|
||||
<td>${optionalBooleanText(row.structuredOutput)}</td>
|
||||
<td>${booleanText(row.temperature)}</td>
|
||||
<td>${weightsText(row.openWeights)}</td>
|
||||
<td>${knowledgeText(row.knowledge)}</td>
|
||||
<td>${escapeHtml(row.releaseDate)}</td>
|
||||
<td>${escapeHtml(row.lastUpdated)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
export function getLargestRow(rows: TableRow[]): TableRow {
|
||||
const worst: TableRow = {
|
||||
providerId: "", providerName: "", providerLogoSvg: "", modelId: "", modelName: "",
|
||||
toolCall: true, reasoning: true,
|
||||
input: [], output: [],
|
||||
contextLimit: 0, outputLimit: 0,
|
||||
structuredOutput: true, temperature: true, openWeights: false,
|
||||
releaseDate: "", lastUpdated: "",
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.providerName.length > worst.providerName.length) worst.providerName = row.providerName;
|
||||
if (row.modelName.length > worst.modelName.length) worst.modelName = row.modelName;
|
||||
if ((row.family ?? "").length > (worst.family ?? "").length) worst.family = row.family;
|
||||
if (row.providerId.length > worst.providerId.length) worst.providerId = row.providerId;
|
||||
if (row.modelId.length > worst.modelId.length) worst.modelId = row.modelId;
|
||||
if ((row.knowledge ?? "").length > (worst.knowledge ?? "").length) worst.knowledge = row.knowledge;
|
||||
if (row.releaseDate.length > worst.releaseDate.length) worst.releaseDate = row.releaseDate;
|
||||
if (row.lastUpdated.length > worst.lastUpdated.length) worst.lastUpdated = row.lastUpdated;
|
||||
if (row.input.length > worst.input.length) worst.input = row.input;
|
||||
if (row.output.length > worst.output.length) worst.output = row.output;
|
||||
|
||||
const costWider = (a: number | undefined, b: number | undefined) =>
|
||||
b !== undefined && (a === undefined || formatCost(b).length > formatCost(a).length);
|
||||
if (costWider(worst.inputCost, row.inputCost)) worst.inputCost = row.inputCost;
|
||||
if (costWider(worst.outputCost, row.outputCost)) worst.outputCost = row.outputCost;
|
||||
if (costWider(worst.reasoningCost, row.reasoningCost)) worst.reasoningCost = row.reasoningCost;
|
||||
if (costWider(worst.cacheReadCost, row.cacheReadCost)) worst.cacheReadCost = row.cacheReadCost;
|
||||
if (costWider(worst.cacheWriteCost, row.cacheWriteCost)) worst.cacheWriteCost = row.cacheWriteCost;
|
||||
if (costWider(worst.audioInputCost, row.audioInputCost)) worst.audioInputCost = row.audioInputCost;
|
||||
if (costWider(worst.audioOutputCost, row.audioOutputCost)) worst.audioOutputCost = row.audioOutputCost;
|
||||
|
||||
const numWider = (a: number | undefined, b: number | undefined) =>
|
||||
b !== undefined && (a === undefined || formatNumber(b).length > formatNumber(a).length);
|
||||
if (numWider(worst.contextLimit as number | undefined, row.contextLimit)) worst.contextLimit = row.contextLimit;
|
||||
if (numWider(worst.inputLimit, row.inputLimit)) worst.inputLimit = row.inputLimit;
|
||||
if (numWider(worst.outputLimit as number | undefined, row.outputLimit)) worst.outputLimit = row.outputLimit;
|
||||
}
|
||||
|
||||
return worst;
|
||||
}
|
||||
+7
-8
@@ -1,6 +1,6 @@
|
||||
name = "xAI: Grok 3"
|
||||
release_date = "2025-02-17"
|
||||
last_updated = "2025-02-17"
|
||||
name = "MiniMax-M2.7-highspeed"
|
||||
release_date = "2026-03-19"
|
||||
last_updated = "2026-03-19"
|
||||
attachment = false
|
||||
reasoning = false
|
||||
temperature = true
|
||||
@@ -8,13 +8,12 @@ tool_call = true
|
||||
open_weights = false
|
||||
|
||||
[cost]
|
||||
input = 3.0
|
||||
output = 15.0
|
||||
cache_read = 0.75
|
||||
input = 0.600
|
||||
output = 4.800
|
||||
|
||||
[limit]
|
||||
context = 131072
|
||||
output = 26215
|
||||
context = 204_800
|
||||
output = 131_072
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
@@ -1,6 +1,6 @@
|
||||
name = "Qwen: Qwen-Max "
|
||||
release_date = "2024-04-03"
|
||||
last_updated = "2026-03-15"
|
||||
name = "MiniMax-M2.7"
|
||||
release_date = "2026-03-19"
|
||||
last_updated = "2026-03-19"
|
||||
attachment = false
|
||||
reasoning = false
|
||||
temperature = true
|
||||
@@ -8,13 +8,12 @@ tool_call = true
|
||||
open_weights = false
|
||||
|
||||
[cost]
|
||||
input = 1.04
|
||||
output = 4.16
|
||||
cache_read = 0.32
|
||||
input = 0.300
|
||||
output = 1.200
|
||||
|
||||
[limit]
|
||||
context = 32768
|
||||
output = 8192
|
||||
context = 204_800
|
||||
output = 131_072
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
+5
-7
@@ -1,19 +1,17 @@
|
||||
name = "Claude Sonnet 3.5 v2"
|
||||
family = "claude-sonnet"
|
||||
name = "claude-3-5-haiku-20241022"
|
||||
family = "claude-haiku"
|
||||
release_date = "2024-10-22"
|
||||
last_updated = "2024-10-22"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
knowledge = "2024-04"
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2024-07-31"
|
||||
|
||||
[cost]
|
||||
input = 3.00
|
||||
output = 15.00
|
||||
cache_read = 0.30
|
||||
cache_write = 3.75
|
||||
input = 0.800
|
||||
output = 4.000
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
+7
-7
@@ -1,21 +1,21 @@
|
||||
name = "Claude Haiku 3"
|
||||
name = "claude-3-5-haiku-latest"
|
||||
family = "claude-haiku"
|
||||
release_date = "2024-03-13"
|
||||
last_updated = "2024-03-13"
|
||||
release_date = "2024-10-22"
|
||||
last_updated = "2024-10-22"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
knowledge = "2024-02"
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2024-07-31"
|
||||
|
||||
[cost]
|
||||
input = 0.25
|
||||
output = 1.25
|
||||
input = 0.800
|
||||
output = 4.000
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
output = 4_096
|
||||
output = 8_192
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "pdf"]
|
||||
@@ -1,12 +1,13 @@
|
||||
name = "claude-haiku-4-5-20251001"
|
||||
family = "claude-haiku"
|
||||
release_date = "2025-10-16"
|
||||
last_updated = "2025-10-16"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-03"
|
||||
knowledge = "2025-02-28"
|
||||
|
||||
[cost]
|
||||
input = 1.000
|
||||
@@ -17,5 +18,5 @@ context = 200_000
|
||||
output = 64_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name = "claude-haiku-4-5"
|
||||
family = "claude-haiku"
|
||||
release_date = "2025-10-16"
|
||||
last_updated = "2025-10-16"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-02-28"
|
||||
|
||||
[cost]
|
||||
input = 1.000
|
||||
output = 5.000
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
output = 64_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
@@ -1,12 +1,13 @@
|
||||
name = "claude-opus-4-1-20250805"
|
||||
family = "claude-opus"
|
||||
release_date = "2025-08-05"
|
||||
last_updated = "2025-08-05"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-03"
|
||||
knowledge = "2025-03-31"
|
||||
|
||||
[cost]
|
||||
input = 15.000
|
||||
@@ -17,5 +18,5 @@ context = 200_000
|
||||
output = 32_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
|
||||
+4
-6
@@ -1,19 +1,17 @@
|
||||
name = "Claude Opus 4 (US)"
|
||||
name = "claude-opus-4-20250514"
|
||||
family = "claude-opus"
|
||||
release_date = "2025-05-22"
|
||||
last_updated = "2025-05-22"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
knowledge = "2024-04"
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-03-31"
|
||||
|
||||
[cost]
|
||||
input = 15.00
|
||||
output = 75.00
|
||||
cache_read = 1.50
|
||||
cache_write = 18.75
|
||||
input = 15.000
|
||||
output = 75.000
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
@@ -1,12 +1,13 @@
|
||||
name = "claude-opus-4-5-20251101"
|
||||
family = "claude-opus"
|
||||
release_date = "2025-11-25"
|
||||
last_updated = "2025-11-25"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-03"
|
||||
knowledge = "2025-03-31"
|
||||
|
||||
[cost]
|
||||
input = 5.000
|
||||
@@ -17,5 +18,5 @@ context = 200_000
|
||||
output = 64_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name = "claude-opus-4-5"
|
||||
family = "claude-opus"
|
||||
release_date = "2025-11-25"
|
||||
last_updated = "2025-11-25"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-03-31"
|
||||
|
||||
[cost]
|
||||
input = 5.000
|
||||
output = 25.000
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
output = 64_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,21 @@
|
||||
name = "claude-opus-4-6-thinking"
|
||||
release_date = "2026-02-06"
|
||||
last_updated = "2026-03-13"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-05"
|
||||
|
||||
[cost]
|
||||
input = 5.000
|
||||
output = 25.000
|
||||
|
||||
[limit]
|
||||
context = 1_000_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,22 @@
|
||||
name = "claude-opus-4-6"
|
||||
family = "claude-opus"
|
||||
release_date = "2026-02-06"
|
||||
last_updated = "2026-03-13"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-05-31"
|
||||
|
||||
[cost]
|
||||
input = 5.000
|
||||
output = 25.000
|
||||
|
||||
[limit]
|
||||
context = 1_000_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,31 @@
|
||||
name = "claude-opus-4-7"
|
||||
family = "claude-opus"
|
||||
release_date = "2026-04-17"
|
||||
last_updated = "2026-04-17"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2026-01-31"
|
||||
|
||||
[cost]
|
||||
input = 5.000
|
||||
output = 25.000
|
||||
cache_read = 0.500
|
||||
cache_write = 6.250
|
||||
|
||||
[[cost.tiers]]
|
||||
tier = { size = 200_000 }
|
||||
input = 10.000
|
||||
output = 37.500
|
||||
cache_read = 1.000
|
||||
cache_write = 12.500
|
||||
|
||||
[limit]
|
||||
context = 1_000_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
+4
-6
@@ -1,19 +1,17 @@
|
||||
name = "Claude Sonnet 4"
|
||||
name = "claude-sonnet-4-20250514"
|
||||
family = "claude-sonnet"
|
||||
release_date = "2025-05-22"
|
||||
last_updated = "2025-05-22"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
knowledge = "2024-04"
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-03-31"
|
||||
|
||||
[cost]
|
||||
input = 3.00
|
||||
output = 15.00
|
||||
cache_read = 0.30
|
||||
cache_write = 3.75
|
||||
input = 3.000
|
||||
output = 15.000
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
@@ -17,5 +17,5 @@ context = 200_000
|
||||
output = 64_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
name = "claude-sonnet-4-5-20250929"
|
||||
release_date = "2025-09-29"
|
||||
last_updated = "2025-09-29"
|
||||
family = "claude-sonnet"
|
||||
release_date = "2025-09-30"
|
||||
last_updated = "2025-09-30"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-03"
|
||||
knowledge = "2025-07-31"
|
||||
|
||||
[cost]
|
||||
input = 3.000
|
||||
@@ -17,5 +18,5 @@ context = 200_000
|
||||
output = 64_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name = "claude-sonnet-4-5"
|
||||
family = "claude-sonnet"
|
||||
release_date = "2025-09-30"
|
||||
last_updated = "2025-09-30"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-07-31"
|
||||
|
||||
[cost]
|
||||
input = 3.000
|
||||
output = 15.000
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
output = 64_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,21 @@
|
||||
name = "claude-sonnet-4-6-thinking"
|
||||
release_date = "2026-02-18"
|
||||
last_updated = "2026-03-13"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-08"
|
||||
|
||||
[cost]
|
||||
input = 3.000
|
||||
output = 15.000
|
||||
|
||||
[limit]
|
||||
context = 1_000_000
|
||||
output = 64_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,22 @@
|
||||
name = "claude-sonnet-4-6"
|
||||
family = "claude-sonnet"
|
||||
release_date = "2026-02-18"
|
||||
last_updated = "2026-03-13"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-08-31"
|
||||
|
||||
[cost]
|
||||
input = 3.000
|
||||
output = 15.000
|
||||
|
||||
[limit]
|
||||
context = 1_000_000
|
||||
output = 64_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,21 @@
|
||||
name = "gemini-3.1-flash-image-preview"
|
||||
release_date = "2026-02-27"
|
||||
last_updated = "2026-02-27"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
tool_call = false
|
||||
open_weights = false
|
||||
knowledge = "2025-01"
|
||||
|
||||
[cost]
|
||||
input = 0.500
|
||||
output = 60.000
|
||||
|
||||
[limit]
|
||||
context = 131_072
|
||||
output = 32_768
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text", "image"]
|
||||
+7
-9
@@ -1,19 +1,17 @@
|
||||
name = "GLM-4.5"
|
||||
family = "glm"
|
||||
release_date = "2025-07-28"
|
||||
last_updated = "2025-07-28"
|
||||
name = "glm-4.5-air"
|
||||
family = "glm-air"
|
||||
release_date = "2025-07-29"
|
||||
last_updated = "2025-07-29"
|
||||
attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
knowledge = "2025-04"
|
||||
open_weights = true
|
||||
knowledge = "2025-04"
|
||||
|
||||
[cost]
|
||||
input = 0
|
||||
output = 0
|
||||
cache_read = 0
|
||||
cache_write = 0
|
||||
input = 0.1143
|
||||
output = 0.286
|
||||
|
||||
[limit]
|
||||
context = 131_072
|
||||
+9
-9
@@ -1,21 +1,21 @@
|
||||
name = "Qwen3 Coder 480B A35B Instruct"
|
||||
family = "qwen"
|
||||
release_date = "2025-07-23"
|
||||
last_updated = "2025-10-04"
|
||||
name = "glm-4.5-airx"
|
||||
family = "glm"
|
||||
release_date = "2025-07-29"
|
||||
last_updated = "2025-07-29"
|
||||
attachment = false
|
||||
reasoning = false
|
||||
temperature = true
|
||||
knowledge = "2025-04"
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-04"
|
||||
|
||||
[cost]
|
||||
input = 0.40
|
||||
output = 1.80
|
||||
input = 0.572
|
||||
output = 1.714
|
||||
|
||||
[limit]
|
||||
context = 262144
|
||||
output = 66536
|
||||
context = 128_000
|
||||
output = 16_384
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
@@ -0,0 +1,22 @@
|
||||
name = "glm-4.5-x"
|
||||
family = "glm"
|
||||
release_date = "2025-07-29"
|
||||
last_updated = "2025-07-29"
|
||||
attachment = false
|
||||
reasoning = false
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-04"
|
||||
|
||||
[cost]
|
||||
input = 1.143
|
||||
output = 2.290
|
||||
|
||||
[limit]
|
||||
context = 128_000
|
||||
output = 16_384
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
output = ["text"]
|
||||
@@ -1,19 +1,20 @@
|
||||
name = "GLM-4.5"
|
||||
family = "glm"
|
||||
release_date = "2025-07-29"
|
||||
last_updated = "2025-07-29"
|
||||
attachment = false
|
||||
reasoning = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2024-10"
|
||||
open_weights = true
|
||||
knowledge = "2025-04"
|
||||
|
||||
[cost]
|
||||
input = 0.286
|
||||
output = 1.142
|
||||
|
||||
[limit]
|
||||
context = 128_000
|
||||
context = 131_072
|
||||
output = 98_304
|
||||
|
||||
[modalities]
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
name = "GLM-4.5V"
|
||||
release_date = "2025-07-29"
|
||||
last_updated = "2025-07-29"
|
||||
family = "glm"
|
||||
release_date = "2025-08-12"
|
||||
last_updated = "2025-08-12"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2024-10"
|
||||
open_weights = true
|
||||
knowledge = "2025-04"
|
||||
|
||||
[cost]
|
||||
input = 0.290
|
||||
@@ -17,5 +18,5 @@ context = 64_000
|
||||
output = 16_384
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
input = ["text", "image", "video"]
|
||||
output = ["text"]
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
name = "glm-4.6"
|
||||
family = "glm"
|
||||
release_date = "2025-09-30"
|
||||
last_updated = "2025-09-30"
|
||||
attachment = false
|
||||
reasoning = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-03"
|
||||
open_weights = true
|
||||
knowledge = "2025-04"
|
||||
|
||||
[cost]
|
||||
input = 0.286
|
||||
output = 1.142
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
context = 204_800
|
||||
output = 131_072
|
||||
|
||||
[modalities]
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
name = "GLM-4.6V"
|
||||
family = "glm"
|
||||
release_date = "2025-12-08"
|
||||
last_updated = "2025-12-08"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-03"
|
||||
open_weights = true
|
||||
knowledge = "2025-04"
|
||||
|
||||
[cost]
|
||||
input = 0.145
|
||||
@@ -17,5 +18,5 @@ context = 128_000
|
||||
output = 32_768
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
input = ["text", "image", "video"]
|
||||
output = ["text"]
|
||||
|
||||
+6
-8
@@ -1,19 +1,17 @@
|
||||
name = "GLM-4.7-FlashX"
|
||||
name = "glm-4.7-flashx"
|
||||
family = "glm-flash"
|
||||
release_date = "2026-01-19"
|
||||
last_updated = "2026-01-19"
|
||||
release_date = "2026-01-20"
|
||||
last_updated = "2026-01-20"
|
||||
attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
knowledge = "2025-04"
|
||||
open_weights = true
|
||||
knowledge = "2025-04"
|
||||
|
||||
[cost]
|
||||
input = 0.07
|
||||
output = 0.40
|
||||
cache_read = 0.01
|
||||
cache_write = 0
|
||||
input = 0.0715
|
||||
output = 0.429
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
@@ -1,19 +1,23 @@
|
||||
name = "glm-4.7"
|
||||
family = "glm"
|
||||
release_date = "2025-12-22"
|
||||
last_updated = "2025-12-22"
|
||||
attachment = false
|
||||
reasoning = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
knowledge = "2025-06"
|
||||
open_weights = true
|
||||
knowledge = "2025-04"
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 0.286
|
||||
output = 1.142
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
context = 204_800
|
||||
output = 131_072
|
||||
|
||||
[modalities]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name = "glm-5-turbo"
|
||||
family = "glm"
|
||||
release_date = "2026-03-16"
|
||||
last_updated = "2026-03-16"
|
||||
attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 0.720
|
||||
output = 3.200
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
output = 131_072
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,25 @@
|
||||
name = "glm-5.1"
|
||||
family = "glm"
|
||||
release_date = "2026-04-10"
|
||||
last_updated = "2026-04-10"
|
||||
attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 0.860
|
||||
output = 3.500
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
output = 131_072
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
output = ["text"]
|
||||
@@ -1,4 +1,4 @@
|
||||
name = "GLM5"
|
||||
name = "glm-5"
|
||||
family = "glm"
|
||||
release_date = "2026-02-12"
|
||||
last_updated = "2026-02-12"
|
||||
@@ -6,19 +6,18 @@ attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = true
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 0.0
|
||||
output = 0.0
|
||||
input = 0.600
|
||||
output = 2.600
|
||||
|
||||
[limit]
|
||||
context = 202752
|
||||
output = 131000
|
||||
context = 204_800
|
||||
output = 131_072
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
@@ -0,0 +1,24 @@
|
||||
name = "GLM-5V-Turbo"
|
||||
family = "glm"
|
||||
release_date = "2026-04-02"
|
||||
last_updated = "2026-04-02"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 0.720
|
||||
output = 3.200
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
output = 131_072
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "video", "audio", "pdf"]
|
||||
output = ["text"]
|
||||
+5
-8
@@ -1,4 +1,4 @@
|
||||
name = "GLM 4.6 (exacto)"
|
||||
name = "glm-for-coding"
|
||||
family = "glm"
|
||||
release_date = "2025-09-30"
|
||||
last_updated = "2025-09-30"
|
||||
@@ -6,18 +6,15 @@ attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
knowledge = "2025-09"
|
||||
open_weights = true
|
||||
open_weights = false
|
||||
|
||||
[cost]
|
||||
input = 0.60
|
||||
output = 1.90
|
||||
cache_read = 0.11
|
||||
input = 0.086
|
||||
output = 0.343
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
output = 128_000
|
||||
output = 131_072
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
@@ -6,6 +6,7 @@ attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2024-04"
|
||||
|
||||
@@ -14,9 +15,9 @@ input = 0.400
|
||||
output = 1.600
|
||||
|
||||
[limit]
|
||||
context = 1_000_000
|
||||
context = 1_047_576
|
||||
output = 32_768
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
|
||||
@@ -6,6 +6,7 @@ attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2024-04"
|
||||
|
||||
@@ -14,7 +15,7 @@ input = 0.100
|
||||
output = 0.400
|
||||
|
||||
[limit]
|
||||
context = 1_000_000
|
||||
context = 1_047_576
|
||||
output = 32_768
|
||||
|
||||
[modalities]
|
||||
|
||||
@@ -6,6 +6,7 @@ attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2024-04"
|
||||
|
||||
@@ -14,9 +15,9 @@ input = 2.000
|
||||
output = 8.000
|
||||
|
||||
[limit]
|
||||
context = 1_000_000
|
||||
context = 1_047_576
|
||||
output = 32_768
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
|
||||
@@ -6,6 +6,7 @@ attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2023-09"
|
||||
|
||||
@@ -18,5 +19,5 @@ context = 128_000
|
||||
output = 16_384
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
name = "gpt-5-mini"
|
||||
family = "gpt-mini"
|
||||
release_date = "2025-08-08"
|
||||
last_updated = "2025-08-08"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2024-10"
|
||||
knowledge = "2024-05-30"
|
||||
|
||||
[cost]
|
||||
input = 0.250
|
||||
@@ -14,6 +16,7 @@ output = 2.000
|
||||
|
||||
[limit]
|
||||
context = 400_000
|
||||
input = 272_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
name = "gpt-5-pro"
|
||||
family = "gpt-pro"
|
||||
release_date = "2025-10-08"
|
||||
last_updated = "2025-10-08"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2024-10"
|
||||
knowledge = "2024-09-30"
|
||||
|
||||
[cost]
|
||||
input = 15.000
|
||||
@@ -14,6 +16,7 @@ output = 120.000
|
||||
|
||||
[limit]
|
||||
context = 400_000
|
||||
input = 272_000
|
||||
output = 272_000
|
||||
|
||||
[modalities]
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
name = "gpt-5.1-chat-latest"
|
||||
family = "gpt-codex"
|
||||
release_date = "2025-11-14"
|
||||
last_updated = "2025-11-14"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2024-10"
|
||||
knowledge = "2024-09-30"
|
||||
|
||||
[cost]
|
||||
input = 1.250
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
name = "gpt-5.1"
|
||||
family = "gpt"
|
||||
release_date = "2025-11-14"
|
||||
last_updated = "2025-11-14"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2024-10"
|
||||
knowledge = "2024-09-30"
|
||||
|
||||
[cost]
|
||||
input = 1.250
|
||||
@@ -14,6 +16,7 @@ output = 10.000
|
||||
|
||||
[limit]
|
||||
context = 400_000
|
||||
input = 272_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
name = "gpt-5.2-chat-latest"
|
||||
family = "gpt-codex"
|
||||
release_date = "2025-12-12"
|
||||
last_updated = "2025-12-12"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2024-10"
|
||||
knowledge = "2025-08-31"
|
||||
|
||||
[cost]
|
||||
input = 1.750
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
name = "gpt-5.2"
|
||||
family = "gpt"
|
||||
release_date = "2025-12-12"
|
||||
last_updated = "2025-12-12"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2024-10"
|
||||
knowledge = "2025-08-31"
|
||||
|
||||
[cost]
|
||||
input = 1.750
|
||||
@@ -14,6 +16,7 @@ output = 14.000
|
||||
|
||||
[limit]
|
||||
context = 400_000
|
||||
input = 272_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name = "gpt-5.4-mini-2026-03-17"
|
||||
family = "gpt-mini"
|
||||
release_date = "2026-03-19"
|
||||
last_updated = "2026-03-19"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2025-08-31"
|
||||
|
||||
[cost]
|
||||
input = 0.750
|
||||
output = 4.500
|
||||
|
||||
[limit]
|
||||
context = 400_000
|
||||
input = 272_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,24 @@
|
||||
name = "gpt-5.4-mini"
|
||||
family = "gpt-mini"
|
||||
release_date = "2026-03-19"
|
||||
last_updated = "2026-03-19"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2025-08-31"
|
||||
|
||||
[cost]
|
||||
input = 0.750
|
||||
output = 4.500
|
||||
|
||||
[limit]
|
||||
context = 400_000
|
||||
input = 272_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,24 @@
|
||||
name = "gpt-5.4-nano-2026-03-17"
|
||||
family = "gpt-nano"
|
||||
release_date = "2026-03-19"
|
||||
last_updated = "2026-03-19"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2025-08-31"
|
||||
|
||||
[cost]
|
||||
input = 0.200
|
||||
output = 1.250
|
||||
|
||||
[limit]
|
||||
context = 400_000
|
||||
input = 272_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,24 @@
|
||||
name = "gpt-5.4-nano"
|
||||
family = "gpt-nano"
|
||||
release_date = "2026-03-19"
|
||||
last_updated = "2026-03-19"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2025-08-31"
|
||||
|
||||
[cost]
|
||||
input = 0.200
|
||||
output = 1.250
|
||||
|
||||
[limit]
|
||||
context = 400_000
|
||||
input = 272_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,31 @@
|
||||
name = "gpt-5.4-pro"
|
||||
family = "gpt-pro"
|
||||
release_date = "2026-03-05"
|
||||
last_updated = "2026-03-05"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = false
|
||||
open_weights = false
|
||||
knowledge = "2025-08-31"
|
||||
|
||||
[cost]
|
||||
input = 30.000
|
||||
output = 180.000
|
||||
cache_read = 0
|
||||
cache_write = 0
|
||||
|
||||
[[cost.tiers]]
|
||||
tier = { size = 272_000 }
|
||||
input = 60.000
|
||||
output = 270.000
|
||||
|
||||
[limit]
|
||||
context = 1_050_000
|
||||
input = 922_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,31 @@
|
||||
name = "gpt-5.4"
|
||||
family = "gpt"
|
||||
release_date = "2026-03-05"
|
||||
last_updated = "2026-03-05"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2025-08-31"
|
||||
|
||||
[cost]
|
||||
input = 2.500
|
||||
output = 15.000
|
||||
cache_read = 0.250
|
||||
cache_write = 0
|
||||
|
||||
[[cost.tiers]]
|
||||
tier = { size = 272_000 }
|
||||
input = 5.000
|
||||
output = 22.500
|
||||
|
||||
[limit]
|
||||
context = 1_050_000
|
||||
input = 922_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
@@ -1,12 +1,14 @@
|
||||
name = "gpt-5"
|
||||
family = "gpt"
|
||||
release_date = "2025-08-08"
|
||||
last_updated = "2025-08-08"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = false
|
||||
knowledge = "2024-10"
|
||||
knowledge = "2024-09-30"
|
||||
|
||||
[cost]
|
||||
input = 1.250
|
||||
@@ -14,6 +16,7 @@ output = 10.000
|
||||
|
||||
[limit]
|
||||
context = 400_000
|
||||
input = 272_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
|
||||
+5
-8
@@ -1,18 +1,15 @@
|
||||
name = "Grok 4 Fast (Non-Reasoning)"
|
||||
family = "grok"
|
||||
release_date = "2025-09-19"
|
||||
last_updated = "2025-09-19"
|
||||
name = "grok-4.20-beta-0309-non-reasoning"
|
||||
release_date = "2026-03-16"
|
||||
last_updated = "2026-03-16"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
temperature = true
|
||||
knowledge = "2025-07"
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
|
||||
[cost]
|
||||
input = 0.20
|
||||
output = 0.50
|
||||
cache_read = 0.05
|
||||
input = 2.000
|
||||
output = 6.000
|
||||
|
||||
[limit]
|
||||
context = 2_000_000
|
||||
+5
-8
@@ -1,18 +1,15 @@
|
||||
name = "Grok 4 Fast"
|
||||
family = "grok"
|
||||
release_date = "2025-09-19"
|
||||
last_updated = "2025-09-19"
|
||||
name = "grok-4.20-beta-0309-reasoning"
|
||||
release_date = "2026-03-16"
|
||||
last_updated = "2026-03-16"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
knowledge = "2025-07"
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
|
||||
[cost]
|
||||
input = 0.20
|
||||
output = 0.50
|
||||
cache_read = 0.05
|
||||
input = 2.000
|
||||
output = 6.000
|
||||
|
||||
[limit]
|
||||
context = 2_000_000
|
||||
+6
-9
@@ -1,18 +1,15 @@
|
||||
name = "Grok 4.1 Fast"
|
||||
family = "grok"
|
||||
release_date = "2025-11-19"
|
||||
last_updated = "2025-11-19"
|
||||
name = "grok-4.20-multi-agent-beta-0309"
|
||||
release_date = "2026-03-16"
|
||||
last_updated = "2026-03-16"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
knowledge = "2025-07"
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
|
||||
[cost]
|
||||
input = 0.20
|
||||
output = 0.50
|
||||
cache_read = 0.05
|
||||
input = 2.000
|
||||
output = 6.000
|
||||
|
||||
[limit]
|
||||
context = 2_000_000
|
||||
@@ -20,4 +17,4 @@ output = 30_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
output = ["text"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 220 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<text x="0" y="24" fill="currentColor">
|
||||
<tspan font-size="24">Abliteration</tspan>
|
||||
<tspan dx="8" dy="2" font-size="12" fill-opacity="0.6">.ai</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,22 @@
|
||||
name = "Abliterated Model"
|
||||
release_date = "2026-01-06"
|
||||
last_updated = "2026-01-06"
|
||||
attachment = true
|
||||
reasoning = false
|
||||
tool_call = true
|
||||
structured_output = false
|
||||
temperature = true
|
||||
open_weights = true
|
||||
|
||||
[cost]
|
||||
input = 3.00
|
||||
output = 3.00
|
||||
|
||||
[limit]
|
||||
context = 150_000
|
||||
input = 150_000
|
||||
output = 8_192
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,5 @@
|
||||
name = "abliteration.ai"
|
||||
env = ["ABLIT_KEY"]
|
||||
npm = "@ai-sdk/openai-compatible"
|
||||
api = "https://api.abliteration.ai/v1"
|
||||
doc = "https://docs.abliteration.ai/models"
|
||||
@@ -1,22 +0,0 @@
|
||||
name = "Kimi K2 0905"
|
||||
family = "kimi"
|
||||
release_date = "2025-09-05"
|
||||
last_updated = "2025-09-05"
|
||||
attachment = false
|
||||
reasoning = false
|
||||
temperature = true
|
||||
tool_call = true
|
||||
knowledge = "2024-10"
|
||||
open_weights = true
|
||||
|
||||
[cost]
|
||||
input = 0.55
|
||||
output = 2.19
|
||||
|
||||
[limit]
|
||||
context = 262_144
|
||||
output = 262_144
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,27 @@
|
||||
name = "DeepSeek V4 Flash (Alibaba Cloud)"
|
||||
family = "deepseek-flash"
|
||||
release_date = "2026-04-24"
|
||||
last_updated = "2026-04-24"
|
||||
attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
knowledge = "2025-05"
|
||||
open_weights = true
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 0.14
|
||||
output = 0.28
|
||||
cache_read = 0.028
|
||||
|
||||
[limit]
|
||||
context = 1_000_000
|
||||
output = 384_000
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,27 @@
|
||||
name = "DeepSeek V4 Pro (Alibaba Cloud)"
|
||||
family = "deepseek-thinking"
|
||||
release_date = "2026-04-24"
|
||||
last_updated = "2026-04-24"
|
||||
attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
knowledge = "2025-05"
|
||||
open_weights = true
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 1.69
|
||||
output = 3.38
|
||||
cache_read = 0.13
|
||||
|
||||
[limit]
|
||||
context = 1_000_000
|
||||
output = 384_000
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
output = ["text"]
|
||||
+12
-11
@@ -1,7 +1,7 @@
|
||||
name = "GLM 4.6 TEE"
|
||||
name = "GLM-5.1 (Alibaba Cloud)"
|
||||
family = "glm"
|
||||
release_date = "2025-12-29"
|
||||
last_updated = "2026-01-10"
|
||||
release_date = "2026-03-27"
|
||||
last_updated = "2026-03-27"
|
||||
attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
@@ -9,18 +9,19 @@ tool_call = true
|
||||
structured_output = true
|
||||
open_weights = true
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 0.40
|
||||
output = 1.70
|
||||
cache_read = 0.20
|
||||
input = 0.84
|
||||
output = 3.38
|
||||
cache_read = 0.169
|
||||
cache_write = 1.05625
|
||||
|
||||
[limit]
|
||||
context = 202_752
|
||||
output = 65_536
|
||||
context = 200_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
output = ["text"]
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
@@ -1,28 +1,33 @@
|
||||
name = "Claude Opus 4.6 Think"
|
||||
name = "Claude Opus 4.6 Thinking"
|
||||
family = "claude-opus"
|
||||
release_date = "2026-02-05"
|
||||
last_updated = "2026-02-05"
|
||||
last_updated = "2026-03-13"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
knowledge = "2025-05-31"
|
||||
open_weights = false
|
||||
|
||||
[cost]
|
||||
input = 5.00
|
||||
output = 25.00
|
||||
cache_read = 0.30
|
||||
cache_write = 3.75
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost.context_over_200k]
|
||||
input = 6.00
|
||||
output = 22.00
|
||||
cache_read = 0.60
|
||||
cache_write = 7.50
|
||||
[cost]
|
||||
input = 5
|
||||
output = 25
|
||||
cache_read = 0.5
|
||||
cache_write = 6.25
|
||||
|
||||
[[cost.tiers]]
|
||||
tier = { size = 200_000 }
|
||||
input = 10
|
||||
output = 37.5
|
||||
cache_read = 1.0
|
||||
cache_write = 12.5
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
context = 1_000_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
name = "Claude Opus 4.6"
|
||||
family = "claude-opus"
|
||||
release_date = "2026-02-05"
|
||||
last_updated = "2026-02-05"
|
||||
last_updated = "2026-03-13"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
knowledge = "2025-05-31"
|
||||
open_weights = false
|
||||
|
||||
[cost]
|
||||
input = 5.00
|
||||
output = 25.00
|
||||
cache_read = 0.30
|
||||
cache_write = 3.75
|
||||
interleaved = true
|
||||
|
||||
[cost.context_over_200k]
|
||||
input = 6.00
|
||||
output = 22.00
|
||||
cache_read = 0.60
|
||||
cache_write = 7.50
|
||||
[cost]
|
||||
input = 5
|
||||
output = 25
|
||||
cache_read = 0.5
|
||||
cache_write = 6.25
|
||||
|
||||
[[cost.tiers]]
|
||||
tier = { size = 200_000 }
|
||||
input = 10
|
||||
output = 37.5
|
||||
cache_read = 1.0
|
||||
cache_write = 12.5
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
context = 1_000_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name = "Claude Opus 4.7 Thinking"
|
||||
family = "claude-opus"
|
||||
release_date = "2026-04-16"
|
||||
last_updated = "2026-04-16"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
knowledge = "2026-01-31"
|
||||
open_weights = false
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 5
|
||||
output = 25
|
||||
cache_read = 0.5
|
||||
cache_write = 6.25
|
||||
|
||||
[[cost.tiers]]
|
||||
tier = { size = 200_000 }
|
||||
input = 10
|
||||
output = 37.5
|
||||
cache_read = 1.0
|
||||
cache_write = 12.5
|
||||
|
||||
[limit]
|
||||
context = 1_000_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
@@ -0,0 +1,34 @@
|
||||
name = "Claude Opus 4.7"
|
||||
family = "claude-opus"
|
||||
release_date = "2026-04-16"
|
||||
last_updated = "2026-04-16"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = false
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
knowledge = "2026-01-31"
|
||||
open_weights = false
|
||||
|
||||
interleaved = true
|
||||
|
||||
[cost]
|
||||
input = 5
|
||||
output = 25
|
||||
cache_read = 0.5
|
||||
cache_write = 6.25
|
||||
|
||||
[[cost.tiers]]
|
||||
tier = { size = 200_000 }
|
||||
input = 10
|
||||
output = 37.5
|
||||
cache_read = 1.0
|
||||
cache_write = 12.5
|
||||
|
||||
[limit]
|
||||
context = 1_000_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text", "image", "pdf"]
|
||||
output = ["text"]
|
||||
@@ -1,28 +1,33 @@
|
||||
name = "Claude Sonnet 4.6 Think"
|
||||
name = "Claude Sonnet 4.6 Thinking"
|
||||
family = "claude-sonnet"
|
||||
release_date = "2026-02-17"
|
||||
last_updated = "2026-02-17"
|
||||
last_updated = "2026-03-13"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
knowledge = "2025-08-31"
|
||||
open_weights = false
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 3.00
|
||||
output = 15.00
|
||||
cache_read = 0.30
|
||||
cache_write = 3.75
|
||||
|
||||
[cost.context_over_200k]
|
||||
[[cost.tiers]]
|
||||
tier = { size = 200_000 }
|
||||
input = 6.00
|
||||
output = 22.50
|
||||
cache_read = 0.60
|
||||
cache_write = 7.50
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
context = 1_000_000
|
||||
output = 64_000
|
||||
|
||||
[modalities]
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
name = "Claude Sonnet 4.6"
|
||||
family = "claude-sonnet"
|
||||
release_date = "2026-02-17"
|
||||
last_updated = "2026-02-17"
|
||||
last_updated = "2026-03-13"
|
||||
attachment = true
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
knowledge = "2025-08-31"
|
||||
open_weights = false
|
||||
|
||||
interleaved = true
|
||||
|
||||
[cost]
|
||||
input = 3.00
|
||||
output = 15.00
|
||||
cache_read = 0.30
|
||||
cache_write = 3.75
|
||||
|
||||
[cost.context_over_200k]
|
||||
[[cost.tiers]]
|
||||
tier = { size = 200_000 }
|
||||
input = 6.00
|
||||
output = 22.50
|
||||
cache_read = 0.60
|
||||
cache_write = 7.50
|
||||
|
||||
[limit]
|
||||
context = 200_000
|
||||
context = 1_000_000
|
||||
output = 64_000
|
||||
|
||||
[modalities]
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
name = "Coding GLM 4.7 Free"
|
||||
family = "glm"
|
||||
release_date = "2025-12-22"
|
||||
last_updated = "2025-12-22"
|
||||
attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
knowledge = "2025-04"
|
||||
open_weights = true
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_details"
|
||||
|
||||
[cost]
|
||||
input = 0
|
||||
output = 0
|
||||
cache_read = 0
|
||||
cache_write = 0
|
||||
|
||||
[limit]
|
||||
context = 204800
|
||||
output = 131072
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
output = ["text"]
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
name = "Coding-GLM-4.7"
|
||||
family = "glm"
|
||||
release_date = "2025-12-22"
|
||||
last_updated = "2025-12-22"
|
||||
attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
knowledge = "2025-04"
|
||||
open_weights = true
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_details"
|
||||
|
||||
[cost]
|
||||
input = 0.06
|
||||
output = 0.22
|
||||
cache_read = 0.01
|
||||
|
||||
[limit]
|
||||
context = 204800
|
||||
output = 131072
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
output = ["text"]
|
||||
|
||||
+8
-7
@@ -1,23 +1,24 @@
|
||||
name = "GLM-5.1"
|
||||
family = "glm"
|
||||
name = "Coding GLM 5.1 (free)"
|
||||
family = "glm-free"
|
||||
release_date = "2026-04-11"
|
||||
last_updated = "2026-04-11"
|
||||
attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
structured_output = true
|
||||
open_weights = true
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 0.84
|
||||
output = 3.38
|
||||
input = 0
|
||||
output = 0
|
||||
|
||||
[limit]
|
||||
context = 200000
|
||||
output = 128000
|
||||
context = 200_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
@@ -1,4 +1,4 @@
|
||||
name = "Coding-GLM-5.1"
|
||||
name = "Coding GLM 5.1"
|
||||
family = "glm"
|
||||
release_date = "2026-04-11"
|
||||
last_updated = "2026-04-11"
|
||||
@@ -6,7 +6,8 @@ attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
open_weights = false
|
||||
structured_output = true
|
||||
open_weights = true
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
@@ -14,10 +15,11 @@ field = "reasoning_content"
|
||||
[cost]
|
||||
input = 0.06
|
||||
output = 0.22
|
||||
cache_read = 0.013
|
||||
|
||||
[limit]
|
||||
context = 200000
|
||||
output = 128000
|
||||
context = 200_000
|
||||
output = 128_000
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
|
||||
+11
-11
@@ -1,7 +1,7 @@
|
||||
name = "Hermes 4 405B FP8 TEE"
|
||||
family = "nousresearch"
|
||||
release_date = "2025-12-29"
|
||||
last_updated = "2026-01-10"
|
||||
name = "Coding MiniMax M2.7 (Free)"
|
||||
family = "minimax-free"
|
||||
release_date = "2026-03-18"
|
||||
last_updated = "2026-03-18"
|
||||
attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
@@ -9,17 +9,17 @@ tool_call = true
|
||||
structured_output = true
|
||||
open_weights = true
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 0.30
|
||||
output = 1.20
|
||||
input = 0
|
||||
output = 0
|
||||
|
||||
[limit]
|
||||
context = 131_072
|
||||
output = 65_536
|
||||
context = 204_800
|
||||
output = 128_100
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
output = ["text"]
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
@@ -0,0 +1,25 @@
|
||||
name = "Coding MiniMax M2.7 Highspeed"
|
||||
family = "minimax"
|
||||
release_date = "2026-03-18"
|
||||
last_updated = "2026-03-18"
|
||||
attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
tool_call = true
|
||||
structured_output = true
|
||||
open_weights = true
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 0.2
|
||||
output = 0.2
|
||||
|
||||
[limit]
|
||||
context = 204_800
|
||||
output = 128_100
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
output = ["text"]
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
name = "MiniMax M2.1 TEE"
|
||||
name = "Coding MiniMax M2.7"
|
||||
family = "minimax"
|
||||
release_date = "2025-12-29"
|
||||
last_updated = "2026-01-27"
|
||||
release_date = "2026-03-18"
|
||||
last_updated = "2026-03-18"
|
||||
attachment = false
|
||||
reasoning = true
|
||||
temperature = true
|
||||
@@ -9,17 +9,17 @@ tool_call = true
|
||||
structured_output = true
|
||||
open_weights = true
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
|
||||
[cost]
|
||||
input = 0.27
|
||||
output = 1.12
|
||||
input = 0.2
|
||||
output = 0.2
|
||||
|
||||
[limit]
|
||||
context = 196_608
|
||||
output = 65_536
|
||||
context = 204_800
|
||||
output = 128_100
|
||||
|
||||
[modalities]
|
||||
input = ["text"]
|
||||
output = ["text"]
|
||||
|
||||
[interleaved]
|
||||
field = "reasoning_content"
|
||||
@@ -0,0 +1,17 @@
|
||||
name = "Coding Xiaomi MiMo-V2.5-Pro"
|
||||
family = "mimo-v2.5-pro"
|
||||
last_updated = "2026-05-13"
|
||||
|
||||
[extends]
|
||||
from = "xiaomi/mimo-v2.5-pro"
|
||||
|
||||
[cost]
|
||||
input = 0.20
|
||||
output = 0.60
|
||||
cache_read = 0.04
|
||||
|
||||
[[cost.tiers]]
|
||||
tier = { size = 256_000 }
|
||||
input = 0.40
|
||||
output = 1.20
|
||||
cache_read = 0.08
|
||||
@@ -0,0 +1,17 @@
|
||||
name = "Coding Xiaomi MiMo-V2.5"
|
||||
family = "mimo-v2.5"
|
||||
last_updated = "2026-05-13"
|
||||
|
||||
[extends]
|
||||
from = "xiaomi/mimo-v2.5"
|
||||
|
||||
[cost]
|
||||
input = 0.08
|
||||
output = 0.40
|
||||
cache_read = 0.016
|
||||
|
||||
[[cost.tiers]]
|
||||
tier = { size = 256_000 }
|
||||
input = 0.16
|
||||
output = 0.80
|
||||
cache_read = 0.032
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user