Compare commits
6 Commits
main
...
fdeda20373
| Author | SHA1 | Date | |
|---|---|---|---|
|
fdeda20373
|
|||
|
f4f3b9402d
|
|||
|
34b1a1870a
|
|||
|
70b331a7db
|
|||
| c071936aab | |||
|
95b233fc85
|
@@ -12,11 +12,11 @@ Browser: Firefox 🦊 (actually [Zen Browser](https://github.com/zen-browser/des
|
|||||||
|
|
||||||
Text Editor: [Doom Emacs](https://github.com/doomemacs/doomemacs)
|
Text Editor: [Doom Emacs](https://github.com/doomemacs/doomemacs)
|
||||||
|
|
||||||
Terminal: [ghostty](https://ghostty.org/)
|
Terminal: [alacritty](https://github.com/alacritty/alacritty)
|
||||||
|
|
||||||
Shell: [fish](https://fishshell.com/) with the [pure](https://github.com/pure-fish/pure) prompt
|
Shell: [fish](https://fishshell.com/) with the [pure](https://github.com/pure-fish/pure) prompt
|
||||||
|
|
||||||
WM: [niri](https://github.com/YaLTeR/niri)
|
WM: [niri](https://github.com/YaLTeR/niri) (KDE on my desktop)
|
||||||
|
|
||||||
### Background
|
### Background
|
||||||
- Got my background from [here](https://old.reddit.com/r/celestegame/comments/11dtgwg/all_most_of_the_backgrounds_in_celeste_edited/) and used the command `magick input.png -filter Point -resize 2880x1920! output.png` to upscale it bilinearly
|
- Got my background from [here](https://old.reddit.com/r/celestegame/comments/11dtgwg/all_most_of_the_backgrounds_in_celeste_edited/) and used the command `magick input.png -filter Point -resize 2880x1920! output.png` to upscale it bilinearly
|
||||||
|
|||||||
120
flake.lock
generated
120
flake.lock
generated
@@ -140,11 +140,11 @@
|
|||||||
},
|
},
|
||||||
"crane": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777242778,
|
"lastModified": 1776635034,
|
||||||
"narHash": "sha256-VWTeqWeb8Sel/QiWyaPvCa9luAbcGawR+Rw09FJoHz0=",
|
"narHash": "sha256-OEOJrT3ZfwbChzODfIH4GzlNTtOFuZFWPtW7jIeR8xU=",
|
||||||
"owner": "ipetkov",
|
"owner": "ipetkov",
|
||||||
"repo": "crane",
|
"repo": "crane",
|
||||||
"rev": "ad8b31ad0ba8448bd958d7a5d50d811dc5d271c0",
|
"rev": "dc7496d8ea6e526b1254b55d09b966e94673750f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -222,11 +222,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777293736,
|
"lastModified": 1777083982,
|
||||||
"narHash": "sha256-/60J4/D2wY0afSPbjMBrfIQ1nYvxT6Aacu1RlOxtuY4=",
|
"narHash": "sha256-O44P8qcFEv0PYQd+9vFAgCu/e9RclHIAyAmRDJ8qR5s=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "emacs-overlay",
|
"repo": "emacs-overlay",
|
||||||
"rev": "dd3b17c608252cc107e3df25496132d04c9c0233",
|
"rev": "42711d50137a45b8065c3e329946e2d4525235d0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -266,11 +266,11 @@
|
|||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"dir": "pkgs/firefox-addons",
|
"dir": "pkgs/firefox-addons",
|
||||||
"lastModified": 1777295287,
|
"lastModified": 1777089773,
|
||||||
"narHash": "sha256-BkdAlwRrxqFf3PRbfFXr9j2JS+dzsNMme6edRBW4H60=",
|
"narHash": "sha256-ZIlNuebeWTncyl7mcV9VbceSLAaZki+UeXLPQG959xI=",
|
||||||
"owner": "rycee",
|
"owner": "rycee",
|
||||||
"repo": "nur-expressions",
|
"repo": "nur-expressions",
|
||||||
"rev": "c4ce0a56da1b9a816ef4e5129be136c94ea8b564",
|
"rev": "402ba229617a12d918c2a887a4c83a9a24f9a36c",
|
||||||
"type": "gitlab"
|
"type": "gitlab"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -484,11 +484,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777295000,
|
"lastModified": 1777086106,
|
||||||
"narHash": "sha256-xzWerLYQG2W+VGJfaZ+8/Puswbok1o8Tix6/6hIW1rY=",
|
"narHash": "sha256-hlNpIN18pw3xo34Lsrp6vAMUPn0aB/zFBqL0QXI1Pmk=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "b408d49b845167add697937761a89c41c996ac7a",
|
"rev": "5826802354a74af18540aef0b01bc1320f82cc17",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -564,11 +564,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777132364,
|
"lastModified": 1776962372,
|
||||||
"narHash": "sha256-qK6A0xRDAgLf8DUHpDWpVL6NcWi4IhoVClcov+GjLP0=",
|
"narHash": "sha256-Y2imW4kyIhupx8myNSeNCzDbEx2X+h+AmhNjWXA/7Yw=",
|
||||||
"owner": "Jovian-Experiments",
|
"owner": "Jovian-Experiments",
|
||||||
"repo": "Jovian-NixOS",
|
"repo": "Jovian-NixOS",
|
||||||
"rev": "7ae8615cc307c282555b025f88e0c8d7c185bcbf",
|
"rev": "ee3a1184a978e311194a2d3d352c5e6aba67a4b5",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -610,11 +610,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777299656,
|
"lastModified": 1776797459,
|
||||||
"narHash": "sha256-c0r3xXp2+xFJwkryS+nhyQwoACbFzSt4C1TVs3QMh8E=",
|
"narHash": "sha256-utv296Xwk0PwjONe9dsyKx+9Z5xAB70aAsMI//aakpg=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "lanzaboote",
|
"repo": "lanzaboote",
|
||||||
"rev": "079c608988c2747db3902c9de033572cd50e8656",
|
"rev": "4eda91dd5abd2157a2c7bfb33142fc64da668b0a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -657,11 +657,11 @@
|
|||||||
"treefmt-nix": "treefmt-nix"
|
"treefmt-nix": "treefmt-nix"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777266861,
|
"lastModified": 1777093284,
|
||||||
"narHash": "sha256-cdSr2nIz4I+ysG1gAZxbKQo+f79vCCKfQCdiRYnyPec=",
|
"narHash": "sha256-tBvsFPJy0/2gocc6QGYFXJF44TvJ8PC726NsdTpFJ44=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "llm-agents.nix",
|
"repo": "llm-agents.nix",
|
||||||
"rev": "c8f7c7882804510f2b807021cac0a69c1aeb4829",
|
"rev": "6b4673fddbbe1f2656b3fa8d2a32666570aafbfa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -704,11 +704,11 @@
|
|||||||
"xwayland-satellite-unstable": "xwayland-satellite-unstable"
|
"xwayland-satellite-unstable": "xwayland-satellite-unstable"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777240421,
|
"lastModified": 1777068473,
|
||||||
"narHash": "sha256-ooPmu+8tqOGh4kozPW4rJC7Y7WM/FHtEY3OK1PoNW7g=",
|
"narHash": "sha256-atEzEdMgJMRPm/yxOiBvOSEcjSUgU20ieXYQeDfxhTo=",
|
||||||
"owner": "sodiboo",
|
"owner": "sodiboo",
|
||||||
"repo": "niri-flake",
|
"repo": "niri-flake",
|
||||||
"rev": "2bb22af2985e5f3cfd051b3d977ebfbf81126280",
|
"rev": "d543523b5cd4c1f10e41ad8801c49808198b9ca5",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -737,11 +737,11 @@
|
|||||||
"niri-unstable": {
|
"niri-unstable": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777237919,
|
"lastModified": 1777045529,
|
||||||
"narHash": "sha256-bZHBzo4EuW/xLzXnnMKsIMdZYqgY2O0mIMdplwDHB8Y=",
|
"narHash": "sha256-EeAwmrvONsovL2qPwKGXF2xGhbo7MySesY3fW2pNLpM=",
|
||||||
"owner": "YaLTeR",
|
"owner": "YaLTeR",
|
||||||
"repo": "niri",
|
"repo": "niri",
|
||||||
"rev": "a85b922919815c32a3ae34e0838830fe522d6a1c",
|
"rev": "9438f59e2b9d8deb6fcec5922f8aca18162b673c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -761,11 +761,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777227006,
|
"lastModified": 1777054238,
|
||||||
"narHash": "sha256-A7GcOXjfo2xmZ3ERgN0j6GcqaVzqIf5zpYQcdfDaMr0=",
|
"narHash": "sha256-qaqHPZO3oQJiIZgD6sp5HKwvYAVyMtHVJiXVwPSEkx0=",
|
||||||
"owner": "xddxdd",
|
"owner": "xddxdd",
|
||||||
"repo": "nix-cachyos-kernel",
|
"repo": "nix-cachyos-kernel",
|
||||||
"rev": "0f7e2bea4088227a80502557f6c0e3b74949d6b5",
|
"rev": "acb94409639d6d6d64bea140f939ac34938560b1",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -787,11 +787,11 @@
|
|||||||
"systems": "systems_6"
|
"systems": "systems_6"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777289939,
|
"lastModified": 1776938345,
|
||||||
"narHash": "sha256-wZnl3HB88oTME6oL7zGLrhxQmMNeH+QUCpXIfrci1pE=",
|
"narHash": "sha256-3/BFiytDNoIXMUQHcJLoxa7JK0Q1/49M0ffOR9pbzvw=",
|
||||||
"owner": "marienz",
|
"owner": "marienz",
|
||||||
"repo": "nix-doom-emacs-unstraightened",
|
"repo": "nix-doom-emacs-unstraightened",
|
||||||
"rev": "29e1722cb93ede486b79369e81a9c15d7d7b7a48",
|
"rev": "eb25c754986165e509ad2ab8c6b6729f4a861f0c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -802,11 +802,11 @@
|
|||||||
},
|
},
|
||||||
"nix-flatpak": {
|
"nix-flatpak": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777229239,
|
"lastModified": 1776625032,
|
||||||
"narHash": "sha256-OwSaWqlBdKn8QIa7BrPtJmlrr46U7AuwMc/toDKuMZw=",
|
"narHash": "sha256-edvwHiFhgOiwywt6/Iwe+sSn6ybhU3WZGnIoiGcKjfQ=",
|
||||||
"owner": "gmodena",
|
"owner": "gmodena",
|
||||||
"repo": "nix-flatpak",
|
"repo": "nix-flatpak",
|
||||||
"rev": "3f1d78b63b6af353c0685b8a7411c04d980426e4",
|
"rev": "479e19f1decb390aa5b75cae13ddf87d763c74cc",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -937,11 +937,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777077449,
|
"lastModified": 1776734388,
|
||||||
"narHash": "sha256-AIiMJiqvGrN4HyLEbKAoCSRRYn0rnlW5VbKNIMIYqm4=",
|
"narHash": "sha256-vl3dkhlE5gzsItuHoEMVe+DlonsK+0836LIRDnm6MXQ=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "a4bf06618f0b5ee50f14ed8f0da77d34ecc19160",
|
"rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -991,11 +991,11 @@
|
|||||||
"noctalia-qs": "noctalia-qs"
|
"noctalia-qs": "noctalia-qs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777253304,
|
"lastModified": 1777079905,
|
||||||
"narHash": "sha256-XqSHEKEW5pSAx9MoMo8mKPgkjoy4FEhZ4x0a6hGYrSI=",
|
"narHash": "sha256-TvYEXwkZnRFQRuFyyqTNSfPnU2tMdhtiBOXSk2AWLJA=",
|
||||||
"owner": "noctalia-dev",
|
"owner": "noctalia-dev",
|
||||||
"repo": "noctalia-shell",
|
"repo": "noctalia-shell",
|
||||||
"rev": "6773c4750a12c9e9af9c4ce2365e083f1d0d0ad8",
|
"rev": "a50c92167c8d438000270f7eca36f6eea74f388e",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -1014,11 +1014,11 @@
|
|||||||
"treefmt-nix": "treefmt-nix_2"
|
"treefmt-nix": "treefmt-nix_2"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777167795,
|
"lastModified": 1776585574,
|
||||||
"narHash": "sha256-VHdtmxVX7oF2+FxYQQPARQmtaHw23FoTBiTaH6ucOEg=",
|
"narHash": "sha256-j35EWhKoGhKrfcXcAOpoRVgXEPQt41Eukji/h59cnjk=",
|
||||||
"owner": "noctalia-dev",
|
"owner": "noctalia-dev",
|
||||||
"repo": "noctalia-qs",
|
"repo": "noctalia-qs",
|
||||||
"rev": "697db4c14e27d841956ff76887fc312443e6fb17",
|
"rev": "75d180c28a9ab4470e980f3d6f706ad6c5213add",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -1037,11 +1037,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1776796298,
|
"lastModified": 1775585728,
|
||||||
"narHash": "sha256-PcRvlWayisPSjd0UcRQbhG8Oqw78AcPE6x872cPRHN8=",
|
"narHash": "sha256-8Psjt+TWvE4thRKktJsXfR6PA/fWWsZ04DVaY6PUhr4=",
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "pre-commit-hooks.nix",
|
"repo": "pre-commit-hooks.nix",
|
||||||
"rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad",
|
"rev": "580633fa3fe5fc0379905986543fd7495481913d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -1133,11 +1133,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777259803,
|
"lastModified": 1777086717,
|
||||||
"narHash": "sha256-fIb/EoVu/1U0qVrE6qZCJ2WCfprRpywNIAVzKEACIQc=",
|
"narHash": "sha256-vEl3cGHRxEFdVNuP9PbrhAWnmU98aPOLGy9/1JXzSuM=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "a6cb2224d975e16b5e67de688c6ad306f7203425",
|
"rev": "3be56bd430bfd65d3c468a50626c3a601c7dee03",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -1190,11 +1190,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777275019,
|
"lastModified": 1777000965,
|
||||||
"narHash": "sha256-bTnyyCZ89TpvSHMEcBqS5PKqbc/lPc0Km8KdbMVKdsw=",
|
"narHash": "sha256-xcrhVgfI13s1WH4hg5MLL83zAp6/htfF8Pjw4RPiKM8=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "srvos",
|
"repo": "srvos",
|
||||||
"rev": "ab8cddb4a783231e99ff868f90512ed744a39a02",
|
"rev": "7ae6f096b2ffbd25d17da8a4d0fe299a164c4eac",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -1356,11 +1356,11 @@
|
|||||||
"trackerlist": {
|
"trackerlist": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777241384,
|
"lastModified": 1777068584,
|
||||||
"narHash": "sha256-mzqjBOMvL8951W4qt5VA31rQB+TiOYDRyMXTQ7ScSUY=",
|
"narHash": "sha256-UZr6mQfauhIUo8n3SDYnBWeq11xs5lTAoc9onh2MHBc=",
|
||||||
"owner": "ngosang",
|
"owner": "ngosang",
|
||||||
"repo": "trackerslist",
|
"repo": "trackerslist",
|
||||||
"rev": "50a204edfeb4f5f904a28e20b650966241203edb",
|
"rev": "747c048c604c8d12b9d20cfccea4800a32382a66",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -1524,11 +1524,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777269342,
|
"lastModified": 1777084302,
|
||||||
"narHash": "sha256-8Wok2HzykE2yc9V3vtMXuBNuV8Yh4+JMdzIET9PghfM=",
|
"narHash": "sha256-qHE5XpgtRedzND5xzaqzbSOw4amse0aA4/BaVI4ONcU=",
|
||||||
"owner": "0xc000022070",
|
"owner": "0xc000022070",
|
||||||
"repo": "zen-browser-flake",
|
"repo": "zen-browser-flake",
|
||||||
"rev": "3c01a7253335cb590da182eec76862b981b00ad9",
|
"rev": "f6bab88f8566ddc13fb5e5500bd6c720b61d5321",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
./no-gui.nix
|
./no-gui.nix
|
||||||
../progs/ghostty.nix
|
# ../progs/ghostty.nix
|
||||||
|
../progs/alacritty.nix
|
||||||
../progs/emacs.nix
|
../progs/emacs.nix
|
||||||
# ../progs/trezor.nix # - broken
|
# ../progs/trezor.nix # - broken
|
||||||
../progs/flatpak.nix
|
../progs/flatpak.nix
|
||||||
|
|||||||
131
home/progs/alacritty.nix
Normal file
131
home/progs/alacritty.nix
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
{ pkgs, ... }:
|
||||||
|
{
|
||||||
|
home.sessionVariables = {
|
||||||
|
TERMINAL = "alacritty";
|
||||||
|
};
|
||||||
|
|
||||||
|
programs.alacritty = {
|
||||||
|
enable = true;
|
||||||
|
package = pkgs.alacritty;
|
||||||
|
settings = {
|
||||||
|
# some programs can't handle alacritty
|
||||||
|
env.TERM = "xterm-256color";
|
||||||
|
|
||||||
|
window = {
|
||||||
|
# using a window manager, no decorations needed
|
||||||
|
decorations = "none";
|
||||||
|
|
||||||
|
# semi-transparent
|
||||||
|
opacity = 0.90;
|
||||||
|
|
||||||
|
# padding between the content of the terminal and the edge
|
||||||
|
padding = {
|
||||||
|
x = 10;
|
||||||
|
y = 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
columns = 80;
|
||||||
|
lines = 40;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
scrolling = {
|
||||||
|
history = 1000;
|
||||||
|
multiplier = 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
font =
|
||||||
|
let
|
||||||
|
baseFont = {
|
||||||
|
family = "JetBrains Mono Nerd Font";
|
||||||
|
style = "Regular";
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
size = 12;
|
||||||
|
|
||||||
|
normal = baseFont;
|
||||||
|
|
||||||
|
bold = baseFont // {
|
||||||
|
style = "Bold";
|
||||||
|
};
|
||||||
|
|
||||||
|
italic = baseFont // {
|
||||||
|
style = "Italic";
|
||||||
|
};
|
||||||
|
|
||||||
|
offset.y = 0;
|
||||||
|
glyph_offset.y = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
# color scheme
|
||||||
|
colors =
|
||||||
|
let
|
||||||
|
normal = {
|
||||||
|
black = "0x1b1e28";
|
||||||
|
red = "0xd0679d";
|
||||||
|
green = "0x5de4c7";
|
||||||
|
yellow = "0xfffac2";
|
||||||
|
blue = "#435c89";
|
||||||
|
magenta = "0xfcc5e9";
|
||||||
|
cyan = "0xadd7ff";
|
||||||
|
white = "0xffffff";
|
||||||
|
};
|
||||||
|
|
||||||
|
bright = {
|
||||||
|
black = "0xa6accd";
|
||||||
|
red = normal.red;
|
||||||
|
green = normal.green;
|
||||||
|
yellow = normal.yellow;
|
||||||
|
blue = normal.cyan;
|
||||||
|
magenta = "0xfae4fc";
|
||||||
|
cyan = "0x89ddff";
|
||||||
|
white = normal.white;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit normal bright;
|
||||||
|
primary = {
|
||||||
|
background = "0x131621";
|
||||||
|
foreground = bright.black;
|
||||||
|
};
|
||||||
|
|
||||||
|
cursor = {
|
||||||
|
text = "CellBackground";
|
||||||
|
cursor = "CellForeground";
|
||||||
|
};
|
||||||
|
|
||||||
|
search =
|
||||||
|
let
|
||||||
|
foreground = normal.black;
|
||||||
|
background = normal.cyan;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
matches = {
|
||||||
|
inherit foreground background;
|
||||||
|
};
|
||||||
|
|
||||||
|
focused_match = {
|
||||||
|
inherit foreground background;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
selection = {
|
||||||
|
text = "CellForeground";
|
||||||
|
background = "0x303340";
|
||||||
|
};
|
||||||
|
|
||||||
|
vi_mode_cursor = {
|
||||||
|
text = "CellBackground";
|
||||||
|
cursor = "CellForeground";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
cursor = {
|
||||||
|
style = "Underline";
|
||||||
|
vi_mode_style = "Underline";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,71 +1,12 @@
|
|||||||
{ ... }:
|
{ pkgs, ... }:
|
||||||
{
|
{
|
||||||
# https://mynixos.com/home-manager/option/programs.ghostty
|
# https://mynixos.com/home-manager/option/programs.ghostty
|
||||||
programs.ghostty = {
|
programs.ghostty = {
|
||||||
enable = true;
|
enable = true;
|
||||||
enableFishIntegration = true;
|
enableFishIntegration = true;
|
||||||
|
|
||||||
# custom palette ported verbatim from the previous alacritty config
|
|
||||||
# (poimandres-ish). lives in ~/.config/ghostty/themes/poimandres and is
|
|
||||||
# selected by `theme = "poimandres"` below.
|
|
||||||
themes.poimandres = {
|
|
||||||
palette = [
|
|
||||||
"0=#1b1e28"
|
|
||||||
"1=#d0679d"
|
|
||||||
"2=#5de4c7"
|
|
||||||
"3=#fffac2"
|
|
||||||
"4=#435c89"
|
|
||||||
"5=#fcc5e9"
|
|
||||||
"6=#add7ff"
|
|
||||||
"7=#ffffff"
|
|
||||||
"8=#a6accd"
|
|
||||||
"9=#d0679d"
|
|
||||||
"10=#5de4c7"
|
|
||||||
"11=#fffac2"
|
|
||||||
"12=#add7ff"
|
|
||||||
"13=#fae4fc"
|
|
||||||
"14=#89ddff"
|
|
||||||
"15=#ffffff"
|
|
||||||
];
|
|
||||||
background = "131621";
|
|
||||||
foreground = "a6accd";
|
|
||||||
cursor-color = "a6accd";
|
|
||||||
cursor-text = "131621";
|
|
||||||
selection-background = "303340";
|
|
||||||
selection-foreground = "a6accd";
|
|
||||||
};
|
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
theme = "poimandres";
|
theme = "Adventure";
|
||||||
|
background-opacity = 0.7;
|
||||||
# font
|
|
||||||
font-family = "JetBrainsMono Nerd Font";
|
|
||||||
font-size = 12;
|
|
||||||
|
|
||||||
# window
|
|
||||||
window-decoration = false;
|
|
||||||
window-padding-x = 10;
|
|
||||||
window-padding-y = 10;
|
|
||||||
window-width = 80;
|
|
||||||
window-height = 40;
|
|
||||||
|
|
||||||
# semi-transparent background
|
|
||||||
background-opacity = 0.90;
|
|
||||||
|
|
||||||
# cursor
|
|
||||||
cursor-style = "underline";
|
|
||||||
|
|
||||||
# always open new windows at $HOME instead of inheriting whatever cwd the
|
|
||||||
# currently-focused ghostty window has. with gtk-single-instance, the
|
|
||||||
# focused-window inherit rule otherwise sticks the daemon's first cwd to
|
|
||||||
# every subsequent niri Mod+T launch.
|
|
||||||
window-inherit-working-directory = false;
|
|
||||||
working-directory = "home";
|
|
||||||
|
|
||||||
# keep one daemon alive so subsequent launches (e.g. niri Mod+T) are
|
|
||||||
# instant instead of paying GTK + wgpu init each time. relies on the
|
|
||||||
# dbus-activated systemd user service that the HM module wires up.
|
|
||||||
gtk-single-instance = true;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
};
|
};
|
||||||
wallpaper = {
|
wallpaper = {
|
||||||
enabled = true;
|
enabled = true;
|
||||||
skipStartupTransition = true;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,9 +41,17 @@ in
|
|||||||
# silently ignores the standard `patches` attribute. Apply patches via `prePatch` instead
|
# silently ignores the standard `patches` attribute. Apply patches via `prePatch` instead
|
||||||
# so they actually take effect. Tracking: nothing upstream yet.
|
# so they actually take effect. Tracking: nothing upstream yet.
|
||||||
(inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.omp.overrideAttrs (old: {
|
(inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.omp.overrideAttrs (old: {
|
||||||
prePatch = (old.prePatch or "") + ''
|
prePatch =
|
||||||
patch -p1 < ${../../patches/omp/0001-fix-reasoning_content.patch}
|
(old.prePatch or "")
|
||||||
'';
|
+ ''
|
||||||
|
# 0001 — retry without strict tools when DeepSeek (via OpenRouter) rejects strict-mode
|
||||||
|
# `anyOf` nullable unions with `Invalid tool parameters schema : field \`anyOf\`:
|
||||||
|
# missing field \`type\``.
|
||||||
|
patch -p1 < ${../../patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch}
|
||||||
|
# 0002 — require `reasoning_content` for OpenRouter reasoning models so DeepSeek V4 Pro
|
||||||
|
# et al. accept follow-up requests in thinking mode.
|
||||||
|
patch -p1 < ${../../patches/omp/0002-openai-completions-stub-reasoning-content-for-openrouter.patch}
|
||||||
|
'';
|
||||||
}))
|
}))
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,8 @@
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
services.kmscon.enable = true;
|
||||||
|
|
||||||
environment.systemPackages = with pkgs; [
|
environment.systemPackages = with pkgs; [
|
||||||
doas-sudo-shim
|
doas-sudo-shim
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -166,30 +166,12 @@
|
|||||||
DRM_NOUVEAU = lib.mkForce no;
|
DRM_NOUVEAU = lib.mkForce no;
|
||||||
|
|
||||||
# other gpus not present
|
# other gpus not present
|
||||||
DRM_RADEON = lib.mkForce no;
|
|
||||||
DRM_GMA500 = lib.mkForce no;
|
DRM_GMA500 = lib.mkForce no;
|
||||||
DRM_AST = lib.mkForce no;
|
DRM_AST = lib.mkForce no;
|
||||||
DRM_MGAG200 = lib.mkForce no;
|
DRM_MGAG200 = lib.mkForce no;
|
||||||
DRM_HISI_HIBMC = lib.mkForce no;
|
DRM_HISI_HIBMC = lib.mkForce no;
|
||||||
DRM_APPLETBDRM = lib.mkForce no;
|
DRM_APPLETBDRM = lib.mkForce no;
|
||||||
|
|
||||||
# legacy AMD IP blocks. hosts are Navi 32 RDNA3 dGPU (7800 XT, yarn,
|
|
||||||
# 2023, gfx1101, DCN 3.2) and Krackan Point RDNA 3.5 iGPU (mreow,
|
|
||||||
# 2024, gfx1150, DCN 3.5). everything below pre-dates those by a
|
|
||||||
# decade. upstream only exposes per-generation toggles for SI and
|
|
||||||
# CIK — no switch for VI/Polaris/Vega/Navi1x, those stay in amdgpu.
|
|
||||||
DRM_AMDGPU_SI = lib.mkForce no; # Southern Islands / GCN 1 (2012): HD 7950/7970, R9 280/280X, R7 260X
|
|
||||||
DRM_AMDGPU_CIK = lib.mkForce no; # Sea Islands / GCN 2 (2013): R9 290/290X/390, Kaveri APUs (A10-7850K), Steam Machine Bonaire
|
|
||||||
DRM_AMD_SECURE_DISPLAY = lib.mkForce no; # HDCP region-CRC debugfs helper, needs custom DMCU firmware
|
|
||||||
|
|
||||||
# early-boot framebuffer chain: drop every alternative to amdgpu so
|
|
||||||
# the console never transitions simpledrm -> dummy -> amdgpu (visible
|
|
||||||
# as a flash + scrolled dmesg). amdgpu owns the display from initrd
|
|
||||||
# onward; pre-amdgpu kernel output stays in the printk ring buffer.
|
|
||||||
DRM_SIMPLEDRM = lib.mkForce no;
|
|
||||||
FB_EFI = lib.mkForce no;
|
|
||||||
FB_VESA = lib.mkForce no;
|
|
||||||
|
|
||||||
# intel cpu / platform
|
# intel cpu / platform
|
||||||
INTEL_IOMMU = lib.mkForce no;
|
INTEL_IOMMU = lib.mkForce no;
|
||||||
INTEL_IDLE = lib.mkForce no;
|
INTEL_IDLE = lib.mkForce no;
|
||||||
|
|||||||
@@ -1,804 +0,0 @@
|
|||||||
From e145b627cffb6907e6bde348f1318f48acba3801 Mon Sep 17 00:00:00 2001
|
|
||||||
From: sonhyrd <son.hong.do@hyrd.ai>
|
|
||||||
Date: Mon, 27 Apr 2026 00:00:18 +0700
|
|
||||||
Subject: [PATCH 1/5] fix(ai/providers): cover opencode-go reasoning tool-call
|
|
||||||
history
|
|
||||||
|
|
||||||
---
|
|
||||||
.../providers/openai-completions-compat.ts | 12 +++--
|
|
||||||
.../ai/src/providers/openai-completions.ts | 4 +-
|
|
||||||
.../ai/test/openai-completions-compat.test.ts | 51 +++++++++++++++----
|
|
||||||
3 files changed, 49 insertions(+), 18 deletions(-)
|
|
||||||
|
|
||||||
diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts
|
|
||||||
index 69f4811c8..c777f312b 100644
|
|
||||||
--- a/packages/ai/src/providers/openai-completions-compat.ts
|
|
||||||
+++ b/packages/ai/src/providers/openai-completions-compat.ts
|
|
||||||
@@ -107,12 +107,14 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
|
|
||||||
reasoningContentField: "reasoning_content",
|
|
||||||
// Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`:
|
|
||||||
// - Kimi: documented invariant on its native API and via OpenCode-Go.
|
|
||||||
- // - Any reasoning-capable model reached through OpenRouter: DeepSeek V4 Pro and similar enforce
|
|
||||||
- // this server-side whenever the request is in thinking mode. We can't translate Anthropic's
|
|
||||||
- // redacted/encrypted reasoning into DeepSeek's plaintext form, so cross-provider continuations
|
|
||||||
- // rely on a placeholder — see `convertMessages` for the placeholder injection.
|
|
||||||
+ // - Reasoning-capable models reached through OpenRouter or OpenCode-Go: DeepSeek V4 Pro and
|
|
||||||
+ // similar enforce this server-side whenever the request is in thinking mode.
|
|
||||||
+ // We can't translate Anthropic's redacted/encrypted reasoning into DeepSeek's plaintext form, so
|
|
||||||
+ // cross-provider continuations rely on a placeholder — see `convertMessages` for injection rules.
|
|
||||||
requiresReasoningContentForToolCalls:
|
|
||||||
- isKimiModel || ((provider === "openrouter" || baseUrl.includes("openrouter.ai")) && Boolean(model.reasoning)),
|
|
||||||
+ isKimiModel ||
|
|
||||||
+ ((provider === "openrouter" || baseUrl.includes("openrouter.ai") || provider === "opencode-go" ||
|
|
||||||
+ baseUrl.includes("opencode.ai/zen/go")) && Boolean(model.reasoning)),
|
|
||||||
requiresAssistantContentForToolCalls: isKimiModel,
|
|
||||||
openRouterRouting: undefined,
|
|
||||||
vercelGatewayRouting: undefined,
|
|
||||||
diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts
|
|
||||||
index 3785af106..70f2e3b63 100644
|
|
||||||
--- a/packages/ai/src/providers/openai-completions.ts
|
|
||||||
+++ b/packages/ai/src/providers/openai-completions.ts
|
|
||||||
@@ -1213,8 +1213,8 @@ export function convertMessages(
|
|
||||||
// Inject a `reasoning_content` placeholder on assistant tool-call turns when the backend
|
|
||||||
// rejects history without it. The compat flag captures the rule:
|
|
||||||
// - Kimi (native or via OpenCode-Go): chat completion endpoint demands the field.
|
|
||||||
- // - Reasoning models reached through OpenRouter (e.g. DeepSeek V4 Pro): the underlying
|
|
||||||
- // provider's thinking-mode validator demands it on every prior assistant turn. omp
|
|
||||||
+ // - Reasoning models reached through OpenRouter or OpenCode-Go (e.g. DeepSeek V4 Pro):
|
|
||||||
+ // the upstream thinking-mode validator demands it on every prior assistant turn. omp
|
|
||||||
// cannot synthesize real reasoning when the conversation was warmed up by another
|
|
||||||
// provider whose reasoning is redacted/encrypted (Anthropic) or simply absent, so we
|
|
||||||
// emit a placeholder. Real captured reasoning, when present, is preserved earlier via
|
|
||||||
diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
index 6fc3ca9af..6d60ba5e4 100644
|
|
||||||
--- a/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
+++ b/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
@@ -283,23 +283,59 @@ describe("openai-completions compatibility", () => {
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("kimi model detection via detectCompat", () => {
|
|
||||||
- function kimiOpenCodeModel(id: string): Model<"openai-completions"> {
|
|
||||||
+ function openCodeGoModel(id: string, reasoning = true): Model<"openai-completions"> {
|
|
||||||
return {
|
|
||||||
...getBundledModel("openai", "gpt-4o-mini"),
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "opencode-go",
|
|
||||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
|
||||||
id,
|
|
||||||
- reasoning: true,
|
|
||||||
+ reasoning,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
+ function kimiOpenCodeModel(id: string): Model<"openai-completions"> {
|
|
||||||
+ return openCodeGoModel(id, true);
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
it("requires reasoning_content for tool calls on kimi-k2.5 (opencode-go)", () => {
|
|
||||||
const compat = detectCompat(kimiOpenCodeModel("kimi-k2.5"));
|
|
||||||
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
expect(compat.requiresAssistantContentForToolCalls).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
+ it("requires reasoning_content for tool calls on reasoning DeepSeek models via opencode-go", () => {
|
|
||||||
+ const compat = detectCompat(openCodeGoModel("deepseek-v4-pro", true));
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-go", () => {
|
|
||||||
+ const model = openCodeGoModel("deepseek-v4-pro", true);
|
|
||||||
+ const compat = detectCompat(model);
|
|
||||||
+ const toolCallMessage: AssistantMessage = {
|
|
||||||
+ role: "assistant",
|
|
||||||
+ content: [{ type: "toolCall", id: "call_ds_go", name: "web_search", arguments: { query: "hi" } }],
|
|
||||||
+ api: model.api,
|
|
||||||
+ provider: model.provider,
|
|
||||||
+ model: model.id,
|
|
||||||
+ usage: {
|
|
||||||
+ input: 0,
|
|
||||||
+ output: 0,
|
|
||||||
+ cacheRead: 0,
|
|
||||||
+ cacheWrite: 0,
|
|
||||||
+ totalTokens: 0,
|
|
||||||
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
+ },
|
|
||||||
+ stopReason: "toolUse",
|
|
||||||
+ timestamp: Date.now(),
|
|
||||||
+ };
|
|
||||||
+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
|
||||||
+ const assistant = messages.find(m => m.role === "assistant");
|
|
||||||
+ expect(assistant).toBeDefined();
|
|
||||||
+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
it("injects reasoning_content placeholder when assistant with tool calls has no reasoning field", () => {
|
|
||||||
const model = kimiOpenCodeModel("kimi-k2.5");
|
|
||||||
const compat = detectCompat(model);
|
|
||||||
@@ -338,15 +374,8 @@ describe("kimi model detection via detectCompat", () => {
|
|
||||||
expect((reasoningContent as string).length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
- it("does not inject reasoning_content when model is not kimi", () => {
|
|
||||||
- const model: Model<"openai-completions"> = {
|
|
||||||
- ...getBundledModel("openai", "gpt-4o-mini"),
|
|
||||||
- api: "openai-completions",
|
|
||||||
- provider: "opencode-go",
|
|
||||||
- baseUrl: "https://opencode.ai/zen/go/v1",
|
|
||||||
- id: "some-other-model",
|
|
||||||
- };
|
|
||||||
- const compat = detectCompat(model);
|
|
||||||
+ it("does not require reasoning_content when opencode-go model is not reasoning-capable", () => {
|
|
||||||
+ const compat = detectCompat(openCodeGoModel("some-other-model", false));
|
|
||||||
expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
From 70eda0132d7ff48314cbf2dc9560339f0a765d9e Mon Sep 17 00:00:00 2001
|
|
||||||
From: sonhyrd <son.hong.do@hyrd.ai>
|
|
||||||
Date: Mon, 27 Apr 2026 00:08:04 +0700
|
|
||||||
Subject: [PATCH 2/5] fix(ai/providers): generalize opencode reasoning_content
|
|
||||||
gating
|
|
||||||
|
|
||||||
---
|
|
||||||
.../providers/openai-completions-compat.ts | 14 +-
|
|
||||||
.../ai/src/providers/openai-completions.ts | 4 +-
|
|
||||||
.../ai/test/openai-completions-compat.test.ts | 160 ++++++++----------
|
|
||||||
3 files changed, 82 insertions(+), 96 deletions(-)
|
|
||||||
|
|
||||||
diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts
|
|
||||||
index c777f312b..b4825a31c 100644
|
|
||||||
--- a/packages/ai/src/providers/openai-completions-compat.ts
|
|
||||||
+++ b/packages/ai/src/providers/openai-completions-compat.ts
|
|
||||||
@@ -54,6 +54,8 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
|
|
||||||
const isKimiModel = model.id.includes("moonshotai/kimi") || /^kimi[-.]/i.test(model.id);
|
|
||||||
const isAlibaba = provider === "alibaba-coding-plan" || baseUrl.includes("dashscope");
|
|
||||||
const isQwen = model.id.toLowerCase().includes("qwen");
|
|
||||||
+ const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai");
|
|
||||||
+ const isOpenCode = provider === "opencode-zen" || provider === "opencode-go" || baseUrl.includes("opencode.ai/zen");
|
|
||||||
|
|
||||||
const isNonStandard =
|
|
||||||
isCerebras ||
|
|
||||||
@@ -99,22 +101,20 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
|
|
||||||
requiresMistralToolIds: isMistral,
|
|
||||||
thinkingFormat: isZai
|
|
||||||
? "zai"
|
|
||||||
- : provider === "openrouter" || baseUrl.includes("openrouter.ai")
|
|
||||||
+ : isOpenRouter
|
|
||||||
? "openrouter"
|
|
||||||
: isAlibaba || isQwen
|
|
||||||
? "qwen"
|
|
||||||
: "openai",
|
|
||||||
reasoningContentField: "reasoning_content",
|
|
||||||
// Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`:
|
|
||||||
- // - Kimi: documented invariant on its native API and via OpenCode-Go.
|
|
||||||
- // - Reasoning-capable models reached through OpenRouter or OpenCode-Go: DeepSeek V4 Pro and
|
|
||||||
- // similar enforce this server-side whenever the request is in thinking mode.
|
|
||||||
+ // - Kimi: documented invariant on its native API and via OpenCode.
|
|
||||||
+ // - Reasoning-capable models reached through OpenRouter or OpenCode (Zen/Go): DeepSeek V4 Pro,
|
|
||||||
+ // Kimi, and similar models can enforce this server-side whenever the request is in thinking mode.
|
|
||||||
// We can't translate Anthropic's redacted/encrypted reasoning into DeepSeek's plaintext form, so
|
|
||||||
// cross-provider continuations rely on a placeholder — see `convertMessages` for injection rules.
|
|
||||||
requiresReasoningContentForToolCalls:
|
|
||||||
- isKimiModel ||
|
|
||||||
- ((provider === "openrouter" || baseUrl.includes("openrouter.ai") || provider === "opencode-go" ||
|
|
||||||
- baseUrl.includes("opencode.ai/zen/go")) && Boolean(model.reasoning)),
|
|
||||||
+ isKimiModel || ((isOpenRouter || isOpenCode) && Boolean(model.reasoning)),
|
|
||||||
requiresAssistantContentForToolCalls: isKimiModel,
|
|
||||||
openRouterRouting: undefined,
|
|
||||||
vercelGatewayRouting: undefined,
|
|
||||||
diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts
|
|
||||||
index 70f2e3b63..e25aeffb3 100644
|
|
||||||
--- a/packages/ai/src/providers/openai-completions.ts
|
|
||||||
+++ b/packages/ai/src/providers/openai-completions.ts
|
|
||||||
@@ -1212,8 +1212,8 @@ export function convertMessages(
|
|
||||||
(assistantMsg as any).reasoning_text !== undefined;
|
|
||||||
// Inject a `reasoning_content` placeholder on assistant tool-call turns when the backend
|
|
||||||
// rejects history without it. The compat flag captures the rule:
|
|
||||||
- // - Kimi (native or via OpenCode-Go): chat completion endpoint demands the field.
|
|
||||||
- // - Reasoning models reached through OpenRouter or OpenCode-Go (e.g. DeepSeek V4 Pro):
|
|
||||||
+ // - Kimi (native or via OpenCode Zen/Go): chat completion endpoint demands the field.
|
|
||||||
+ // - Reasoning models reached through OpenRouter or OpenCode Zen/Go (e.g. DeepSeek V4 Pro):
|
|
||||||
// the upstream thinking-mode validator demands it on every prior assistant turn. omp
|
|
||||||
// cannot synthesize real reasoning when the conversation was warmed up by another
|
|
||||||
// provider whose reasoning is redacted/encrypted (Anthropic) or simply absent, so we
|
|
||||||
diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
index 6d60ba5e4..c743dd246 100644
|
|
||||||
--- a/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
+++ b/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
@@ -282,105 +282,91 @@ describe("openai-completions compatibility", () => {
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
-describe("kimi model detection via detectCompat", () => {
|
|
||||||
- function openCodeGoModel(id: string, reasoning = true): Model<"openai-completions"> {
|
|
||||||
+describe("opencode reasoning-content compatibility via detectCompat", () => {
|
|
||||||
+ type OpenCodeProvider = "opencode-go" | "opencode-zen";
|
|
||||||
+
|
|
||||||
+ function openCodeModel(provider: OpenCodeProvider, id: string, reasoning = true): Model<"openai-completions"> {
|
|
||||||
+ const baseUrl = provider === "opencode-go" ? "https://opencode.ai/zen/go/v1" : "https://opencode.ai/zen/v1";
|
|
||||||
return {
|
|
||||||
...getBundledModel("openai", "gpt-4o-mini"),
|
|
||||||
api: "openai-completions",
|
|
||||||
- provider: "opencode-go",
|
|
||||||
- baseUrl: "https://opencode.ai/zen/go/v1",
|
|
||||||
+ provider,
|
|
||||||
+ baseUrl,
|
|
||||||
id,
|
|
||||||
reasoning,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
- function kimiOpenCodeModel(id: string): Model<"openai-completions"> {
|
|
||||||
- return openCodeGoModel(id, true);
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- it("requires reasoning_content for tool calls on kimi-k2.5 (opencode-go)", () => {
|
|
||||||
- const compat = detectCompat(kimiOpenCodeModel("kimi-k2.5"));
|
|
||||||
- expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
- expect(compat.requiresAssistantContentForToolCalls).toBe(true);
|
|
||||||
- });
|
|
||||||
-
|
|
||||||
- it("requires reasoning_content for tool calls on reasoning DeepSeek models via opencode-go", () => {
|
|
||||||
- const compat = detectCompat(openCodeGoModel("deepseek-v4-pro", true));
|
|
||||||
- expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
- expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
|
||||||
- });
|
|
||||||
-
|
|
||||||
- it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-go", () => {
|
|
||||||
- const model = openCodeGoModel("deepseek-v4-pro", true);
|
|
||||||
- const compat = detectCompat(model);
|
|
||||||
- const toolCallMessage: AssistantMessage = {
|
|
||||||
- role: "assistant",
|
|
||||||
- content: [{ type: "toolCall", id: "call_ds_go", name: "web_search", arguments: { query: "hi" } }],
|
|
||||||
- api: model.api,
|
|
||||||
- provider: model.provider,
|
|
||||||
- model: model.id,
|
|
||||||
- usage: {
|
|
||||||
- input: 0,
|
|
||||||
- output: 0,
|
|
||||||
- cacheRead: 0,
|
|
||||||
- cacheWrite: 0,
|
|
||||||
- totalTokens: 0,
|
|
||||||
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
- },
|
|
||||||
- stopReason: "toolUse",
|
|
||||||
- timestamp: Date.now(),
|
|
||||||
+ it.each(["opencode-go", "opencode-zen"] as const)(
|
|
||||||
+ "requires reasoning_content for tool calls on kimi-k2.5 via %s",
|
|
||||||
+ provider => {
|
|
||||||
+ const compat = detectCompat(openCodeModel(provider, "kimi-k2.5", true));
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(true);
|
|
||||||
+ },
|
|
||||||
+ );
|
|
||||||
+
|
|
||||||
+ it.each(["opencode-go", "opencode-zen"] as const)(
|
|
||||||
+ "requires reasoning_content for tool calls on reasoning DeepSeek models via %s",
|
|
||||||
+ provider => {
|
|
||||||
+ const compat = detectCompat(openCodeModel(provider, "deepseek-v4-pro", true));
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
|
||||||
+ },
|
|
||||||
+ );
|
|
||||||
+
|
|
||||||
+ it("requires reasoning_content when custom openai provider targets opencode zen baseUrl", () => {
|
|
||||||
+ const model: Model<"openai-completions"> = {
|
|
||||||
+ ...getBundledModel("openai", "gpt-4o-mini"),
|
|
||||||
+ api: "openai-completions",
|
|
||||||
+ provider: "openai",
|
|
||||||
+ baseUrl: "https://opencode.ai/zen/v1",
|
|
||||||
+ id: "deepseek-v4-pro",
|
|
||||||
+ reasoning: true,
|
|
||||||
};
|
|
||||||
- const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
|
||||||
- const assistant = messages.find(m => m.role === "assistant");
|
|
||||||
- expect(assistant).toBeDefined();
|
|
||||||
- expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
|
||||||
- });
|
|
||||||
-
|
|
||||||
- it("injects reasoning_content placeholder when assistant with tool calls has no reasoning field", () => {
|
|
||||||
- const model = kimiOpenCodeModel("kimi-k2.5");
|
|
||||||
const compat = detectCompat(model);
|
|
||||||
- const toolCallMessage: AssistantMessage = {
|
|
||||||
- role: "assistant",
|
|
||||||
- content: [
|
|
||||||
- // Thinking returned as plain text (as kimi-k2.5 on opencode-go does)
|
|
||||||
- { type: "text", text: "Let me research this." },
|
|
||||||
- {
|
|
||||||
- type: "toolCall",
|
|
||||||
- id: "call_abc123",
|
|
||||||
- name: "web_search",
|
|
||||||
- arguments: { query: "beads gastownhall" },
|
|
||||||
- },
|
|
||||||
- ],
|
|
||||||
- api: model.api,
|
|
||||||
- provider: model.provider,
|
|
||||||
- model: model.id,
|
|
||||||
- usage: {
|
|
||||||
- input: 0,
|
|
||||||
- output: 0,
|
|
||||||
- cacheRead: 0,
|
|
||||||
- cacheWrite: 0,
|
|
||||||
- totalTokens: 0,
|
|
||||||
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
- },
|
|
||||||
- stopReason: "toolUse",
|
|
||||||
- timestamp: Date.now(),
|
|
||||||
- };
|
|
||||||
- const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
|
||||||
- const assistant = messages.find(m => m.role === "assistant");
|
|
||||||
- expect(assistant).toBeDefined();
|
|
||||||
- const reasoningContent = Reflect.get(assistant as object, "reasoning_content");
|
|
||||||
- expect(reasoningContent).toBeDefined();
|
|
||||||
- expect(typeof reasoningContent).toBe("string");
|
|
||||||
- expect((reasoningContent as string).length).toBeGreaterThan(0);
|
|
||||||
- });
|
|
||||||
-
|
|
||||||
- it("does not require reasoning_content when opencode-go model is not reasoning-capable", () => {
|
|
||||||
- const compat = detectCompat(openCodeGoModel("some-other-model", false));
|
|
||||||
- expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
- it.each(["kimi-k2.5", "kimi-k1.5", "kimi-k2-5"])("matches kimi model id: %s", id => {
|
|
||||||
- const compat = detectCompat(kimiOpenCodeModel(id));
|
|
||||||
+ it.each(["opencode-go", "opencode-zen"] as const)(
|
|
||||||
+ "injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via %s",
|
|
||||||
+ provider => {
|
|
||||||
+ const model = openCodeModel(provider, "deepseek-v4-pro", true);
|
|
||||||
+ const compat = detectCompat(model);
|
|
||||||
+ const toolCallMessage: AssistantMessage = {
|
|
||||||
+ role: "assistant",
|
|
||||||
+ content: [{ type: "toolCall", id: `call_ds_${provider}`, name: "web_search", arguments: { query: "hi" } }],
|
|
||||||
+ api: model.api,
|
|
||||||
+ provider: model.provider,
|
|
||||||
+ model: model.id,
|
|
||||||
+ usage: {
|
|
||||||
+ input: 0,
|
|
||||||
+ output: 0,
|
|
||||||
+ cacheRead: 0,
|
|
||||||
+ cacheWrite: 0,
|
|
||||||
+ totalTokens: 0,
|
|
||||||
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
+ },
|
|
||||||
+ stopReason: "toolUse",
|
|
||||||
+ timestamp: Date.now(),
|
|
||||||
+ };
|
|
||||||
+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
|
||||||
+ const assistant = messages.find(m => m.role === "assistant");
|
|
||||||
+ expect(assistant).toBeDefined();
|
|
||||||
+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
|
||||||
+ },
|
|
||||||
+ );
|
|
||||||
+
|
|
||||||
+ it.each(["opencode-go", "opencode-zen"] as const)(
|
|
||||||
+ "does not require reasoning_content when %s model is not reasoning-capable",
|
|
||||||
+ provider => {
|
|
||||||
+ const compat = detectCompat(openCodeModel(provider, "some-other-model", false));
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
|
||||||
+ },
|
|
||||||
+ );
|
|
||||||
+
|
|
||||||
+ it.each(["kimi-k2.5", "kimi-k1.5", "kimi-k2-5"])("matches kimi model id pattern via opencode-zen: %s", id => {
|
|
||||||
+ const compat = detectCompat(openCodeModel("opencode-zen", id, true));
|
|
||||||
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
From 76c1fe9ee083836ecca43900fefc458c8cf4c4fb Mon Sep 17 00:00:00 2001
|
|
||||||
From: sonhyrd <son.hong.do@hyrd.ai>
|
|
||||||
Date: Mon, 27 Apr 2026 00:14:27 +0700
|
|
||||||
Subject: [PATCH 3/5] test(ai): restore non-kimi coverage while adding
|
|
||||||
opencode-zen cases
|
|
||||||
|
|
||||||
---
|
|
||||||
.../ai/test/openai-completions-compat.test.ts | 215 +++++++++++++-----
|
|
||||||
1 file changed, 154 insertions(+), 61 deletions(-)
|
|
||||||
|
|
||||||
diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
index c743dd246..8b8cef393 100644
|
|
||||||
--- a/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
+++ b/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
@@ -282,38 +282,56 @@ describe("openai-completions compatibility", () => {
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
-describe("opencode reasoning-content compatibility via detectCompat", () => {
|
|
||||||
- type OpenCodeProvider = "opencode-go" | "opencode-zen";
|
|
||||||
+describe("kimi model detection via detectCompat", () => {
|
|
||||||
+ function openCodeGoModel(id: string, reasoning = true): Model<"openai-completions"> {
|
|
||||||
+ return {
|
|
||||||
+ ...getBundledModel("openai", "gpt-4o-mini"),
|
|
||||||
+ api: "openai-completions",
|
|
||||||
+ provider: "opencode-go",
|
|
||||||
+ baseUrl: "https://opencode.ai/zen/go/v1",
|
|
||||||
+ id,
|
|
||||||
+ reasoning,
|
|
||||||
+ };
|
|
||||||
+ }
|
|
||||||
|
|
||||||
- function openCodeModel(provider: OpenCodeProvider, id: string, reasoning = true): Model<"openai-completions"> {
|
|
||||||
- const baseUrl = provider === "opencode-go" ? "https://opencode.ai/zen/go/v1" : "https://opencode.ai/zen/v1";
|
|
||||||
+ function openCodeZenModel(id: string, reasoning = true): Model<"openai-completions"> {
|
|
||||||
return {
|
|
||||||
...getBundledModel("openai", "gpt-4o-mini"),
|
|
||||||
api: "openai-completions",
|
|
||||||
- provider,
|
|
||||||
- baseUrl,
|
|
||||||
+ provider: "opencode-zen",
|
|
||||||
+ baseUrl: "https://opencode.ai/zen/v1",
|
|
||||||
id,
|
|
||||||
reasoning,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
- it.each(["opencode-go", "opencode-zen"] as const)(
|
|
||||||
- "requires reasoning_content for tool calls on kimi-k2.5 via %s",
|
|
||||||
- provider => {
|
|
||||||
- const compat = detectCompat(openCodeModel(provider, "kimi-k2.5", true));
|
|
||||||
- expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
- expect(compat.requiresAssistantContentForToolCalls).toBe(true);
|
|
||||||
- },
|
|
||||||
- );
|
|
||||||
-
|
|
||||||
- it.each(["opencode-go", "opencode-zen"] as const)(
|
|
||||||
- "requires reasoning_content for tool calls on reasoning DeepSeek models via %s",
|
|
||||||
- provider => {
|
|
||||||
- const compat = detectCompat(openCodeModel(provider, "deepseek-v4-pro", true));
|
|
||||||
- expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
- expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
|
||||||
- },
|
|
||||||
- );
|
|
||||||
+ function kimiOpenCodeModel(id: string): Model<"openai-completions"> {
|
|
||||||
+ return openCodeGoModel(id, true);
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ it("requires reasoning_content for tool calls on kimi-k2.5 (opencode-go)", () => {
|
|
||||||
+ const compat = detectCompat(kimiOpenCodeModel("kimi-k2.5"));
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(true);
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it("requires reasoning_content for tool calls on kimi-k2.5 (opencode-zen)", () => {
|
|
||||||
+ const compat = detectCompat(openCodeZenModel("kimi-k2.5", true));
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(true);
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it("requires reasoning_content for tool calls on reasoning DeepSeek models via opencode-go", () => {
|
|
||||||
+ const compat = detectCompat(openCodeGoModel("deepseek-v4-pro", true));
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it("requires reasoning_content for tool calls on reasoning DeepSeek models via opencode-zen", () => {
|
|
||||||
+ const compat = detectCompat(openCodeZenModel("deepseek-v4-pro", true));
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
|
||||||
+ });
|
|
||||||
|
|
||||||
it("requires reasoning_content when custom openai provider targets opencode zen baseUrl", () => {
|
|
||||||
const model: Model<"openai-completions"> = {
|
|
||||||
@@ -328,45 +346,120 @@ describe("opencode reasoning-content compatibility via detectCompat", () => {
|
|
||||||
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
- it.each(["opencode-go", "opencode-zen"] as const)(
|
|
||||||
- "injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via %s",
|
|
||||||
- provider => {
|
|
||||||
- const model = openCodeModel(provider, "deepseek-v4-pro", true);
|
|
||||||
- const compat = detectCompat(model);
|
|
||||||
- const toolCallMessage: AssistantMessage = {
|
|
||||||
- role: "assistant",
|
|
||||||
- content: [{ type: "toolCall", id: `call_ds_${provider}`, name: "web_search", arguments: { query: "hi" } }],
|
|
||||||
- api: model.api,
|
|
||||||
- provider: model.provider,
|
|
||||||
- model: model.id,
|
|
||||||
- usage: {
|
|
||||||
- input: 0,
|
|
||||||
- output: 0,
|
|
||||||
- cacheRead: 0,
|
|
||||||
- cacheWrite: 0,
|
|
||||||
- totalTokens: 0,
|
|
||||||
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
+ it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-go", () => {
|
|
||||||
+ const model = openCodeGoModel("deepseek-v4-pro", true);
|
|
||||||
+ const compat = detectCompat(model);
|
|
||||||
+ const toolCallMessage: AssistantMessage = {
|
|
||||||
+ role: "assistant",
|
|
||||||
+ content: [{ type: "toolCall", id: "call_ds_go", name: "web_search", arguments: { query: "hi" } }],
|
|
||||||
+ api: model.api,
|
|
||||||
+ provider: model.provider,
|
|
||||||
+ model: model.id,
|
|
||||||
+ usage: {
|
|
||||||
+ input: 0,
|
|
||||||
+ output: 0,
|
|
||||||
+ cacheRead: 0,
|
|
||||||
+ cacheWrite: 0,
|
|
||||||
+ totalTokens: 0,
|
|
||||||
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
+ },
|
|
||||||
+ stopReason: "toolUse",
|
|
||||||
+ timestamp: Date.now(),
|
|
||||||
+ };
|
|
||||||
+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
|
||||||
+ const assistant = messages.find(m => m.role === "assistant");
|
|
||||||
+ expect(assistant).toBeDefined();
|
|
||||||
+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-zen", () => {
|
|
||||||
+ const model = openCodeZenModel("deepseek-v4-pro", true);
|
|
||||||
+ const compat = detectCompat(model);
|
|
||||||
+ const toolCallMessage: AssistantMessage = {
|
|
||||||
+ role: "assistant",
|
|
||||||
+ content: [{ type: "toolCall", id: "call_ds_zen", name: "web_search", arguments: { query: "hi" } }],
|
|
||||||
+ api: model.api,
|
|
||||||
+ provider: model.provider,
|
|
||||||
+ model: model.id,
|
|
||||||
+ usage: {
|
|
||||||
+ input: 0,
|
|
||||||
+ output: 0,
|
|
||||||
+ cacheRead: 0,
|
|
||||||
+ cacheWrite: 0,
|
|
||||||
+ totalTokens: 0,
|
|
||||||
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
+ },
|
|
||||||
+ stopReason: "toolUse",
|
|
||||||
+ timestamp: Date.now(),
|
|
||||||
+ };
|
|
||||||
+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
|
||||||
+ const assistant = messages.find(m => m.role === "assistant");
|
|
||||||
+ expect(assistant).toBeDefined();
|
|
||||||
+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it("injects reasoning_content placeholder when assistant with tool calls has no reasoning field", () => {
|
|
||||||
+ const model = kimiOpenCodeModel("kimi-k2.5");
|
|
||||||
+ const compat = detectCompat(model);
|
|
||||||
+ const toolCallMessage: AssistantMessage = {
|
|
||||||
+ role: "assistant",
|
|
||||||
+ content: [
|
|
||||||
+ // Thinking returned as plain text (as kimi-k2.5 on opencode-go does)
|
|
||||||
+ { type: "text", text: "Let me research this." },
|
|
||||||
+ {
|
|
||||||
+ type: "toolCall",
|
|
||||||
+ id: "call_abc123",
|
|
||||||
+ name: "web_search",
|
|
||||||
+ arguments: { query: "beads gastownhall" },
|
|
||||||
},
|
|
||||||
- stopReason: "toolUse",
|
|
||||||
- timestamp: Date.now(),
|
|
||||||
- };
|
|
||||||
- const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
|
||||||
- const assistant = messages.find(m => m.role === "assistant");
|
|
||||||
- expect(assistant).toBeDefined();
|
|
||||||
- expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
|
||||||
- },
|
|
||||||
- );
|
|
||||||
-
|
|
||||||
- it.each(["opencode-go", "opencode-zen"] as const)(
|
|
||||||
- "does not require reasoning_content when %s model is not reasoning-capable",
|
|
||||||
- provider => {
|
|
||||||
- const compat = detectCompat(openCodeModel(provider, "some-other-model", false));
|
|
||||||
- expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
|
||||||
- },
|
|
||||||
- );
|
|
||||||
-
|
|
||||||
- it.each(["kimi-k2.5", "kimi-k1.5", "kimi-k2-5"])("matches kimi model id pattern via opencode-zen: %s", id => {
|
|
||||||
- const compat = detectCompat(openCodeModel("opencode-zen", id, true));
|
|
||||||
+ ],
|
|
||||||
+ api: model.api,
|
|
||||||
+ provider: model.provider,
|
|
||||||
+ model: model.id,
|
|
||||||
+ usage: {
|
|
||||||
+ input: 0,
|
|
||||||
+ output: 0,
|
|
||||||
+ cacheRead: 0,
|
|
||||||
+ cacheWrite: 0,
|
|
||||||
+ totalTokens: 0,
|
|
||||||
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
+ },
|
|
||||||
+ stopReason: "toolUse",
|
|
||||||
+ timestamp: Date.now(),
|
|
||||||
+ };
|
|
||||||
+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
|
||||||
+ const assistant = messages.find(m => m.role === "assistant");
|
|
||||||
+ expect(assistant).toBeDefined();
|
|
||||||
+ const reasoningContent = Reflect.get(assistant as object, "reasoning_content");
|
|
||||||
+ expect(reasoningContent).toBeDefined();
|
|
||||||
+ expect(typeof reasoningContent).toBe("string");
|
|
||||||
+ expect((reasoningContent as string).length).toBeGreaterThan(0);
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it("does not inject reasoning_content when model is not kimi", () => {
|
|
||||||
+ const model: Model<"openai-completions"> = {
|
|
||||||
+ ...getBundledModel("openai", "gpt-4o-mini"),
|
|
||||||
+ api: "openai-completions",
|
|
||||||
+ provider: "opencode-go",
|
|
||||||
+ baseUrl: "https://opencode.ai/zen/go/v1",
|
|
||||||
+ id: "some-other-model",
|
|
||||||
+ };
|
|
||||||
+ const compat = detectCompat(model);
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it("does not require reasoning_content when opencode-go model is not reasoning-capable", () => {
|
|
||||||
+ const compat = detectCompat(openCodeGoModel("some-other-model", false));
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it("does not require reasoning_content when opencode-zen model is not reasoning-capable", () => {
|
|
||||||
+ const compat = detectCompat(openCodeZenModel("some-other-model", false));
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it.each(["kimi-k2.5", "kimi-k1.5", "kimi-k2-5"])("matches kimi model id: %s", id => {
|
|
||||||
+ const compat = detectCompat(kimiOpenCodeModel(id));
|
|
||||||
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
From 9c7a8958c682b16990504500551827320508087d Mon Sep 17 00:00:00 2001
|
|
||||||
From: sonhyrd <son.hong.do@hyrd.ai>
|
|
||||||
Date: Mon, 27 Apr 2026 00:29:48 +0700
|
|
||||||
Subject: [PATCH 4/5] fix(ai/providers): gate reasoning_content stubs on
|
|
||||||
deepseek models
|
|
||||||
|
|
||||||
---
|
|
||||||
.../providers/openai-completions-compat.ts | 7 ++--
|
|
||||||
.../ai/src/providers/openai-completions.ts | 4 +--
|
|
||||||
.../ai/test/openai-completions-compat.test.ts | 36 +++++++++++++++++++
|
|
||||||
3 files changed, 42 insertions(+), 5 deletions(-)
|
|
||||||
|
|
||||||
diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts
|
|
||||||
index b4825a31c..bba1cef70 100644
|
|
||||||
--- a/packages/ai/src/providers/openai-completions-compat.ts
|
|
||||||
+++ b/packages/ai/src/providers/openai-completions-compat.ts
|
|
||||||
@@ -54,6 +54,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
|
|
||||||
const isKimiModel = model.id.includes("moonshotai/kimi") || /^kimi[-.]/i.test(model.id);
|
|
||||||
const isAlibaba = provider === "alibaba-coding-plan" || baseUrl.includes("dashscope");
|
|
||||||
const isQwen = model.id.toLowerCase().includes("qwen");
|
|
||||||
+ const isDeepSeekModel = model.id.toLowerCase().includes("deepseek");
|
|
||||||
const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai");
|
|
||||||
const isOpenCode = provider === "opencode-zen" || provider === "opencode-go" || baseUrl.includes("opencode.ai/zen");
|
|
||||||
|
|
||||||
@@ -109,12 +110,12 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
|
|
||||||
reasoningContentField: "reasoning_content",
|
|
||||||
// Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`:
|
|
||||||
// - Kimi: documented invariant on its native API and via OpenCode.
|
|
||||||
- // - Reasoning-capable models reached through OpenRouter or OpenCode (Zen/Go): DeepSeek V4 Pro,
|
|
||||||
- // Kimi, and similar models can enforce this server-side whenever the request is in thinking mode.
|
|
||||||
+ // - DeepSeek reasoning models reached through OpenRouter or OpenCode (Zen/Go): enforced when
|
|
||||||
+ // thinking mode is enabled on those model families.
|
|
||||||
// We can't translate Anthropic's redacted/encrypted reasoning into DeepSeek's plaintext form, so
|
|
||||||
// cross-provider continuations rely on a placeholder — see `convertMessages` for injection rules.
|
|
||||||
requiresReasoningContentForToolCalls:
|
|
||||||
- isKimiModel || ((isOpenRouter || isOpenCode) && Boolean(model.reasoning)),
|
|
||||||
+ isKimiModel || (isDeepSeekModel && (isOpenRouter || isOpenCode) && Boolean(model.reasoning)),
|
|
||||||
requiresAssistantContentForToolCalls: isKimiModel,
|
|
||||||
openRouterRouting: undefined,
|
|
||||||
vercelGatewayRouting: undefined,
|
|
||||||
diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts
|
|
||||||
index e25aeffb3..89a997a0f 100644
|
|
||||||
--- a/packages/ai/src/providers/openai-completions.ts
|
|
||||||
+++ b/packages/ai/src/providers/openai-completions.ts
|
|
||||||
@@ -1213,8 +1213,8 @@ export function convertMessages(
|
|
||||||
// Inject a `reasoning_content` placeholder on assistant tool-call turns when the backend
|
|
||||||
// rejects history without it. The compat flag captures the rule:
|
|
||||||
// - Kimi (native or via OpenCode Zen/Go): chat completion endpoint demands the field.
|
|
||||||
- // - Reasoning models reached through OpenRouter or OpenCode Zen/Go (e.g. DeepSeek V4 Pro):
|
|
||||||
- // the upstream thinking-mode validator demands it on every prior assistant turn. omp
|
|
||||||
+ // - DeepSeek reasoning models reached through OpenRouter or OpenCode Zen/Go: the upstream
|
|
||||||
+ // thinking-mode validator demands it on every prior assistant turn. omp
|
|
||||||
// cannot synthesize real reasoning when the conversation was warmed up by another
|
|
||||||
// provider whose reasoning is redacted/encrypted (Anthropic) or simply absent, so we
|
|
||||||
// emit a placeholder. Real captured reasoning, when present, is preserved earlier via
|
|
||||||
diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
index 8b8cef393..c083c2151 100644
|
|
||||||
--- a/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
+++ b/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
@@ -333,6 +333,29 @@ describe("kimi model detection via detectCompat", () => {
|
|
||||||
expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
+ it("does not require reasoning_content for non-DeepSeek reasoning models via opencode-go", () => {
|
|
||||||
+ const compat = detectCompat(openCodeGoModel("glm-5", true));
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it("does not require reasoning_content for non-DeepSeek reasoning models via opencode-zen", () => {
|
|
||||||
+ const compat = detectCompat(openCodeZenModel("glm-5", true));
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it("does not require reasoning_content when custom openai provider targets opencode zen baseUrl with non-DeepSeek model", () => {
|
|
||||||
+ const model: Model<"openai-completions"> = {
|
|
||||||
+ ...getBundledModel("openai", "gpt-4o-mini"),
|
|
||||||
+ api: "openai-completions",
|
|
||||||
+ provider: "openai",
|
|
||||||
+ baseUrl: "https://opencode.ai/zen/v1",
|
|
||||||
+ id: "glm-5",
|
|
||||||
+ reasoning: true,
|
|
||||||
+ };
|
|
||||||
+ const compat = detectCompat(model);
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
it("requires reasoning_content when custom openai provider targets opencode zen baseUrl", () => {
|
|
||||||
const model: Model<"openai-completions"> = {
|
|
||||||
...getBundledModel("openai", "gpt-4o-mini"),
|
|
||||||
@@ -453,6 +476,19 @@ describe("kimi model detection via detectCompat", () => {
|
|
||||||
expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
+ it("does not require reasoning_content for non-DeepSeek reasoning models via openrouter", () => {
|
|
||||||
+ const model: Model<"openai-completions"> = {
|
|
||||||
+ ...getBundledModel("openai", "gpt-4o-mini"),
|
|
||||||
+ api: "openai-completions",
|
|
||||||
+ provider: "openrouter",
|
|
||||||
+ baseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
+ id: "openai/gpt-4.1-mini",
|
|
||||||
+ reasoning: true,
|
|
||||||
+ };
|
|
||||||
+ const compat = detectCompat(model);
|
|
||||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
it("does not require reasoning_content when opencode-zen model is not reasoning-capable", () => {
|
|
||||||
const compat = detectCompat(openCodeZenModel("some-other-model", false));
|
|
||||||
expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
|
||||||
|
|
||||||
From 53a03286cf658bb4aeab67dad3246b7ba80cf244 Mon Sep 17 00:00:00 2001
|
|
||||||
From: sonhyrd <son.hong.do@hyrd.ai>
|
|
||||||
Date: Mon, 27 Apr 2026 00:52:22 +0700
|
|
||||||
Subject: [PATCH 5/5] fix(ai/providers): set content when reasoning placeholder
|
|
||||||
is injected
|
|
||||||
|
|
||||||
---
|
|
||||||
packages/ai/src/providers/openai-completions.ts | 3 ++-
|
|
||||||
packages/ai/test/openai-completions-compat.test.ts | 2 ++
|
|
||||||
2 files changed, 4 insertions(+), 1 deletion(-)
|
|
||||||
|
|
||||||
diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts
|
|
||||||
index 89a997a0f..b490e254e 100644
|
|
||||||
--- a/packages/ai/src/providers/openai-completions.ts
|
|
||||||
+++ b/packages/ai/src/providers/openai-completions.ts
|
|
||||||
@@ -1206,7 +1206,7 @@ export function convertMessages(
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolCalls = msg.content.filter(b => b.type === "toolCall") as ToolCall[];
|
|
||||||
- const hasReasoningField =
|
|
||||||
+ let hasReasoningField =
|
|
||||||
(assistantMsg as any).reasoning_content !== undefined ||
|
|
||||||
(assistantMsg as any).reasoning !== undefined ||
|
|
||||||
(assistantMsg as any).reasoning_text !== undefined;
|
|
||||||
@@ -1227,6 +1227,7 @@ export function convertMessages(
|
|
||||||
if (toolCalls.length > 0 && stubsReasoningContent && !hasReasoningField) {
|
|
||||||
const reasoningField = compat.reasoningContentField ?? "reasoning_content";
|
|
||||||
(assistantMsg as any)[reasoningField] = ".";
|
|
||||||
+ hasReasoningField = true;
|
|
||||||
}
|
|
||||||
if (toolCalls.length > 0) {
|
|
||||||
assistantMsg.tool_calls = toolCalls.map((tc, toolCallIndex) => {
|
|
||||||
diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
index c083c2151..8efae899a 100644
|
|
||||||
--- a/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
+++ b/packages/ai/test/openai-completions-compat.test.ts
|
|
||||||
@@ -393,6 +393,7 @@ describe("kimi model detection via detectCompat", () => {
|
|
||||||
const assistant = messages.find(m => m.role === "assistant");
|
|
||||||
expect(assistant).toBeDefined();
|
|
||||||
expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
|
||||||
+ expect(Reflect.get(assistant as object, "content")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-zen", () => {
|
|
||||||
@@ -419,6 +420,7 @@ describe("kimi model detection via detectCompat", () => {
|
|
||||||
const assistant = messages.find(m => m.role === "assistant");
|
|
||||||
expect(assistant).toBeDefined();
|
|
||||||
expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
|
||||||
+ expect(Reflect.get(assistant as object, "content")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("injects reasoning_content placeholder when assistant with tool calls has no reasoning field", () => {
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
Subject: [PATCH] fix(openai-completions): retry without strict tools for DeepSeek-via-OpenRouter anyOf rejections
|
||||||
|
|
||||||
|
The retry-on-strict-tool-error path in openai-completions failed to recover when
|
||||||
|
DeepSeek (and similar backends fronted by OpenRouter) reject strict-mode tool
|
||||||
|
schemas with errors of the form:
|
||||||
|
|
||||||
|
Invalid tool parameters schema : field `anyOf`: missing field `type`
|
||||||
|
|
||||||
|
Two reasons:
|
||||||
|
|
||||||
|
1. Retry only triggered in "all_strict" mode. OpenRouter defaults to "mixed"
|
||||||
|
(per-tool strict), so the early return prevented retry.
|
||||||
|
2. The error-message regex required "strict" near "tool". DeepSeek's message
|
||||||
|
never mentions "strict".
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
- Allow retry whenever any tool was sent with strict (i.e. mode != "none").
|
||||||
|
- Recognize "Invalid tool parameters" in the regex.
|
||||||
|
|
||||||
|
Includes a regression test reproducing the exact DeepSeek error body via
|
||||||
|
OpenRouter mixed-strict mode.
|
||||||
|
|
||||||
|
Applies cleanly against v14.2.1.
|
||||||
|
|
||||||
|
---
|
||||||
|
diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts
|
||||||
|
index e58189607..3c20631c1 100644
|
||||||
|
--- a/packages/ai/src/providers/openai-completions.ts
|
||||||
|
+++ b/packages/ai/src/providers/openai-completions.ts
|
||||||
|
@@ -1245,7 +1245,10 @@ function shouldRetryWithoutStrictTools(
|
||||||
|
toolStrictMode: AppliedToolStrictMode,
|
||||||
|
tools: Tool[] | undefined,
|
||||||
|
): boolean {
|
||||||
|
- if (!tools || tools.length === 0 || toolStrictMode !== "all_strict") {
|
||||||
|
+ // Retry whenever any tool was sent with `strict: true`. OpenRouter routes to underlying
|
||||||
|
+ // providers (e.g. DeepSeek) whose schema validators reject the strict-mode `anyOf` shape
|
||||||
|
+ // even when omp emitted strict per-tool ("mixed"), not just provider-wide ("all_strict").
|
||||||
|
+ if (!tools || tools.length === 0 || toolStrictMode === "none") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const status = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status;
|
||||||
|
@@ -1255,7 +1258,14 @@ function shouldRetryWithoutStrictTools(
|
||||||
|
const messageParts = [error instanceof Error ? error.message : undefined, capturedErrorResponse?.bodyText]
|
||||||
|
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
|
.join("\n");
|
||||||
|
- return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool/i.test(messageParts);
|
||||||
|
+ // Patterns:
|
||||||
|
+ // - `wrong_api_format`, `mixed values for 'strict'`: OpenAI rejecting mixed strict flags.
|
||||||
|
+ // - `tool ... strict` / `strict ... tool`: generic strict-tool complaints.
|
||||||
|
+ // - `Invalid tool parameters schema`: DeepSeek (via OpenRouter) rejecting strict-mode
|
||||||
|
+ // nullable unions because their validator demands `type` alongside `anyOf`.
|
||||||
|
+ return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool|invalid tool parameters/i.test(
|
||||||
|
+ messageParts,
|
||||||
|
+ );
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
|
||||||
|
diff --git a/packages/ai/test/openai-tool-strict-mode.test.ts b/packages/ai/test/openai-tool-strict-mode.test.ts
|
||||||
|
index 2bf17e6d8..24d5a09d5 100644
|
||||||
|
--- a/packages/ai/test/openai-tool-strict-mode.test.ts
|
||||||
|
+++ b/packages/ai/test/openai-tool-strict-mode.test.ts
|
||||||
|
@@ -231,6 +231,64 @@ describe("OpenAI tool strict mode", () => {
|
||||||
|
expect(result.content).toContainEqual({ type: "text", text: "Hello" });
|
||||||
|
expect(strictFlags).toEqual([[true], [false]]);
|
||||||
|
});
|
||||||
|
+ it("retries with non-strict tool schemas when OpenRouter backend rejects strict anyOf nullable unions", async () => {
|
||||||
|
+ // Reproduces deepseek/deepseek-v4-pro via OpenRouter rejecting the strict-mode schema with:
|
||||||
|
+ // 400 Provider returned error
|
||||||
|
+ // {"error":{"message":"Invalid tool parameters schema : field `anyOf`: missing field `type`",...}}
|
||||||
|
+ // OpenRouter is in mixed-strict mode by default (per-tool strict), so the original retry condition
|
||||||
|
+ // (only "all_strict") prevented recovery. The retry now triggers whenever any tool sent strict=true.
|
||||||
|
+ const model = getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">;
|
||||||
|
+ const strictFlags: boolean[][] = [];
|
||||||
|
+ global.fetch = Object.assign(
|
||||||
|
+ async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||||
|
+ const bodyText = typeof init?.body === "string" ? init.body : "";
|
||||||
|
+ const payload = JSON.parse(bodyText) as {
|
||||||
|
+ tools?: Array<{ function?: { strict?: boolean } }>;
|
||||||
|
+ };
|
||||||
|
+ strictFlags.push((payload.tools ?? []).map(tool => tool.function?.strict === true));
|
||||||
|
+ if (strictFlags.length === 1) {
|
||||||
|
+ return new Response(
|
||||||
|
+ JSON.stringify({
|
||||||
|
+ error: {
|
||||||
|
+ message: "Invalid tool parameters schema : field `anyOf`: missing field `type`",
|
||||||
|
+ type: "invalid_request_error",
|
||||||
|
+ param: null,
|
||||||
|
+ code: "invalid_request_error",
|
||||||
|
+ },
|
||||||
|
+ }),
|
||||||
|
+ {
|
||||||
|
+ status: 400,
|
||||||
|
+ headers: { "content-type": "application/json" },
|
||||||
|
+ },
|
||||||
|
+ );
|
||||||
|
+ }
|
||||||
|
+ return createSseResponse([
|
||||||
|
+ {
|
||||||
|
+ id: "chatcmpl-or",
|
||||||
|
+ object: "chat.completion.chunk",
|
||||||
|
+ created: 0,
|
||||||
|
+ model: model.id,
|
||||||
|
+ choices: [{ index: 0, delta: { content: "Hello" } }],
|
||||||
|
+ },
|
||||||
|
+ {
|
||||||
|
+ id: "chatcmpl-or",
|
||||||
|
+ object: "chat.completion.chunk",
|
||||||
|
+ created: 0,
|
||||||
|
+ model: model.id,
|
||||||
|
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||||
|
+ },
|
||||||
|
+ "[DONE]",
|
||||||
|
+ ]);
|
||||||
|
+ },
|
||||||
|
+ { preconnect: originalFetch.preconnect },
|
||||||
|
+ );
|
||||||
|
+
|
||||||
|
+ const result = await streamOpenAICompletions(model, testContext, { apiKey: "test-key" }).result();
|
||||||
|
+ expect(result.stopReason).toBe("stop");
|
||||||
|
+ expect(result.content).toContainEqual({ type: "text", text: "Hello" });
|
||||||
|
+ expect(strictFlags).toEqual([[true], [false]]);
|
||||||
|
+ });
|
||||||
|
+
|
||||||
|
|
||||||
|
it("sends strict=true for openai-responses tool schemas on OpenAI", async () => {
|
||||||
|
const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">;
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
Subject: [PATCH] fix(openai-completions): require `reasoning_content` for OpenRouter reasoning models
|
||||||
|
|
||||||
|
DeepSeek V4 Pro (and similar reasoning models reached via OpenRouter) reject
|
||||||
|
multi-turn requests in thinking mode with:
|
||||||
|
|
||||||
|
400 The `reasoning_content` in the thinking mode must be passed back to
|
||||||
|
the API.
|
||||||
|
|
||||||
|
omp's existing kimi placeholder injection (`requiresReasoningContentForToolCalls`)
|
||||||
|
covered this requirement only for `thinkingFormat === "openai"`. OpenRouter
|
||||||
|
sets `thinkingFormat === "openrouter"`, so the gate never fired even though
|
||||||
|
the underlying providers behind OpenRouter (DeepSeek, Kimi, etc.) all enforce
|
||||||
|
the same invariant.
|
||||||
|
|
||||||
|
This patch:
|
||||||
|
|
||||||
|
1. Extends `requiresReasoningContentForToolCalls` detection: any
|
||||||
|
reasoning-capable model fronted by OpenRouter now sets the flag.
|
||||||
|
2. Extends the placeholder gate in `convertMessages` to accept
|
||||||
|
`thinkingFormat === "openrouter"` alongside `"openai"`.
|
||||||
|
|
||||||
|
Cross-provider continuations are the dominant trigger: a conversation warmed
|
||||||
|
up by Anthropic Claude (whose reasoning is redacted/encrypted on the wire)
|
||||||
|
followed by a switch to DeepSeek V4 Pro via OpenRouter. omp cannot
|
||||||
|
synthesize plaintext `reasoning_content` from Anthropic's encrypted blocks,
|
||||||
|
so the placeholder satisfies DeepSeek's validator without fabricating a
|
||||||
|
reasoning trace. Real captured reasoning, when present, short-circuits the
|
||||||
|
placeholder via `hasReasoningField` and survives intact.
|
||||||
|
|
||||||
|
Side benefit: also closes a latent gap where Kimi-via-OpenRouter
|
||||||
|
(`thinkingFormat === "openrouter"`) had the compat flag set but the
|
||||||
|
placeholder gate silently rejected it.
|
||||||
|
|
||||||
|
Regression tests cover:
|
||||||
|
- compat flag detection on OpenRouter reasoning models
|
||||||
|
- opt-out for non-reasoning OpenRouter models
|
||||||
|
- cross-provider redacted-thinking placeholder
|
||||||
|
- Kimi-via-OpenRouter placeholder firing
|
||||||
|
- real reasoning preserved over the placeholder
|
||||||
|
|
||||||
|
Applies cleanly on top of patch 0001.
|
||||||
|
|
||||||
|
---
|
||||||
|
diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts
|
||||||
|
--- a/packages/ai/src/providers/openai-completions-compat.ts
|
||||||
|
+++ b/packages/ai/src/providers/openai-completions-compat.ts
|
||||||
|
@@ -105,7 +105,14 @@
|
||||||
|
? "qwen"
|
||||||
|
: "openai",
|
||||||
|
reasoningContentField: "reasoning_content",
|
||||||
|
- requiresReasoningContentForToolCalls: isKimiModel,
|
||||||
|
+ // Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`:
|
||||||
|
+ // - Kimi: documented invariant on its native API and via OpenCode-Go.
|
||||||
|
+ // - Any reasoning-capable model reached through OpenRouter: DeepSeek V4 Pro and similar enforce
|
||||||
|
+ // this server-side whenever the request is in thinking mode. We can't translate Anthropic's
|
||||||
|
+ // redacted/encrypted reasoning into DeepSeek's plaintext form, so cross-provider continuations
|
||||||
|
+ // rely on a placeholder — see `convertMessages` for the placeholder injection.
|
||||||
|
+ requiresReasoningContentForToolCalls:
|
||||||
|
+ isKimiModel || ((provider === "openrouter" || baseUrl.includes("openrouter.ai")) && Boolean(model.reasoning)),
|
||||||
|
requiresAssistantContentForToolCalls: isKimiModel,
|
||||||
|
openRouterRouting: undefined,
|
||||||
|
vercelGatewayRouting: undefined,
|
||||||
|
diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts
|
||||||
|
--- a/packages/ai/src/providers/openai-completions.ts
|
||||||
|
+++ b/packages/ai/src/providers/openai-completions.ts
|
||||||
|
@@ -1059,12 +1059,21 @@
|
||||||
|
(assistantMsg as any).reasoning_content !== undefined ||
|
||||||
|
(assistantMsg as any).reasoning !== undefined ||
|
||||||
|
(assistantMsg as any).reasoning_text !== undefined;
|
||||||
|
- if (
|
||||||
|
- toolCalls.length > 0 &&
|
||||||
|
+ // Inject a `reasoning_content` placeholder on assistant tool-call turns when the backend
|
||||||
|
+ // rejects history without it. The compat flag captures the rule:
|
||||||
|
+ // - Kimi (native or via OpenCode-Go): chat completion endpoint demands the field.
|
||||||
|
+ // - Reasoning models reached through OpenRouter (e.g. DeepSeek V4 Pro): the underlying
|
||||||
|
+ // provider's thinking-mode validator demands it on every prior assistant turn. omp
|
||||||
|
+ // cannot synthesize real reasoning when the conversation was warmed up by another
|
||||||
|
+ // provider whose reasoning is redacted/encrypted (Anthropic) or simply absent, so we
|
||||||
|
+ // emit a placeholder. Real captured reasoning, when present, is preserved earlier via
|
||||||
|
+ // the `thinkingSignature` echo path and short-circuits via `hasReasoningField`.
|
||||||
|
+ // `thinkingFormat` is gated to formats that consume the field (openai/openrouter chat
|
||||||
|
+ // completions); formats with their own conventions (zai, qwen) are excluded.
|
||||||
|
+ const stubsReasoningContent =
|
||||||
|
compat.requiresReasoningContentForToolCalls &&
|
||||||
|
- compat.thinkingFormat === "openai" &&
|
||||||
|
- !hasReasoningField
|
||||||
|
- ) {
|
||||||
|
+ (compat.thinkingFormat === "openai" || compat.thinkingFormat === "openrouter");
|
||||||
|
+ if (toolCalls.length > 0 && stubsReasoningContent && !hasReasoningField) {
|
||||||
|
const reasoningField = compat.reasoningContentField ?? "reasoning_content";
|
||||||
|
(assistantMsg as any)[reasoningField] = ".";
|
||||||
|
}
|
||||||
|
diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts
|
||||||
|
--- a/packages/ai/test/openai-completions-compat.test.ts
|
||||||
|
+++ b/packages/ai/test/openai-completions-compat.test.ts
|
||||||
|
@@ -367,4 +367,137 @@
|
||||||
|
const compat = detectCompat(model);
|
||||||
|
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||||
|
});
|
||||||
|
+
|
||||||
|
+ it("requires reasoning_content for tool calls on reasoning-capable models via OpenRouter", () => {
|
||||||
|
+ const model: Model<"openai-completions"> = {
|
||||||
|
+ ...(getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">),
|
||||||
|
+ reasoning: true,
|
||||||
|
+ };
|
||||||
|
+ const compat = detectCompat(model);
|
||||||
|
+ expect(compat.thinkingFormat).toBe("openrouter");
|
||||||
|
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||||
|
+ });
|
||||||
|
+
|
||||||
|
+ it("does not require reasoning_content for non-reasoning OpenRouter models", () => {
|
||||||
|
+ const model: Model<"openai-completions"> = {
|
||||||
|
+ ...(getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">),
|
||||||
|
+ reasoning: false,
|
||||||
|
+ };
|
||||||
|
+ const compat = detectCompat(model);
|
||||||
|
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||||
|
+ });
|
||||||
|
+
|
||||||
|
+ it("injects reasoning_content placeholder for OpenRouter reasoning models lacking captured reasoning", () => {
|
||||||
|
+ // Reproduces the failing path from real usage: a conversation generated under Anthropic Claude (whose
|
||||||
|
+ // reasoning is redacted/encrypted) is continued with deepseek/deepseek-v4-pro via OpenRouter. The
|
||||||
|
+ // prior assistant turns persist as ThinkingContent blocks with empty `thinking` text plus an opaque
|
||||||
|
+ // Anthropic signature cookie. omp cannot translate that into DeepSeek's plain-text `reasoning_content`,
|
||||||
|
+ // so the empty thinking block is filtered out and the placeholder fires — satisfying DeepSeek's
|
||||||
|
+ // thinking-mode validator without fabricating a reasoning trace.
|
||||||
|
+ const model: Model<"openai-completions"> = {
|
||||||
|
+ ...(getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">),
|
||||||
|
+ reasoning: true,
|
||||||
|
+ };
|
||||||
|
+ const compat = detectCompat(model);
|
||||||
|
+ const toolCallMessage: AssistantMessage = {
|
||||||
|
+ role: "assistant",
|
||||||
|
+ content: [
|
||||||
|
+ // Anthropic-style redacted thinking block: empty text plus opaque signature.
|
||||||
|
+ // `thinking.trim().length === 0` filters this out before the signature echo can fire.
|
||||||
|
+ { type: "thinking", thinking: "", thinkingSignature: "Ep4CClkIDRgCKkDOpaqueAnthropicCookie" },
|
||||||
|
+ { type: "toolCall", id: "call_anth_to_ds", name: "web_search", arguments: { query: "hi" } },
|
||||||
|
+ ],
|
||||||
|
+ api: model.api,
|
||||||
|
+ provider: model.provider,
|
||||||
|
+ model: model.id,
|
||||||
|
+ usage: {
|
||||||
|
+ input: 0,
|
||||||
|
+ output: 0,
|
||||||
|
+ cacheRead: 0,
|
||||||
|
+ cacheWrite: 0,
|
||||||
|
+ totalTokens: 0,
|
||||||
|
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
+ },
|
||||||
|
+ stopReason: "toolUse",
|
||||||
|
+ timestamp: Date.now(),
|
||||||
|
+ };
|
||||||
|
+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
||||||
|
+ const assistant = messages.find(m => m.role === "assistant");
|
||||||
|
+ expect(assistant).toBeDefined();
|
||||||
|
+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
||||||
|
+ });
|
||||||
|
+
|
||||||
|
+ it("injects reasoning_content placeholder for kimi-k2-5 via OpenRouter (closes the kimi-via-openrouter gap)", () => {
|
||||||
|
+ // Before this fix, `requiresReasoningContentForToolCalls` was true for Kimi via OpenRouter but the
|
||||||
|
+ // stub gate only fired when `thinkingFormat === "openai"`. OpenRouter sets thinkingFormat="openrouter",
|
||||||
|
+ // so the stub silently never fired and Kimi-via-OpenRouter conversations 400'd the same way.
|
||||||
|
+ const model: Model<"openai-completions"> = {
|
||||||
|
+ ...getBundledModel("openai", "gpt-4o-mini"),
|
||||||
|
+ api: "openai-completions",
|
||||||
|
+ provider: "openrouter",
|
||||||
|
+ baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
+ id: "moonshotai/kimi-k2-5",
|
||||||
|
+ reasoning: true,
|
||||||
|
+ };
|
||||||
|
+ const compat = detectCompat(model);
|
||||||
|
+ const toolCallMessage: AssistantMessage = {
|
||||||
|
+ role: "assistant",
|
||||||
|
+ content: [
|
||||||
|
+ { type: "toolCall", id: "call_kimi_or", name: "web_search", arguments: { query: "hi" } },
|
||||||
|
+ ],
|
||||||
|
+ api: model.api,
|
||||||
|
+ provider: model.provider,
|
||||||
|
+ model: model.id,
|
||||||
|
+ usage: {
|
||||||
|
+ input: 0,
|
||||||
|
+ output: 0,
|
||||||
|
+ cacheRead: 0,
|
||||||
|
+ cacheWrite: 0,
|
||||||
|
+ totalTokens: 0,
|
||||||
|
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
+ },
|
||||||
|
+ stopReason: "toolUse",
|
||||||
|
+ timestamp: Date.now(),
|
||||||
|
+ };
|
||||||
|
+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
||||||
|
+ const assistant = messages.find(m => m.role === "assistant");
|
||||||
|
+ expect(assistant).toBeDefined();
|
||||||
|
+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
||||||
|
+ });
|
||||||
|
+
|
||||||
|
+ it("preserves real captured reasoning over the placeholder when the assistant has non-empty thinking", () => {
|
||||||
|
+ // Sanity check: the placeholder must not overwrite real reasoning. When the prior assistant turn was
|
||||||
|
+ // generated by the same provider and surfaces plaintext reasoning, the existing thinkingSignature
|
||||||
|
+ // echo path sets `reasoning_content` first, and `hasReasoningField` short-circuits the stub.
|
||||||
|
+ const model: Model<"openai-completions"> = {
|
||||||
|
+ ...(getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">),
|
||||||
|
+ reasoning: true,
|
||||||
|
+ };
|
||||||
|
+ const compat = detectCompat(model);
|
||||||
|
+ const toolCallMessage: AssistantMessage = {
|
||||||
|
+ role: "assistant",
|
||||||
|
+ content: [
|
||||||
|
+ { type: "thinking", thinking: "Step 1: read the file. Step 2: search.", thinkingSignature: "reasoning_content" },
|
||||||
|
+ { type: "toolCall", id: "call_real", name: "web_search", arguments: { query: "hi" } },
|
||||||
|
+ ],
|
||||||
|
+ api: model.api,
|
||||||
|
+ provider: model.provider,
|
||||||
|
+ model: model.id,
|
||||||
|
+ usage: {
|
||||||
|
+ input: 0,
|
||||||
|
+ output: 0,
|
||||||
|
+ cacheRead: 0,
|
||||||
|
+ cacheWrite: 0,
|
||||||
|
+ totalTokens: 0,
|
||||||
|
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
+ },
|
||||||
|
+ stopReason: "toolUse",
|
||||||
|
+ timestamp: Date.now(),
|
||||||
|
+ };
|
||||||
|
+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
||||||
|
+ const assistant = messages.find(m => m.role === "assistant");
|
||||||
|
+ expect(assistant).toBeDefined();
|
||||||
|
+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe("Step 1: read the file. Step 2: search.");
|
||||||
|
+ });
|
||||||
|
+
|
||||||
|
});
|
||||||
@@ -38,7 +38,6 @@ class JellyfinQBittorrentMonitor:
|
|||||||
stream_bitrate_headroom=1.1,
|
stream_bitrate_headroom=1.1,
|
||||||
webhook_port=0,
|
webhook_port=0,
|
||||||
webhook_bind="127.0.0.1",
|
webhook_bind="127.0.0.1",
|
||||||
gateway_ip=None,
|
|
||||||
):
|
):
|
||||||
self.jellyfin_url = jellyfin_url
|
self.jellyfin_url = jellyfin_url
|
||||||
self.qbittorrent_url = qbittorrent_url
|
self.qbittorrent_url = qbittorrent_url
|
||||||
@@ -78,15 +77,6 @@ class JellyfinQBittorrentMonitor:
|
|||||||
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
|
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
|
||||||
]
|
]
|
||||||
|
|
||||||
# Hairpin marker. When a LAN client reaches Jellyfin via the public
|
|
||||||
# hostname, the router NAT-loopbacks the packet and SNATs the source
|
|
||||||
# to itself — the session arrives looking local but still costs WAN
|
|
||||||
# bandwidth. Sessions whose source equals the gateway must therefore
|
|
||||||
# NOT be skipped. None disables the check (pre-hairpin-aware behavior).
|
|
||||||
if gateway_ip is None:
|
|
||||||
gateway_ip = self._discover_default_gateway()
|
|
||||||
self.gateway_ip = gateway_ip
|
|
||||||
|
|
||||||
def is_local_ip(self, ip_address: str) -> bool:
|
def is_local_ip(self, ip_address: str) -> bool:
|
||||||
"""Check if an IP address is from a local network"""
|
"""Check if an IP address is from a local network"""
|
||||||
try:
|
try:
|
||||||
@@ -96,39 +86,6 @@ class JellyfinQBittorrentMonitor:
|
|||||||
logger.warning(f"Invalid IP address format: {ip_address}")
|
logger.warning(f"Invalid IP address format: {ip_address}")
|
||||||
return True # Treat invalid IPs as local for safety
|
return True # Treat invalid IPs as local for safety
|
||||||
|
|
||||||
def _discover_default_gateway(self) -> str | None:
|
|
||||||
"""Read the IPv4 default gateway from /proc/net/route, or None."""
|
|
||||||
try:
|
|
||||||
with open("/proc/net/route") as f:
|
|
||||||
next(f) # skip header
|
|
||||||
for line in f:
|
|
||||||
fields = line.split()
|
|
||||||
if len(fields) < 8 or fields[1] != "00000000":
|
|
||||||
continue
|
|
||||||
flags = int(fields[3], 16)
|
|
||||||
if not flags & 0x2: # RTF_GATEWAY
|
|
||||||
continue
|
|
||||||
gw_bytes = bytes.fromhex(fields[2])[::-1] # little-endian
|
|
||||||
if len(gw_bytes) != 4:
|
|
||||||
continue
|
|
||||||
return ".".join(str(b) for b in gw_bytes)
|
|
||||||
except (OSError, ValueError) as e:
|
|
||||||
logger.warning(f"Could not autodetect default gateway: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def is_skippable(self, ip_address: str) -> bool:
|
|
||||||
"""True iff this source IP can be ignored when deciding to throttle.
|
|
||||||
|
|
||||||
Truly LAN-direct sessions are skippable (no WAN cost). Hairpin-NAT'd
|
|
||||||
LAN sessions arrive with the LAN gateway as their source — those still
|
|
||||||
cost WAN bandwidth and must NOT be skipped.
|
|
||||||
"""
|
|
||||||
if not self.is_local_ip(ip_address):
|
|
||||||
return False
|
|
||||||
if self.gateway_ip and ip_address == self.gateway_ip:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def signal_handler(self, signum, frame):
|
def signal_handler(self, signum, frame):
|
||||||
logger.info("Received shutdown signal, cleaning up...")
|
logger.info("Received shutdown signal, cleaning up...")
|
||||||
self.running = False
|
self.running = False
|
||||||
@@ -207,7 +164,7 @@ class JellyfinQBittorrentMonitor:
|
|||||||
if (
|
if (
|
||||||
"NowPlayingItem" in session
|
"NowPlayingItem" in session
|
||||||
and not session.get("PlayState", {}).get("IsPaused", True)
|
and not session.get("PlayState", {}).get("IsPaused", True)
|
||||||
and not self.is_skippable(session.get("RemoteEndPoint", ""))
|
and not self.is_local_ip(session.get("RemoteEndPoint", ""))
|
||||||
):
|
):
|
||||||
item = session["NowPlayingItem"]
|
item = session["NowPlayingItem"]
|
||||||
item_type = item.get("Type", "").lower()
|
item_type = item.get("Type", "").lower()
|
||||||
@@ -397,9 +354,6 @@ class JellyfinQBittorrentMonitor:
|
|||||||
logger.info(f"Default stream bitrate: {self.default_stream_bitrate} bps")
|
logger.info(f"Default stream bitrate: {self.default_stream_bitrate} bps")
|
||||||
logger.info(f"Minimum torrent speed: {self.min_torrent_speed} KB/s")
|
logger.info(f"Minimum torrent speed: {self.min_torrent_speed} KB/s")
|
||||||
logger.info(f"Stream bitrate headroom: {self.stream_bitrate_headroom}x")
|
logger.info(f"Stream bitrate headroom: {self.stream_bitrate_headroom}x")
|
||||||
logger.info(
|
|
||||||
f"LAN gateway (hairpin marker): {self.gateway_ip or 'none / autodetect failed'}"
|
|
||||||
)
|
|
||||||
if self.webhook_port:
|
if self.webhook_port:
|
||||||
logger.info(f"Webhook receiver: {self.webhook_bind}:{self.webhook_port}")
|
logger.info(f"Webhook receiver: {self.webhook_bind}:{self.webhook_port}")
|
||||||
|
|
||||||
@@ -530,7 +484,6 @@ if __name__ == "__main__":
|
|||||||
stream_bitrate_headroom = float(os.getenv("STREAM_BITRATE_HEADROOM", "1.1"))
|
stream_bitrate_headroom = float(os.getenv("STREAM_BITRATE_HEADROOM", "1.1"))
|
||||||
webhook_port = int(os.getenv("WEBHOOK_PORT", "0"))
|
webhook_port = int(os.getenv("WEBHOOK_PORT", "0"))
|
||||||
webhook_bind = os.getenv("WEBHOOK_BIND", "127.0.0.1")
|
webhook_bind = os.getenv("WEBHOOK_BIND", "127.0.0.1")
|
||||||
gateway_ip = os.getenv("LAN_GATEWAY_IP") or None
|
|
||||||
|
|
||||||
monitor = JellyfinQBittorrentMonitor(
|
monitor = JellyfinQBittorrentMonitor(
|
||||||
jellyfin_url=jellyfin_url,
|
jellyfin_url=jellyfin_url,
|
||||||
@@ -546,7 +499,6 @@ if __name__ == "__main__":
|
|||||||
stream_bitrate_headroom=stream_bitrate_headroom,
|
stream_bitrate_headroom=stream_bitrate_headroom,
|
||||||
webhook_port=webhook_port,
|
webhook_port=webhook_port,
|
||||||
webhook_bind=webhook_bind,
|
webhook_bind=webhook_bind,
|
||||||
gateway_ip=gateway_ip,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
monitor.run()
|
monitor.run()
|
||||||
|
|||||||
@@ -428,73 +428,6 @@ pkgs.testers.runNixOSTest {
|
|||||||
local_playback["PositionTicks"] = 50000000
|
local_playback["PositionTicks"] = 50000000
|
||||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' -d '{json.dumps(local_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}, Token={local_token}'")
|
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' -d '{json.dumps(local_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}, Token={local_token}'")
|
||||||
|
|
||||||
with subtest("Hairpin'd LAN session (source IP = configured gateway) DOES throttle"):
|
|
||||||
# Simulates a LAN client reaching Jellyfin via the public hostname:
|
|
||||||
# the router SNATs the source to itself, so Jellyfin sees the gateway
|
|
||||||
# IP and IsInLocalNetwork=True even though WAN bandwidth is in play.
|
|
||||||
# We use 127.0.0.1 as the "gateway" in this VM because the localhost
|
|
||||||
# curl below produces source 127.0.0.1 from Jellyfin's view.
|
|
||||||
server.succeed("systemctl stop monitor-test || true")
|
|
||||||
time.sleep(1)
|
|
||||||
server.succeed(f"""
|
|
||||||
systemd-run --unit=monitor-hairpin \
|
|
||||||
--setenv=JELLYFIN_URL=http://localhost:8096 \
|
|
||||||
--setenv=JELLYFIN_API_KEY={token} \
|
|
||||||
--setenv=QBITTORRENT_URL=http://localhost:8080 \
|
|
||||||
--setenv=CHECK_INTERVAL=1 \
|
|
||||||
--setenv=STREAMING_START_DELAY=1 \
|
|
||||||
--setenv=STREAMING_STOP_DELAY=1 \
|
|
||||||
--setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \
|
|
||||||
--setenv=SERVICE_BUFFER=2000000 \
|
|
||||||
--setenv=DEFAULT_STREAM_BITRATE=10000000 \
|
|
||||||
--setenv=MIN_TORRENT_SPEED=100 \
|
|
||||||
--setenv=LAN_GATEWAY_IP=127.0.0.1 \
|
|
||||||
{python} {monitor}
|
|
||||||
""")
|
|
||||||
time.sleep(2)
|
|
||||||
assert not is_throttled(), "Should start unthrottled (no streams yet)"
|
|
||||||
|
|
||||||
hairpin_auth = 'MediaBrowser Client="Hairpin Client", DeviceId="hairpin-2222", Device="HairpinDevice", Version="1.0"'
|
|
||||||
hairpin_auth_result = json.loads(server.succeed(
|
|
||||||
f"curl -sf -X POST 'http://localhost:8096/Users/AuthenticateByName' -d '@${jfLib.payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{hairpin_auth}'"
|
|
||||||
))
|
|
||||||
hairpin_token = hairpin_auth_result["AccessToken"]
|
|
||||||
|
|
||||||
hairpin_playback = {
|
|
||||||
"ItemId": movie_id,
|
|
||||||
"MediaSourceId": media_source_id,
|
|
||||||
"PlaySessionId": "test-play-session-hairpin",
|
|
||||||
"CanSeek": True,
|
|
||||||
"IsPaused": False,
|
|
||||||
}
|
|
||||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing' -d '{json.dumps(hairpin_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{hairpin_auth}, Token={hairpin_token}'")
|
|
||||||
time.sleep(3)
|
|
||||||
assert is_throttled(), "Hairpin'd session (source=gateway) should throttle even though source is RFC1918"
|
|
||||||
|
|
||||||
# Cleanup: stop the playback and the override-monitor, restore the normal one.
|
|
||||||
hairpin_playback["PositionTicks"] = 50000000
|
|
||||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' -d '{json.dumps(hairpin_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{hairpin_auth}, Token={hairpin_token}'")
|
|
||||||
time.sleep(2)
|
|
||||||
assert not is_throttled(), "Should unthrottle after hairpin'd playback stops"
|
|
||||||
|
|
||||||
server.succeed("systemctl stop monitor-hairpin || true")
|
|
||||||
time.sleep(1)
|
|
||||||
server.succeed(f"""
|
|
||||||
systemd-run --unit=monitor-test \
|
|
||||||
--setenv=JELLYFIN_URL=http://localhost:8096 \
|
|
||||||
--setenv=JELLYFIN_API_KEY={token} \
|
|
||||||
--setenv=QBITTORRENT_URL=http://localhost:8080 \
|
|
||||||
--setenv=CHECK_INTERVAL=1 \
|
|
||||||
--setenv=STREAMING_START_DELAY=1 \
|
|
||||||
--setenv=STREAMING_STOP_DELAY=1 \
|
|
||||||
--setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \
|
|
||||||
--setenv=SERVICE_BUFFER=2000000 \
|
|
||||||
--setenv=DEFAULT_STREAM_BITRATE=10000000 \
|
|
||||||
--setenv=MIN_TORRENT_SPEED=100 \
|
|
||||||
{python} {monitor}
|
|
||||||
""")
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
# === WEBHOOK TESTS ===
|
# === WEBHOOK TESTS ===
|
||||||
#
|
#
|
||||||
# Configure the Jellyfin Webhook plugin to target the monitor, then verify
|
# Configure the Jellyfin Webhook plugin to target the monitor, then verify
|
||||||
@@ -656,7 +589,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
server.succeed("systemctl restart jellyfin.service")
|
server.succeed("systemctl restart jellyfin.service")
|
||||||
server.wait_for_unit("jellyfin.service")
|
server.wait_for_unit("jellyfin.service")
|
||||||
server.wait_for_open_port(8096)
|
server.wait_for_open_port(8096)
|
||||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=180)
|
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60)
|
||||||
|
|
||||||
# During Jellyfin restart, monitor can't reach Jellyfin
|
# During Jellyfin restart, monitor can't reach Jellyfin
|
||||||
# After restart, sessions are cleared - monitor should eventually unthrottle
|
# After restart, sessions are cleared - monitor should eventually unthrottle
|
||||||
@@ -712,7 +645,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
server.succeed("systemctl start jellyfin.service")
|
server.succeed("systemctl start jellyfin.service")
|
||||||
server.wait_for_unit("jellyfin.service")
|
server.wait_for_unit("jellyfin.service")
|
||||||
server.wait_for_open_port(8096)
|
server.wait_for_open_port(8096)
|
||||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=180)
|
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60)
|
||||||
|
|
||||||
# After Jellyfin comes back, sessions are gone - should unthrottle
|
# After Jellyfin comes back, sessions are gone - should unthrottle
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|||||||
Reference in New Issue
Block a user