mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-22 18:56:10 +00:00
Compare commits
602
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
411eff73f0 | ||
|
|
50eee1f5a4 | ||
|
|
0a601cf334 | ||
|
|
c9db6e9a1f | ||
|
|
130038eb63 | ||
|
|
4438f69aac | ||
|
|
76ced5418f | ||
|
|
c40e3e74a7 | ||
|
|
0317531906 | ||
|
|
5f241f1cc1 | ||
|
|
b513fafe83 | ||
|
|
21c4c93770 | ||
|
|
abc4b83f58 | ||
|
|
cb562b2c62 | ||
|
|
2d2339d37d | ||
|
|
849c2598ab | ||
|
|
3033afba51 | ||
|
|
5a8ee27254 | ||
|
|
4cc022481c | ||
|
|
d36a2d8981 | ||
|
|
96ff82a11f | ||
|
|
4872c48c23 | ||
|
|
47fc6f2991 | ||
|
|
da312b009c | ||
|
|
70535a1bad | ||
|
|
43e472bba7 | ||
|
|
b67fda133a | ||
|
|
e2531dff0f | ||
|
|
cc3615b535 | ||
|
|
5542415b64 | ||
|
|
8bec40a816 | ||
|
|
4a81e8392b | ||
|
|
a19b52e85b | ||
|
|
9105f35966 | ||
|
|
3ba3954aa0 | ||
|
|
3fc5af6dd1 | ||
|
|
5586f9675e | ||
|
|
3b719aba48 | ||
|
|
20a3a2138e | ||
|
|
cd46f22d51 | ||
|
|
cfa134f042 | ||
|
|
61653a20a5 | ||
|
|
2a097f3af7 | ||
|
|
ba4b8e21f4 | ||
|
|
7985c2066a | ||
|
|
67caf894e0 | ||
|
|
78587c141b | ||
|
|
f5573281ca | ||
|
|
4817228f96 | ||
|
|
5a5117b36c | ||
|
|
6a288426b8 | ||
|
|
573ac85983 | ||
|
|
1221bbbe14 | ||
|
|
6bea0f4fa4 | ||
|
|
cc34084dfe | ||
|
|
b8142c7aa8 | ||
|
|
ba3f966ed0 | ||
|
|
b95fe7b2d7 | ||
|
|
9da9ed9de6 | ||
|
|
fab2133129 | ||
|
|
901c9e7329 | ||
|
|
3476e6baa5 | ||
|
|
86e04d4174 | ||
|
|
45cd8d7692 | ||
|
|
a46374e90e | ||
|
|
41405c9eb0 | ||
|
|
49d2dd8a38 | ||
|
|
0df2f6245a | ||
|
|
38989c57a0 | ||
|
|
69a80663a2 | ||
|
|
518b0bf9e3 | ||
|
|
be08207a88 | ||
|
|
efb6cc2d4b | ||
|
|
4bffbb655f | ||
|
|
ba6cf38607 | ||
|
|
86978e7a8c | ||
|
|
82a3270cf2 | ||
|
|
c0a258b22a | ||
|
|
ed926be253 | ||
|
|
b527f605d9 | ||
|
|
aca52ba198 | ||
|
|
f7d5a1cbc7 | ||
|
|
3238daa851 | ||
|
|
90291fc6df | ||
|
|
a414e06d0c | ||
|
|
52f65f3a0b | ||
|
|
3a1c6df9e2 | ||
|
|
4436678f7d | ||
|
|
08fb473735 | ||
|
|
51213520f5 | ||
|
|
0bc9a28b5e | ||
|
|
1d2a7b4c86 | ||
|
|
453b61e27b | ||
|
|
95ebf50ace | ||
|
|
d8a1b932eb | ||
|
|
0c91e6a620 | ||
|
|
ef3b673084 | ||
|
|
c69abee0c7 | ||
|
|
dc3ff4b7aa | ||
|
|
958e08e109 | ||
|
|
590a11ed98 | ||
|
|
17544802c3 | ||
|
|
4a760b5743 | ||
|
|
888c4cb504 | ||
|
|
4394b324c9 | ||
|
|
a27ffb20f0 | ||
|
|
81141dc562 | ||
|
|
285d315b4e | ||
|
|
656f299017 | ||
|
|
04bdf7732b | ||
|
|
ab7dfb1edc | ||
|
|
05c3e40a4e | ||
|
|
b6478dcebf | ||
|
|
571e7b852f | ||
|
|
888dd26e1d | ||
|
|
279c8bdd7f | ||
|
|
865e4d5c86 | ||
|
|
64950cd358 | ||
|
|
36157938a8 | ||
|
|
c0dea334fb | ||
|
|
9b49daf3a9 | ||
|
|
d6f1b08ce8 | ||
|
|
75cf4cc8a8 | ||
|
|
4a181c3574 | ||
|
|
bfddf05782 | ||
|
|
cdee625a13 | ||
|
|
5f7091ab4e | ||
|
|
e7e7a97648 | ||
|
|
f58b8cb67b | ||
|
|
775f687ca9 | ||
|
|
265a93927d | ||
|
|
3a938bb6d8 | ||
|
|
6f9a4e70da | ||
|
|
dedb4133dc | ||
|
|
21f8184c58 | ||
|
|
59fe350972 | ||
|
|
5bdfcaac3c | ||
|
|
323d687173 | ||
|
|
cdca203be2 | ||
|
|
77a31d2b6e | ||
|
|
a1ed99278a | ||
|
|
729bd438e4 | ||
|
|
7304bb2751 | ||
|
|
456521f689 | ||
|
|
ee69079bb4 | ||
|
|
de14bb2e0f | ||
|
|
0539d56fc4 | ||
|
|
729adde022 | ||
|
|
b7111d5c62 | ||
|
|
1669713fbc | ||
|
|
cb8be9ba12 | ||
|
|
15046658ba | ||
|
|
7255ce9a57 | ||
|
|
2b9cf5d388 | ||
|
|
a625d35f7b | ||
|
|
449c64928b | ||
|
|
35c88c3fc8 | ||
|
|
b5e09024d5 | ||
|
|
9a51765bd2 | ||
|
|
bb31c9b92c | ||
|
|
5117679148 | ||
|
|
0d5798c0bf | ||
|
|
808677a612 | ||
|
|
fe41e9d482 | ||
|
|
0768c4655e | ||
|
|
1586a26594 | ||
|
|
f026c1422c | ||
|
|
b7fdb595df | ||
|
|
049ee1ce5e | ||
|
|
e71fbb6d48 | ||
|
|
c9976d69c0 | ||
|
|
27734409e4 | ||
|
|
e434ce01d3 | ||
|
|
c2f93ae2ad | ||
|
|
92cc8f553a | ||
|
|
49d997aec3 | ||
|
|
67aa9cee23 | ||
|
|
7ec3d67829 | ||
|
|
5401ebaede | ||
|
|
99668cfdce | ||
|
|
b3a012cbdd | ||
|
|
51b9c726cf | ||
|
|
b4e49d5b32 | ||
|
|
2b29854589 | ||
|
|
8ce5038bd8 | ||
|
|
446510a6a9 | ||
|
|
49028b6245 | ||
|
|
d595c7e265 | ||
|
|
17cd4a8c32 | ||
|
|
9bbf7918de | ||
|
|
97f502e79a | ||
|
|
ac5e249083 | ||
|
|
140e796b21 | ||
|
|
dd02cea9e7 | ||
|
|
a8062ea314 | ||
|
|
f47684787a | ||
|
|
8168f0f0f6 | ||
|
|
8036440693 | ||
|
|
cf7503687a | ||
|
|
6f8e1dda15 | ||
|
|
4dcfd9182c | ||
|
|
d7c0db8cee | ||
|
|
184da0e42e | ||
|
|
a244d82aba | ||
|
|
34e5809059 | ||
|
|
2db96c9b7e | ||
|
|
9976269ab1 | ||
|
|
5df822be11 | ||
|
|
233f069070 | ||
|
|
4a1982f5c9 | ||
|
|
1c7f65f105 | ||
|
|
a84151e482 | ||
|
|
0340a4ff77 | ||
|
|
f4faacbe87 | ||
|
|
7dbfd0b934 | ||
|
|
08a2a623dc | ||
|
|
60c88336b9 | ||
|
|
8a03fc265b | ||
|
|
d0ba538924 | ||
|
|
772e976d80 | ||
|
|
c769380776 | ||
|
|
01131c75d8 | ||
|
|
ea982c3832 | ||
|
|
d09d5612f3 | ||
|
|
85ef35dd64 | ||
|
|
251177d56c | ||
|
|
13b9e55c72 | ||
|
|
b94e55c7c0 | ||
|
|
9c6bb4ff72 | ||
|
|
c2e019ba7d | ||
|
|
b8374b5a7c | ||
|
|
b4665a8bf8 | ||
|
|
08096b5e61 | ||
|
|
2e43d41af0 | ||
|
|
518772c2ba | ||
|
|
fc9021ccec | ||
|
|
3bebfe6b1b | ||
|
|
3b18c64782 | ||
|
|
c6c599b872 | ||
|
|
6b41ae910c | ||
|
|
5c860d4142 | ||
|
|
4f9207daac | ||
|
|
ae7d63272c | ||
|
|
5cc3a51357 | ||
|
|
e357c90212 | ||
|
|
fb1edc4918 | ||
|
|
353e0eef8a | ||
|
|
a4c17f08b1 | ||
|
|
3cd9ee5a73 | ||
|
|
7c56319aa4 | ||
|
|
13fa4937b2 | ||
|
|
1db5c2402c | ||
|
|
0abbcddac2 | ||
|
|
dd25d143c5 | ||
|
|
d8a57ee15c | ||
|
|
aa52d30d7f | ||
|
|
83d633fd13 | ||
|
|
824455e2f1 | ||
|
|
62de2b3695 | ||
|
|
7550aa4b89 | ||
|
|
c33a2011c4 | ||
|
|
fff6764ed4 | ||
|
|
9644a008a4 | ||
|
|
ccb6b7c3ea | ||
|
|
f95f877e5f | ||
|
|
fe96d4aebd | ||
|
|
3a95d56144 | ||
|
|
ecd8be7593 | ||
|
|
a72c3880bb | ||
|
|
ccd8676bee | ||
|
|
5a533e366f | ||
|
|
77429f5982 | ||
|
|
6cfe4bc34f | ||
|
|
95013d2936 | ||
|
|
14a5529793 | ||
|
|
0b2e8707e3 | ||
|
|
adf178a6b9 | ||
|
|
78f85b1cd6 | ||
|
|
888dc67cc3 | ||
|
|
fd9ee435ec | ||
|
|
1c25b2f298 | ||
|
|
ea9fffce2e | ||
|
|
516f0266b8 | ||
|
|
39397deb8d | ||
|
|
65fd2e5c91 | ||
|
|
c5fe32fbb1 | ||
|
|
c48b77d6f9 | ||
|
|
64fd7a2027 | ||
|
|
ae259d87f0 | ||
|
|
50374abab8 | ||
|
|
0497e8badf | ||
|
|
2a08f8f5f4 | ||
|
|
9353559088 | ||
|
|
6aff2ee169 | ||
|
|
254a481e5d | ||
|
|
26885e7118 | ||
|
|
dba08013d1 | ||
|
|
fc09646973 | ||
|
|
c3b9c9d888 | ||
|
|
203885d241 | ||
|
|
728e86b287 | ||
|
|
d5aa79c73a | ||
|
|
eb6ff0c1e0 | ||
|
|
0df44c4e91 | ||
|
|
561070fbc2 | ||
|
|
b0e41ff2c4 | ||
|
|
977a40af68 | ||
|
|
dffecb6478 | ||
|
|
7f57d2a9ac | ||
|
|
377d5d2287 | ||
|
|
38bb38ecb2 | ||
|
|
3a149ba71c | ||
|
|
14df88eab5 | ||
|
|
e12cb7fb6b | ||
|
|
d4f7039932 | ||
|
|
e0ec9be238 | ||
|
|
2b34df94fa | ||
|
|
68f225a11d | ||
|
|
e9f5d34096 | ||
|
|
d3459eb740 | ||
|
|
be73f465df | ||
|
|
f14eafe9db | ||
|
|
b7e4f1ef74 | ||
|
|
efd5f0a80f | ||
|
|
7135bc4fb5 | ||
|
|
7cf8d1ca6d | ||
|
|
78bca5c707 | ||
|
|
82db86e2a1 | ||
|
|
476e3fc382 | ||
|
|
a12d50e15a | ||
|
|
1b9b260458 | ||
|
|
a8983bd2c7 | ||
|
|
709af58612 | ||
|
|
bcbbf32569 | ||
|
|
7a8e7c88f4 | ||
|
|
a22676720d | ||
|
|
f63a451b6f | ||
|
|
8b6a2b173b | ||
|
|
933dbfdb36 | ||
|
|
b44bc0ad07 | ||
|
|
436cd39d14 | ||
|
|
ed6dc879be | ||
|
|
96d53c6716 | ||
|
|
911fb7063a | ||
|
|
27e8e2e22b | ||
|
|
39c6dd1c32 | ||
|
|
a09447bc9b | ||
|
|
16815473a4 | ||
|
|
1f47bbfce2 | ||
|
|
33ed95f09c | ||
|
|
2152624f37 | ||
|
|
7d84cb05ef | ||
|
|
eba0bd0397 | ||
|
|
9aab24e95e | ||
|
|
a4fed69a82 | ||
|
|
41a3cfcdd9 | ||
|
|
72845e09fc | ||
|
|
abaab29cb3 | ||
|
|
30936a9bca | ||
|
|
a9144eccf8 | ||
|
|
458ec7b372 | ||
|
|
d46c02ba73 | ||
|
|
caedf36844 | ||
|
|
4c6e2a92ce | ||
|
|
bf58fae51f | ||
|
|
eb3476660f | ||
|
|
7d48a22a63 | ||
|
|
3f0fc22b6e | ||
|
|
2409c7a3d5 | ||
|
|
379adee35c | ||
|
|
2ef1a5991c | ||
|
|
83c638eaac | ||
|
|
cb93114424 | ||
|
|
04d236ceed | ||
|
|
5455fed78e | ||
|
|
6dba0cc498 | ||
|
|
c07ac0db2f | ||
|
|
20b090e493 | ||
|
|
cb54824f2f | ||
|
|
3adfb970bf | ||
|
|
5ecd19db9f | ||
|
|
299daa2815 | ||
|
|
7457139849 | ||
|
|
373cd08b98 | ||
|
|
f52424e05f | ||
|
|
4a42caef2c | ||
|
|
7d2618637f | ||
|
|
fbb95a6ee3 | ||
|
|
40233595b0 | ||
|
|
403164a6ac | ||
|
|
1fb2ecc721 | ||
|
|
27c9b130c7 | ||
|
|
5fecf7ae9e | ||
|
|
a0d64b12c9 | ||
|
|
d6b897188a | ||
|
|
39dfbb53d0 | ||
|
|
a39db9c6f6 | ||
|
|
6ca60d9204 | ||
|
|
b3934992d1 | ||
|
|
4de7e90d8d | ||
|
|
917eca4f55 | ||
|
|
3cf71808c4 | ||
|
|
f266e829cf | ||
|
|
3df5556223 | ||
|
|
fc29f99190 | ||
|
|
beb586a383 | ||
|
|
5668d36477 | ||
|
|
3706b33ee0 | ||
|
|
85e8ac3b36 | ||
|
|
91ca75c4dc | ||
|
|
262ef4fdc2 | ||
|
|
74e5644646 | ||
|
|
7de5aa08ff | ||
|
|
9495e3e903 | ||
|
|
1e73b76ea6 | ||
|
|
6697cf3fd8 | ||
|
|
87aa62d1e2 | ||
|
|
3136b1ba97 | ||
|
|
246dea3a58 | ||
|
|
4b1c7f28a0 | ||
|
|
9410eda4e3 | ||
|
|
f014686b07 | ||
|
|
8b68dc0d7f | ||
|
|
2f4d36d64e | ||
|
|
6f2ff35656 | ||
|
|
cd00422298 | ||
|
|
34ee94232f | ||
|
|
4d91f0cf28 | ||
|
|
bf18cc971f | ||
|
|
5e1a1ed2e2 | ||
|
|
bd7f7ad98b | ||
|
|
af72cec799 | ||
|
|
55552c521f | ||
|
|
2b611a5b14 | ||
|
|
45a437cbb2 | ||
|
|
439ec9b76f | ||
|
|
ae3fefb42a | ||
|
|
f1407e41c4 | ||
|
|
c88ca972cf | ||
|
|
928c37bc02 | ||
|
|
afff74eb2c | ||
|
|
3a669d528b | ||
|
|
6789214b31 | ||
|
|
6636683323 | ||
|
|
3af9f64265 | ||
|
|
5a23bdcb69 | ||
|
|
e78726854a | ||
|
|
472d0f376e | ||
|
|
c8fde60ad4 | ||
|
|
a4b6047e64 | ||
|
|
6387f955cf | ||
|
|
5691f36e98 | ||
|
|
74e7a378bb | ||
|
|
fced9c5a2f | ||
|
|
20445ca031 | ||
|
|
6fc9405618 | ||
|
|
c86066efef | ||
|
|
fa9ba2938e | ||
|
|
63e24880e5 | ||
|
|
eaf42c4ab0 | ||
|
|
8e10ab0aa7 | ||
|
|
b329fcceb2 | ||
|
|
bd83177c48 | ||
|
|
06cb0de0b1 | ||
|
|
3ca89ac796 | ||
|
|
3aa2860058 | ||
|
|
ad63cf1590 | ||
|
|
aa56750f8b | ||
|
|
4aaed42640 | ||
|
|
f69f677a32 | ||
|
|
8289883de8 | ||
|
|
003c22b4a3 | ||
|
|
1982d9832b | ||
|
|
90f0576222 | ||
|
|
e34822db6b | ||
|
|
b19ae6547f | ||
|
|
6b074ed718 | ||
|
|
e687eb936b | ||
|
|
797cb530e5 | ||
|
|
0a5bed2bc2 | ||
|
|
ba41dadc91 | ||
|
|
451876b0bd | ||
|
|
91561bbdfb | ||
|
|
018ce34b39 | ||
|
|
ce82b21073 | ||
|
|
1f37c26574 | ||
|
|
cb3e28d207 | ||
|
|
8f1e13f299 | ||
|
|
2fe68b5e91 | ||
|
|
2ace237938 | ||
|
|
5b62211167 | ||
|
|
60b6229c4e | ||
|
|
4caec80e67 | ||
|
|
e9d4ca7e0f | ||
|
|
d71454c701 | ||
|
|
884c256033 | ||
|
|
78235385dd | ||
|
|
fd213e6df6 | ||
|
|
3726052307 | ||
|
|
7d33a6f7c9 | ||
|
|
7a035d7fc0 | ||
|
|
9151af7045 | ||
|
|
15bcbb1d7a | ||
|
|
4d3294727c | ||
|
|
aae0e89519 | ||
|
|
93a7b4ab76 | ||
|
|
0ebe74b625 | ||
|
|
b9dae8593c | ||
|
|
d8f9388610 | ||
|
|
be14739cce | ||
|
|
762588c251 | ||
|
|
d6e54e9042 | ||
|
|
d4fd528152 | ||
|
|
de185559dd | ||
|
|
bc5ce5eab1 | ||
|
|
b10d617c80 | ||
|
|
18466b8020 | ||
|
|
71ec022b47 | ||
|
|
f7eeb08942 | ||
|
|
9205dfe724 | ||
|
|
a3776429aa | ||
|
|
c0e43c0c65 | ||
|
|
08c5a2a5e8 | ||
|
|
7077c70d60 | ||
|
|
1fd8bf526d | ||
|
|
6d9539f469 | ||
|
|
e5101d9651 | ||
|
|
b0151e1d02 | ||
|
|
c3637753bc | ||
|
|
48fc9e3cc3 | ||
|
|
82a482b36d | ||
|
|
7fac84319d | ||
|
|
0a5e617da8 | ||
|
|
b5f92c9f48 | ||
|
|
2070fd9bc2 | ||
|
|
48dc05eee7 | ||
|
|
84bb706537 | ||
|
|
be8cfa7e08 | ||
|
|
56a789d926 | ||
|
|
5409151ad4 | ||
|
|
f90d154465 | ||
|
|
078385d386 | ||
|
|
beaaa174ea | ||
|
|
fb59606bb4 | ||
|
|
846d548154 | ||
|
|
84336e4f91 | ||
|
|
01a5c69244 | ||
|
|
d78f91afeb | ||
|
|
33762292f7 | ||
|
|
5d6aa3b41a | ||
|
|
683aca5dbe | ||
|
|
92025e9a47 | ||
|
|
411e053572 | ||
|
|
b862d178bf | ||
|
|
bda0ddc207 | ||
|
|
58ba99e505 | ||
|
|
6ee817d041 | ||
|
|
dfeb1b5051 | ||
|
|
61a7f6db35 | ||
|
|
6446b8ae3b | ||
|
|
ae53163cad | ||
|
|
41202819a4 | ||
|
|
a31698f99b | ||
|
|
6248542c49 | ||
|
|
d25c91e5eb | ||
|
|
5d63020dcd | ||
|
|
062f54590e | ||
|
|
a76c6918d2 | ||
|
|
2b91a6f210 | ||
|
|
10579cceb2 | ||
|
|
25702e01ce | ||
|
|
ecc5c44d9a | ||
|
|
f5a0b920a2 | ||
|
|
2caa016fe1 | ||
|
|
6861fedd09 | ||
|
|
3d072112ce | ||
|
|
bdfea046db | ||
|
|
a2d08fb63b | ||
|
|
5a55135d89 | ||
|
|
8870d36e0f | ||
|
|
eb923c27ca | ||
|
|
9903abc704 | ||
|
|
225a1fbf35 | ||
|
|
93159bccbf | ||
|
|
fab8ec4f54 | ||
|
|
a3035c53ea | ||
|
|
36c416e143 | ||
|
|
e1e0304a96 | ||
|
|
71c3a7c8f2 | ||
|
|
ecdfff5a42 | ||
|
|
4b948c5d74 | ||
|
|
cd56c51e2d | ||
|
|
43e39d7f68 | ||
|
|
7a17925495 | ||
|
|
5acb2530b4 | ||
|
|
44a6787359 | ||
|
|
42e6b7db32 | ||
|
|
2c02f8bace | ||
|
|
2ec20e576b | ||
|
|
20f47fec7a | ||
|
|
65210f2d97 | ||
|
|
af0b7ffae7 |
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/protocol": patch
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose background-service lifecycle status, preserve one process-held owner through startup and failure, reconnect TUIs without activating replacement, and stop exact service instances gracefully.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose a TUI plugin slot at the top of the session view.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/plugin": patch
|
||||
"@opencode-ai/protocol": patch
|
||||
---
|
||||
|
||||
Expose transient, read-only session generation through the HTTP API, generated clients, and V2 plugin session context.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose a TUI plugin slot above the session composer.
|
||||
@@ -1,3 +1,2 @@
|
||||
packages/core/migration/**/snapshot.json linguist-generated
|
||||
packages/core/src/database/migration.gen.ts linguist-generated
|
||||
packages/core/src/**/*.txt text eol=lf
|
||||
|
||||
@@ -6,7 +6,6 @@ on:
|
||||
branches:
|
||||
- ci
|
||||
- dev
|
||||
- v2
|
||||
- beta
|
||||
- fix/npm-native-binary-install
|
||||
- snapshot-*
|
||||
@@ -32,9 +31,6 @@ permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -90,18 +86,11 @@ jobs:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Build legacy CLI
|
||||
if: github.ref_name != 'v2'
|
||||
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
- name: Build preview CLI
|
||||
- name: Build
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
run: |
|
||||
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
@@ -109,7 +98,6 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: |
|
||||
@@ -117,7 +105,6 @@ jobs:
|
||||
packages/opencode/dist/opencode-linux*
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist/opencode-windows*
|
||||
@@ -130,61 +117,12 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
build-node-cli:
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- target: linux-arm64
|
||||
host: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- target: linux-x64
|
||||
host: blacksmith-4vcpu-ubuntu-2404
|
||||
- target: darwin-arm64
|
||||
host: macos-26
|
||||
- target: windows-arm64
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
- target: windows-x64
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
install-flags: --os=* --cpu=*
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "26.4.0"
|
||||
|
||||
- name: Build
|
||||
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
|
||||
- name: Verify service lifecycle
|
||||
if: matrix.settings.target != 'windows-arm64'
|
||||
working-directory: packages/cli
|
||||
run: bun run script/service-smoke.ts --node
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-node-cli-${{ matrix.settings.target }}
|
||||
path: packages/cli/dist/node/cli-node-*
|
||||
if-no-files-found: error
|
||||
|
||||
sign-cli-windows:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -283,7 +221,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -471,7 +409,6 @@ jobs:
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
- build-node-cli
|
||||
- sign-cli-windows
|
||||
- build-electron
|
||||
if: always() && !failure() && !cancelled()
|
||||
@@ -500,19 +437,16 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
@@ -522,12 +456,6 @@ jobs:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
pattern: opencode-node-cli-*
|
||||
path: packages/cli/dist/node
|
||||
merge-multiple: true
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: needs.version.outputs.release
|
||||
with:
|
||||
|
||||
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -70,36 +69,18 @@ jobs:
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
working-directory: packages/cli
|
||||
run: |
|
||||
bun run script/build.ts --single --skip-install
|
||||
bun run script/service-smoke.ts
|
||||
|
||||
- name: Setup Node build runtime
|
||||
if: always()
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "26.4.0"
|
||||
|
||||
- name: Verify Node build
|
||||
if: always()
|
||||
timeout-minutes: 15
|
||||
working-directory: packages/cli
|
||||
run: |
|
||||
bun run script/build-node.ts --single --skip-install --outdir=dist/node
|
||||
bun run script/service-smoke.ts --node
|
||||
|
||||
- name: Check generated client
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/client
|
||||
run: bun run check:generated
|
||||
|
||||
- name: Run HttpApi exerciser gates
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/opencode
|
||||
run: bun run test:httpapi
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
if: github.ref_name != 'v2' && github.head_ref != 'v2'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -2,9 +2,9 @@ name: typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, v2]
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
branches: [dev, v2]
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -11,10 +11,8 @@ node_modules
|
||||
playground
|
||||
tmp
|
||||
dist
|
||||
dist-node
|
||||
ts-dist
|
||||
.turbo
|
||||
.typecheck-profiles
|
||||
**/.serena
|
||||
.serena/
|
||||
**/.omo
|
||||
@@ -26,7 +24,6 @@ Session.vim
|
||||
a.out
|
||||
target
|
||||
.scripts
|
||||
.cache
|
||||
.direnv/
|
||||
|
||||
# Local dev files
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
---
|
||||
name: opencode-drive
|
||||
description: Use when an agent needs drive OpenCode via a script or interact with an isolated instance
|
||||
---
|
||||
|
||||
# OpenCode Drive
|
||||
|
||||
Use `opencode-drive` to launch an isolated OpenCode instance and control it via commands or a script.
|
||||
|
||||
There are two modes. Always default to using a script unless specifically directed to be interactive (connect
|
||||
to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate
|
||||
on changes).
|
||||
|
||||
Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits,
|
||||
stops all processes, and cleans up all artifacts.
|
||||
|
||||
# Prepare The Environment
|
||||
|
||||
Use `init` when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later `start` with the same name reuses it.
|
||||
|
||||
```bash
|
||||
artifacts=$(opencode-drive init --name demo)
|
||||
cp -R ./fixtures/home/. "$artifacts/"
|
||||
cp -R ./fixtures/project/. "$artifacts/files/"
|
||||
opencode-drive start --name demo --dev ~/projects/opencode
|
||||
```
|
||||
|
||||
The simulated project is under `$artifacts/files`. Running `start` without a prior `init` initializes the artifacts automatically.
|
||||
|
||||
# Scripted usage
|
||||
|
||||
You can write scripts that walk through entire flows, and gives you full access to controlling
|
||||
the backend too. See examples of the script API at the bottom of this file.
|
||||
|
||||
After creating or editing a script, always typecheck it before running. Never skip this step:
|
||||
|
||||
```bash
|
||||
opencode-drive check ./reproduce-stale-exploring-empty.ts
|
||||
```
|
||||
|
||||
Run it by passing `--script` to start:
|
||||
|
||||
```bash
|
||||
opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts
|
||||
```
|
||||
|
||||
It will output information about the run, including paths to log files which you can read
|
||||
to inspect what happened. If you need to dig into failures that aren't clear, read those log
|
||||
files. If the script is unsuccessful, automatically fix the script and run it again.
|
||||
|
||||
Scripts use one typed definition object. `setup` runs before OpenCode starts,
|
||||
and `fs.writeFile` always writes inside the simulated project.
|
||||
|
||||
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
|
||||
|
||||
```ts
|
||||
import { defineScript } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
async setup({ fs, config }) {
|
||||
config.autoupdate = false
|
||||
await fs.writeFile("src/example.ts", "export const value = 1\n")
|
||||
},
|
||||
|
||||
async run({ ui, llm }) {
|
||||
await ui.submit("Open src/example.ts")
|
||||
await llm.send(llm.text("The file exports `value`."))
|
||||
await ui.waitFor("The file exports `value`.")
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`setup` receives the current OpenCode config object, which starts from the
|
||||
default drive config unless the prepared instance already has one. When a script
|
||||
needs custom config, mutate this `config` parameter instead of generating and
|
||||
writing a new config object from scratch, so the script keeps the default
|
||||
provider/model settings unless it intentionally changes them.
|
||||
|
||||
Note that the simulated model is a GPT model type, and opencode uses the `patch` tool for working with files Do not use a `edit` or `write` tool to edit files.
|
||||
|
||||
Use `launch: "manual"` when the script needs to launch the server and every TUI
|
||||
itself (this is extremely rare, do not use this unless explicitly asked). In this
|
||||
mode `ui` is typed as `null`; call `server.launch()` exactly
|
||||
once before launching clients. Each `clients.launch(name)` result provides the
|
||||
same UI methods as the automatic client. You can see an example of this API
|
||||
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
|
||||
|
||||
Use the exported `wait(milliseconds)` utility for an unconditional delay.
|
||||
|
||||
`await llm.send(...)` waits for the next request and resolves after OpenCode
|
||||
acknowledges its complete response. `llm.queue(...)` declares responses in
|
||||
advance. Chunks may be built with `text`, `reasoning`, `toolCall`, `raw`,
|
||||
`finish`, and `disconnect`. A normal response receives `finish("stop")`
|
||||
automatically unless it yields or queues an explicit terminal event.
|
||||
|
||||
`llm.text(text, { delay, chunkSize })` defaults to a 2 ms delay and a
|
||||
15-character target varied by plus or minus 5 per chunk.
|
||||
|
||||
`llm.reasoning` accepts the same options, and `llm.pause(milliseconds)` adds a
|
||||
delay between any two outputs.
|
||||
|
||||
Use `llm.serve` for an ongoing typed response generator:
|
||||
|
||||
```ts
|
||||
llm.serve(async function* (request, index) {
|
||||
yield llm.reasoning(`Handling request ${index + 1}`)
|
||||
yield llm.text(`Received ${request.id}`)
|
||||
yield llm.finish("stop")
|
||||
})
|
||||
```
|
||||
|
||||
The backend connection, response cleanup, cancellation, and recording
|
||||
completion are automatic.
|
||||
|
||||
You can see some example scripts here:
|
||||
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
|
||||
|
||||
## Prune
|
||||
|
||||
- `prune` removes artifact directories. These are always cleaned up after running a script
|
||||
successfully, but leftover on failed runs. Always call this if a script fails.
|
||||
|
||||
```bash
|
||||
opencode-drive prune --name demo
|
||||
|
||||
// --force cleans up all artifcat directories
|
||||
opencode-dirve prune --force
|
||||
```
|
||||
|
||||
# Live interaction usage
|
||||
|
||||
- Always give headless instances a unique `--name`. Visible instances may omit it.
|
||||
- A normal headless `start` detaches automatically and returns after the instance is ready.
|
||||
- Do not add `&`; the long-running owner already runs in the background.
|
||||
- Configure simulated model responses after startup when needed.
|
||||
- Send ordered UI commands with `send`.
|
||||
- Always stop the instance when finished.
|
||||
|
||||
```bash
|
||||
opencode-drive start --name demo
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.type '{"text":"Explain this project"}' \
|
||||
--command.ui.enter
|
||||
|
||||
opencode-drive stop --name demo
|
||||
```
|
||||
|
||||
## Send UI Commands
|
||||
|
||||
- Every `send` opens a connection to the named instance, runs its commands in order, and exits.
|
||||
- Combine typing and Enter in one command when submitting a prompt.
|
||||
- JSON-valued commands require one JSON argument.
|
||||
- Multiple command flags execute from left to right.
|
||||
|
||||
Commands:
|
||||
|
||||
- `--command.ui.type <json>` types into the focused editor. Arguments: `text` string.
|
||||
- `--command.ui.press <json>` presses a key. Arguments: `key` string; optional `modifiers` object with boolean `ctrl`, `shift`, `meta`, `super`, or `hyper`.
|
||||
- `--command.ui.enter` presses Enter. Arguments: none.
|
||||
- `--command.ui.arrow <json>` presses an arrow key. Arguments: `direction` is `up`, `down`, `left`, or `right`.
|
||||
- `--command.ui.focus <json>` focuses an element. Arguments: `target` is the numeric element `num` returned by `ui.state`.
|
||||
- `--command.ui.click <json>` clicks an element. Arguments: numeric `target`, `x`, and `y`; use the element `num` returned by `ui.state` as `target`.
|
||||
- `--command.ui.state` prints focus and interactive element metadata as JSON. Arguments: none.
|
||||
- `--command.ui.matches <json>` prints whether literal, case-sensitive text appears on screen. Arguments: `text` string.
|
||||
|
||||
```bash
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.type '{"text":"Find the relevant code and explain it"}' \
|
||||
--command.ui.enter
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.arrow '{"direction":"down"}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.focus '{"target":12}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.click '{"target":12,"x":4,"y":1}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.matches '{"text":"OpenCode"}'
|
||||
```
|
||||
|
||||
To read the UI state and see information about interactable elements, use the `ui.state` command:
|
||||
|
||||
```bash
|
||||
opencode-drive send --name demo --command.ui.state
|
||||
```
|
||||
|
||||
## Configure LLM Responses
|
||||
|
||||
- `responses` controls what the LLM responds with
|
||||
- Only use this if you are wanting to reproduce an exact type of response
|
||||
- Defaults are `text,reasoning,diff,tool` with `write,apply_patch`.
|
||||
- Supported types are `text`, `reasoning`, `diff`, and `tool`.
|
||||
- `--tools` limits generated tool calls to names offered by OpenCode.
|
||||
|
||||
```bash
|
||||
opencode-drive responses --name demo \
|
||||
--types text,reasoning,diff,tool \
|
||||
--tools write,apply_patch
|
||||
|
||||
opencode-drive responses --name demo \
|
||||
--types tool \
|
||||
--tools read,glob,grep
|
||||
```
|
||||
|
||||
## Inspect The UI
|
||||
|
||||
- `ui.state` prints focus and interactive element metadata as JSON.
|
||||
- `ui.matches` checks for literal, case-sensitive screen text.
|
||||
- `screenshot` prints the generated image path.
|
||||
|
||||
```bash
|
||||
opencode-drive screenshot --name demo
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- `stop` waits for recording export and owner cleanup before returning.
|
||||
|
||||
```bash
|
||||
opencode-drive stop --name demo
|
||||
```
|
||||
|
||||
# Record The UI
|
||||
|
||||
- Start with `--record` to capture a headless instance from its first rendered frame.
|
||||
- `stop` finishes the recording, exports an MP4, and prints its path.
|
||||
|
||||
```bash
|
||||
opencode-drive start --name demo --record
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.type '{"text":"Show me the current architecture"}' \
|
||||
--command.ui.enter
|
||||
|
||||
opencode-drive stop --name demo
|
||||
```
|
||||
|
||||
# Artifacts dir
|
||||
|
||||
- `dir` prints the artifact directory for the instance.
|
||||
|
||||
```bash
|
||||
opencode-drive dir --name demo
|
||||
```
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
name: sample-skill
|
||||
description: Use when the user says sample skill, skill demo, or asks how an opencode SKILL.md should be structured; demonstrates a tiny project-local skill with practical assistant workflow guidance.
|
||||
---
|
||||
|
||||
# Sample Skill
|
||||
|
||||
This is a minimal project-local opencode skill. It exists as a reference for how a skill is structured and as a tiny reusable workflow the assistant can load when the user asks for a skill example.
|
||||
|
||||
## When To Use
|
||||
|
||||
- Use when the user asks for a sample skill or skill template.
|
||||
- Use when demonstrating the required `SKILL.md` frontmatter and body format.
|
||||
- Do not use for unrelated coding tasks just because a skill exists.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Confirm the specific outcome the user wants if the request is ambiguous.
|
||||
- Inspect the relevant files before changing anything.
|
||||
- Make the smallest correct change.
|
||||
- Verify the result with a focused read, typecheck, test, or other lightweight check when available.
|
||||
- Summarize the changed files and any required restart or reload step.
|
||||
|
||||
## Example Response Style
|
||||
|
||||
When this skill is relevant, keep responses direct and actionable:
|
||||
|
||||
```text
|
||||
I created a project-local skill at .opencode/skills/sample-skill/SKILL.md.
|
||||
Restart opencode for the new skill to be discovered by future sessions.
|
||||
```
|
||||
@@ -1,7 +1,6 @@
|
||||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
@@ -25,7 +24,6 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
|
||||
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
@@ -152,15 +150,12 @@ const table = sqliteTable("session", {
|
||||
|
||||
## V2 Session Core
|
||||
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
|
||||
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
# OpenCode Session Runtime
|
||||
|
||||
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
|
||||
|
||||
## Language
|
||||
|
||||
**System Context**:
|
||||
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
|
||||
_Avoid_: System prompt
|
||||
|
||||
**Session History**:
|
||||
The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs.
|
||||
_Avoid_: Session Context
|
||||
|
||||
**Context Source**:
|
||||
One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources.
|
||||
_Avoid_: Prompt fragment
|
||||
|
||||
**System Context Registry**:
|
||||
The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**.
|
||||
|
||||
**Mid-Conversation System Message**:
|
||||
A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**.
|
||||
_Avoid_: System update, system notification, raw text diff
|
||||
|
||||
**Context Epoch**:
|
||||
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
|
||||
|
||||
**Baseline System Context**:
|
||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||
_Avoid_: Live system prompt
|
||||
|
||||
**Context Snapshot**:
|
||||
The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn.
|
||||
|
||||
**Unavailable Context**:
|
||||
An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
|
||||
|
||||
**Safe Provider-Turn Boundary**:
|
||||
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
||||
|
||||
**Admitted Prompt**:
|
||||
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
||||
|
||||
**Prompt Promotion**:
|
||||
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||
|
||||
**Provider Turn**:
|
||||
One request to a model provider and the response projected from that request.
|
||||
|
||||
**Session Drain**:
|
||||
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
|
||||
**Model Tool Output**:
|
||||
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||
|
||||
**Managed Tool Output File**:
|
||||
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
|
||||
|
||||
**Model Request Options**:
|
||||
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
|
||||
_Avoid_: Request body, wire options
|
||||
|
||||
**Generation Controls**:
|
||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||
|
||||
**Native Continuation Metadata**:
|
||||
Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier.
|
||||
|
||||
**PTY Environment**:
|
||||
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
|
||||
|
||||
**OpenCode Client**:
|
||||
The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers.
|
||||
_Avoid_: Remote client
|
||||
|
||||
**SDK Contract IR**:
|
||||
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
|
||||
|
||||
**Embedded OpenCode**:
|
||||
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
|
||||
_Avoid_: Local implementation
|
||||
|
||||
**Page**:
|
||||
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
|
||||
_Avoid_: Response envelope
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||
- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state.
|
||||
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**.
|
||||
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
|
||||
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
|
||||
- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**.
|
||||
- A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key.
|
||||
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
|
||||
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
|
||||
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
|
||||
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
|
||||
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
|
||||
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
|
||||
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
||||
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
|
||||
- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic.
|
||||
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
|
||||
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
|
||||
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
|
||||
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
|
||||
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
||||
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
|
||||
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
|
||||
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
|
||||
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
|
||||
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
|
||||
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
|
||||
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
|
||||
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
||||
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
||||
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
||||
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- A **Context Epoch** begins with one immutable **Baseline System Context**.
|
||||
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
||||
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
|
||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||
- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
|
||||
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
|
||||
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
|
||||
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
|
||||
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
|
||||
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
|
||||
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
|
||||
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
|
||||
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
|
||||
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
|
||||
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
|
||||
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
|
||||
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
|
||||
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
|
||||
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
|
||||
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
|
||||
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
|
||||
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
|
||||
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
|
||||
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
|
||||
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
|
||||
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
|
||||
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
|
||||
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
|
||||
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
|
||||
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
|
||||
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
||||
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
|
||||
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
|
||||
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
|
||||
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
|
||||
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
||||
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
|
||||
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
|
||||
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
|
||||
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
|
||||
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
|
||||
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
|
||||
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
|
||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
|
||||
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
|
||||
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
||||
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
||||
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
|
||||
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
|
||||
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
|
||||
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
|
||||
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
||||
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
|
||||
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
|
||||
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
|
||||
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
|
||||
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
|
||||
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
|
||||
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
|
||||
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
|
||||
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
|
||||
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
|
||||
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
|
||||
|
||||
## Client contract architecture
|
||||
|
||||
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
|
||||
|
||||
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
|
||||
|
||||
Before stabilizing the client API:
|
||||
|
||||
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
|
||||
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
|
||||
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
|
||||
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
|
||||
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
|
||||
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
|
||||
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
|
||||
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
|
||||
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current **System Context**."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics.
|
||||
Binary file not shown.
@@ -1,685 +0,0 @@
|
||||
# Service Lifecycle: Election, Restart, and Reconnect
|
||||
|
||||
Status: in progress
|
||||
|
||||
Incident: [#36688](https://github.com/anomalyco/opencode/issues/36688)
|
||||
|
||||
## Summary
|
||||
|
||||
The managed V2 service keeps its current update policy: the background updater
|
||||
may install a new package, but only a freshly launched TUI activates that update
|
||||
after finding an older running service. Existing TUIs never replace a service;
|
||||
they only reconnect.
|
||||
|
||||
The restart path changes in three places:
|
||||
|
||||
1. A process-held OS lock, not the HTTP port or registration file, elects
|
||||
exactly one server owner for its lifetime.
|
||||
2. The elected process binds and registers a minimal lifecycle surface before
|
||||
it initializes the application, so clients can distinguish a slow winner
|
||||
from an absent server.
|
||||
3. TUIs rediscover and reconnect indefinitely. Transport loss is never a
|
||||
terminal error by itself.
|
||||
|
||||
Several clients may spawn small contenders during a restart. This is safe and
|
||||
intentional: one contender acquires the lock and initializes, while every loser
|
||||
exits before expensive server boot. The design does not require clients to
|
||||
agree on a single initiator.
|
||||
|
||||
This proposal does not introduce a supervisor process, warm candidate server,
|
||||
protocol negotiation, idle background restart, or general execution-recovery
|
||||
framework.
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```text
|
||||
╭───────────────────╮
|
||||
│ CLI ServiceConfig │
|
||||
╰─────────┬─────────╯
|
||||
│
|
||||
▼
|
||||
╭──────────────────────╮
|
||||
│ CLI ServerConnection │
|
||||
╰───────────┬──────────╯
|
||||
╭──────────────────╰───────────────────╮
|
||||
▼ ▼
|
||||
╭──────────────────────────╮ ╭─────────────────────────╮
|
||||
│ Client Service lifecycle │ │ CLI runPromiseWith seam │
|
||||
╰─────────────┬────────────╯ ╰─────────────┬───────────╯
|
||||
╰─────╮ │
|
||||
▼ ▼
|
||||
╭────────────────────────────╮ ╭─────────────╮
|
||||
│ Background service process │ │ TUI / Solid │
|
||||
╰──────────────┬─────────────╯ ╰──────┬──────╯
|
||||
│ │
|
||||
╰────────────◀────────────────────╯
|
||||
╭───────────────────────╮
|
||||
│ Server HTTP transport │
|
||||
╰───────────┬───────────╯
|
||||
│
|
||||
▼
|
||||
╭──────────────────╮
|
||||
│ Core application │
|
||||
╰──────────────────╯
|
||||
```
|
||||
|
||||
| Owner | Responsibility |
|
||||
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| `packages/client/src/effect/service.ts` | Effect-native discovery, start, and stop lifecycle operations |
|
||||
| `packages/cli/src/services/service-config.ts` | CLI registration path, installed version, and daemon command |
|
||||
| `packages/cli/src/services/server-connection.ts` | Resolve an endpoint and, only for the shared service, grouped reconnect and restart Effects |
|
||||
| `packages/cli/src/server-process.ts` | Daemon election, registration, and server process boot |
|
||||
| `packages/server/src/process.ts` | HTTP lifecycle shell and application transport |
|
||||
| `packages/core` | Application behavior behind the transport |
|
||||
| CLI default handler | Convert lifecycle Effects with the outer `FileSystem` context and pass grouped Promise capabilities |
|
||||
| `packages/tui` Solid client context | Own event-stream reconnect, endpoint replacement, status, and user-triggered restart UI |
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Area | State |
|
||||
| ------------------------- | --------------------------------------------------------------------- |
|
||||
| Lifetime ownership | Implemented on this branch with a scoped OS lock |
|
||||
| Contender behavior | Implemented; losers exit before the server module is imported |
|
||||
| Registration repair | Implemented; the owner reasserts deleted or corrupt discovery |
|
||||
| Channel isolation | Implemented with no-clobber migration for legacy preview discovery |
|
||||
| Client startup waiting | Implemented; slow winners are not killed and waiting is indefinite |
|
||||
| Lifecycle shell | Implemented; the owner binds and registers before application boot |
|
||||
| Failed-state latching | Implemented; deterministic boot failure stays bound and actionable |
|
||||
| Recovery diagnostics | Implemented; the TUI shows status instead of transport internals |
|
||||
| Cross-platform validation | macOS runtime verified; Linux and Windows run in the unit-test matrix |
|
||||
|
||||
## Context
|
||||
|
||||
The V2 CLI runs a shared managed service that owns Sessions, location graphs,
|
||||
plugins, permissions, and tool execution. The service updater can replace the
|
||||
installed package while the current process continues running the old image.
|
||||
A later TUI launch then detects the version mismatch and replaces the service.
|
||||
|
||||
Incident #36688 showed four failures in that replacement path:
|
||||
|
||||
- Multiple TUIs spawned heavyweight server contenders.
|
||||
- A winner remained unobservable while it cold-booted, so another wave treated
|
||||
it as absent and displaced it.
|
||||
- A fresh TUI exhausted its reconnect budget and crashed with an unhandled
|
||||
transport defect.
|
||||
- A losing contender remained alive and consumed about 1 GB of RSS.
|
||||
|
||||
The `origin/v2` baseline serializes service startup with `EffectFlock`. A
|
||||
contender acquires a three-second heartbeat lease, checks whether another
|
||||
service became discoverable, and only the winner crosses the application-boot
|
||||
boundary. This already prevents simultaneous heavy boots and makes startup
|
||||
losers exit.
|
||||
|
||||
The lease is released immediately after registration, however, so it is not
|
||||
lifetime ownership. Registration then reverts to last-writer-wins authority: a
|
||||
deleted or corrupt registration can admit a second boot, a displaced server
|
||||
terminates itself through its 10-second registration self-check, and a stalled
|
||||
lease holder can be displaced after the three-second service staleness timeout.
|
||||
|
||||
`Flock` and `EffectFlock` live in `packages/core/src/util` and are also used for
|
||||
config writes, MCP auth, npm installs, and repository caching. Despite the
|
||||
name, the primitive is an atomic-mkdir lease with heartbeat and staleness
|
||||
takeover, not an OS-held lock. It remains appropriate for bounded critical
|
||||
sections, including today's startup fence, but is not lifetime service
|
||||
ownership.
|
||||
|
||||
The current implementation also mixes three different concepts:
|
||||
|
||||
- **Ownership:** which process is allowed to be the managed server.
|
||||
- **Discovery:** where clients can reach that process.
|
||||
- **Lifecycle:** whether that process is starting, ready, stopping, or failed.
|
||||
|
||||
This design gives each concept one authority.
|
||||
|
||||
```definitions
|
||||
[
|
||||
{
|
||||
"term": "Owner",
|
||||
"definition": "The one process holding the process-held OS service lock."
|
||||
},
|
||||
{
|
||||
"term": "Contender",
|
||||
"definition": "A small serve process attempting to acquire the service lock. It must not initialize the application before winning."
|
||||
},
|
||||
{
|
||||
"term": "Registration",
|
||||
"definition": "An atomic discovery record containing the elected owner's identity and endpoint. Registration never grants ownership."
|
||||
},
|
||||
{
|
||||
"term": "Lifecycle shell",
|
||||
"definition": "The minimal HTTP surface bound by the elected process before application initialization. It serves health and retryable startup responses."
|
||||
},
|
||||
{
|
||||
"term": "Application",
|
||||
"definition": "The full server routes and global or location-scoped modules used for normal OpenCode work."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Goals
|
||||
|
||||
- At most one process initializes and serves the managed application.
|
||||
- Losing contenders exit before database, route, plugin, MCP, or location boot.
|
||||
- A slow winner becomes observable before expensive initialization.
|
||||
- Existing and freshly launched TUIs survive retryable service unavailability.
|
||||
- Reconnect follows service state instead of displaying retry counts or raw
|
||||
transport failures.
|
||||
- Version-mismatch replacement remains triggered by a fresh TUI launch.
|
||||
- A stale or malformed registration cannot create a second owner.
|
||||
- An unresponsive owner is never killed automatically by an arbitrary TUI.
|
||||
- Every spawned contender has a bounded path to ownership or exit.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Restarting automatically when a background update finds an idle window.
|
||||
- Running old and candidate application servers concurrently.
|
||||
- Adding a permanent steward, proxy, or supervisor process.
|
||||
- Zero-downtime worker handoff or automatic rollback.
|
||||
- Application protocol negotiation or automatic TUI self-restart.
|
||||
- General hard-crash recovery for active Sessions.
|
||||
- Defining recovery semantics for provider attempts, tools, shells, sub-agents,
|
||||
permissions, questions, or background jobs.
|
||||
- Automatically killing a frozen owner.
|
||||
- Bounding concurrent location cold boots after clients reconnect.
|
||||
- Multi-machine or clustered service placement.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. **The service lock is ownership.** Exactly one process may hold the OS lock
|
||||
for one installation channel and service profile.
|
||||
2. **Ownership precedes boot.** A contender performs no expensive application
|
||||
initialization before it acquires the lock.
|
||||
3. **Ownership lasts for the process lifetime.** The owner holds an open lock
|
||||
handle until the managed server exits. The OS releases it on process death
|
||||
without a cleanup callback.
|
||||
4. **The port is transport, not election.** The owner may select a dynamic port
|
||||
after acquiring the lock.
|
||||
5. **Registration is discovery, not election.** Deleting, corrupting, or
|
||||
replacing registration does not invalidate a live owner's lock.
|
||||
6. **Only a fresh launch enforces package version.** Existing TUIs reconnect to
|
||||
the current owner without initiating version replacement.
|
||||
7. **Transport loss is retryable.** It never terminates a TUI without a separate
|
||||
diagnosed, non-retryable cause.
|
||||
8. **Clients do not kill an unresponsive owner automatically.** Destructive
|
||||
recovery requires the explicit `service restart` command.
|
||||
9. **Lifecycle does not promise execution semantics.** Graceful replacement
|
||||
invokes Session suspension and resumption hooks, but tool-level continuity
|
||||
belongs to a separate design.
|
||||
|
||||
## System Model
|
||||
|
||||
```text
|
||||
╭───────────────────────╮ ╭──────────────────────────────╮
|
||||
│ Fresh or existing TUI │ │ Process-held OS service lock │
|
||||
╰───────────┬───────────╯ ╰───────────────┬──────────────╯
|
||||
╰─────┬ normal requests observe ───────────────────────╮ │
|
||||
│ discover │ ├──╯ authorizes one owner
|
||||
▼ │ ▼
|
||||
╭───────────────────╮ │ ╭─────────────────╮
|
||||
│ Registration file │ │ │ Lifecycle shell │
|
||||
╰───────────────────╯ │ ╰────────┬────────╯
|
||||
│ │
|
||||
├────────────────────────╯
|
||||
▼
|
||||
╭──────────────────────╮
|
||||
│ OpenCode application │
|
||||
╰──────────────────────╯
|
||||
```
|
||||
|
||||
The lifecycle shell and application run in the same process. The distinction is
|
||||
initialization order and responsibility, not process topology.
|
||||
|
||||
## Service Status
|
||||
|
||||
The server reports one small status value:
|
||||
|
||||
```typescript
|
||||
type ServiceStatus =
|
||||
| {
|
||||
type: "starting"
|
||||
}
|
||||
| {
|
||||
type: "ready"
|
||||
}
|
||||
| {
|
||||
type: "stopping"
|
||||
targetVersion?: string
|
||||
}
|
||||
| {
|
||||
type: "failed"
|
||||
message: string
|
||||
action: string
|
||||
}
|
||||
```
|
||||
|
||||
The client adds only the discovery states needed by callers:
|
||||
|
||||
```typescript
|
||||
type Status = { type: "missing" } | { type: "unreachable" } | { type: "unresponsive" } | ServiceStatus
|
||||
```
|
||||
|
||||
The health response retains the existing fields for old clients and adds the
|
||||
status discriminant:
|
||||
|
||||
```typescript
|
||||
type ServiceHealth = {
|
||||
healthy: true
|
||||
version: string
|
||||
pid: number
|
||||
instanceID: string
|
||||
status: ServiceStatus
|
||||
}
|
||||
```
|
||||
|
||||
`healthy: true` means the registered lifecycle shell is responding and its
|
||||
identity matches registration. New clients use `status.type === "ready"` as
|
||||
the application-readiness signal.
|
||||
|
||||
During `starting` or `stopping`, application requests are not held in memory.
|
||||
They receive an immediate retryable response:
|
||||
|
||||
```http
|
||||
HTTP/1.1 503 Service Unavailable
|
||||
Retry-After: 1
|
||||
Content-Type: application/json
|
||||
|
||||
{"code":"service_starting"}
|
||||
```
|
||||
|
||||
`stopping` uses `service_stopping`. A failed application boot uses
|
||||
`service_failed` and includes a safe diagnostic message.
|
||||
|
||||
A failed owner remains bound and keeps holding the service lock. Exiting on
|
||||
failure would let every waiting client's `ensureRunning` loop elect a new
|
||||
contender that repeats the same heavy failing boot, so staying bound turns a
|
||||
deterministic boot failure into one observable `failed` state instead of a
|
||||
client-driven respawn loop. Recovery still works: a fresh launch observes the
|
||||
failed instance through the stop path, and explicit `service restart` replaces
|
||||
it.
|
||||
|
||||
## Registration Contract
|
||||
|
||||
Registration contains only discovery identity:
|
||||
|
||||
```typescript
|
||||
type ServiceRegistration = {
|
||||
schema: 1
|
||||
instanceID: string
|
||||
version: string
|
||||
url: string
|
||||
pid: number
|
||||
}
|
||||
```
|
||||
|
||||
Authentication continues to use the existing private service credential
|
||||
storage. The registration schema does not change that policy.
|
||||
|
||||
The owner writes registration only after the lifecycle shell has bound:
|
||||
|
||||
1. Bind the lifecycle shell.
|
||||
2. Write a temporary registration file with mode `0600`.
|
||||
3. Atomically rename it over the old registration.
|
||||
4. Serve lifecycle health as `starting`.
|
||||
|
||||
On shutdown, the owner removes registration only if the current file still has
|
||||
its `instanceID`. An old finalizer can never remove a successor's registration.
|
||||
|
||||
While running, the owner periodically asserts its registration. Because the
|
||||
lock guarantees exactly one live owner, any registration that does not name the
|
||||
owner is stale or corrupt, and the owner rewrites it. A deleted or clobbered
|
||||
registration therefore heals within one assertion interval instead of leaving
|
||||
clients waiting on absent discovery. This inverts today's self-check loop,
|
||||
which terminates the displaced process instead of repairing discovery.
|
||||
|
||||
Legacy registration shapes are decoded by a compatibility adapter. The new
|
||||
domain type does not make fields optional to represent old formats.
|
||||
|
||||
## Election
|
||||
|
||||
This design promotes today's startup fence into lifetime ownership.
|
||||
Last-writer-wins registration is replaced by a process-held OS lock that is
|
||||
acquired before any expensive boot work and held for the entire service
|
||||
lifetime.
|
||||
|
||||
A heartbeat-and-staleness lease, including the existing `Flock` utility, is not
|
||||
sufficient for service ownership: the service configures a three-second stale
|
||||
timeout, after which its lock can be broken and recreated. An event-loop stall,
|
||||
a suspended machine, or a debugger pause can therefore make a live owner appear
|
||||
stale and allow a contender to displace it. Service ownership requires a
|
||||
process-held OS lock: `flock` on Unix and an exclusively bound named pipe on
|
||||
Windows. It cannot be broken because a heartbeat exceeded a timeout. Process
|
||||
death releases the lock through the OS.
|
||||
|
||||
Neither Bun nor Node exposes `flock` directly, the existing `Flock` utility is
|
||||
an mkdir-plus-heartbeat lease rather than an OS-held lock, and the common
|
||||
lockfile packages are staleness-based leases as well. The platform layer uses
|
||||
`bun:ffi` to call `flock` on POSIX and Node's named-pipe server support on
|
||||
Windows, where Bun FFI is not available on every shipped architecture. It lives
|
||||
alongside the existing utility in `packages/core/src/util`. This primitive is
|
||||
the foundation of the design, so the delivery sequence spikes it first.
|
||||
|
||||
```text
|
||||
Contender Lock Lifecycle Application
|
||||
│ │ │ │
|
||||
├─ try acquire ───▶ │ │
|
||||
│ │ │ │
|
||||
╭─ alt: lock held ────────────────────────────────────────────────╮
|
||||
│ │ │ │ │ │
|
||||
│ ◀─ busy ──────────┤ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ├─────────╮ │ │ │ │
|
||||
│ │ exit │ │ │ │ │
|
||||
│ ◀─────────╯ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
├─ else: lock acquired ───────────────────────────────────────────┤
|
||||
│ │ │ │ │ │
|
||||
│ ◀─ owner ─────────┤ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ├─ bind, register, starting ────────▶ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ├─ initialize ──────────────────────────────────────────────▶ │
|
||||
│ │ │ │ │ │
|
||||
│╭─ alt: boot succeeds ──────────────────────────────────────────╮│
|
||||
││ │ │ │ │ ││
|
||||
││ │ │ ◀─ ready ───────────────┤ ││
|
||||
││ │ │ │ │ ││
|
||||
│├─ else: boot fails ────────────────────────────────────────────┤│
|
||||
││ │ │ │ │ ││
|
||||
││ │ │ ◀─ failed, stay bound ──┤ ││
|
||||
││ │ │ │ │ ││
|
||||
│╰───────────────────────────────────────────────────────────────╯│
|
||||
│ │ │ │ │ │
|
||||
╰─────────────────────────────────────────────────────────────────╯
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
Lock acquisition by a contender is nonblocking or tightly bounded. A loser
|
||||
must exit before constructing application routes or importing startup-heavy
|
||||
modules.
|
||||
|
||||
Several clients may spawn contenders concurrently. The design guarantees one
|
||||
heavy winner, not one process spawn. If the winner crashes during startup, the
|
||||
OS releases the lock and a later client retry starts another election.
|
||||
|
||||
The lock is scoped by installation channel and service profile. Local, preview,
|
||||
and stable installations cannot displace one another.
|
||||
|
||||
## Update Activation
|
||||
|
||||
Background update behavior remains unchanged:
|
||||
|
||||
1. The running service checks for an update.
|
||||
2. The updater installs the package in the background.
|
||||
3. The running process continues using its existing process image.
|
||||
4. No idle check or automatic restart occurs.
|
||||
|
||||
A fresh TUI launch activates the installed update:
|
||||
|
||||
1. Read registration and authenticate the responding service.
|
||||
2. If its package version matches the fresh client, attach normally.
|
||||
3. If the version differs, request graceful stop of that exact registered
|
||||
instance using the existing authenticated stop path.
|
||||
4. Re-check instance identity before every signal or escalation in that path.
|
||||
5. Wait for the old process to exit and release the service lock.
|
||||
6. Call `ensureRunning` until a compatible service becomes ready.
|
||||
|
||||
Concurrent fresh launchers may all observe the same old instance. Stopping that
|
||||
exact instance must be idempotent. Once registration names a different instance,
|
||||
a stale launcher stops signaling and returns to discovery.
|
||||
|
||||
No durable restart-transition record is introduced. The initiating fresh TUI
|
||||
already knows the source and target versions and can display its update
|
||||
preflight. Existing TUIs may display `Updating...` if they observed `stopping`;
|
||||
otherwise `Waiting for background service...` is the honest fallback.
|
||||
|
||||
## Fresh Launch Versus Reconnect
|
||||
|
||||
Fresh launch and reconnect deliberately have different version policies:
|
||||
|
||||
```typescript
|
||||
type ManagedConnection =
|
||||
| {
|
||||
type: "launch"
|
||||
requiredVersion: string
|
||||
}
|
||||
| {
|
||||
type: "reconnect"
|
||||
}
|
||||
```
|
||||
|
||||
- `launch` requires the installed package version and may activate replacement.
|
||||
- `reconnect` accepts the current owner and never activates replacement.
|
||||
|
||||
This preserves today's permissive reconnect behavior. Explicit application
|
||||
protocol negotiation and automatic TUI re-exec remain follow-ups.
|
||||
|
||||
## Client Reconnect
|
||||
|
||||
Fresh and existing TUIs use the same status loop after startup:
|
||||
|
||||
1. Read registration on every attempt. Do not retry a stale URL indefinitely.
|
||||
2. If registration is absent, call `ensureRunning` and continue waiting.
|
||||
3. If registration is unreachable, call `ensureRunning`. A live owner prevents
|
||||
contenders from acquiring the lock; a dead owner does not.
|
||||
4. If status is `starting` or `stopping`, wait.
|
||||
5. If status is `failed`, show its actionable message.
|
||||
6. If status is `ready`, rebuild HTTP and event-stream clients for the new
|
||||
endpoint and perform authoritative state reconciliation.
|
||||
|
||||
Retry cadence is internal policy. Retry counts are telemetry, not user-facing
|
||||
state. The TUI waits until the service is ready or the user exits.
|
||||
|
||||
Transport failures are handled at the TUI run boundary. A raw client transport
|
||||
error or Effect defect must not escape to the terminal. Hard exit is reserved
|
||||
for diagnosed causes such as invalid local configuration, failed authentication,
|
||||
or a foreign process occupying an explicitly configured port.
|
||||
|
||||
The UI derives text from status:
|
||||
|
||||
| Status | User-facing state |
|
||||
| ------------------------ | ----------------------------------- |
|
||||
| No registration | `Starting background service...` |
|
||||
| Registration unreachable | `Waiting for background service...` |
|
||||
| `starting` | `Starting OpenCode vX...` |
|
||||
| `stopping` | `Updating to vX...` |
|
||||
| `failed` | Actionable failure message |
|
||||
| `ready` | Normal TUI |
|
||||
|
||||
## Graceful Session Continuity
|
||||
|
||||
Version-mismatch replacement uses the existing graceful Session suspension and
|
||||
resumption hooks:
|
||||
|
||||
1. The old server snapshots active Session IDs during graceful teardown.
|
||||
2. The successor schedules those Sessions for continuation.
|
||||
3. The runner reloads durable Session history before continuing.
|
||||
|
||||
This lifecycle design does not define what an interrupted physical provider
|
||||
attempt or tool invocation means. It does not promise that external side effects
|
||||
did not occur, replay the exact interrupted tool, preserve an in-memory form, or
|
||||
recover process-local background work.
|
||||
|
||||
Those concerns require a separate execution-continuity design covering tools,
|
||||
shells, sub-agents, permissions, questions, provider attempts, and hard-crash
|
||||
recovery.
|
||||
|
||||
## Unresponsive Owner
|
||||
|
||||
An unreachable registration does not prove that the owner is dead. A contender
|
||||
attempts the service lock:
|
||||
|
||||
- If the lock is free, the contender starts a replacement.
|
||||
- If the lock is held, the contender exits and the client keeps waiting.
|
||||
|
||||
After a bounded diagnostic threshold, the client may show:
|
||||
|
||||
```text
|
||||
The background service owns the service lock but is not responding.
|
||||
Run `opencode service restart` to recover it.
|
||||
```
|
||||
|
||||
Only explicit `service restart` may perform destructive recovery. It verifies
|
||||
the complete registration and process instance before signaling, waits for
|
||||
graceful exit, re-checks identity before escalation, and refuses to kill a
|
||||
process it cannot positively identify.
|
||||
|
||||
Automatic frozen-owner recovery is deferred.
|
||||
|
||||
## Failure Walkthroughs
|
||||
|
||||
### Update with open TUIs
|
||||
|
||||
1. The old service installs vNext but keeps running.
|
||||
2. A fresh vNext TUI finds the healthy vOld service and requests graceful stop.
|
||||
3. The old service reports `stopping`, suspends active Sessions, and exits.
|
||||
4. Open TUIs enter their indefinite status loops.
|
||||
5. One or more clients spawn contenders.
|
||||
6. One contender acquires the service lock. Losers exit before heavy boot.
|
||||
7. The winner binds and registers the lifecycle shell as `starting`.
|
||||
8. Clients stop spawning and wait on the observable winner.
|
||||
9. The winner initializes the application and reports `ready`.
|
||||
10. TUIs rebuild clients, reconcile state, and resume.
|
||||
|
||||
### Server crashes while ready
|
||||
|
||||
1. The endpoint becomes unreachable and registration may remain stale.
|
||||
2. Clients call `ensureRunning`.
|
||||
3. Process death has released the service lock.
|
||||
4. One contender wins, replaces registration, and starts normally.
|
||||
5. Detailed active-execution recovery is outside this design.
|
||||
|
||||
### Winner crashes during startup
|
||||
|
||||
1. Clients observed `starting` and remain alive.
|
||||
2. Process death releases the service lock.
|
||||
3. A later reconnect attempt starts another election.
|
||||
4. One new contender wins; all other contenders exit.
|
||||
|
||||
### Registration is deleted while the owner is healthy
|
||||
|
||||
1. Clients may call `ensureRunning` because discovery is absent.
|
||||
2. Every contender fails to acquire the owner's lock and exits.
|
||||
3. No second application initializes.
|
||||
4. The owner's next registration assertion republishes discovery.
|
||||
|
||||
### Owner is alive but unresponsive
|
||||
|
||||
1. Health fails, but the process still holds the service lock.
|
||||
2. Contenders fail lock acquisition and exit.
|
||||
3. Clients wait and eventually show explicit recovery guidance.
|
||||
4. No TUI kills the owner automatically.
|
||||
|
||||
## TDD Verification
|
||||
|
||||
Implementation should proceed test-first with real subprocesses and real locks.
|
||||
Mocks cannot establish process death, lock release, loser cleanup, or port
|
||||
behavior.
|
||||
|
||||
### Election tests
|
||||
|
||||
| Scenario | Required result |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Ten contenders start simultaneously | Exactly one crosses the application-boot boundary |
|
||||
| Winner pauses after lock acquisition | No loser initializes or remains alive |
|
||||
| Winner event loop pauses beyond the old stale timeout | Ownership is not displaced |
|
||||
| Winner crashes before bind | Lock releases; a later attempt wins |
|
||||
| Winner crashes after bind but before registration | Lock releases; a later attempt replaces stale discovery |
|
||||
| Registration is deleted while owner runs | No second owner initializes |
|
||||
| Registration is malformed | Lock still prevents a second owner |
|
||||
| Registration names a dead PID | New contender can acquire the released lock |
|
||||
| Two installation channels start | Each elects an independent owner |
|
||||
| Explicit configured port is foreign-owned | Fail diagnostically; do not kill the foreign process |
|
||||
|
||||
The fixture records a marker immediately before application initialization. The
|
||||
tests assert that only one process writes that marker and that every loser exits
|
||||
within a bounded interval. The harness should also assert that a loser's peak
|
||||
RSS stays an order of magnitude below an application boot, since import weight
|
||||
was the observed incident cost.
|
||||
|
||||
### Lifecycle tests
|
||||
|
||||
| Scenario | Required result |
|
||||
| ----------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| Winner owns lock but application boot is paused | Health reports `starting` |
|
||||
| Application request arrives during startup | Immediate retryable `503` |
|
||||
| Application becomes ready | Status changes once from `starting` to `ready` |
|
||||
| Graceful replacement begins | Status reports `stopping` before disconnect |
|
||||
| Application initialization fails | Actionable `failed` status; owner stays bound and holds the lock |
|
||||
| Registration is deleted while owner runs | Owner republishes it within one assertion interval |
|
||||
| Owner exits | Registration is removed only if it still names that owner |
|
||||
|
||||
### Update tests
|
||||
|
||||
| Scenario | Required result |
|
||||
| -------------------------------------- | -------------------------------------------------------- |
|
||||
| Background update installs vNext | Running vOld service does not restart |
|
||||
| Fresh vNext launch finds vOld | Exact old instance stops; vNext eventually becomes ready |
|
||||
| Two fresh vNext launches race | One heavy successor; both clients attach |
|
||||
| Existing vOld TUI reconnects to vNext | It never requests replacement |
|
||||
| Stale launcher observes a new instance | It does not signal the new instance |
|
||||
|
||||
### Reconnect tests
|
||||
|
||||
| Scenario | Required result |
|
||||
| --------------------------------------------------- | -------------------------------------------------- |
|
||||
| Endpoint disappears and changes port | TUI rediscovers and rebuilds clients |
|
||||
| Service remains unavailable beyond old retry budget | TUI remains alive |
|
||||
| Event stream reconnects | Client performs authoritative state reconciliation |
|
||||
| Transport returns an unexpected defect | TUI formats it; no raw stack escapes |
|
||||
| Owner remains unresponsive | TUI waits and shows explicit restart guidance |
|
||||
|
||||
## Delivery Sequence
|
||||
|
||||
1. **Spike the lock primitive.** Prove a nonblocking, process-held OS lock
|
||||
under Bun on macOS, Linux, and Windows (`bun:ffi` to `flock` on POSIX and a
|
||||
named pipe on Windows), including release on hard kill and behavior across
|
||||
containers and network filesystems used in CI.
|
||||
2. **Expand the subprocess test harness.** Begin from the baseline
|
||||
two-contender test and cover ten contenders, lock release on crash, a paused
|
||||
winner, deleted or corrupt registration, and bounded loser exit before
|
||||
changing ownership.
|
||||
3. **Contain client failure.** Make transport loss nonterminal, rediscover on
|
||||
every cycle, and format unexpected failures at the TUI boundary.
|
||||
4. **Promote the startup fence to process-held ownership.** Preserve the
|
||||
existing pre-boot acquisition seam, replace its lease with the OS lock, hold
|
||||
it until process exit, and invert the registration self-check from
|
||||
self-termination to reassertion.
|
||||
5. **Bind the lifecycle shell first.** Publish registration and `starting`,
|
||||
return retryable `503` for application requests, then initialize the app.
|
||||
The health contract change is public API: regenerate clients from
|
||||
`packages/client` with `bun run generate`.
|
||||
6. **Codify launch versus reconnect.** Fresh launch enforces installed version;
|
||||
reconnect never activates replacement.
|
||||
7. **Integrate graceful replacement.** Preserve current background-install and
|
||||
fresh-launch activation behavior while invoking Session continuity hooks.
|
||||
8. **Harden explicit recovery.** Verify exact process identity during explicit
|
||||
`service restart`; never automatically kill an unresponsive owner.
|
||||
9. **Run the full multi-process suite.** Include repeated restart cycles and
|
||||
assert that no contender or child process remains afterward.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Ten concurrent restart observers produce one application initialization.
|
||||
- No losing contender survives or builds a location graph.
|
||||
- A 30-second application boot remains continuously observable as `starting`.
|
||||
- A TUI remains alive through a service outage longer than the previous retry
|
||||
budget.
|
||||
- A service endpoint change does not require restarting an existing TUI.
|
||||
- Background installation alone does not restart the service.
|
||||
- A fresh mismatched TUI eventually attaches to the installed service version.
|
||||
- Existing reconnecting TUIs never replace the current owner.
|
||||
- Registration corruption cannot produce two owners.
|
||||
- A deleted registration heals without restarting the owner or any client.
|
||||
- An unresponsive owner is not killed without an explicit recovery command.
|
||||
- Raw transport defects never escape to the terminal.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Idle background update activation with an admission fence.
|
||||
- Application protocol compatibility and automatic local TUI re-exec.
|
||||
- Durable execution recovery for provider attempts and tools.
|
||||
- Shell, sub-agent, permission, question, and background-job continuity.
|
||||
- Automatic recovery for a positively identified frozen owner.
|
||||
- Cold-boot concurrency limits and interaction-prioritized location loading.
|
||||
- A steward or socket-handoff architecture if zero-downtime replacement becomes
|
||||
a real requirement.
|
||||
@@ -495,6 +495,7 @@ async function subscribeSessionEvents() {
|
||||
console.log("Subscribing to session events...")
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
||||
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
||||
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
||||
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
||||
|
||||
+7
-19
@@ -2,24 +2,18 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||
"dev:www": "bun run --cwd packages/www dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||
"typecheck": "bun turbo typecheck --concurrency=3",
|
||||
"typecheck:profile": "bun script/profile-typecheck.ts",
|
||||
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||
"prepare": "husky",
|
||||
@@ -37,9 +31,9 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-beta.98",
|
||||
"@effect/platform-node": "4.0.0-beta.98",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
|
||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -69,13 +63,12 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.98",
|
||||
"effect": "4.0.0-beta.83",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"luxon": "3.6.1",
|
||||
"marked": "17.0.6",
|
||||
"marked-shiki": "1.2.1",
|
||||
@@ -86,11 +79,9 @@
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
"resolve.exports": "2.0.3",
|
||||
"sst": "4.13.1",
|
||||
"shiki": "4.2.0",
|
||||
"solid-list": "0.3.0",
|
||||
"string-width": "7.2.0",
|
||||
"tailwindcss": "4.1.11",
|
||||
"vite": "7.1.4",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
@@ -105,9 +96,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@ast-grep/cli": "0.44.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -166,7 +154,7 @@
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
# @opencode-ai/ai
|
||||
|
||||
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMClient } from "@opencode-ai/ai"
|
||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||
|
||||
const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
|
||||
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "You are concise.",
|
||||
prompt: "Say hello in one short sentence.",
|
||||
generation: { maxTokens: 40 },
|
||||
})
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
console.log(response.text)
|
||||
})
|
||||
```
|
||||
|
||||
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
|
||||
## Image generation
|
||||
|
||||
Use `Image.generate` with an image model for direct asset generation:
|
||||
|
||||
```ts
|
||||
import { Image, ImageInput } from "@opencode-ai/ai"
|
||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
options: {
|
||||
n: 2,
|
||||
size: "1024x1024",
|
||||
quality: "high", // inferred from the OpenAI image model
|
||||
outputFormat: "webp",
|
||||
future_option: true, // unknown native options pass through unchanged
|
||||
},
|
||||
})
|
||||
|
||||
return response.images // GeneratedImage[] with owned bytes or a provider URL
|
||||
})
|
||||
```
|
||||
|
||||
Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
|
||||
|
||||
```ts
|
||||
const response =
|
||||
yield *
|
||||
Image.generate({
|
||||
model,
|
||||
prompt: "Combine these product photos into one studio scene",
|
||||
images: [
|
||||
ImageInput.bytes(firstBytes, "image/png"),
|
||||
ImageInput.url("https://example.com/second.webp"),
|
||||
ImageInput.file("file_123"),
|
||||
],
|
||||
options,
|
||||
http,
|
||||
})
|
||||
```
|
||||
|
||||
`ImageInput.fileUri(uri, mediaType)` represents provider file URIs such as Gemini Files. Raw strings are not
|
||||
accepted as image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images`
|
||||
uses text-to-image generation; a non-empty array selects the provider's edit behavior without enforcing provider
|
||||
image-count limits locally. `images` is the only common image-editing field. OpenAI uses multipart for byte/data-URL
|
||||
edits and its JSON reference body for URL or file-ID edits. Its provider-specific `options.mask` accepts an
|
||||
`ImageInput` for inpainting:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: OpenAI.configure({ apiKey }).image("gpt-image-2"),
|
||||
prompt,
|
||||
images: [ImageInput.bytes(sourceBytes, "image/png")],
|
||||
options: { mask: ImageInput.bytes(maskBytes, "image/png") },
|
||||
})
|
||||
```
|
||||
|
||||
The OpenAI adapter extracts this helper value into the edit request's native `mask` field rather than passing the
|
||||
tagged `ImageInput` object through as an ordinary option. On multipart requests, `http.body` can override option
|
||||
fields but not structural `model`, `prompt`, `image[]`, or `mask` fields, and the transport owns the multipart
|
||||
`Content-Type` boundary. For JSON requests, `http.body` remains the final raw-native overlay. Gemini does not fetch
|
||||
public HTTP URLs, and hosted Z.ai image generation does not accept image inputs. These cases fail with
|
||||
`InvalidRequest` before network I/O.
|
||||
|
||||
Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
|
||||
|
||||
```ts
|
||||
const model = OpenAI.configure({ apiKey }).image("gpt-image-2")
|
||||
|
||||
yield *
|
||||
Image.generate({
|
||||
model,
|
||||
prompt,
|
||||
options: { quality: "medium" },
|
||||
http,
|
||||
})
|
||||
```
|
||||
|
||||
xAI image models use the same request API with xAI-native controls:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: XAI.configure({ apiKey }).image("any-model-id"),
|
||||
prompt,
|
||||
options: {
|
||||
n: 2,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1k",
|
||||
responseFormat: "b64_json",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
```
|
||||
|
||||
Google's current Gemini image models use the same direct API:
|
||||
|
||||
```ts
|
||||
import { Google } from "@opencode-ai/ai/providers"
|
||||
|
||||
const googleProgram = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: Google.configure({ apiKey }).image("any-model-id"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
options: {
|
||||
aspectRatio: "16:9",
|
||||
imageSize: "2K",
|
||||
seed: 42,
|
||||
thinkingLevel: "HIGH",
|
||||
includeThoughts: true,
|
||||
futureOption: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
|
||||
return response.images
|
||||
})
|
||||
```
|
||||
|
||||
Google image options are request-scoped and inferred from the selected model. Known fields autocomplete while
|
||||
future string values and arbitrary native Gemini `generationConfig` fields remain available. Native fields override
|
||||
their mapped aliases, and `http.body` is the final deep overlay. The selected model ID is sent to Gemini
|
||||
`generateContent` without a local allowlist.
|
||||
|
||||
Z.ai image models infer open Z.ai-native options from the selected model:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: ZAI.configure({ apiKey }).image("any-model-id"),
|
||||
prompt,
|
||||
options: {
|
||||
quality: "hd",
|
||||
userID: "user-123",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
```
|
||||
|
||||
Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
|
||||
`application/octet-stream`. Output URLs expire after 30 days; download and persist them promptly if they must
|
||||
remain available.
|
||||
|
||||
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
|
||||
|
||||
```ts
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey }).responses("gpt-5"),
|
||||
prompt: "Design a solarpunk rooftop garden, then show me.",
|
||||
tools: [OpenAI.imageGeneration({ quality: "high" })],
|
||||
}),
|
||||
)
|
||||
|
||||
return response.message
|
||||
})
|
||||
```
|
||||
|
||||
The hosted result is represented as a provider-executed tool call and tool result. Its image is a `file` content item with a data URI, so retaining `response.message` preserves the generated image for continuation.
|
||||
|
||||
## Public API
|
||||
|
||||
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
||||
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
|
||||
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
|
||||
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
|
||||
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
|
||||
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
|
||||
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
|
||||
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
|
||||
|
||||
## Caching
|
||||
|
||||
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
|
||||
|
||||
### Auto placement
|
||||
|
||||
`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
|
||||
|
||||
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
|
||||
|
||||
### Opting out
|
||||
|
||||
```ts
|
||||
LLM.request({
|
||||
model,
|
||||
system,
|
||||
prompt: "one-off question",
|
||||
cache: "none",
|
||||
})
|
||||
```
|
||||
|
||||
### Granular policy
|
||||
|
||||
```ts
|
||||
cache: {
|
||||
tools?: boolean,
|
||||
system?: boolean,
|
||||
messages?: "latest-user-message" | "latest-assistant" | { tail: number },
|
||||
ttlSeconds?: number, // ≥ 3600 → 1h on Anthropic/Bedrock; else 5m
|
||||
}
|
||||
```
|
||||
|
||||
### Manual hints
|
||||
|
||||
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
|
||||
|
||||
```ts
|
||||
LLM.request({
|
||||
model,
|
||||
system: [
|
||||
{ type: "text", text: "stable system prompt", cache: { type: "ephemeral" } },
|
||||
],
|
||||
...
|
||||
})
|
||||
```
|
||||
|
||||
### Provider behavior table
|
||||
|
||||
| Protocol | `cache: "auto"` |
|
||||
| ----------------------- | ------------------------------------------------------------------------- |
|
||||
| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
|
||||
| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
|
||||
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
|
||||
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
|
||||
|
||||
Normalized cache usage is read back into `response.usage.cacheReadInputTokens` and `cacheWriteInputTokens` across every provider.
|
||||
|
||||
## Providers
|
||||
|
||||
Provider facades configure endpoint/auth/deployment details first, then expose model selectors that take only a model or deployment id. The selected model carries the executable route value used at runtime.
|
||||
|
||||
```ts
|
||||
import { OpenAI, CloudflareAIGateway } from "@opencode-ai/ai/providers"
|
||||
|
||||
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
|
||||
const gateway = CloudflareAIGateway.configure({
|
||||
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
|
||||
gatewayApiKey: process.env.CLOUDFLARE_API_TOKEN,
|
||||
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
||||
```
|
||||
|
||||
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
||||
|
||||
### Package-like entrypoints
|
||||
|
||||
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
transport: "websocket",
|
||||
headers: { "x-application": "opencode" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
})
|
||||
```
|
||||
|
||||
OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
|
||||
|
||||
- `@opencode-ai/ai/providers/openai/chat`
|
||||
- `@opencode-ai/ai/providers/openai/responses`
|
||||
- `@opencode-ai/ai/providers/openai-compatible/responses`
|
||||
- `@opencode-ai/ai/providers/anthropic-compatible`
|
||||
- `@opencode-ai/ai/providers/google-vertex/gemini`
|
||||
- `@opencode-ai/ai/providers/google-vertex/chat`
|
||||
- `@opencode-ai/ai/providers/google-vertex/responses`
|
||||
- `@opencode-ai/ai/providers/google-vertex/messages`
|
||||
|
||||
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; compatible Responses is separate at `providers/openai-compatible/responses`. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
|
||||
|
||||
Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`.
|
||||
|
||||
Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890` and require OAuth or ADC; Vertex express-mode API keys support publisher models only.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
|
||||
|
||||
model("gemini-3.5-flash", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
|
||||
|
||||
model("deepseek-ai/deepseek-v3.2-maas", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
|
||||
|
||||
model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
|
||||
|
||||
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path.
|
||||
|
||||
Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package.
|
||||
|
||||
## Provider options & HTTP overlays
|
||||
|
||||
Three escape hatches in order of stability:
|
||||
|
||||
1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop).
|
||||
2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
|
||||
3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
|
||||
|
||||
Route/provider defaults are overridden by request-level values for each axis.
|
||||
|
||||
## Routes
|
||||
|
||||
Adding a new model or deployment is usually 5-15 lines using `Route.make({ protocol, endpoint, auth, framing, ... })`. The route owns endpoint/auth/framing and the protocol owns body construction plus stream parsing. Transports are reusable IO templates that receive route endpoint/auth at compile time. Capability/catalog metadata lives outside this low-level package; unsupported request shapes fail during protocol lowering. See `AGENTS.md` for the architectural detail.
|
||||
|
||||
## Effect
|
||||
|
||||
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for LLM dispatch and `ImageClient.layer` for image dispatch, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
|
||||
|
||||
## See also
|
||||
|
||||
- `AGENTS.md` — architecture, route construction, contributor guide
|
||||
- `STATUS.md` — native provider parity status and AI SDK migration gaps
|
||||
- `example/tutorial.ts` — runnable end-to-end walkthrough
|
||||
- `test/provider/*.test.ts` — fixture-first protocol tests; `*.recorded.test.ts` files cover live cassettes
|
||||
@@ -1,108 +0,0 @@
|
||||
# LLM Provider Parity Status
|
||||
|
||||
Last reviewed: 2026-07-17
|
||||
|
||||
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
|
||||
|
||||
## Existing Status Sources
|
||||
|
||||
| File | What it tracks | Limitation |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| `packages/ai/DESIGN.md` | Future clean-break API proposal for `@opencode-ai/ai`. | Not a provider parity tracker. |
|
||||
| `packages/ai/example/call-sites.md` | Route/value/provider-facade migration checklist and call-site sketches. | Architecture migration only; not AI SDK package parity. |
|
||||
|
||||
## Current Implementation Snapshot
|
||||
|
||||
| Native slice | Source | Current state | Main gaps |
|
||||
| ---------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
|
||||
| OpenAI Responses HTTP | `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Supports hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
|
||||
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
|
||||
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
|
||||
| OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. |
|
||||
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
|
||||
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
|
||||
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
|
||||
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
|
||||
| Vertex Chat | `src/protocols/openai-chat.ts`, `src/providers/google-vertex-chat.ts` | Usable for MaaS models through OpenAI-compatible Chat Completions with explicit OAuth tokens or ADC and project/location endpoint derivation. | Core runner/catalog mapping and recorded provider coverage are missing; MaaS family-specific request parity needs review. |
|
||||
| Vertex Responses | `src/protocols/openai-responses.ts`, `src/providers/google-vertex-responses.ts` | Usable for Grok models through OpenAI-compatible Responses with explicit OAuth tokens or ADC, project/location endpoint derivation, and storage disabled by default. | Core runner/catalog mapping and recorded provider coverage are missing; stateful continuation is not supported by Vertex. |
|
||||
| Vertex Messages | `src/protocols/anthropic-messages.ts`, `src/providers/google-vertex-messages.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. |
|
||||
| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. |
|
||||
| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. |
|
||||
| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. |
|
||||
| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. |
|
||||
| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. |
|
||||
| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. |
|
||||
|
||||
## V2 Runner Status
|
||||
|
||||
`packages/core/src/session/runner/model.ts` currently resolves only this native subset from catalog `aisdk` metadata:
|
||||
|
||||
| Catalog API | Native route used today |
|
||||
| --------------------------------------------------- | ---------------------------- |
|
||||
| `aisdk:@ai-sdk/openai` | `OpenAIResponses.route` |
|
||||
| `aisdk:@ai-sdk/anthropic` | `AnthropicMessages.route` |
|
||||
| `aisdk:@ai-sdk/openai-compatible` with explicit URL | `OpenAICompatibleChat.route` |
|
||||
|
||||
Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently fall back through the AI SDK loader in the production runner. The dependency-free resolver seam rejects them with `SessionRunnerModel.UnsupportedPackageError`; they are not native route mappings yet.
|
||||
|
||||
## AI SDK Package Parity Matrix
|
||||
|
||||
| AI SDK package | Intended native target | Status | Biggest gaps |
|
||||
| --------------------------------- | -------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses`, `OpenAI.responsesWebSocket` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner route selection between Chat/Responses/WebSocket. |
|
||||
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. |
|
||||
| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. |
|
||||
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
|
||||
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
|
||||
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
|
||||
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
|
||||
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
|
||||
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
|
||||
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
|
||||
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. |
|
||||
|
||||
## Highest-Risk Gaps
|
||||
|
||||
1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata.
|
||||
2. OpenAI-compatible Responses is available as a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it.
|
||||
3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade.
|
||||
4. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing.
|
||||
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
|
||||
6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage.
|
||||
7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed.
|
||||
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection; the missing native boundary is Bedrock Mantle.
|
||||
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults.
|
||||
|
||||
## Native Namespace Shape
|
||||
|
||||
These are implementation/API slices, not separate npm packages.
|
||||
|
||||
| API slice | Package-like entrypoint | Purpose |
|
||||
| ----------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
|
||||
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
|
||||
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
|
||||
| OpenAI-compatible Responses | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic OpenAI-compatible `/responses`. |
|
||||
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
|
||||
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
||||
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
|
||||
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
|
||||
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
|
||||
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex OpenAI-compatible Responses for Grok models. |
|
||||
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
|
||||
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
||||
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
|
||||
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
||||
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
||||
|
||||
## Suggested Next Work Slices
|
||||
|
||||
1. Add native runner/catalog mappings for `@ai-sdk/azure`, `@ai-sdk/google`, and `@ai-sdk/amazon-bedrock` where the existing native facades are already close.
|
||||
2. Add API-aware runner/catalog selection between OpenAI-compatible Chat and Responses.
|
||||
3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling.
|
||||
4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini, Chat, Responses, and Messages entrypoints.
|
||||
5. Decide Chat/Responses selection for `@ai-sdk/google-vertex/xai` catalog models.
|
||||
6. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model.
|
||||
7. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples.
|
||||
8. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages.
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.17.20",
|
||||
"name": "@opencode-ai/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"setup:recording-env": "bun run script/setup-recording-env.ts",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.types.json",
|
||||
"build": "tsc -p tsconfig.build.json"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/http-recorder": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"aws4fetch": "1.0.20",
|
||||
"effect": "catalog:",
|
||||
"google-auth-library": "10.5.0"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||
process.chdir(dir)
|
||||
|
||||
async function published(name: string, version: string) {
|
||||
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
|
||||
}
|
||||
|
||||
await $`bun run build`
|
||||
const originalText = await Bun.file("package.json").text()
|
||||
const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string>
|
||||
}
|
||||
if (await published(pkg.name, pkg.version)) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(pkg.exports)) {
|
||||
const file = value.replace("./src/", "./dist/").replace(".ts", "")
|
||||
// @ts-ignore
|
||||
pkg.exports[key] = {
|
||||
import: file + ".js",
|
||||
types: file + ".d.ts",
|
||||
}
|
||||
}
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2))
|
||||
try {
|
||||
await $`bun pm pack`
|
||||
await $`npm publish *.tgz --tag ${Script.channel} --access public`
|
||||
} finally {
|
||||
await Bun.write("package.json", originalText)
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { RequestExecutor } from "./route/executor"
|
||||
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image"
|
||||
import type { LLMError } from "./schema"
|
||||
|
||||
export type Execute = RequestExecutor.Interface["execute"]
|
||||
|
||||
export interface Interface {
|
||||
readonly generate: <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
) => Effect.Effect<ImageResponse, LLMError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
|
||||
|
||||
export const generate = <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
): Effect.Effect<ImageResponse, LLMError> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.generate(request)
|
||||
}) as Effect.Effect<ImageResponse, LLMError>
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
return Service.of({
|
||||
generate: (request) => request.model.route.generate(request, executor.execute),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const ImageClient = {
|
||||
Service,
|
||||
layer,
|
||||
generate,
|
||||
} as const
|
||||
@@ -1,166 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
|
||||
import { ImageClient, Service, type Execute as ImageExecute } from "./image-client"
|
||||
|
||||
export interface ImageRoute<Options extends ImageOptions = ImageOptions> {
|
||||
readonly id: string
|
||||
readonly generate: (
|
||||
request: ImageRequestFor<Options>,
|
||||
execute: ImageExecute,
|
||||
) => Effect.Effect<ImageResponse, LLMError>
|
||||
}
|
||||
|
||||
export type ImageOptions = Record<string, unknown>
|
||||
|
||||
export class ImageModel<Options extends ImageOptions = ImageOptions> {
|
||||
declare protected readonly _Options: (options: Options) => Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: ImageRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
|
||||
constructor(input: ImageModel.Input<Options>) {
|
||||
this.id = input.id
|
||||
this.provider = input.provider
|
||||
this.route = input.route
|
||||
this.http = input.http
|
||||
}
|
||||
|
||||
static make<Options extends ImageOptions = ImageOptions>(input: ImageModel.MakeInput<Options>) {
|
||||
return new ImageModel<Options>({
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ImageModel {
|
||||
export interface Input<Options extends ImageOptions = ImageOptions> {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: ImageRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
export interface MakeInput<Options extends ImageOptions = ImageOptions>
|
||||
extends Omit<Input<Options>, "id" | "provider"> {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
}
|
||||
}
|
||||
|
||||
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
|
||||
expected: "Image.Model",
|
||||
})
|
||||
|
||||
const ImageBytesInput = Schema.Struct({
|
||||
type: Schema.Literal("bytes"),
|
||||
data: Schema.Uint8Array,
|
||||
mediaType: Schema.String,
|
||||
})
|
||||
const ImageUrlInput = Schema.Struct({
|
||||
type: Schema.Literal("url"),
|
||||
url: Schema.String,
|
||||
})
|
||||
const ImageFileIDInput = Schema.Struct({
|
||||
type: Schema.Literal("file-id"),
|
||||
id: Schema.String,
|
||||
})
|
||||
const ImageFileURIInput = Schema.Struct({
|
||||
type: Schema.Literal("file-uri"),
|
||||
uri: Schema.String,
|
||||
mediaType: Schema.String,
|
||||
})
|
||||
|
||||
export const ImageInputSchema = Schema.Union([
|
||||
ImageBytesInput,
|
||||
ImageUrlInput,
|
||||
ImageFileIDInput,
|
||||
ImageFileURIInput,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type ImageInput = Schema.Schema.Type<typeof ImageInputSchema>
|
||||
|
||||
export const ImageInput = {
|
||||
bytes: (data: Uint8Array, mediaType: string): ImageInput => ({ type: "bytes", data, mediaType }),
|
||||
url: (url: string): ImageInput => ({ type: "url", url }),
|
||||
file: (id: string): ImageInput => ({ type: "file-id", id }),
|
||||
fileUri: (uri: string, mediaType: string): ImageInput => ({ type: "file-uri", uri, mediaType }),
|
||||
} as const
|
||||
|
||||
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
|
||||
model: ImageModelSchema,
|
||||
prompt: Schema.String,
|
||||
images: Schema.optional(Schema.Array(ImageInputSchema)),
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
http: Schema.optional(HttpOptions),
|
||||
}) {
|
||||
declare protected readonly _ImageRequest: void
|
||||
}
|
||||
|
||||
export type ImageRequestFor<Options extends ImageOptions = ImageOptions> = Omit<ImageRequest, "model" | "options"> & {
|
||||
readonly model: ImageModel<Options>
|
||||
readonly options?: Options
|
||||
}
|
||||
|
||||
export type ImageModelOptions<Model> = Model extends ImageModel<infer Options> ? Options : never
|
||||
|
||||
export type ImageRequestInput<Model extends object = ImageModel> = Omit<
|
||||
ConstructorParameters<typeof ImageRequest>[0],
|
||||
"model" | "options" | "http"
|
||||
> & {
|
||||
readonly model: Model
|
||||
readonly options?: NoInfer<ImageModelOptions<Model>>
|
||||
readonly http?: HttpOptions.Input
|
||||
} & (Model extends ImageModel<ImageModelOptions<Model>> ? unknown : never)
|
||||
|
||||
export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
|
||||
mediaType: Schema.String,
|
||||
data: Schema.Union([Schema.String, Schema.Uint8Array]),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")({
|
||||
images: Schema.Array(GeneratedImage),
|
||||
usage: Schema.optional(Usage),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {
|
||||
get image() {
|
||||
return this.images[0]
|
||||
}
|
||||
}
|
||||
|
||||
export function request<const Model extends object>(
|
||||
input: ImageRequestInput<Model>,
|
||||
): ImageRequestFor<ImageModelOptions<Model>>
|
||||
export function request(input: ImageRequest): ImageRequest
|
||||
export function request(input: ImageRequest | ImageRequestInput) {
|
||||
if (input instanceof ImageRequest) return input
|
||||
return new ImageRequest({
|
||||
...input,
|
||||
model: input.model as unknown as ImageModel,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
}
|
||||
|
||||
export function generate<const Model extends object>(
|
||||
input: ImageRequestInput<Model>,
|
||||
): Effect.Effect<ImageResponse, LLMError, Service>
|
||||
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, LLMError, Service>
|
||||
export function generate(input: ImageRequest | ImageRequestInput) {
|
||||
return Effect.try({
|
||||
try: () => (input instanceof ImageRequest ? input : request(input)),
|
||||
catch: (error) =>
|
||||
new LLMError({
|
||||
module: "Image",
|
||||
method: "generate",
|
||||
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
|
||||
}),
|
||||
}).pipe(Effect.flatMap((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
|
||||
}
|
||||
|
||||
export const Image = {
|
||||
request,
|
||||
generate,
|
||||
} as const
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./protocols/index"
|
||||
@@ -1,314 +0,0 @@
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
GeneratedImage,
|
||||
ImageModel,
|
||||
ImageResponse,
|
||||
type ImageInput,
|
||||
type ImageRequestFor,
|
||||
type ImageRoute,
|
||||
} from "../image"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
LLMError,
|
||||
Usage,
|
||||
mergeHttpOptions,
|
||||
mergeJsonRecords,
|
||||
type HttpOptions,
|
||||
type ProviderMetadata,
|
||||
} from "../schema"
|
||||
import { ProviderShared } from "./shared"
|
||||
import { ImageInputs } from "./utils/image-input"
|
||||
|
||||
const ADAPTER = "google-images"
|
||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
export type GoogleImageString<Known extends string> = Known | (string & {})
|
||||
|
||||
export type GoogleImageOptions = {
|
||||
readonly aspectRatio?: GoogleImageString<
|
||||
"1:1" | "2:3" | "3:2" | "3:4" | "4:3" | "4:5" | "5:4" | "9:16" | "16:9" | "21:9"
|
||||
>
|
||||
readonly imageSize?: GoogleImageString<"1K" | "2K" | "4K">
|
||||
readonly seed?: number
|
||||
readonly thinkingLevel?: GoogleImageString<"MINIMAL" | "LOW" | "MEDIUM" | "HIGH">
|
||||
readonly includeThoughts?: boolean
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type GoogleImageBody = Record<string, unknown> & {
|
||||
readonly contents: ReadonlyArray<{
|
||||
readonly role: "user"
|
||||
readonly parts: ReadonlyArray<Record<string, unknown>>
|
||||
}>
|
||||
readonly generationConfig: Record<string, unknown>
|
||||
}
|
||||
|
||||
const GoogleUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
cachedContentTokenCount: Schema.optional(Schema.Number),
|
||||
thoughtsTokenCount: Schema.optional(Schema.Number),
|
||||
promptTokenCount: Schema.optional(Schema.Number),
|
||||
candidatesTokenCount: Schema.optional(Schema.Number),
|
||||
totalTokenCount: Schema.optional(Schema.Number),
|
||||
promptTokensDetails: Schema.optional(Schema.Unknown),
|
||||
candidatesTokensDetails: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const GoogleImageResponse = Schema.Struct({
|
||||
candidates: Schema.optional(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
index: Schema.optional(Schema.Number),
|
||||
content: Schema.optional(
|
||||
Schema.Struct({
|
||||
parts: Schema.Array(
|
||||
Schema.Struct({
|
||||
text: Schema.optional(Schema.String),
|
||||
thought: Schema.optional(Schema.Boolean),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
inlineData: Schema.optional(
|
||||
Schema.Struct({
|
||||
mimeType: Schema.String,
|
||||
data: Schema.String,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
finishReason: Schema.optional(Schema.String),
|
||||
finishMessage: Schema.optional(Schema.String),
|
||||
safetyRatings: Schema.optional(Schema.Unknown),
|
||||
citationMetadata: Schema.optional(Schema.Unknown),
|
||||
groundingMetadata: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
),
|
||||
),
|
||||
usageMetadata: Schema.optional(GoogleUsage),
|
||||
modelVersion: Schema.optional(Schema.String),
|
||||
responseId: Schema.optional(Schema.String),
|
||||
promptFeedback: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
|
||||
export interface ModelInput {
|
||||
readonly id: string
|
||||
readonly auth: AuthDefinition
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
const nativeOptions = (options: GoogleImageOptions | undefined) => {
|
||||
const { aspectRatio, imageSize, seed, thinkingLevel, includeThoughts, ...native } = options ?? {}
|
||||
const image = {
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
}
|
||||
const thinkingConfig = {
|
||||
thinkingLevel,
|
||||
includeThoughts,
|
||||
}
|
||||
return (
|
||||
mergeJsonRecords(
|
||||
{
|
||||
responseModalities: ["IMAGE"],
|
||||
imageConfig: Object.values(image).some((value) => value !== undefined) ? image : undefined,
|
||||
seed,
|
||||
thinkingConfig: Object.values(thinkingConfig).some((value) => value !== undefined) ? thinkingConfig : undefined,
|
||||
},
|
||||
native,
|
||||
) ?? { responseModalities: ["IMAGE"] }
|
||||
)
|
||||
}
|
||||
|
||||
const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
|
||||
new LLMError({
|
||||
module: ADAPTER,
|
||||
method: "generate",
|
||||
reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }),
|
||||
})
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
export const model = (input: ModelInput) => {
|
||||
const route: ImageRoute<GoogleImageOptions> = {
|
||||
id: ADAPTER,
|
||||
generate: Effect.fn("GoogleImages.generate")(function* (request: ImageRequestFor<GoogleImageOptions>, execute) {
|
||||
const imageParts = yield* Effect.forEach(request.images ?? [], googleImagePart)
|
||||
const http = mergeHttpOptions(request.model.http, request.http)
|
||||
const requestBody = mergeJsonRecords(
|
||||
{
|
||||
contents: [{ role: "user", parts: [{ text: request.prompt }, ...imageParts] }],
|
||||
generationConfig: nativeOptions(request.options),
|
||||
},
|
||||
http?.body,
|
||||
) as GoogleImageBody
|
||||
const text = ProviderShared.encodeJson(requestBody)
|
||||
const url = applyQuery(
|
||||
`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/models/${request.model.id}:generateContent`,
|
||||
http?.query,
|
||||
)
|
||||
const headers = yield* Auth.toEffect(input.auth)({
|
||||
request,
|
||||
method: "POST",
|
||||
url,
|
||||
body: text,
|
||||
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||
})
|
||||
const response = yield* execute(
|
||||
HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
const payload = yield* response.json.pipe(
|
||||
Effect.mapError(() => invalidOutput("Failed to read the Google Images response")),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(GoogleImageResponse)(payload).pipe(
|
||||
Effect.mapError(() => invalidOutput("Google Images returned an invalid response")),
|
||||
)
|
||||
const candidates = decoded.candidates ?? []
|
||||
const candidateMetadata = candidates.map((candidate, candidateIndex) => ({
|
||||
index: candidate.index ?? candidateIndex,
|
||||
finishReason: candidate.finishReason,
|
||||
finishMessage: candidate.finishMessage,
|
||||
safetyRatings: candidate.safetyRatings,
|
||||
citationMetadata: candidate.citationMetadata,
|
||||
groundingMetadata: candidate.groundingMetadata,
|
||||
parts: (candidate.content?.parts ?? []).map((part) =>
|
||||
part.inlineData === undefined
|
||||
? {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
thought: part.thought,
|
||||
thoughtSignature: part.thoughtSignature,
|
||||
}
|
||||
: {
|
||||
type: "inlineData",
|
||||
mediaType: part.inlineData.mimeType,
|
||||
thought: part.thought,
|
||||
thoughtSignature: part.thoughtSignature,
|
||||
},
|
||||
),
|
||||
}))
|
||||
const encoded = candidates.flatMap((candidate, candidateIndex) =>
|
||||
(candidate.content?.parts ?? []).flatMap((part, partIndex) =>
|
||||
part.inlineData === undefined || part.thought === true
|
||||
? []
|
||||
: [{ candidate, candidateIndex, partIndex, inlineData: part.inlineData }],
|
||||
),
|
||||
)
|
||||
const images = yield* Effect.forEach(encoded, (item) =>
|
||||
Effect.fromResult(Encoding.decodeBase64(item.inlineData.data)).pipe(
|
||||
Effect.mapError(() =>
|
||||
invalidOutput(
|
||||
`Google Images candidate ${item.candidateIndex} part ${item.partIndex} contains invalid base64 data`,
|
||||
),
|
||||
),
|
||||
Effect.map(
|
||||
(data) =>
|
||||
new GeneratedImage({
|
||||
mediaType: item.inlineData.mimeType,
|
||||
data,
|
||||
providerMetadata: {
|
||||
google: {
|
||||
candidateIndex: item.candidate.index ?? item.candidateIndex,
|
||||
partIndex: item.partIndex,
|
||||
finishReason: item.candidate.finishReason,
|
||||
safetyRatings: item.candidate.safetyRatings,
|
||||
citationMetadata: item.candidate.citationMetadata,
|
||||
groundingMetadata: item.candidate.groundingMetadata,
|
||||
thoughtSignature: item.candidate.content?.parts[item.partIndex]?.thoughtSignature,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (images.length === 0) {
|
||||
const finishReasons = candidates.flatMap((candidate) =>
|
||||
candidate.finishReason === undefined ? [] : [candidate.finishReason],
|
||||
)
|
||||
return yield* invalidOutput(
|
||||
`Google Images returned no final images${
|
||||
finishReasons.length === 0 ? "" : ` (finish reasons: ${finishReasons.join(", ")})`
|
||||
}; inspect reason.providerMetadata.google for prompt feedback and candidate details`,
|
||||
{
|
||||
google: {
|
||||
promptFeedback: decoded.promptFeedback,
|
||||
candidates: candidateMetadata,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
const usage = decoded.usageMetadata
|
||||
const outputTokens =
|
||||
usage?.candidatesTokenCount === undefined
|
||||
? undefined
|
||||
: usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0)
|
||||
return new ImageResponse({
|
||||
images,
|
||||
usage:
|
||||
usage === undefined
|
||||
? undefined
|
||||
: new Usage({
|
||||
inputTokens: usage.promptTokenCount,
|
||||
outputTokens,
|
||||
nonCachedInputTokens: ProviderShared.subtractTokens(
|
||||
usage.promptTokenCount,
|
||||
usage.cachedContentTokenCount,
|
||||
),
|
||||
cacheReadInputTokens: usage.cachedContentTokenCount,
|
||||
reasoningTokens: usage.thoughtsTokenCount,
|
||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||
providerMetadata: { google: usage },
|
||||
}),
|
||||
providerMetadata: {
|
||||
google: {
|
||||
modelVersion: decoded.modelVersion,
|
||||
responseId: decoded.responseId,
|
||||
promptFeedback: decoded.promptFeedback,
|
||||
candidates: candidateMetadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
}
|
||||
return ImageModel.make<GoogleImageOptions>({ id: input.id, provider: "google", route, http: input.http })
|
||||
}
|
||||
|
||||
const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, LLMError> => {
|
||||
if (image.type === "bytes")
|
||||
return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
|
||||
if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
|
||||
if (image.type === "url")
|
||||
return ImageInputs.decodeDataUrl(image.url, ADAPTER).pipe(
|
||||
Effect.flatMap((decoded) => {
|
||||
if (decoded === undefined)
|
||||
return Effect.fail(
|
||||
ImageInputs.invalid(
|
||||
ADAPTER,
|
||||
"Google generateContent does not fetch public image URLs; use bytes, a data URL, or a Gemini file URI",
|
||||
),
|
||||
)
|
||||
return Effect.succeed({
|
||||
inlineData: { mimeType: decoded.mediaType, data: Encoding.encodeBase64(decoded.data) },
|
||||
})
|
||||
}),
|
||||
)
|
||||
return Effect.fail(
|
||||
ImageInputs.invalid(ADAPTER, "Google generateContent requires Gemini file URIs rather than provider file IDs"),
|
||||
)
|
||||
}
|
||||
|
||||
export const GoogleImages = {
|
||||
model,
|
||||
} as const
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Route, type RouteRoutedModelInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { OpenAIResponses } from "./openai-responses"
|
||||
|
||||
const ADAPTER = "openai-compatible-responses"
|
||||
|
||||
export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput
|
||||
|
||||
/**
|
||||
* Route for providers that expose an OpenAI Responses-compatible `/responses`
|
||||
* endpoint. Provider helpers configure identity, endpoint, and auth before
|
||||
* model selection while this route reuses the OpenAI Responses protocol.
|
||||
*/
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
providerMetadataKey: "openai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenAIResponses.PATH),
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: { providerOptions: { openai: { store: false } } },
|
||||
})
|
||||
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||
@@ -1,270 +0,0 @@
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import {
|
||||
ImageModel,
|
||||
GeneratedImage,
|
||||
ImageResponse,
|
||||
type ImageInput,
|
||||
type ImageRequestFor,
|
||||
type ImageRoute,
|
||||
} from "../image"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
LLMError,
|
||||
Usage,
|
||||
mergeHttpOptions,
|
||||
mergeJsonRecords,
|
||||
type HttpOptions,
|
||||
} from "../schema"
|
||||
import { ProviderShared } from "./shared"
|
||||
import { ImageInputs } from "./utils/image-input"
|
||||
import { OpenAIImage } from "./utils/openai-image"
|
||||
|
||||
const ADAPTER = "openai-images"
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = "/images/generations"
|
||||
export const EDIT_PATH = "/images/edits"
|
||||
|
||||
export type OpenAIImageString<Known extends string> = Known | (string & {})
|
||||
|
||||
export type OpenAIImageOptions = {
|
||||
readonly mask?: ImageInput
|
||||
readonly n?: number
|
||||
readonly size?: OpenAIImageString<
|
||||
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
|
||||
>
|
||||
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
|
||||
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
|
||||
readonly moderation?: OpenAIImageString<"auto" | "low">
|
||||
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
|
||||
readonly outputCompression?: number
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type OpenAIImageBody = Record<string, unknown> & {
|
||||
readonly model: string
|
||||
readonly prompt: string
|
||||
}
|
||||
|
||||
const OpenAIImageResponse = Schema.Struct({
|
||||
data: Schema.Array(
|
||||
Schema.Struct({
|
||||
b64_json: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
revised_prompt: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
output_format: Schema.optional(Schema.String),
|
||||
usage: Schema.optional(
|
||||
Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
input_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export interface ModelInput {
|
||||
readonly id: string
|
||||
readonly auth: AuthDefinition
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
const nativeOptions = (options: OpenAIImageOptions | undefined) => {
|
||||
if (!options) return undefined
|
||||
const { mask: _, outputFormat, outputCompression, ...native } = options
|
||||
return {
|
||||
output_format: outputFormat,
|
||||
output_compression: outputCompression,
|
||||
...native,
|
||||
}
|
||||
}
|
||||
|
||||
const invalidOutput = (message: string) =>
|
||||
new LLMError({
|
||||
module: ADAPTER,
|
||||
method: "generate",
|
||||
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||
})
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
export const model = (input: ModelInput) => {
|
||||
const route: ImageRoute<OpenAIImageOptions> = {
|
||||
id: ADAPTER,
|
||||
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
|
||||
const mask = request.options?.mask
|
||||
if (mask !== undefined && (request.images?.length ?? 0) === 0)
|
||||
return yield* ImageInputs.invalid(ADAPTER, "An OpenAI image mask requires at least one input image")
|
||||
const http = mergeHttpOptions(request.model.http, request.http)
|
||||
const sourceImages = request.images ?? []
|
||||
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
|
||||
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
|
||||
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
|
||||
return Effect.succeed(undefined)
|
||||
})
|
||||
const multipartMask =
|
||||
mask === undefined
|
||||
? undefined
|
||||
: mask.type === "bytes"
|
||||
? { data: mask.data, mediaType: mask.mediaType }
|
||||
: mask.type === "url"
|
||||
? yield* ImageInputs.decodeDataUrl(mask.url, ADAPTER)
|
||||
: undefined
|
||||
const useMultipart =
|
||||
sourceImages.length > 0 &&
|
||||
multipartImages.every((image) => image !== undefined) &&
|
||||
(mask === undefined || multipartMask !== undefined)
|
||||
const path = sourceImages.length === 0 ? PATH : EDIT_PATH
|
||||
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${path}`, http?.query)
|
||||
|
||||
if (useMultipart) {
|
||||
const form = new FormData()
|
||||
form.append("model", request.model.id)
|
||||
form.append("prompt", request.prompt)
|
||||
Object.entries(mergeJsonRecords(nativeOptions(request.options), http?.body) ?? {}).forEach(([key, value]) => {
|
||||
if (["model", "prompt", "image", "image[]", "images", "mask"].includes(key)) return
|
||||
form.append(key, typeof value === "string" ? value : ProviderShared.encodeJson(value))
|
||||
})
|
||||
multipartImages.forEach((image, index) => {
|
||||
if (image === undefined) return
|
||||
form.append("image[]", imageBlob(image.data, image.mediaType), `image-${index}`)
|
||||
})
|
||||
if (multipartMask !== undefined)
|
||||
form.append("mask", imageBlob(multipartMask.data, multipartMask.mediaType), "mask")
|
||||
const headers = yield* Auth.toEffect(input.auth)({
|
||||
request,
|
||||
method: "POST",
|
||||
url,
|
||||
body: "[multipart/form-data]",
|
||||
headers: Headers.remove(Headers.fromInput({ ...input.headers, ...http?.headers }), "content-type"),
|
||||
})
|
||||
const response = yield* execute(
|
||||
HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.bodyFormData(form)),
|
||||
)
|
||||
return yield* parseResponse(response, request.options, http?.body)
|
||||
}
|
||||
|
||||
const references = sourceImages.map((image) => {
|
||||
if (image.type === "bytes") return { image_url: ImageInputs.dataUrl(image) }
|
||||
if (image.type === "url") return { image_url: image.url }
|
||||
if (image.type === "file-id") return { file_id: image.id }
|
||||
return undefined
|
||||
})
|
||||
if (references.some((image) => image === undefined))
|
||||
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||
const maskReference =
|
||||
mask === undefined
|
||||
? undefined
|
||||
: mask.type === "bytes"
|
||||
? { image_url: ImageInputs.dataUrl(mask) }
|
||||
: mask.type === "url"
|
||||
? { image_url: mask.url }
|
||||
: mask.type === "file-id"
|
||||
? { file_id: mask.id }
|
||||
: undefined
|
||||
if (mask !== undefined && maskReference === undefined)
|
||||
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
|
||||
const requestBody = mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
prompt: request.prompt,
|
||||
images: references.length === 0 ? undefined : references,
|
||||
mask: maskReference,
|
||||
},
|
||||
nativeOptions(request.options),
|
||||
http?.body,
|
||||
) as OpenAIImageBody
|
||||
const text = ProviderShared.encodeJson(requestBody)
|
||||
const headers = yield* Auth.toEffect(input.auth)({
|
||||
request,
|
||||
method: "POST",
|
||||
url,
|
||||
body: text,
|
||||
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||
})
|
||||
const response = yield* execute(
|
||||
HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
return yield* parseResponse(response, request.options, http?.body)
|
||||
}),
|
||||
}
|
||||
return ImageModel.make<OpenAIImageOptions>({ id: input.id, provider: "openai", route, http: input.http })
|
||||
}
|
||||
|
||||
const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
options: OpenAIImageOptions | undefined,
|
||||
overlay: Record<string, unknown> | undefined,
|
||||
) {
|
||||
const payload = yield* response.json.pipe(
|
||||
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
|
||||
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
|
||||
)
|
||||
const requestBody = mergeJsonRecords(nativeOptions(options), overlay)
|
||||
const format =
|
||||
decoded.output_format ?? (typeof requestBody?.output_format === "string" ? requestBody.output_format : "png")
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||
if (item.b64_json)
|
||||
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
|
||||
Effect.map(
|
||||
(data) =>
|
||||
new GeneratedImage({
|
||||
mediaType: `image/${format}`,
|
||||
data,
|
||||
providerMetadata:
|
||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (item.url)
|
||||
return Effect.succeed(
|
||||
new GeneratedImage({
|
||||
mediaType: `image/${format}`,
|
||||
data: item.url,
|
||||
providerMetadata:
|
||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
)
|
||||
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
|
||||
})
|
||||
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
|
||||
return new ImageResponse({
|
||||
images,
|
||||
usage:
|
||||
decoded.usage === undefined
|
||||
? undefined
|
||||
: new Usage({
|
||||
inputTokens: decoded.usage.input_tokens,
|
||||
outputTokens: decoded.usage.output_tokens,
|
||||
totalTokens: decoded.usage.total_tokens,
|
||||
providerMetadata: { openai: decoded.usage },
|
||||
}),
|
||||
providerMetadata: { openai: { outputFormat: format } },
|
||||
})
|
||||
})
|
||||
|
||||
const imageBlob = (data: Uint8Array, mediaType: string) => {
|
||||
const buffer = new ArrayBuffer(data.byteLength)
|
||||
new Uint8Array(buffer).set(data)
|
||||
return new Blob([buffer], { type: mediaType })
|
||||
}
|
||||
|
||||
export const OpenAIImages = {
|
||||
model,
|
||||
} as const
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Effect, Encoding } from "effect"
|
||||
import type { ImageInput } from "../../image"
|
||||
import { InvalidRequestReason, LLMError } from "../../schema"
|
||||
|
||||
const invalid = (module: string, message: string) =>
|
||||
new LLMError({
|
||||
module,
|
||||
method: "generate",
|
||||
reason: new InvalidRequestReason({ message }),
|
||||
})
|
||||
|
||||
export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>) =>
|
||||
`data:${input.mediaType};base64,${Encoding.encodeBase64(input.data)}`
|
||||
|
||||
export const decodeDataUrl = (
|
||||
url: string,
|
||||
module: string,
|
||||
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, LLMError> => {
|
||||
if (!url.startsWith("data:")) return Effect.succeed(undefined)
|
||||
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
|
||||
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
|
||||
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
|
||||
Effect.mapError(() => invalid(module, "Image data URL contains invalid base64 data")),
|
||||
Effect.map((data) => ({ mediaType: match[1], data })),
|
||||
)
|
||||
}
|
||||
|
||||
export const invalidImageInput = invalid
|
||||
|
||||
export const ImageInputs = {
|
||||
dataUrl,
|
||||
decodeDataUrl,
|
||||
invalid: invalidImageInput,
|
||||
} as const
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
const dimensions = (value: string) => {
|
||||
const match = /^(\d+)x(\d+)$/.exec(value)
|
||||
if (!match) return undefined
|
||||
return { width: Number(match[1]), height: Number(match[2]) }
|
||||
}
|
||||
|
||||
export const Size = Schema.String.check(
|
||||
Schema.makeFilter((value) => {
|
||||
if (value === "auto") return undefined
|
||||
const parsed = dimensions(value)
|
||||
if (!parsed) return "image size must be `auto` or `{width}x{height}`"
|
||||
return parsed.width > 0 && parsed.height > 0 ? undefined : "image dimensions must be positive integers"
|
||||
}),
|
||||
)
|
||||
|
||||
export const OpenAIImage = {
|
||||
Size,
|
||||
} as const
|
||||
@@ -1,202 +0,0 @@
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
LLMError,
|
||||
Usage,
|
||||
mergeHttpOptions,
|
||||
mergeJsonRecords,
|
||||
type HttpOptions,
|
||||
} from "../schema"
|
||||
import { ProviderShared, optionalNull } from "./shared"
|
||||
import { ImageInputs } from "./utils/image-input"
|
||||
|
||||
const ADAPTER = "xai-images"
|
||||
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
|
||||
export const PATH = "/images/generations"
|
||||
export const EDIT_PATH = "/images/edits"
|
||||
|
||||
export type XAIImageString<Known extends string> = Known | (string & {})
|
||||
|
||||
export type XAIImageOptions = {
|
||||
readonly n?: number
|
||||
readonly aspectRatio?: XAIImageString<
|
||||
| "1:1"
|
||||
| "3:4"
|
||||
| "4:3"
|
||||
| "9:16"
|
||||
| "16:9"
|
||||
| "2:3"
|
||||
| "3:2"
|
||||
| "9:19.5"
|
||||
| "19.5:9"
|
||||
| "9:20"
|
||||
| "20:9"
|
||||
| "1:2"
|
||||
| "2:1"
|
||||
| "auto"
|
||||
>
|
||||
readonly aspect_ratio?: XAIImageString<
|
||||
| "1:1"
|
||||
| "3:4"
|
||||
| "4:3"
|
||||
| "9:16"
|
||||
| "16:9"
|
||||
| "2:3"
|
||||
| "3:2"
|
||||
| "9:19.5"
|
||||
| "19.5:9"
|
||||
| "9:20"
|
||||
| "20:9"
|
||||
| "1:2"
|
||||
| "2:1"
|
||||
| "auto"
|
||||
>
|
||||
readonly resolution?: XAIImageString<"1k" | "2k">
|
||||
readonly responseFormat?: XAIImageString<"url" | "b64_json">
|
||||
readonly response_format?: XAIImageString<"url" | "b64_json">
|
||||
} & Record<string, unknown>
|
||||
|
||||
type XAIImageBody = Record<string, unknown> & {
|
||||
readonly model: string
|
||||
readonly prompt: string
|
||||
}
|
||||
|
||||
const XAIImageResponse = Schema.Struct({
|
||||
data: Schema.Array(
|
||||
Schema.Struct({
|
||||
b64_json: optionalNull(Schema.String),
|
||||
url: optionalNull(Schema.String),
|
||||
revised_prompt: optionalNull(Schema.String),
|
||||
mime_type: optionalNull(Schema.String),
|
||||
}),
|
||||
),
|
||||
usage: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
|
||||
export interface ModelInput {
|
||||
readonly id: string
|
||||
readonly auth: AuthDefinition
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
const nativeOptions = (options: XAIImageOptions | undefined) => {
|
||||
if (!options) return undefined
|
||||
const { aspectRatio, responseFormat, ...native } = options
|
||||
return {
|
||||
aspect_ratio: aspectRatio,
|
||||
response_format: responseFormat,
|
||||
...native,
|
||||
}
|
||||
}
|
||||
|
||||
const invalidOutput = (message: string) =>
|
||||
new LLMError({
|
||||
module: ADAPTER,
|
||||
method: "generate",
|
||||
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||
})
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
export const model = (input: ModelInput) => {
|
||||
const route: ImageRoute<XAIImageOptions> = {
|
||||
id: ADAPTER,
|
||||
generate: Effect.fn("XAIImages.generate")(function* (request: ImageRequestFor<XAIImageOptions>, execute) {
|
||||
const http = mergeHttpOptions(request.model.http, request.http)
|
||||
const imageReferences = (request.images ?? []).map((image) => {
|
||||
if (image.type === "bytes") return { url: ImageInputs.dataUrl(image), type: "image_url" as const }
|
||||
if (image.type === "url") return { url: image.url, type: "image_url" as const }
|
||||
if (image.type === "file-id") return { file_id: image.id }
|
||||
return undefined
|
||||
})
|
||||
if (imageReferences.some((image) => image === undefined))
|
||||
return yield* ImageInputs.invalid(ADAPTER, "xAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||
const requestBody = mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
prompt: request.prompt,
|
||||
image: imageReferences.length === 1 ? imageReferences[0] : undefined,
|
||||
images: imageReferences.length > 1 ? imageReferences : undefined,
|
||||
},
|
||||
nativeOptions(request.options),
|
||||
http?.body,
|
||||
) as XAIImageBody
|
||||
const text = ProviderShared.encodeJson(requestBody)
|
||||
const url = applyQuery(
|
||||
`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${imageReferences.length === 0 ? PATH : EDIT_PATH}`,
|
||||
http?.query,
|
||||
)
|
||||
const headers = yield* Auth.toEffect(input.auth)({
|
||||
request,
|
||||
method: "POST",
|
||||
url,
|
||||
body: text,
|
||||
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||
})
|
||||
const response = yield* execute(
|
||||
HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
const payload = yield* response.json.pipe(
|
||||
Effect.mapError(() => invalidOutput("Failed to read the xAI Images response")),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(XAIImageResponse)(payload).pipe(
|
||||
Effect.mapError(() => invalidOutput("xAI Images returned an invalid response")),
|
||||
)
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||
const mediaType = item.mime_type ?? "application/octet-stream"
|
||||
if (item.b64_json)
|
||||
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||
Effect.mapError(() => invalidOutput(`xAI Images result ${index} contains invalid base64 data`)),
|
||||
Effect.map(
|
||||
(data) =>
|
||||
new GeneratedImage({
|
||||
mediaType,
|
||||
data,
|
||||
providerMetadata:
|
||||
item.revised_prompt === undefined || item.revised_prompt === null
|
||||
? undefined
|
||||
: { xai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (item.url)
|
||||
return Effect.succeed(
|
||||
new GeneratedImage({
|
||||
mediaType,
|
||||
data: item.url,
|
||||
providerMetadata:
|
||||
item.revised_prompt === undefined || item.revised_prompt === null
|
||||
? undefined
|
||||
: { xai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
)
|
||||
return Effect.fail(invalidOutput(`xAI Images result ${index} has neither image data nor a URL`))
|
||||
})
|
||||
if (images.length === 0) return yield* invalidOutput("xAI Images returned no images")
|
||||
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
|
||||
return new ImageResponse({
|
||||
images,
|
||||
usage: usage === undefined ? undefined : new Usage({ providerMetadata: { xai: usage } }),
|
||||
providerMetadata: usage === undefined ? undefined : { xai: { usage } },
|
||||
})
|
||||
}),
|
||||
}
|
||||
return ImageModel.make<XAIImageOptions>({ id: input.id, provider: "xai", route, http: input.http })
|
||||
}
|
||||
|
||||
export const XAIImages = {
|
||||
model,
|
||||
} as const
|
||||
@@ -1,132 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||
import { InvalidProviderOutputReason, LLMError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema"
|
||||
import { ProviderShared } from "./shared"
|
||||
import { ImageInputs } from "./utils/image-input"
|
||||
|
||||
const ADAPTER = "zai-images"
|
||||
export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4"
|
||||
export const PATH = "/images/generations"
|
||||
|
||||
export type ZAIImageString<Known extends string> = Known | (string & {})
|
||||
|
||||
export type ZAIImageOptions = {
|
||||
readonly size?: ZAIImageString<
|
||||
"1024x1024" | "768x1344" | "864x1152" | "1344x768" | "1152x864" | "1440x720" | "720x1440"
|
||||
>
|
||||
readonly quality?: ZAIImageString<"hd" | "standard">
|
||||
readonly userID?: string
|
||||
} & Record<string, unknown>
|
||||
|
||||
type ZAIImageBody = Record<string, unknown> & {
|
||||
readonly model: string
|
||||
readonly prompt: string
|
||||
}
|
||||
|
||||
const ZAIImageResponse = Schema.Struct({
|
||||
created: Schema.optional(Schema.Int),
|
||||
id: Schema.optional(Schema.String),
|
||||
request_id: Schema.optional(Schema.String),
|
||||
data: Schema.Array(Schema.Struct({ url: Schema.String })),
|
||||
content_filter: Schema.optional(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
role: Schema.optional(Schema.String),
|
||||
level: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
export interface ModelInput {
|
||||
readonly id: string
|
||||
readonly auth: AuthDefinition
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
const nativeOptions = (options: ZAIImageOptions | undefined) => {
|
||||
if (!options) return undefined
|
||||
const { userID, ...native } = options
|
||||
return {
|
||||
user_id: userID,
|
||||
...native,
|
||||
}
|
||||
}
|
||||
|
||||
const invalidOutput = (message: string) =>
|
||||
new LLMError({
|
||||
module: ADAPTER,
|
||||
method: "generate",
|
||||
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||
})
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
export const model = (input: ModelInput) => {
|
||||
const route: ImageRoute<ZAIImageOptions> = {
|
||||
id: ADAPTER,
|
||||
generate: Effect.fn("ZAIImages.generate")(function* (request: ImageRequestFor<ZAIImageOptions>, execute) {
|
||||
if ((request.images?.length ?? 0) > 0)
|
||||
return yield* ImageInputs.invalid(ADAPTER, "Z.ai hosted image generation does not support image inputs")
|
||||
const http = mergeHttpOptions(request.model.http, request.http)
|
||||
const requestBody = mergeJsonRecords(
|
||||
{ model: request.model.id, prompt: request.prompt },
|
||||
nativeOptions(request.options),
|
||||
http?.body,
|
||||
) as ZAIImageBody
|
||||
const text = ProviderShared.encodeJson(requestBody)
|
||||
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
||||
const headers = yield* Auth.toEffect(input.auth)({
|
||||
request,
|
||||
method: "POST",
|
||||
url,
|
||||
body: text,
|
||||
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||
})
|
||||
const response = yield* execute(
|
||||
HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
const payload = yield* response.json.pipe(
|
||||
Effect.mapError(() => invalidOutput("Failed to read the Z.ai Images response")),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(ZAIImageResponse)(payload).pipe(
|
||||
Effect.mapError(() => invalidOutput("Z.ai Images returned an invalid response")),
|
||||
)
|
||||
if (decoded.data.length === 0) return yield* invalidOutput("Z.ai Images returned no images")
|
||||
return new ImageResponse({
|
||||
images: decoded.data.map(
|
||||
(item) =>
|
||||
new GeneratedImage({
|
||||
mediaType: "application/octet-stream",
|
||||
data: item.url,
|
||||
}),
|
||||
),
|
||||
providerMetadata: {
|
||||
zai: {
|
||||
created: decoded.created,
|
||||
id: decoded.id,
|
||||
requestID: decoded.request_id,
|
||||
contentFilter: decoded.content_filter,
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
}
|
||||
return ImageModel.make<ZAIImageOptions>({ id: input.id, provider: "zai", route, http: input.http })
|
||||
}
|
||||
|
||||
export const ZAIImages = {
|
||||
model,
|
||||
} as const
|
||||
@@ -1,165 +0,0 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
ContentPolicyReason,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
ProviderErrorEvent,
|
||||
ProviderInternalReason,
|
||||
QuotaExceededReason,
|
||||
RateLimitReason,
|
||||
UnknownProviderReason,
|
||||
type HttpContext,
|
||||
type HttpRateLimitDetails,
|
||||
type ProviderMetadata,
|
||||
} from "./schema"
|
||||
|
||||
const patterns = [
|
||||
/prompt is too long/i,
|
||||
/request_too_large/i,
|
||||
/input is too long for requested model/i,
|
||||
/exceeds the context window/i,
|
||||
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
||||
/input token count.*exceeds the maximum/i,
|
||||
/tokens in request more than max tokens allowed/i,
|
||||
/maximum prompt length is \d+/i,
|
||||
/reduce the length of the messages/i,
|
||||
/maximum context length is \d+ tokens/i,
|
||||
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
|
||||
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
|
||||
/exceeds the limit of \d+/i,
|
||||
/exceeds the available context size/i,
|
||||
/greater than the context length/i,
|
||||
/context window exceeds limit/i,
|
||||
/exceeded model token limit/i,
|
||||
/context[_ ]length[_ ]exceeded/i,
|
||||
/request entity too large/i,
|
||||
/context length is only \d+ tokens/i,
|
||||
/input length.*exceeds.*context length/i,
|
||||
/prompt too long; exceeded (?:max )?context length/i,
|
||||
/too large for model with \d+ maximum context length/i,
|
||||
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
|
||||
/model_context_window_exceeded/i,
|
||||
/too many tokens/i,
|
||||
/token limit exceeded/i,
|
||||
]
|
||||
|
||||
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
||||
|
||||
export const isContextOverflow = (message: string) =>
|
||||
!exclusions.some((pattern) => pattern.test(message)) &&
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
|
||||
export const isContextOverflowFailure = (failure: unknown) =>
|
||||
failure instanceof LLMError
|
||||
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
"internal_error",
|
||||
"internalserverexception",
|
||||
"modelstreamerrorexception",
|
||||
"overloaded_error",
|
||||
"server_error",
|
||||
"server_is_overloaded",
|
||||
"serviceunavailableexception",
|
||||
])
|
||||
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
readonly http?: HttpContext | undefined
|
||||
readonly providerMetadata?: ProviderMetadata | undefined
|
||||
}
|
||||
|
||||
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
||||
// session retry policy never needs provider-specific string matching.
|
||||
export function classifyProviderFailure(input: ProviderFailure): LLMError["reason"] {
|
||||
const body = input.http?.body ?? ""
|
||||
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
||||
.filter((code): code is string => code !== undefined)
|
||||
.map((code) => code.toLowerCase())
|
||||
const text = body || input.message
|
||||
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
|
||||
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||
|
||||
if (
|
||||
clientScoped &&
|
||||
(codes.includes("context_length_exceeded") ||
|
||||
codes.includes("model_context_window_exceeded") ||
|
||||
isContextOverflow(text))
|
||||
)
|
||||
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
return new QuotaExceededReason(common)
|
||||
if (input.status === 401) return new AuthenticationReason({ ...common, kind: "invalid" })
|
||||
if (input.status === 403) return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
|
||||
if (codes.includes("authentication_error")) return new AuthenticationReason({ ...common, kind: "invalid" })
|
||||
if (codes.includes("permission_error"))
|
||||
return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
|
||||
if (
|
||||
codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception")
|
||||
)
|
||||
return new RateLimitReason({
|
||||
...common,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (RATE_LIMIT_TEXT.test(text))
|
||||
return new RateLimitReason({
|
||||
...common,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
|
||||
return new ProviderInternalReason({
|
||||
...common,
|
||||
status: input.status,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (input.status === 429) {
|
||||
return new RateLimitReason({
|
||||
...common,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
}
|
||||
if (input.status !== undefined && input.status >= 500)
|
||||
return new ProviderInternalReason({
|
||||
...common,
|
||||
status: input.status,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
||||
if (
|
||||
input.status === 400 ||
|
||||
input.status === 404 ||
|
||||
input.status === 409 ||
|
||||
input.status === 413 ||
|
||||
input.status === 422
|
||||
)
|
||||
return new InvalidRequestReason(common)
|
||||
return new UnknownProviderReason({ ...common, status: input.status })
|
||||
}
|
||||
|
||||
function providerCodes(value: string) {
|
||||
const decoded = Option.getOrUndefined(decodeJson(value))
|
||||
if (!isRecord(decoded)) return []
|
||||
const error = isRecord(decoded.error) ? decoded.error : undefined
|
||||
return [decoded.code, error?.code, error?.type].filter((value): value is string => typeof value === "string")
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Model } from "./schema"
|
||||
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
readonly limits?: {
|
||||
readonly context: number
|
||||
readonly output: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface Definition<ProviderSettings extends Settings = Settings> {
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => Model
|
||||
}
|
||||
|
||||
export * as ProviderPackage from "./provider-package"
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./providers/index"
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages"
|
||||
import { Auth } from "../route/auth"
|
||||
import type { ProviderAuthOption } from "../route/auth-options"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
|
||||
export const id = ProviderID.make("anthropic-compatible")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly provider?: string
|
||||
readonly baseURL: string
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
(
|
||||
| { readonly apiKey?: string; readonly authToken?: never }
|
||||
| { readonly apiKey?: never; readonly authToken?: string }
|
||||
) & {
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
}
|
||||
|
||||
export const routes = [AnthropicMessages.route]
|
||||
|
||||
const auth = (input: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in input && input.auth) return input.auth
|
||||
return Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key"))
|
||||
}
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
|
||||
const provider = input.provider ?? "anthropic-compatible"
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = AnthropicMessages.route.with({
|
||||
...rest,
|
||||
provider,
|
||||
endpoint: { baseURL },
|
||||
auth: auth(input),
|
||||
})
|
||||
return {
|
||||
id: ProviderID.make(provider),
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
export * as AnthropicCompatible from "./anthropic-compatible"
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import type { ProviderAuthOption } from "../route/auth-options"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages"
|
||||
import { AnthropicCompatible } from "./anthropic-compatible"
|
||||
|
||||
export const id = ProviderID.make("anthropic")
|
||||
|
||||
export const routes = [AnthropicMessages.route]
|
||||
|
||||
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
(
|
||||
| { readonly apiKey?: string; readonly authToken?: never }
|
||||
| { readonly apiKey?: never; readonly authToken?: string }
|
||||
) & {
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in options && options.auth) return options.auth
|
||||
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
|
||||
.orElse(Auth.config("ANTHROPIC_API_KEY"))
|
||||
.pipe(Auth.header("x-api-key"))
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
const compatible = AnthropicCompatible.configure({
|
||||
...rest,
|
||||
auth: auth(input),
|
||||
baseURL: baseURL ?? AnthropicMessages.DEFAULT_BASE_URL,
|
||||
provider: id,
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => compatible.model(modelID),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
}).model(modelID)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { chatModel as model } from "../azure"
|
||||
export type { Settings } from "../azure"
|
||||
@@ -1,2 +0,0 @@
|
||||
export { responsesModel as model } from "../azure"
|
||||
export type { Settings } from "../azure"
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
GoogleVertexShared.OAuthOptions & {
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
const route = OpenAICompatibleChat.route.with({
|
||||
id: "google-vertex-chat",
|
||||
provider: id,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
baseURL,
|
||||
location: inputLocation,
|
||||
project: inputProject,
|
||||
...rest
|
||||
} = input
|
||||
const location = GoogleVertexShared.location(inputLocation, "global")
|
||||
const project = GoogleVertexShared.project(inputProject)
|
||||
return route.with({
|
||||
...rest,
|
||||
endpoint: {
|
||||
baseURL:
|
||||
baseURL ??
|
||||
`https://aiplatform.googleapis.com/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/endpoints/openapi`,
|
||||
},
|
||||
auth: GoogleVertexShared.oauth(input, project),
|
||||
})
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { Effect, Schema, Struct } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||
|
||||
const VERSION = "vertex-2023-10-16" as const
|
||||
|
||||
// models.dev uses this provider id even though the API contract is Anthropic Messages.
|
||||
export const id = ProviderID.make("google-vertex-anthropic")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
GoogleVertexShared.OAuthOptions & {
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
id: "google-vertex-messages",
|
||||
provider: id,
|
||||
providerMetadataKey: "anthropic",
|
||||
protocol: Protocol.make({
|
||||
id: AnthropicMessages.protocol.id,
|
||||
body: {
|
||||
schema: Schema.Struct({
|
||||
...Struct.omit(AnthropicMessages.AnthropicMessagesBody.fields, ["model"]),
|
||||
anthropic_version: Schema.Literal(VERSION),
|
||||
}),
|
||||
from: (request) =>
|
||||
AnthropicMessages.protocol.body.from(request).pipe(
|
||||
Effect.map((body) => ({
|
||||
...Struct.omit(body, ["model"]),
|
||||
anthropic_version: VERSION,
|
||||
})),
|
||||
),
|
||||
},
|
||||
stream: AnthropicMessages.protocol.stream,
|
||||
}),
|
||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||
auth: Auth.none,
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new Error("Google Vertex Messages does not support API keys")
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
baseURL,
|
||||
location: inputLocation,
|
||||
project: inputProject,
|
||||
...rest
|
||||
} = input
|
||||
const location = GoogleVertexShared.location(inputLocation, "global")
|
||||
const project = GoogleVertexShared.project(inputProject)
|
||||
return route.with({
|
||||
...rest,
|
||||
endpoint: {
|
||||
baseURL:
|
||||
baseURL ??
|
||||
`https://${GoogleVertexShared.host(location)}/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/publishers/anthropic/models`,
|
||||
},
|
||||
auth: GoogleVertexShared.oauth(input, project),
|
||||
})
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
GoogleVertexShared.OAuthOptions & {
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
const route = OpenAICompatibleResponses.route.with({
|
||||
id: "google-vertex-responses",
|
||||
provider: id,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new Error("Google Vertex Responses does not support API keys")
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
baseURL,
|
||||
location: inputLocation,
|
||||
project: inputProject,
|
||||
...rest
|
||||
} = input
|
||||
const location = GoogleVertexShared.location(inputLocation, "global")
|
||||
const project = GoogleVertexShared.project(inputProject)
|
||||
return route.with({
|
||||
...rest,
|
||||
endpoint: {
|
||||
baseURL:
|
||||
baseURL ??
|
||||
`https://aiplatform.googleapis.com/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/endpoints/openapi`,
|
||||
},
|
||||
auth: GoogleVertexShared.oauth(input, project),
|
||||
})
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { AnyAuthClient } from "google-auth-library"
|
||||
import { Effect, Redacted } from "effect"
|
||||
import { Auth, MissingCredentialError } from "../route/auth"
|
||||
|
||||
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
|
||||
|
||||
export type OAuthOptions =
|
||||
| { readonly accessToken?: string; readonly auth?: never }
|
||||
| { readonly accessToken?: never; readonly auth?: Auth.Definition }
|
||||
|
||||
export type ApiKeyOptions =
|
||||
| (OAuthOptions & { readonly apiKey?: never })
|
||||
| { readonly accessToken?: never; readonly apiKey?: string; readonly auth?: never }
|
||||
|
||||
export const project = (value?: string) =>
|
||||
value ??
|
||||
process.env.GOOGLE_VERTEX_PROJECT ??
|
||||
process.env.GOOGLE_CLOUD_PROJECT ??
|
||||
process.env.GCP_PROJECT ??
|
||||
process.env.GCLOUD_PROJECT
|
||||
|
||||
export const location = (value: string | undefined, fallback: string) =>
|
||||
value ??
|
||||
process.env.GOOGLE_VERTEX_LOCATION ??
|
||||
process.env.GOOGLE_CLOUD_LOCATION ??
|
||||
process.env.VERTEX_LOCATION ??
|
||||
fallback
|
||||
|
||||
export const host = (location: string) => {
|
||||
if (location === "global") return "aiplatform.googleapis.com"
|
||||
// Jurisdictional multi-regions use Regional Endpoint Platform domains.
|
||||
if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com`
|
||||
return `${location}-aiplatform.googleapis.com`
|
||||
}
|
||||
|
||||
export const requireProject = (value: string | undefined) => {
|
||||
if (value) return value
|
||||
throw new Error("Google Vertex requires a project when baseURL is not configured")
|
||||
}
|
||||
|
||||
export const apiKey = (input: ApiKeyOptions) => {
|
||||
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
|
||||
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
|
||||
}
|
||||
|
||||
const adc = (project?: string) => {
|
||||
let client: Promise<AnyAuthClient> | undefined
|
||||
const loadClient = () => {
|
||||
if (client) return client
|
||||
client = import("google-auth-library").then(({ GoogleAuth }) =>
|
||||
new GoogleAuth({ projectId: project, scopes: [SCOPE] }).getClient(),
|
||||
)
|
||||
return client
|
||||
}
|
||||
return Auth.effect(
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const token = await (await loadClient()).getAccessToken()
|
||||
if (!token.token) throw new Error("Google ADC returned an empty access token")
|
||||
return Redacted.make(token.token)
|
||||
},
|
||||
catch: () => new MissingCredentialError("Google Application Default Credentials"),
|
||||
}),
|
||||
).bearer()
|
||||
}
|
||||
|
||||
export const oauth = (input: OAuthOptions, project?: string) => {
|
||||
if (input.accessToken !== undefined && input.auth !== undefined)
|
||||
throw new Error("Google Vertex accessToken cannot be combined with auth")
|
||||
if (input.auth) return input.auth
|
||||
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
|
||||
return adc(project)
|
||||
}
|
||||
|
||||
export * as GoogleVertexShared from "./google-vertex-shared"
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { Gemini } from "../protocols/gemini"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
GoogleVertexShared.ApiKeyOptions & {
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
(
|
||||
| { readonly accessToken?: string; readonly apiKey?: never }
|
||||
| { readonly accessToken?: never; readonly apiKey?: string }
|
||||
) & {
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
id: "google-vertex-gemini",
|
||||
provider: id,
|
||||
providerMetadataKey: "google",
|
||||
protocol: Gemini.protocol,
|
||||
endpoint: Endpoint.path(({ request }) => {
|
||||
const model = String(request.model.id)
|
||||
return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse`
|
||||
}),
|
||||
auth: Auth.none,
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
apiKey: _apiKey,
|
||||
auth: _auth,
|
||||
baseURL,
|
||||
location: inputLocation,
|
||||
project: inputProject,
|
||||
...rest
|
||||
} = input
|
||||
const apiKey = GoogleVertexShared.apiKey(input)
|
||||
const endpointModel = String(modelID).startsWith("endpoints/")
|
||||
if (apiKey !== undefined && endpointModel)
|
||||
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
|
||||
const location = GoogleVertexShared.location(inputLocation, "us-central1")
|
||||
const project = GoogleVertexShared.project(inputProject)
|
||||
const endpoint =
|
||||
baseURL ??
|
||||
(apiKey
|
||||
? "https://aiplatform.googleapis.com/v1/publishers/google"
|
||||
: `https://${GoogleVertexShared.host(location)}/v1beta1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}${endpointModel ? "" : "/publishers/google"}`)
|
||||
return route.with({
|
||||
...rest,
|
||||
endpoint: { baseURL: endpoint },
|
||||
auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey),
|
||||
})
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configuredRoute(input, modelID).model({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
return configure({
|
||||
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { model } from "../google-vertex-chat"
|
||||
export type { Settings } from "../google-vertex-chat"
|
||||
@@ -1,2 +0,0 @@
|
||||
export { model } from "../google-vertex"
|
||||
export type { Settings } from "../google-vertex"
|
||||
@@ -1,2 +0,0 @@
|
||||
export { model } from "../google-vertex-messages"
|
||||
export type { Settings } from "../google-vertex-messages"
|
||||
@@ -1,2 +0,0 @@
|
||||
export { model } from "../google-vertex-responses"
|
||||
export type { Settings } from "../google-vertex-responses"
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import type { ProviderAuthOption } from "../route/auth-options"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { Gemini } from "../protocols/gemini"
|
||||
import { GoogleImages } from "../protocols/google-images"
|
||||
|
||||
export type { GoogleImageOptions } from "../protocols/google-images"
|
||||
|
||||
export const id = ProviderID.make("google")
|
||||
|
||||
export const routes = [Gemini.route]
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in options && options.auth) return options.auth
|
||||
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
|
||||
.orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
|
||||
.pipe(Auth.header("x-goog-api-key"))
|
||||
}
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
return Gemini.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) })
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
const image = (modelID: string | ModelID) =>
|
||||
GoogleImages.model({
|
||||
id: modelID,
|
||||
auth: auth(input),
|
||||
baseURL: input.baseURL,
|
||||
headers: input.headers,
|
||||
http: mergeHttpOptions(input.http === undefined ? undefined : HttpOptions.make(input.http)),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
image,
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export const image = provider.image
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||
|
||||
export const id = ProviderID.make("openai-compatible")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly provider?: string
|
||||
readonly baseURL: string
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const routes = [OpenAICompatibleResponses.route]
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
const provider = input.provider ?? "openai-compatible"
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = OpenAICompatibleResponses.route.with({
|
||||
...rest,
|
||||
provider,
|
||||
endpoint: { baseURL },
|
||||
auth: AuthOptions.bearer(input, []),
|
||||
})
|
||||
return {
|
||||
id: ProviderID.make(provider),
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../openai-compatible-responses"
|
||||
@@ -1,147 +0,0 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import type { Route, RouteDefaultsInput } from "../route/client"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema"
|
||||
import * as OpenAIChat from "../protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images"
|
||||
|
||||
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
||||
export type { OpenAIImageOptions } from "../protocols/openai-images"
|
||||
|
||||
export const id = ProviderID.make("openai")
|
||||
|
||||
export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, OpenAIChat.route]
|
||||
|
||||
// This provider facade wraps the lower-level Responses and Chat model factories
|
||||
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
|
||||
// and default option normalization.
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly queryParams?: Record<string, string>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface ImageGenerationOptions {
|
||||
readonly action?: OpenAIImageString<"auto" | "generate" | "edit">
|
||||
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
|
||||
readonly inputFidelity?: OpenAIImageString<"low" | "high">
|
||||
readonly outputCompression?: number
|
||||
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
|
||||
readonly partialImages?: number
|
||||
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
|
||||
readonly size?: OpenAIImageString<
|
||||
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
|
||||
>
|
||||
}
|
||||
|
||||
export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
||||
ToolDefinition.make({
|
||||
name: "image_generation",
|
||||
description: "Generate or edit an image using OpenAI's hosted image generation tool.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
native: {
|
||||
openai: {
|
||||
type: "image_generation",
|
||||
action: options.action,
|
||||
background: options.background,
|
||||
input_fidelity: options.inputFidelity,
|
||||
output_compression: options.outputCompression,
|
||||
output_format: options.outputFormat,
|
||||
partial_images: options.partialImages,
|
||||
quality: options.quality,
|
||||
size: options.size,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly organization?: string
|
||||
readonly project?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly transport?: "http" | "websocket"
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
||||
|
||||
const defaults = (input: Config) => {
|
||||
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, ...rest } = input
|
||||
return rest
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
|
||||
route.with({
|
||||
auth: auth(input),
|
||||
endpoint: { baseURL: input.baseURL, query: input.queryParams },
|
||||
})
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
|
||||
const responsesWebSocketRoute = configuredRoute(OpenAIResponses.webSocketRoute, input)
|
||||
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
||||
const modelDefaults = defaults(input)
|
||||
const responses = (id: string | ModelID) =>
|
||||
responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
|
||||
const responsesWebSocket = (id: string | ModelID) =>
|
||||
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
|
||||
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
|
||||
const image = (modelID: string | ModelID) =>
|
||||
OpenAIImages.model({
|
||||
id: modelID,
|
||||
auth: auth(input),
|
||||
baseURL: input.baseURL,
|
||||
headers: input.headers,
|
||||
http: mergeHttpOptions(
|
||||
input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
|
||||
),
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
model: responses,
|
||||
responses,
|
||||
responsesWebSocket,
|
||||
chat,
|
||||
image,
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
const headers = {
|
||||
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
|
||||
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
|
||||
...settings.headers,
|
||||
}
|
||||
return {
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: Object.keys(headers).length === 0 ? undefined : headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
}
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
const configured = configure(config(settings))
|
||||
if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID)
|
||||
if (settings.transport === "websocket") return configured.responsesWebSocket(modelID)
|
||||
throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`)
|
||||
}
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure(config(settings)).chat(modelID)
|
||||
export const responses = provider.responses
|
||||
export const responsesWebSocket = provider.responsesWebSocket
|
||||
export const chat = provider.chat
|
||||
export const image = provider.image
|
||||
@@ -1,2 +0,0 @@
|
||||
export { chatModel as model } from "../openai"
|
||||
export type { Settings } from "../openai"
|
||||
@@ -1,2 +0,0 @@
|
||||
export { model } from "../openai"
|
||||
export type { Settings } from "../openai"
|
||||
@@ -1,35 +0,0 @@
|
||||
import { ZAIImages } from "../protocols/zai-images"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema"
|
||||
|
||||
export const id = ProviderID.make("zai")
|
||||
|
||||
export type Config = ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
export type { ZAIImageOptions } from "../protocols/zai-images"
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "ZAI_API_KEY")
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const image = (modelID: string | ModelID) =>
|
||||
ZAIImages.model({
|
||||
id: modelID,
|
||||
auth: auth(input),
|
||||
baseURL: input.baseURL,
|
||||
headers: input.headers,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
image,
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const image = provider.image
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./route/index"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 896 B |
-40
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "minimax",
|
||||
"protocol": "anthropic-messages",
|
||||
"route": "anthropic-messages",
|
||||
"transport": "http",
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:anthropic-compatible-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"text",
|
||||
"golden"
|
||||
],
|
||||
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-text",
|
||||
"recordedAt": "2026-07-18T03:42:22.893Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly with: Hello!\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"1a0b363d0882af316faebcec4d4855a8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":53,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"!\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":53,\"output_tokens\":2,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "minimax",
|
||||
"protocol": "anthropic-messages",
|
||||
"route": "anthropic-messages",
|
||||
"transport": "http",
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:anthropic-compatible-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"tool",
|
||||
"tool-call",
|
||||
"golden"
|
||||
],
|
||||
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-tool-call",
|
||||
"recordedAt": "2026-07-18T03:42:23.876Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"6731ecc323233459d1792df9a733dd98\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":404,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_function_vkxtif4epmvm_1\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":290,\"output_tokens\":27,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "minimax",
|
||||
"protocol": "anthropic-messages",
|
||||
"route": "anthropic-messages",
|
||||
"transport": "http",
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:anthropic-compatible-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"golden"
|
||||
],
|
||||
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-tool-loop",
|
||||
"recordedAt": "2026-07-18T03:42:25.248Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"3807fa12f9ecb9357df511e099da6da0\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":417,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_function_yr64rwmre4gr_1\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":303,\"output_tokens\":27,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_function_yr64rwmre4gr_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_function_yr64rwmre4gr_1\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"92f8a1e86f29946eb2699d40a088fc08\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":41,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":430,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" is sunny.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":41,\"output_tokens\":4,\"cache_read_input_tokens\":430,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-50
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"tags": [
|
||||
"prefix:openai-compatible-chat",
|
||||
"provider:openrouter",
|
||||
"protocol:openai-chat",
|
||||
"reasoning"
|
||||
],
|
||||
"name": "openrouter-reasoning",
|
||||
"recordedAt": "2026-07-18T11:28:39.267Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://openrouter.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"temperature\":0,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"\\n\\n34,600 + 3,287 = 37,887\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"EtgCCosBCA8YAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMMiUlJC3x/5p5PuTwGgwlc8eipZyoM94BHwMiMO45uQx/ymeOjbugi7RDVPZ4jZXSIiEbVi2CD7zPjAK5fFQoVGP1HD55v9CER823JCp6Dg5Xb7Lrk6NUd1XN2KTKrttK7mATE+IBrDTFmor/1cNeg+9gjIbxM/jn/6L5HPmh3/esEVu24Q0IGLZVoE7cTgGgxsrceKMD71Jp2XQgIWD8ltsPfWw3gSc4p+z18UuPN6LuR0mHHENTnClHrAPnOrxbDIl4ZwZgMX8YAQ==\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37887\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":80,\"total_tokens\":141,\"cost\":0.001383,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.001383,\"upstream_inference_prompt_cost\":0.000183,\"upstream_inference_completions_cost\":0.0012},\"completion_tokens_details\":{\"reasoning_tokens\":29,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-55
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": ["prefix:zai-images", "provider:zai", "protocol:zai-images"],
|
||||
"name": "zai-images/generates-an-image",
|
||||
"recordedAt": "2026-07-19T16:03:55.761Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.z.ai/api/paas/v4/images/generations",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"cogview-4-250304\",\"prompt\":\"A simple flat red circle centered on a plain white background.\",\"size\":\"1024x1024\",\"quality\":\"standard\",\"user_id\":\"opencode-image-test\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json; charset=UTF-8"
|
||||
},
|
||||
"body": "{\"created\":1784477028,\"data\":[{\"url\":\"https://mfile.z.ai/1784477035500-43574eab2b6e402da9063d6ac22dfefb.png?ufileattname=202607200003482062c3bba9b04f7d_watermark.png\"}],\"id\":\"202607200003482062c3bba9b04f7d\",\"request_id\":\"202607200003482062c3bba9b04f7d\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { Image, ImageClient, ImageInput } from "../src"
|
||||
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
|
||||
import { it } from "./lib/effect"
|
||||
import { dynamicResponse } from "./lib/http"
|
||||
|
||||
describe("Image", () => {
|
||||
it.effect("generates images through the OpenAI Images API", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: OpenAI.configure({
|
||||
apiKey: "test",
|
||||
baseURL: "https://api.openai.test/v1",
|
||||
queryParams: { "api-version": "v1" },
|
||||
http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
|
||||
}).image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
options: {
|
||||
n: 2,
|
||||
size: "2048x2048",
|
||||
quality: "future-quality",
|
||||
outputFormat: "jpeg",
|
||||
output_format: "avif",
|
||||
outputCompression: 30,
|
||||
output_compression: 40,
|
||||
background: "opaque",
|
||||
native_default: true,
|
||||
future_option: true,
|
||||
},
|
||||
http: {
|
||||
body: { output_format: "webp", output_compression: 50, future_option: "http", request_metadata: "value" },
|
||||
headers: { "x-request": "yes" },
|
||||
query: { trace: "1" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.images).toHaveLength(2)
|
||||
expect(response.image?.mediaType).toBe("image/webp")
|
||||
expect(response.image?.data).toEqual(Uint8Array.from([1, 2, 3]))
|
||||
expect(response.image?.providerMetadata).toEqual({ openai: { revisedPrompt: "A precise robot" } })
|
||||
expect(response.usage?.totalTokens).toBe(12)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe("https://api.openai.test/v1/images/generations?api-version=v1&trace=1")
|
||||
expect(request.headers.get("authorization")).toBe("Bearer test")
|
||||
expect(request.headers.get("x-default")).toBe("yes")
|
||||
expect(request.headers.get("x-request")).toBe("yes")
|
||||
expect(JSON.parse(input.text)).toEqual({
|
||||
model: "gpt-image-2",
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
n: 2,
|
||||
size: "2048x2048",
|
||||
quality: "future-quality",
|
||||
background: "opaque",
|
||||
output_format: "webp",
|
||||
output_compression: 50,
|
||||
native_default: true,
|
||||
future_option: "http",
|
||||
deployment: "test",
|
||||
request_metadata: "value",
|
||||
})
|
||||
return input.respond(
|
||||
JSON.stringify({
|
||||
data: [{ b64_json: "AQID", revised_prompt: "A precise robot" }, { b64_json: "BAUG" }],
|
||||
output_format: "webp",
|
||||
usage: { input_tokens: 4, output_tokens: 8, total_tokens: 12 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("preserves native snake_case and unknown request options", () =>
|
||||
Image.generate({
|
||||
model: OpenAI.configure({
|
||||
apiKey: "test",
|
||||
baseURL: "https://api.openai.test/v1",
|
||||
}).image("future-image-model"),
|
||||
prompt: "A lighthouse in fog",
|
||||
options: {
|
||||
outputFormat: "jpeg",
|
||||
output_format: "avif",
|
||||
outputCompression: 30,
|
||||
output_compression: 40,
|
||||
provider_future_option: { enabled: true },
|
||||
},
|
||||
}).pipe(
|
||||
Effect.tap((response) =>
|
||||
Effect.sync(() => {
|
||||
expect(response.image?.mediaType).toBe("image/avif")
|
||||
}),
|
||||
),
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) => {
|
||||
expect(JSON.parse(input.text)).toEqual({
|
||||
model: "future-image-model",
|
||||
prompt: "A lighthouse in fog",
|
||||
output_format: "avif",
|
||||
output_compression: 40,
|
||||
provider_future_option: { enabled: true },
|
||||
})
|
||||
return Effect.succeed(
|
||||
input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("routes OpenAI byte inputs and masks through multipart edits", () =>
|
||||
Image.generate({
|
||||
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
|
||||
prompt: "Combine these images",
|
||||
images: [
|
||||
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
||||
ImageInput.url("data:image/jpeg;base64,BAUG"),
|
||||
],
|
||||
options: {
|
||||
mask: ImageInput.bytes(Uint8Array.from([7, 8, 9]), "image/png"),
|
||||
quality: "high",
|
||||
future_option: true,
|
||||
},
|
||||
http: {
|
||||
body: { quality: "low", model: "corrupt", prompt: "corrupt", image: "corrupt", "image[]": "corrupt" },
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe("https://api.openai.test/v1/images/edits")
|
||||
expect(request.headers.get("content-type")).toStartWith("multipart/form-data; boundary=")
|
||||
expect(input.text).toContain('name="model"\r\n\r\nfuture-model')
|
||||
expect(input.text).toContain('name="prompt"\r\n\r\nCombine these images')
|
||||
expect(input.text.match(/name="image\[\]"/g)).toHaveLength(2)
|
||||
expect(input.text).toContain('name="mask"')
|
||||
expect(input.text).toContain('name="quality"\r\n\r\nlow')
|
||||
expect(input.text).not.toContain("corrupt")
|
||||
return input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("routes OpenAI URL and file inputs through JSON edits", () =>
|
||||
Image.generate({
|
||||
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
|
||||
prompt: "Combine these images",
|
||||
images: [ImageInput.url("https://example.test/source.png"), ImageInput.file("file_123")],
|
||||
options: { mask: ImageInput.file("file_mask") },
|
||||
http: { body: { future_option: true } },
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) => {
|
||||
expect(JSON.parse(input.text)).toEqual({
|
||||
model: "future-model",
|
||||
prompt: "Combine these images",
|
||||
images: [{ image_url: "https://example.test/source.png" }, { file_id: "file_123" }],
|
||||
mask: { file_id: "file_mask" },
|
||||
future_option: true,
|
||||
})
|
||||
return Effect.succeed(
|
||||
input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("routes ordered xAI image inputs through JSON edits", () =>
|
||||
Image.generate({
|
||||
model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
|
||||
prompt: "Combine these images",
|
||||
images: [
|
||||
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
||||
ImageInput.url("https://example.test/source.jpg"),
|
||||
ImageInput.file("file_123"),
|
||||
],
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) => {
|
||||
expect(JSON.parse(input.text)).toEqual({
|
||||
model: "future-model",
|
||||
prompt: "Combine these images",
|
||||
images: [
|
||||
{ url: "data:image/png;base64,AQID", type: "image_url" },
|
||||
{ url: "https://example.test/source.jpg", type: "image_url" },
|
||||
{ file_id: "file_123" },
|
||||
],
|
||||
})
|
||||
return Effect.succeed(
|
||||
input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses xAI's singular image field for one input", () =>
|
||||
Image.generate({
|
||||
model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
|
||||
prompt: "Edit this image",
|
||||
images: [ImageInput.file("file_123")],
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) => {
|
||||
expect(JSON.parse(input.text)).toEqual({
|
||||
model: "future-model",
|
||||
prompt: "Edit this image",
|
||||
image: { file_id: "file_123" },
|
||||
})
|
||||
return Effect.succeed(
|
||||
input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("lowers ordered Google image inputs into generateContent parts", () =>
|
||||
Image.generate({
|
||||
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).image("future-model"),
|
||||
prompt: "Combine these images",
|
||||
images: [
|
||||
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
||||
ImageInput.url("data:image/jpeg;base64,BAUG"),
|
||||
ImageInput.fileUri("https://generativelanguage.googleapis.com/v1beta/files/123", "image/webp"),
|
||||
],
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) => {
|
||||
expect(JSON.parse(input.text).contents[0].parts).toEqual([
|
||||
{ text: "Combine these images" },
|
||||
{ inlineData: { mimeType: "image/png", data: "AQID" } },
|
||||
{ inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
|
||||
{
|
||||
fileData: {
|
||||
mimeType: "image/webp",
|
||||
fileUri: "https://generativelanguage.googleapis.com/v1beta/files/123",
|
||||
},
|
||||
},
|
||||
])
|
||||
return Effect.succeed(
|
||||
input.respond(
|
||||
JSON.stringify({
|
||||
candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] } }],
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported provider inputs before sending", () =>
|
||||
Effect.gen(function* () {
|
||||
const cases = [
|
||||
Image.generate({
|
||||
model: Google.configure({ apiKey: "test" }).image("model"),
|
||||
prompt: "edit",
|
||||
images: [ImageInput.url("https://example.test/image.png")],
|
||||
}),
|
||||
Image.generate({
|
||||
model: ZAI.configure({ apiKey: "test" }).image("model"),
|
||||
prompt: "edit",
|
||||
images: [ImageInput.bytes(Uint8Array.from([1]), "image/png")],
|
||||
}),
|
||||
]
|
||||
yield* Effect.forEach(cases, (program) =>
|
||||
program.pipe(
|
||||
Effect.flip,
|
||||
Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidRequest"))),
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(dynamicResponse(() => Effect.die("unsupported input reached the network"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("generates images through the Google generateContent API", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: Google.configure({
|
||||
apiKey: "test",
|
||||
baseURL: "https://generativelanguage.test/v1beta/",
|
||||
headers: { "x-default": "yes" },
|
||||
http: { body: { labels: { deployment: "test" } }, query: { api: "v1" } },
|
||||
}).image("any-model-id"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
options: {
|
||||
aspectRatio: "16:9",
|
||||
imageSize: "2K",
|
||||
seed: 42,
|
||||
thinkingLevel: "HIGH",
|
||||
includeThoughts: true,
|
||||
futureOption: true,
|
||||
imageConfig: { aspectRatio: "4:3", nativeImageOption: true },
|
||||
thinkingConfig: { thinkingLevel: "LOW", nativeThinkingOption: true },
|
||||
},
|
||||
http: {
|
||||
body: {
|
||||
safetySettings: [],
|
||||
generationConfig: {
|
||||
imageConfig: { aspectRatio: "3:2", httpImageOption: true },
|
||||
thinkingConfig: { includeThoughts: false, httpThinkingOption: true },
|
||||
futureOption: "http",
|
||||
httpOption: true,
|
||||
},
|
||||
},
|
||||
headers: { "x-request": "yes" },
|
||||
query: { trace: "1" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.images).toHaveLength(3)
|
||||
expect(response.images.map((image) => image.data)).toEqual([
|
||||
Uint8Array.from([1, 2, 3]),
|
||||
Uint8Array.from([4, 5, 6]),
|
||||
Uint8Array.from([7, 8, 9]),
|
||||
])
|
||||
expect(response.images.map((image) => image.mediaType)).toEqual(["image/png", "image/jpeg", "image/webp"])
|
||||
expect(response.images[0].providerMetadata).toMatchObject({ google: { thoughtSignature: "signature-1" } })
|
||||
expect(response.images[1].providerMetadata).toMatchObject({
|
||||
google: { candidateIndex: 0, partIndex: 3, finishReason: "STOP" },
|
||||
})
|
||||
expect(response.images[2].providerMetadata).toMatchObject({ google: { candidateIndex: 7, partIndex: 0 } })
|
||||
expect(response.usage?.inputTokens).toBe(5)
|
||||
expect(response.usage?.outputTokens).toBe(10)
|
||||
expect(response.usage?.reasoningTokens).toBe(3)
|
||||
expect(response.usage?.providerMetadata).toMatchObject({ google: { serviceTier: "STANDARD" } })
|
||||
expect(response.providerMetadata).toEqual({
|
||||
google: {
|
||||
modelVersion: "gemini-3.1-flash-image",
|
||||
responseId: "response-1",
|
||||
promptFeedback: undefined,
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: "STOP",
|
||||
finishMessage: undefined,
|
||||
safetyRatings: [{ category: "safe" }],
|
||||
citationMetadata: undefined,
|
||||
groundingMetadata: undefined,
|
||||
parts: [
|
||||
{
|
||||
type: "inlineData",
|
||||
mediaType: "image/png",
|
||||
thought: undefined,
|
||||
thoughtSignature: "signature-1",
|
||||
},
|
||||
{ type: "text", text: "planning", thought: true, thoughtSignature: "text-signature" },
|
||||
{
|
||||
type: "inlineData",
|
||||
mediaType: "image/png",
|
||||
thought: true,
|
||||
thoughtSignature: "draft-signature",
|
||||
},
|
||||
{
|
||||
type: "inlineData",
|
||||
mediaType: "image/jpeg",
|
||||
thought: undefined,
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
index: 7,
|
||||
finishReason: undefined,
|
||||
finishMessage: undefined,
|
||||
safetyRatings: undefined,
|
||||
citationMetadata: undefined,
|
||||
groundingMetadata: undefined,
|
||||
parts: [
|
||||
{
|
||||
type: "inlineData",
|
||||
mediaType: "image/webp",
|
||||
thought: undefined,
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe(
|
||||
"https://generativelanguage.test/v1beta/models/any-model-id:generateContent?api=v1&trace=1",
|
||||
)
|
||||
expect(request.headers.get("x-goog-api-key")).toBe("test")
|
||||
expect(request.headers.get("x-default")).toBe("yes")
|
||||
expect(request.headers.get("x-request")).toBe("yes")
|
||||
expect(JSON.parse(input.text)).toEqual({
|
||||
contents: [{ role: "user", parts: [{ text: "A robot tending a rooftop garden" }] }],
|
||||
generationConfig: {
|
||||
responseModalities: ["IMAGE"],
|
||||
imageConfig: {
|
||||
aspectRatio: "3:2",
|
||||
imageSize: "2K",
|
||||
nativeImageOption: true,
|
||||
httpImageOption: true,
|
||||
},
|
||||
seed: 42,
|
||||
thinkingConfig: {
|
||||
thinkingLevel: "LOW",
|
||||
includeThoughts: false,
|
||||
nativeThinkingOption: true,
|
||||
httpThinkingOption: true,
|
||||
},
|
||||
futureOption: "http",
|
||||
httpOption: true,
|
||||
},
|
||||
labels: { deployment: "test" },
|
||||
safetySettings: [],
|
||||
})
|
||||
return input.respond(
|
||||
JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: { mimeType: "image/png", data: "AQID" },
|
||||
thoughtSignature: "signature-1",
|
||||
},
|
||||
{ text: "planning", thought: true, thoughtSignature: "text-signature" },
|
||||
{
|
||||
inlineData: { mimeType: "image/png", data: "CgsM" },
|
||||
thought: true,
|
||||
thoughtSignature: "draft-signature",
|
||||
},
|
||||
{ inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
safetyRatings: [{ category: "safe" }],
|
||||
},
|
||||
{
|
||||
index: 7,
|
||||
content: { parts: [{ inlineData: { mimeType: "image/webp", data: "BwgJ" } }] },
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 7,
|
||||
thoughtsTokenCount: 3,
|
||||
totalTokenCount: 15,
|
||||
serviceTier: "STANDARD",
|
||||
},
|
||||
modelVersion: "gemini-3.1-flash-image",
|
||||
responseId: "response-1",
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("includes Google diagnostics when no final image is returned", () =>
|
||||
Image.generate({
|
||||
model: Google.configure({ apiKey: "test", baseURL: "https://generativelanguage.test/v1beta" }).image(
|
||||
"gemini-3.1-flash-image",
|
||||
),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
}).pipe(
|
||||
Effect.flip,
|
||||
Effect.tap((error) =>
|
||||
Effect.sync(() => {
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
if (error.reason._tag !== "InvalidProviderOutput") return
|
||||
expect(error.reason.message).toContain("finish reasons: IMAGE_SAFETY")
|
||||
expect(error.reason.providerMetadata).toEqual({
|
||||
google: {
|
||||
promptFeedback: { blockReason: "SAFETY" },
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: "IMAGE_SAFETY",
|
||||
finishMessage: "The generated image was blocked by safety filters.",
|
||||
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
||||
citationMetadata: undefined,
|
||||
groundingMetadata: undefined,
|
||||
parts: [{ type: "text", text: "blocked", thought: false, thoughtSignature: undefined }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.succeed(
|
||||
input.respond(
|
||||
JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: "blocked", thought: false }] },
|
||||
finishReason: "IMAGE_SAFETY",
|
||||
finishMessage: "The generated image was blocked by safety filters.",
|
||||
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
||||
},
|
||||
],
|
||||
promptFeedback: { blockReason: "SAFETY" },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -1,161 +0,0 @@
|
||||
import {
|
||||
Image,
|
||||
ImageInput,
|
||||
ImageModel,
|
||||
type ImageModelOptions,
|
||||
type ImageOptions,
|
||||
type ImageRequestFor,
|
||||
type ImageRoute,
|
||||
} from "../src"
|
||||
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
|
||||
|
||||
type GoogleLikeOptions = {
|
||||
readonly aspectRatio?: "1:1" | "16:9"
|
||||
readonly imageSize?: "1K" | "2K"
|
||||
} & Record<string, unknown>
|
||||
|
||||
declare const route: ImageRoute<GoogleLikeOptions>
|
||||
const google = ImageModel.make<GoogleLikeOptions>({ id: "gemini-image", provider: "google", route })
|
||||
// @ts-expect-error Extracted model options retain known provider fields.
|
||||
const invalidGoogleOptions: ImageModelOptions<typeof google> = { aspectRatio: "wide" }
|
||||
void invalidGoogleOptions
|
||||
|
||||
Image.generate({
|
||||
model: google,
|
||||
prompt: "A lighthouse",
|
||||
images: [
|
||||
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
||||
ImageInput.url("data:image/jpeg;base64,AQID"),
|
||||
ImageInput.fileUri("https://generativelanguage.googleapis.com/v1beta/files/example", "image/webp"),
|
||||
],
|
||||
options: { aspectRatio: "16:9", imageSize: "2K", futureOption: true },
|
||||
})
|
||||
|
||||
const googleProvider = Google.configure({ apiKey: "test" }).image("any-model-id")
|
||||
Image.generate({
|
||||
model: googleProvider,
|
||||
prompt: "A lighthouse",
|
||||
options: {
|
||||
aspectRatio: "16:9",
|
||||
imageSize: "2K",
|
||||
seed: 42,
|
||||
thinkingLevel: "HIGH",
|
||||
includeThoughts: true,
|
||||
futureOption: true,
|
||||
},
|
||||
})
|
||||
Image.generate({
|
||||
model: googleProvider,
|
||||
prompt: "A lighthouse",
|
||||
options: { aspectRatio: "future-ratio", imageSize: "8K", thinkingLevel: "FUTURE" },
|
||||
})
|
||||
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||
Google.configure({ image: { providerOptions: { imageSize: "2K" } } })
|
||||
// @ts-expect-error Known Google string options retain their value kind.
|
||||
Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { imageSize: 2 } })
|
||||
// @ts-expect-error Known Google numeric options retain their value kind.
|
||||
Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { seed: "42" } })
|
||||
// @ts-expect-error Known Google boolean options retain their value kind.
|
||||
Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { includeThoughts: "yes" } })
|
||||
|
||||
const openai = OpenAI.image("gpt-image-2")
|
||||
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||
OpenAI.configure({ image: { options: { quality: "medium" } } })
|
||||
const futureOpenAIOptions: ImageModelOptions<typeof openai> = { quality: "future-quality" }
|
||||
void futureOpenAIOptions
|
||||
Image.generate({
|
||||
model: openai,
|
||||
prompt: "A lighthouse",
|
||||
images: [ImageInput.url("https://example.com/source.png"), ImageInput.file("file_123")],
|
||||
options: {
|
||||
mask: ImageInput.bytes(Uint8Array.from([1]), "image/png"),
|
||||
quality: "hd",
|
||||
outputFormat: "webp",
|
||||
size: "2048x2048",
|
||||
future_option: true,
|
||||
},
|
||||
})
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: "future-quality", size: "256x256" } })
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", options: { size: "1792x1024" } })
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", options: { native_future_option: true } })
|
||||
// @ts-expect-error Known OpenAI string options retain their value kind.
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: 1 } })
|
||||
// @ts-expect-error Known OpenAI numeric options retain their value kind.
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", options: { outputCompression: "80" } })
|
||||
OpenAI.imageGeneration({ action: "future-action", quality: "future-quality", size: "2048x2048" })
|
||||
// @ts-expect-error Hosted image generation numeric options retain their value kind.
|
||||
OpenAI.imageGeneration({ partialImages: "2" })
|
||||
// @ts-expect-error Known Google-like options are inferred from the selected model.
|
||||
Image.generate({ model: google, prompt: "A lighthouse", options: { aspectRatio: "wide" } })
|
||||
|
||||
const xai = XAI.configure({ apiKey: "test" }).image("any-model-id")
|
||||
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||
XAI.configure({ image: { options: { resolution: "1k" } } })
|
||||
Image.generate({
|
||||
model: xai,
|
||||
prompt: "A lighthouse",
|
||||
images: [ImageInput.url("data:image/png;base64,AQID"), ImageInput.file("file_123")],
|
||||
options: {
|
||||
n: 2,
|
||||
aspectRatio: "future-ratio",
|
||||
resolution: "future-resolution",
|
||||
responseFormat: "future-format",
|
||||
future_option: true,
|
||||
},
|
||||
})
|
||||
Image.generate({
|
||||
model: xai,
|
||||
prompt: "A lighthouse",
|
||||
options: { aspect_ratio: "16:9", response_format: "b64_json", native_future_option: true },
|
||||
})
|
||||
// @ts-expect-error Known xAI numeric options retain their value kind.
|
||||
Image.generate({ model: xai, prompt: "A lighthouse", options: { n: "2" } })
|
||||
// @ts-expect-error Known xAI string options retain their value kind.
|
||||
Image.generate({ model: xai, prompt: "A lighthouse", options: { resolution: 2 } })
|
||||
|
||||
const zai = ZAI.configure({ apiKey: "test" }).image("any-model-id")
|
||||
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||
ZAI.configure({ image: { options: { quality: "hd" } } })
|
||||
Image.generate({
|
||||
model: zai,
|
||||
prompt: "A lighthouse",
|
||||
options: { quality: "future-quality", userID: "user-123", future_option: true },
|
||||
})
|
||||
Image.generate({ model: zai, prompt: "A lighthouse", options: { user_id: "raw-user" } })
|
||||
// @ts-expect-error Known Z.ai string options retain their value kind.
|
||||
Image.generate({ model: zai, prompt: "A lighthouse", options: { quality: 1 } })
|
||||
// @ts-expect-error Known Z.ai user IDs retain their value kind.
|
||||
Image.generate({ model: zai, prompt: "A lighthouse", options: { userID: 1 } })
|
||||
|
||||
declare const generic: ImageModel<ImageOptions>
|
||||
Image.generate({ model: generic, prompt: "A lighthouse", options: { arbitrary: true } })
|
||||
const explicitImageInput: ImageInput = ImageInput.url("https://example.com/image.png")
|
||||
void explicitImageInput
|
||||
|
||||
// @ts-expect-error Raw strings are ambiguous and are not image inputs.
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", images: ["AQID"] })
|
||||
// @ts-expect-error Byte image inputs require an explicit MIME type.
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", images: [{ type: "bytes", data: new Uint8Array() }] })
|
||||
// @ts-expect-error File URIs require an explicit MIME type for Gemini fileData.
|
||||
Image.generate({ model: google, prompt: "A lighthouse", images: [{ type: "file-uri", uri: "files/123" }] })
|
||||
|
||||
const request = Image.request({
|
||||
model: google,
|
||||
prompt: "A lighthouse",
|
||||
options: { aspectRatio: "1:1", futureOption: true },
|
||||
})
|
||||
const typedRequest: ImageRequestFor<GoogleLikeOptions> = request
|
||||
void typedRequest
|
||||
|
||||
// @ts-expect-error Image requests no longer expose a common count option.
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", count: 2 })
|
||||
// @ts-expect-error Image requests no longer expose a common size option.
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", size: { width: 1024, height: 1024 } })
|
||||
// @ts-expect-error Image requests no longer expose a common aspectRatio option.
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", aspectRatio: "16:9" })
|
||||
// @ts-expect-error Image requests no longer expose a common seed option.
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", seed: 1 })
|
||||
// @ts-expect-error Image requests do not expose metadata.
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", metadata: { trace: true } })
|
||||
// @ts-expect-error Masks are provider options, not a common image request field.
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", mask: ImageInput.url("https://example.com/mask.png") })
|
||||
@@ -1,29 +0,0 @@
|
||||
export const dimensions = (data: Uint8Array) => {
|
||||
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
|
||||
return {
|
||||
width: readUint32(data, 16),
|
||||
height: readUint32(data, 20),
|
||||
}
|
||||
if (data[0] === 0xff && data[1] === 0xd8) {
|
||||
for (let offset = 2; offset + 8 < data.length; ) {
|
||||
if (data[offset] !== 0xff) {
|
||||
offset++
|
||||
continue
|
||||
}
|
||||
const marker = data[offset + 1]
|
||||
if (
|
||||
marker !== undefined &&
|
||||
[0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)
|
||||
)
|
||||
return {
|
||||
width: (data[offset + 7] << 8) | data[offset + 8],
|
||||
height: (data[offset + 5] << 8) | data[offset + 6],
|
||||
}
|
||||
offset += 2 + ((data[offset + 2] << 8) | data[offset + 3])
|
||||
}
|
||||
}
|
||||
throw new Error("Unsupported image fixture format")
|
||||
}
|
||||
|
||||
const readUint32 = (data: Uint8Array, offset: number) =>
|
||||
((data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]) >>> 0
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isContextOverflow } from "../src"
|
||||
import { classifyProviderFailure } from "../src/provider-error"
|
||||
|
||||
describe("provider error classification", () => {
|
||||
test("classifies provider token limit messages as context overflow", () => {
|
||||
const messages = [
|
||||
"tokens in request more than max tokens allowed",
|
||||
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
|
||||
"Input length (265330) exceeds model's maximum context length (262144).",
|
||||
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
||||
"The input (516368 tokens) is longer than the model's context length (262144 tokens).",
|
||||
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
|
||||
"Too many tokens",
|
||||
"Token limit exceeded",
|
||||
]
|
||||
|
||||
expect(messages.every(isContextOverflow)).toBe(true)
|
||||
})
|
||||
|
||||
test("does not classify rate limits as context overflow", () => {
|
||||
const messages = [
|
||||
"Throttling error: Too many tokens, please wait before trying again.",
|
||||
"Rate limit exceeded, please retry after 30 seconds.",
|
||||
"Too many requests. Please slow down.",
|
||||
]
|
||||
|
||||
expect(messages.some(isContextOverflow)).toBe(false)
|
||||
})
|
||||
|
||||
test("classifies V1 plain-text rate limit fallbacks", () => {
|
||||
expect(
|
||||
[
|
||||
"Request rate increased too quickly",
|
||||
"Rate limit exceeded, please try again later",
|
||||
"Too many requests, please slow down",
|
||||
].map((message) => classifyProviderFailure({ message })._tag),
|
||||
).toEqual(["RateLimit", "RateLimit", "RateLimit"])
|
||||
})
|
||||
|
||||
test("classifies V1 JSON rate limit fallbacks", () => {
|
||||
expect(
|
||||
[
|
||||
'{"type":"error","error":{"type":"too_many_requests"}}',
|
||||
'{"type":"error","error":{"code":"rate_limit_exceeded"}}',
|
||||
'{"code":"bad_request","error":{"code":"rate_limit_exceeded"}}',
|
||||
'{"type":"error","error":{"code":"unknown","type":"too_many_requests"}}',
|
||||
].map((message) => classifyProviderFailure({ message })._tag),
|
||||
).toEqual(["RateLimit", "RateLimit", "RateLimit", "RateLimit"])
|
||||
})
|
||||
|
||||
test("classifies V1 overloaded provider codes", () => {
|
||||
expect(
|
||||
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map(
|
||||
(message) => classifyProviderFailure({ message })._tag,
|
||||
),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
expect(
|
||||
[
|
||||
'{"code":"bad_request","error":{"code":"usage_not_included"}}',
|
||||
'{"code":"bad_request","error":{"code":"server_error"}}',
|
||||
'{"code":"bad_request","error":{"type":"invalid_request_error"}}',
|
||||
].map((message) => classifyProviderFailure({ message })._tag),
|
||||
).toEqual(["QuotaExceeded", "ProviderInternal", "InvalidRequest"])
|
||||
})
|
||||
|
||||
test("keeps unknown and malformed provider payloads non-retryable", () => {
|
||||
expect(classifyProviderFailure({ message: '{"error":{"message":"no_kv_space"}}' })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: '{"type":"error","error":{"code":123}}' })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
|
||||
})
|
||||
})
|
||||
@@ -1,296 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { model } from "@opencode-ai/ai/providers/openai"
|
||||
|
||||
describe("provider package entrypoints", () => {
|
||||
test("semantic API aliases expose the same contract", async () => {
|
||||
const modules = await Promise.all([
|
||||
import("@opencode-ai/ai/providers/openai"),
|
||||
import("@opencode-ai/ai/providers/openai/responses"),
|
||||
import("@opencode-ai/ai/providers/openai/chat"),
|
||||
import("@opencode-ai/ai/providers/anthropic"),
|
||||
import("@opencode-ai/ai/providers/anthropic-compatible"),
|
||||
import("@opencode-ai/ai/providers/openai-compatible"),
|
||||
import("@opencode-ai/ai/providers/openai-compatible/responses"),
|
||||
import("@opencode-ai/ai/providers/amazon-bedrock"),
|
||||
import("@opencode-ai/ai/providers/azure"),
|
||||
import("@opencode-ai/ai/providers/azure/responses"),
|
||||
import("@opencode-ai/ai/providers/azure/chat"),
|
||||
import("@opencode-ai/ai/providers/google"),
|
||||
import("@opencode-ai/ai/providers/google-vertex"),
|
||||
import("@opencode-ai/ai/providers/google-vertex/gemini"),
|
||||
import("@opencode-ai/ai/providers/google-vertex/chat"),
|
||||
import("@opencode-ai/ai/providers/google-vertex/responses"),
|
||||
import("@opencode-ai/ai/providers/google-vertex/messages"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
expect(modules[8].model).toBe(modules[9].model)
|
||||
expect(modules[12].model).toBe(modules[13].model)
|
||||
})
|
||||
|
||||
test("maps package settings onto the executable model", () => {
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://api.openai.test/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
unrelatedInheritedSetting: true,
|
||||
})
|
||||
|
||||
expect(selected.route.id).toBe("openai-responses")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
})
|
||||
|
||||
test("selects transport without changing the semantic API", () => {
|
||||
expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses")
|
||||
expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe("openai-responses-websocket")
|
||||
})
|
||||
|
||||
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
|
||||
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
|
||||
const selected = OpenAICompatibleResponses.model("custom-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
providerOptions: { openai: { reasoningEffort: "low", store: true } },
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
expect(selected.route.id).toBe("openai-compatible-responses")
|
||||
expect(selected.route.endpoint).toMatchObject({
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
path: "/responses",
|
||||
})
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({
|
||||
openai: { reasoningEffort: "low", store: true },
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Anthropic-compatible settings onto the executable model", async () => {
|
||||
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
||||
const selected = AnthropicCompatible.model("compatible-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
provider: "example",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { metadata: { user_id: "user_1" } },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
expect(selected.route.id).toBe("anthropic-messages")
|
||||
expect(selected.route.endpoint).toMatchObject({
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
path: "/messages",
|
||||
})
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
})
|
||||
|
||||
test("requires an Anthropic-compatible base URL at runtime", async () => {
|
||||
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
|
||||
).toThrow("Anthropic-compatible providers require a baseURL")
|
||||
})
|
||||
|
||||
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
|
||||
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
|
||||
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, [
|
||||
"compatible-model",
|
||||
{
|
||||
apiKey: "fixture",
|
||||
authToken: "token",
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
},
|
||||
]),
|
||||
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
expect(() =>
|
||||
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
|
||||
).toThrow("Anthropic apiKey cannot be combined with authToken")
|
||||
})
|
||||
|
||||
test("maps legacy OpenAI organization and project settings to headers", () => {
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
organization: "org_123",
|
||||
project: "proj_123",
|
||||
})
|
||||
|
||||
expect(selected.route.defaults.headers).toMatchObject({
|
||||
"OpenAI-Organization": "org_123",
|
||||
"OpenAI-Project": "proj_123",
|
||||
})
|
||||
})
|
||||
|
||||
test("selects Azure API entrypoints with the same model contract", async () => {
|
||||
const Azure = await import("@opencode-ai/ai/providers/azure")
|
||||
const AzureChat = await import("@opencode-ai/ai/providers/azure/chat")
|
||||
const AzureResponses = await import("@opencode-ai/ai/providers/azure/responses")
|
||||
const settings = {
|
||||
apiKey: "fixture",
|
||||
resourceName: "opencode-test",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
}
|
||||
|
||||
const responses = AzureResponses.model("deployment", settings)
|
||||
const chat = AzureChat.model("deployment", settings)
|
||||
|
||||
expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
|
||||
expect(responses.route.id).toBe("azure-openai-responses")
|
||||
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
|
||||
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
expect(chat.route.id).toBe("azure-openai-chat")
|
||||
})
|
||||
|
||||
test("maps Google package settings onto the Gemini model", async () => {
|
||||
const Google = await import("@opencode-ai/ai/providers/google")
|
||||
const selected = Google.model("gemini-2.5-flash", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://generativelanguage.test/v1beta",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { safetySettings: [] },
|
||||
limits: { context: 1_000_000, output: 65_536 },
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } },
|
||||
})
|
||||
|
||||
expect(selected.route.id).toBe("gemini")
|
||||
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({
|
||||
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
|
||||
})
|
||||
})
|
||||
|
||||
test("selects Vertex entrypoints with the same model contract", async () => {
|
||||
const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
|
||||
const GoogleVertexGemini = await import("@opencode-ai/ai/providers/google-vertex/gemini")
|
||||
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
|
||||
const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
|
||||
const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
|
||||
const gemini = GoogleVertex.model("gemini-3.5-flash", {
|
||||
apiKey: "fixture",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { safetySettings: [] },
|
||||
limits: { context: 1_000_000, output: 65_536 },
|
||||
})
|
||||
const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
|
||||
accessToken: "fixture",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
})
|
||||
const chat = GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", {
|
||||
accessToken: "fixture",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
})
|
||||
const responses = GoogleVertexResponses.model("xai/grok-4.20-reasoning", {
|
||||
accessToken: "fixture",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
})
|
||||
|
||||
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
|
||||
expect(gemini.route.id).toBe("google-vertex-gemini")
|
||||
expect(gemini.route.protocol).toBe("gemini")
|
||||
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
|
||||
expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
|
||||
expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
|
||||
expect(
|
||||
GoogleVertex.model("gemini-3.5-flash", {
|
||||
accessToken: "fixture",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
}).route.endpoint.baseURL,
|
||||
).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
|
||||
expect(messages.route.id).toBe("google-vertex-messages")
|
||||
expect(messages.route.protocol).toBe("anthropic-messages")
|
||||
expect(messages.route.endpoint.baseURL).toBe(
|
||||
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/publishers/anthropic/models",
|
||||
)
|
||||
expect(chat.route.id).toBe("google-vertex-chat")
|
||||
expect(chat.route.protocol).toBe("openai-chat")
|
||||
expect(chat.route.endpoint).toMatchObject({
|
||||
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
|
||||
path: "/chat/completions",
|
||||
})
|
||||
expect(responses.route.id).toBe("google-vertex-responses")
|
||||
expect(responses.route.protocol).toBe("openai-responses")
|
||||
expect(responses.route.endpoint).toMatchObject({
|
||||
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
|
||||
path: "/responses",
|
||||
})
|
||||
expect(responses.route.defaults.providerOptions).toEqual({ openai: { store: false } })
|
||||
})
|
||||
|
||||
test("rejects conflicting Vertex auth settings at runtime", async () => {
|
||||
const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
|
||||
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
|
||||
const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
|
||||
const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
|
||||
const Providers = await import("@opencode-ai/ai/providers")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertex.model, undefined, [
|
||||
"gemini-3.5-flash",
|
||||
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
|
||||
{ accessToken: "token", auth: {}, project: "vertex-project" },
|
||||
])
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexMessages.model, undefined, [
|
||||
"claude-sonnet-4-6",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexChat.model, undefined, [
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexResponses.model, undefined, [
|
||||
"xai/grok-4.20-reasoning",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Image, ImageInput } from "../../src"
|
||||
import { Google } from "../../src/providers"
|
||||
import { dimensions } from "../lib/image"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const model = Google.configure({
|
||||
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture",
|
||||
}).image("gemini-3.1-flash-image")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "google-images",
|
||||
provider: "google",
|
||||
protocol: "google-images",
|
||||
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("Google Images recorded", () => {
|
||||
recorded.effect("generates an image", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model,
|
||||
prompt: "A simple flat blue circle centered on a plain white background.",
|
||||
options: { aspectRatio: "1:1" },
|
||||
})
|
||||
|
||||
expect(response.images).toHaveLength(1)
|
||||
expect(response.image?.mediaType).toMatch(/^image\//)
|
||||
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||
expect(response.image?.data.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("edits an image", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model,
|
||||
prompt:
|
||||
"Transform this minimal source into a bright orange sun icon with eight rounded rays on a pale blue background.",
|
||||
images: [
|
||||
ImageInput.bytes(
|
||||
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
|
||||
"image/jpeg",
|
||||
),
|
||||
],
|
||||
options: { aspectRatio: "1:1" },
|
||||
})
|
||||
|
||||
expect(response.image?.mediaType).toBe("image/jpeg")
|
||||
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||
if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned Google image bytes")
|
||||
expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,246 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM } from "../../src"
|
||||
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse } from "../lib/http"
|
||||
import { deltaChunk, finishChunk } from "../lib/openai-chunks"
|
||||
import { sseEvents } from "../lib/sse"
|
||||
|
||||
describe("Google Vertex providers", () => {
|
||||
it.effect("sends Gemini requests to the global Vertex endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
}).model("gemini-3.5-flash"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe(
|
||||
"https://aiplatform.googleapis.com/v1beta1/projects/vertex-project/locations/global/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
)
|
||||
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
|
||||
expect(yield* Effect.promise(() => request.json())).toMatchObject({
|
||||
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
|
||||
})
|
||||
return input.respond(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: "model", parts: [{ text: "Hello." }] },
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello.")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: GoogleVertexMessages.configure({
|
||||
accessToken: "vertex-token",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
}).model("claude-sonnet-4-6"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe(
|
||||
"https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict",
|
||||
)
|
||||
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
|
||||
expect(request.headers.get("anthropic-version")).toBeNull()
|
||||
const body = yield* Effect.promise(() => request.json())
|
||||
expect(body).toMatchObject({
|
||||
anthropic_version: "vertex-2023-10-16",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "Say hello." }] }],
|
||||
stream: true,
|
||||
})
|
||||
expect(body).not.toHaveProperty("model")
|
||||
return input.respond(
|
||||
sseEvents(
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello." } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello.")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sends MaaS requests through Vertex Chat Completions", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: GoogleVertexChat.configure({
|
||||
accessToken: "vertex-token",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
}).model("deepseek-ai/deepseek-v3.2-maas"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe(
|
||||
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi/chat/completions",
|
||||
)
|
||||
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
|
||||
expect(yield* Effect.promise(() => request.json())).toMatchObject({
|
||||
model: "deepseek-ai/deepseek-v3.2-maas",
|
||||
messages: [{ role: "user", content: "Say hello." }],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
return input.respond(sseEvents(deltaChunk({ content: "Hello." }), finishChunk("stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello.")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sends Grok requests through Vertex Responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: GoogleVertexResponses.configure({
|
||||
accessToken: "vertex-token",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
}).model("xai/grok-4.20-reasoning"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe(
|
||||
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi/responses",
|
||||
)
|
||||
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
|
||||
expect(yield* Effect.promise(() => request.json())).toMatchObject({
|
||||
model: "xai/grok-4.20-reasoning",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
|
||||
store: false,
|
||||
stream: true,
|
||||
})
|
||||
return input.respond(
|
||||
sseEvents(
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello." },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello.")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("protects the Vertex Messages API version from body overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: GoogleVertexMessages.configure({
|
||||
accessToken: "vertex-token",
|
||||
http: { body: { anthropic_version: "wrong" } },
|
||||
project: "vertex-project",
|
||||
}).model("claude-sonnet-4-6"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): anthropic_version")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes tuned Gemini models through their deployed endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
location: "us-central1",
|
||||
project: "vertex-project",
|
||||
}).model("endpoints/1234567890"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe(
|
||||
"https://us-central1-aiplatform.googleapis.com/v1beta1/projects/vertex-project/locations/us-central1/endpoints/1234567890:streamGenerateContent?alt=sse",
|
||||
)
|
||||
return input.respond(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: "model", parts: [{ text: "Hello." }] },
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello.")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects tuned Gemini models in express mode", () =>
|
||||
Effect.sync(() => {
|
||||
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
|
||||
"Google Vertex tuned models do not support Express Mode API keys",
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,148 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, LLMResponse, Model } from "../../src"
|
||||
import { OpenAIChat } from "../../src/protocols/openai-chat"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: "OpenRouter",
|
||||
model: Model.update(
|
||||
OpenRouter.configure({
|
||||
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
|
||||
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
|
||||
}).model("anthropic/claude-sonnet-4.6"),
|
||||
{ compatibility: { reasoningField: "reasoning" } },
|
||||
),
|
||||
requires: ["OPENROUTER_API_KEY"],
|
||||
cassette: "openrouter-reasoning",
|
||||
structured: true,
|
||||
},
|
||||
{
|
||||
name: "Vercel AI Gateway",
|
||||
model: Model.update(
|
||||
OpenAICompatible.configure({
|
||||
provider: "vercel-ai-gateway",
|
||||
baseURL: "https://ai-gateway.vercel.sh/v1",
|
||||
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
|
||||
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
|
||||
}).model("anthropic/claude-sonnet-4.6"),
|
||||
{ compatibility: { reasoningField: "reasoning" } },
|
||||
),
|
||||
requires: ["AI_GATEWAY_API_KEY"],
|
||||
cassette: "vercel-ai-gateway-reasoning",
|
||||
structured: true,
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const item of cases) {
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-compatible-chat",
|
||||
provider: item.model.provider,
|
||||
protocol: "openai-chat",
|
||||
requires: item.requires,
|
||||
tags: ["reasoning"],
|
||||
metadata: { model: item.model.id },
|
||||
})
|
||||
|
||||
describe(`${item.name} reasoning recorded`, () => {
|
||||
recorded.effect.with(
|
||||
"streams scalar reasoning",
|
||||
{ cassette: item.cassette },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: item.model,
|
||||
system: "Think through the arithmetic, then reply with only the final integer.",
|
||||
prompt: "What is 173 multiplied by 219?",
|
||||
generation: { maxTokens: 1536, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
|
||||
expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
|
||||
expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
|
||||
if (!item.structured) return
|
||||
const details = metadata?.openai?.reasoningDetails
|
||||
if (!Array.isArray(details)) return
|
||||
expect(
|
||||
details.some(
|
||||
(detail) =>
|
||||
typeof detail === "object" &&
|
||||
detail !== null &&
|
||||
"signature" in detail &&
|
||||
typeof detail.signature === "string" &&
|
||||
detail.signature.length > 0,
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model: item.model, messages: [response.message] }),
|
||||
)
|
||||
expect(replay.body.messages).toMatchObject([
|
||||
{ role: "assistant", content: response.text, reasoning: response.reasoning },
|
||||
])
|
||||
const replayDetails =
|
||||
replay.body.messages[0]?.role === "assistant" ? replay.body.messages[0].reasoning_details : undefined
|
||||
expect(Array.isArray(replayDetails)).toBe(true)
|
||||
if (!Array.isArray(replayDetails)) return
|
||||
expect(replayDetails).toEqual(details)
|
||||
expect(replayDetails).toHaveLength(1)
|
||||
expect(replayDetails[0]).toMatchObject({
|
||||
type: "reasoning.text",
|
||||
text: response.reasoning,
|
||||
signature: expect.any(String),
|
||||
})
|
||||
}),
|
||||
30_000,
|
||||
)
|
||||
|
||||
recorded.effect.with(
|
||||
"continues signed reasoning through a tool loop",
|
||||
{ cassette: `${item.cassette}-tool-loop`, tags: ["continuation", "tool", "tool-loop"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* runWeatherToolLoop(
|
||||
goldenWeatherToolLoopRequest({
|
||||
id: `${item.cassette}-tool-loop`,
|
||||
model: item.model,
|
||||
maxTokens: 1536,
|
||||
temperature: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expectWeatherToolLoop(events)
|
||||
expect(
|
||||
LLMResponse.text({
|
||||
events: events.slice(events.findIndex(LLMEvent.is.stepFinish) + 1),
|
||||
}).trim(),
|
||||
).toMatch(/^Paris is sunny\.?$/)
|
||||
const details = events
|
||||
.filter(LLMEvent.is.reasoningEnd)
|
||||
.map((event) => event.providerMetadata?.openai?.reasoningDetails)
|
||||
.find(Array.isArray)
|
||||
expect(Array.isArray(details)).toBe(item.structured)
|
||||
if (!item.structured || !Array.isArray(details)) return
|
||||
expect(
|
||||
details.some(
|
||||
(detail) =>
|
||||
typeof detail === "object" &&
|
||||
detail !== null &&
|
||||
"signature" in detail &&
|
||||
typeof detail.signature === "string" &&
|
||||
detail.signature.length > 0,
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM } from "../../src"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses"
|
||||
import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("OpenAI-compatible Responses route", () => {
|
||||
it.effect("reuses the OpenAI Responses protocol for a configured deployment", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(OpenAICompatibleResponses.route.body).toBe(OpenAIResponses.protocol.body)
|
||||
expect(OpenAICompatibleResponses.route.transport).toBe(OpenAIResponses.httpTransport)
|
||||
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
system: "You are concise.",
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.route).toBe("openai-compatible-responses")
|
||||
expect(prepared.protocol).toBe("openai-responses")
|
||||
expect(prepared.model).toMatchObject({
|
||||
id: "example-model",
|
||||
provider: "example",
|
||||
route: {
|
||||
id: "openai-compatible-responses",
|
||||
endpoint: {
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
path: "/responses",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(prepared.body).toEqual({
|
||||
model: "example-model",
|
||||
input: [
|
||||
{ role: "system", content: "You are concise." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
|
||||
],
|
||||
store: false,
|
||||
stream: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Image, ImageInput } from "../../src"
|
||||
import { OpenAI } from "../../src/providers"
|
||||
import { dimensions } from "../lib/image"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const model = OpenAI.configure({
|
||||
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
||||
}).image("gpt-image-1-mini")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-images",
|
||||
provider: "openai",
|
||||
protocol: "openai-images",
|
||||
requires: ["OPENAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("OpenAI Images recorded", () => {
|
||||
recorded.effect("generates an image", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model,
|
||||
prompt: "A simple flat black circle centered on a plain white background.",
|
||||
options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" },
|
||||
})
|
||||
|
||||
expect(response.images).toHaveLength(1)
|
||||
expect(response.image?.mediaType).toBe("image/jpeg")
|
||||
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||
expect(response.image?.data.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with(
|
||||
"edits an image",
|
||||
{
|
||||
options: {
|
||||
match: (incoming, recorded) => incoming.method === recorded.method && incoming.url === recorded.url,
|
||||
},
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model,
|
||||
prompt: "Keep the simple shape and change it from black to bright green.",
|
||||
images: [
|
||||
ImageInput.bytes(
|
||||
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
|
||||
"image/jpeg",
|
||||
),
|
||||
],
|
||||
options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" },
|
||||
})
|
||||
|
||||
expect(response.image?.mediaType).toBe("image/jpeg")
|
||||
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||
if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned OpenAI image bytes")
|
||||
expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, Message } from "../../src"
|
||||
import { OpenAI } from "../../src/providers"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const openai = OpenAI.configure({
|
||||
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-responses-images",
|
||||
provider: "openai",
|
||||
protocol: "openai-responses",
|
||||
requires: ["OPENAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("OpenAI Responses image generation recorded", () => {
|
||||
recorded.effect("generates and edits an image with the hosted tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const initial = Message.user("Generate a simple flat black triangle centered on a plain white background.")
|
||||
const tools = [
|
||||
OpenAI.imageGeneration({
|
||||
action: "auto",
|
||||
quality: "low",
|
||||
size: "1024x1024",
|
||||
outputFormat: "jpeg",
|
||||
outputCompression: 10,
|
||||
partialImages: 0,
|
||||
}),
|
||||
]
|
||||
const response = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: openai.responses("gpt-5-mini"),
|
||||
messages: [initial],
|
||||
tools,
|
||||
toolChoice: "image_generation",
|
||||
}),
|
||||
)
|
||||
|
||||
const result = response.events.find(LLMEvent.is.toolResult)
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.providerExecuted).toBe(true)
|
||||
expect(result?.result.type).toBe("content")
|
||||
if (result?.result.type !== "content") return
|
||||
expect(result.result.value).toHaveLength(1)
|
||||
expect(result.result.value[0]?.type).toBe("file")
|
||||
if (result.result.value[0]?.type !== "file") return
|
||||
expect(result.result.value[0].mime).toBe("image/jpeg")
|
||||
expect(result.result.value[0].uri.startsWith("data:image/jpeg;base64,")).toBe(true)
|
||||
|
||||
const edited = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: openai.responses("gpt-5-mini"),
|
||||
messages: [initial, response.message, Message.user("Now make the triangle blue.")],
|
||||
tools,
|
||||
toolChoice: "image_generation",
|
||||
}),
|
||||
)
|
||||
const editedResult = edited.events.find(LLMEvent.is.toolResult)
|
||||
expect(editedResult?.result.type).toBe("content")
|
||||
if (editedResult?.result.type !== "content") return
|
||||
expect(editedResult.result.value[0]?.type).toBe("file")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("OpenRouter", () => {
|
||||
it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "openai/gpt-4o-mini",
|
||||
provider: "openrouter",
|
||||
route: { id: "openrouter" },
|
||||
})
|
||||
expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
|
||||
|
||||
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
|
||||
|
||||
expect(prepared.route).toBe("openrouter")
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "openai/gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Say hello." }],
|
||||
stream: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies OpenRouter payload options from the model helper", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({
|
||||
apiKey: "test-key",
|
||||
providerOptions: {
|
||||
openrouter: {
|
||||
usage: true,
|
||||
reasoning: { effort: "high" },
|
||||
promptCacheKey: "session_123",
|
||||
},
|
||||
},
|
||||
}).model("anthropic/claude-3.7-sonnet:thinking"),
|
||||
prompt: "Think briefly.",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
usage: { include: true },
|
||||
reasoning: { effort: "high" },
|
||||
prompt_cache_key: "session_123",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves manually supplied reasoning details", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
{ type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
|
||||
]
|
||||
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Thinking",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning: "Thinking",
|
||||
reasoning_details: details,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves opaque and duplicate continuation details", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
|
||||
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
|
||||
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
|
||||
]
|
||||
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
text: "Thinking",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not merge distinct adjacent reasoning text blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
{ type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" },
|
||||
{ type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" },
|
||||
]
|
||||
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
text: "AB",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits scalar reasoning without continuation details", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Image, ImageInput } from "../../src"
|
||||
import { XAI } from "../../src/providers"
|
||||
import { dimensions } from "../lib/image"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const model = XAI.configure({
|
||||
apiKey: process.env.XAI_API_KEY ?? "fixture",
|
||||
}).image("grok-imagine-image")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "xai-images",
|
||||
provider: "xai",
|
||||
protocol: "xai-images",
|
||||
requires: ["XAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("xAI Images recorded", () => {
|
||||
recorded.effect("generates an image", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model,
|
||||
prompt: "A simple flat black diamond centered on a plain white background.",
|
||||
options: { aspectRatio: "1:1", resolution: "1k", responseFormat: "b64_json" },
|
||||
})
|
||||
|
||||
expect(response.images).toHaveLength(1)
|
||||
expect(response.image?.mediaType.startsWith("image/")).toBe(true)
|
||||
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||
expect(response.image?.data.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("edits an image", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model,
|
||||
prompt: "Keep the simple shape and change it from black to bright purple.",
|
||||
images: [
|
||||
ImageInput.bytes(
|
||||
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
|
||||
"image/jpeg",
|
||||
),
|
||||
],
|
||||
options: { aspectRatio: "1:1", resolution: "1k", responseFormat: "b64_json" },
|
||||
})
|
||||
|
||||
expect(response.image?.mediaType).toMatch(/^image\/(jpeg|png)$/)
|
||||
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||
if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned xAI image bytes")
|
||||
expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Image, ImageClient } from "../../src"
|
||||
import { XAI } from "../../src/providers"
|
||||
import { Auth } from "../../src/route"
|
||||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse } from "../lib/http"
|
||||
|
||||
describe("xAI Images", () => {
|
||||
it.effect("generates through the OpenAI-compatible Images API", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: XAI.configure({
|
||||
apiKey: "test",
|
||||
baseURL: "https://api.xai.test/v1",
|
||||
http: { body: { configured: true }, headers: { "x-default": "yes" } },
|
||||
}).image("grok-imagine-image"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
options: {
|
||||
n: 2,
|
||||
aspectRatio: "16:9",
|
||||
aspect_ratio: "4:3",
|
||||
resolution: "1k",
|
||||
responseFormat: "url",
|
||||
response_format: "b64_json",
|
||||
future_option: true,
|
||||
},
|
||||
http: {
|
||||
body: { resolution: "2k", future_option: "http" },
|
||||
headers: { "x-request": "yes" },
|
||||
query: { trace: "1" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.images).toHaveLength(2)
|
||||
expect(response.image?.mediaType).toBe("image/jpeg")
|
||||
expect(response.image?.data).toEqual(Uint8Array.from([1, 2, 3]))
|
||||
expect(response.images[1]?.mediaType).toBe("application/octet-stream")
|
||||
expect(response.images[1]?.data).toBe("https://api.xai.test/image.jpg")
|
||||
expect(response.usage?.providerMetadata).toEqual({ xai: { num_images: 2 } })
|
||||
expect(response.providerMetadata).toEqual({ xai: { usage: { num_images: 2 } } })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe("https://api.xai.test/v1/images/generations?trace=1")
|
||||
expect(request.headers.get("authorization")).toBe("Bearer test")
|
||||
expect(request.headers.get("x-default")).toBe("yes")
|
||||
expect(request.headers.get("x-request")).toBe("yes")
|
||||
expect(JSON.parse(input.text)).toEqual({
|
||||
model: "grok-imagine-image",
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
n: 2,
|
||||
aspect_ratio: "4:3",
|
||||
resolution: "2k",
|
||||
response_format: "b64_json",
|
||||
future_option: "http",
|
||||
configured: true,
|
||||
})
|
||||
return input.respond(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{ b64_json: "AQID", url: null, mime_type: "image/jpeg" },
|
||||
{ b64_json: null, url: "https://api.xai.test/image.jpg", mime_type: null },
|
||||
],
|
||||
usage: { num_images: 2 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("supports request-level custom auth", () =>
|
||||
Image.generate({
|
||||
model: XAI.configure({
|
||||
baseURL: "https://api.xai.test/v1",
|
||||
auth: Auth.custom((input) =>
|
||||
Effect.succeed(Headers.set(input.headers, "x-custom-auth", new URL(input.url).hostname)),
|
||||
),
|
||||
}).image("grok-imagine-image"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.headers.get("x-custom-auth")).toBe("api.xai.test")
|
||||
return input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Image } from "../../src"
|
||||
import { ZAI } from "../../src/providers"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const model = ZAI.configure({ apiKey: process.env.ZAI_API_KEY ?? "fixture" }).image("cogview-4-250304")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "zai-images",
|
||||
provider: "zai",
|
||||
protocol: "zai-images",
|
||||
requires: ["ZAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("Z.ai Images recorded", () => {
|
||||
recorded.effect("generates an image", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model,
|
||||
prompt: "A simple flat red circle centered on a plain white background.",
|
||||
options: { size: "1024x1024", quality: "standard", userID: "opencode-image-test" },
|
||||
})
|
||||
|
||||
expect(response.images).toHaveLength(1)
|
||||
expect(response.image?.mediaType).toBe("application/octet-stream")
|
||||
expect(response.image?.data).toBeString()
|
||||
expect(response.image?.data).toStartWith("https://")
|
||||
expect(response.providerMetadata?.zai).toBeDefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,130 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { Image, ImageClient } from "../../src"
|
||||
import { ZAI } from "../../src/providers"
|
||||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http"
|
||||
|
||||
describe("Z.ai Images", () => {
|
||||
it.effect("generates through the Z.ai Images API", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: ZAI.configure({
|
||||
apiKey: "test",
|
||||
baseURL: "https://api.z.ai.test/api/paas/v4",
|
||||
headers: { "x-default": "yes" },
|
||||
http: { body: { configured: true, quality: "configured" }, query: { trace: "default" } },
|
||||
}).image("glm-image"),
|
||||
prompt: "A red circle on a white background",
|
||||
options: {
|
||||
quality: "hd",
|
||||
userID: "alias-user",
|
||||
user_id: "raw-user",
|
||||
future_option: true,
|
||||
},
|
||||
http: {
|
||||
headers: { "x-request": "yes" },
|
||||
query: { trace: "request" },
|
||||
body: { quality: "final", user_id: "final-user" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.images).toHaveLength(1)
|
||||
expect(response.image?.mediaType).toBe("application/octet-stream")
|
||||
expect(response.image?.data).toBe("https://cdn.z.ai/generated.png")
|
||||
expect(response.providerMetadata).toEqual({
|
||||
zai: {
|
||||
created: 1_760_335_349,
|
||||
id: "generation-1",
|
||||
requestID: "request-1",
|
||||
contentFilter: [{ role: "future-role", level: 4.5 }],
|
||||
},
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe("https://api.z.ai.test/api/paas/v4/images/generations?trace=request")
|
||||
expect(request.headers.get("authorization")).toBe("Bearer test")
|
||||
expect(request.headers.get("x-default")).toBe("yes")
|
||||
expect(request.headers.get("x-request")).toBe("yes")
|
||||
expect(JSON.parse(input.text)).toEqual({
|
||||
model: "glm-image",
|
||||
prompt: "A red circle on a white background",
|
||||
quality: "final",
|
||||
user_id: "final-user",
|
||||
future_option: true,
|
||||
configured: true,
|
||||
})
|
||||
return input.respond(
|
||||
JSON.stringify({
|
||||
created: 1_760_335_349,
|
||||
id: "generation-1",
|
||||
request_id: "request-1",
|
||||
data: [{ url: "https://cdn.z.ai/generated.png" }],
|
||||
content_filter: [{ role: "future-role", level: 4.5 }],
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("lets raw native options override aliases", () =>
|
||||
Image.generate({
|
||||
model: ZAI.configure({ apiKey: "test" }).image("model"),
|
||||
prompt: "test",
|
||||
options: { quality: "future-quality", userID: "x", user_id: "raw-user" },
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) => {
|
||||
expect(JSON.parse(input.text)).toMatchObject({ quality: "future-quality", user_id: "raw-user" })
|
||||
return Effect.succeed(
|
||||
input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects invalid response structures", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = ZAI.configure({ apiKey: "test" }).image("model")
|
||||
const payloads = [
|
||||
{},
|
||||
{ data: [] },
|
||||
{ data: [{ b64_json: "image" }] },
|
||||
{ data: [{ url: 1 }] },
|
||||
{ data: [{ url: "https://example.test/image.jpg" }], content_filter: [{ role: 1, level: "high" }] },
|
||||
]
|
||||
|
||||
yield* Effect.forEach(payloads, (payload) =>
|
||||
Image.generate({ model, prompt: "test" }).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
fixedResponse(JSON.stringify(payload), { headers: { "content-type": "application/json" } }),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidProviderOutput"))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": false,
|
||||
"noEmit": false
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig.json",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["test/**/*.types.ts"]
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const profiles = [
|
||||
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
||||
{
|
||||
name: "multi patch",
|
||||
tool: "patch",
|
||||
tool: "apply_patch",
|
||||
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
||||
},
|
||||
] as const
|
||||
|
||||
@@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||
textPart(followingID, "Following incremental patch"),
|
||||
],
|
||||
{ completed: false },
|
||||
@@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"patch",
|
||||
"apply_patch",
|
||||
"running",
|
||||
{ files: [first.filePath, second.filePath] },
|
||||
{ metadata: { files: [first, second] } },
|
||||
@@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"patch",
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: [first.filePath, second.filePath, third.filePath] },
|
||||
{ metadata: { files: [first, second, third] } },
|
||||
|
||||
@@ -33,9 +33,11 @@ test.describe("timeline tool state stability", () => {
|
||||
}
|
||||
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
||||
const questionID = "prt_state_question"
|
||||
const todoID = "prt_state_todo"
|
||||
const initial = [
|
||||
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
||||
toolPart(questionID, "question", "pending", questionInput()),
|
||||
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
textPart("prt_state_following", "Following lightweight tools"),
|
||||
]
|
||||
const childID = "ses_timeline_child"
|
||||
@@ -47,6 +49,7 @@ test.describe("timeline tool state stability", () => {
|
||||
await timeline.send(status("busy"), 120)
|
||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
|
||||
const regionIDs = [
|
||||
"prt_state_webfetch",
|
||||
@@ -102,6 +105,7 @@ test.describe("timeline tool state stability", () => {
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
await expect(
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
|
||||
@@ -295,7 +295,7 @@ function performanceTurn(index: number) {
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: `call_0000_${suffix}_patch`,
|
||||
tool: "patch",
|
||||
tool: "apply_patch",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { patchText: realisticPatch(index) },
|
||||
|
||||
@@ -131,7 +131,7 @@ function toolPart(
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
metadataOverride ??
|
||||
(tool === "patch"
|
||||
(tool === "apply_patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
@@ -219,7 +219,7 @@ function turn(index: number): Message[] {
|
||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
@@ -269,6 +269,7 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
||||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
|
||||
@@ -90,7 +90,7 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
|
||||
@@ -65,7 +65,7 @@ async function mockServers(page: Page) {
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
|
||||
@@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
id,
|
||||
"patch",
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts"] },
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
patchID,
|
||||
"patch",
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: files.map((file) => file.filePath) },
|
||||
{ metadata: { files } },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user