miawrouter 1.2.3 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (640) hide show
  1. package/README.md +7 -0
  2. package/app/.next-cli-build/BUILD_ID +1 -1
  3. package/app/.next-cli-build/app-path-routes-manifest.json +9 -9
  4. package/app/.next-cli-build/build-manifest.json +2 -2
  5. package/app/.next-cli-build/prerender-manifest.json +3 -3
  6. package/app/.next-cli-build/server/app/(dashboard)/dashboard/a2a/page.js.nft.json +1 -1
  7. package/app/.next-cli-build/server/app/(dashboard)/dashboard/a2a/page_client-reference-manifest.js +1 -1
  8. package/app/.next-cli-build/server/app/(dashboard)/dashboard/activity/page.js.nft.json +1 -1
  9. package/app/.next-cli-build/server/app/(dashboard)/dashboard/activity/page_client-reference-manifest.js +1 -1
  10. package/app/.next-cli-build/server/app/(dashboard)/dashboard/agent-skills/page.js.nft.json +1 -1
  11. package/app/.next-cli-build/server/app/(dashboard)/dashboard/agent-skills/page_client-reference-manifest.js +1 -1
  12. package/app/.next-cli-build/server/app/(dashboard)/dashboard/api-endpoints/page.js.nft.json +1 -1
  13. package/app/.next-cli-build/server/app/(dashboard)/dashboard/api-endpoints/page_client-reference-manifest.js +1 -1
  14. package/app/.next-cli-build/server/app/(dashboard)/dashboard/basic-chat/page.js.nft.json +1 -1
  15. package/app/.next-cli-build/server/app/(dashboard)/dashboard/basic-chat/page_client-reference-manifest.js +1 -1
  16. package/app/.next-cli-build/server/app/(dashboard)/dashboard/batch/page.js.nft.json +1 -1
  17. package/app/.next-cli-build/server/app/(dashboard)/dashboard/batch/page_client-reference-manifest.js +1 -1
  18. package/app/.next-cli-build/server/app/(dashboard)/dashboard/cache/page.js.nft.json +1 -1
  19. package/app/.next-cli-build/server/app/(dashboard)/dashboard/cache/page_client-reference-manifest.js +1 -1
  20. package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/[toolId]/page.js.nft.json +1 -1
  21. package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/[toolId]/page_client-reference-manifest.js +1 -1
  22. package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/page.js +1 -1
  23. package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/page.js.nft.json +1 -1
  24. package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/page_client-reference-manifest.js +1 -1
  25. package/app/.next-cli-build/server/app/(dashboard)/dashboard/cloud-agents/page.js.nft.json +1 -1
  26. package/app/.next-cli-build/server/app/(dashboard)/dashboard/cloud-agents/page_client-reference-manifest.js +1 -1
  27. package/app/.next-cli-build/server/app/(dashboard)/dashboard/combos/page.js.nft.json +1 -1
  28. package/app/.next-cli-build/server/app/(dashboard)/dashboard/combos/page_client-reference-manifest.js +1 -1
  29. package/app/.next-cli-build/server/app/(dashboard)/dashboard/console-log/page.js.nft.json +1 -1
  30. package/app/.next-cli-build/server/app/(dashboard)/dashboard/console-log/page_client-reference-manifest.js +1 -1
  31. package/app/.next-cli-build/server/app/(dashboard)/dashboard/costs/page.js.nft.json +1 -1
  32. package/app/.next-cli-build/server/app/(dashboard)/dashboard/costs/page_client-reference-manifest.js +1 -1
  33. package/app/.next-cli-build/server/app/(dashboard)/dashboard/discovery/page.js.nft.json +1 -1
  34. package/app/.next-cli-build/server/app/(dashboard)/dashboard/discovery/page_client-reference-manifest.js +1 -1
  35. package/app/.next-cli-build/server/app/(dashboard)/dashboard/endpoint/page.js.nft.json +1 -1
  36. package/app/.next-cli-build/server/app/(dashboard)/dashboard/endpoint/page_client-reference-manifest.js +1 -1
  37. package/app/.next-cli-build/server/app/(dashboard)/dashboard/free-provider-rankings/page.js.nft.json +1 -1
  38. package/app/.next-cli-build/server/app/(dashboard)/dashboard/free-provider-rankings/page_client-reference-manifest.js +1 -1
  39. package/app/.next-cli-build/server/app/(dashboard)/dashboard/free-tiers/page.js.nft.json +1 -1
  40. package/app/.next-cli-build/server/app/(dashboard)/dashboard/free-tiers/page_client-reference-manifest.js +1 -1
  41. package/app/.next-cli-build/server/app/(dashboard)/dashboard/guardrails/page.js.nft.json +1 -1
  42. package/app/.next-cli-build/server/app/(dashboard)/dashboard/guardrails/page_client-reference-manifest.js +1 -1
  43. package/app/.next-cli-build/server/app/(dashboard)/dashboard/health/page.js.nft.json +1 -1
  44. package/app/.next-cli-build/server/app/(dashboard)/dashboard/health/page_client-reference-manifest.js +1 -1
  45. package/app/.next-cli-build/server/app/(dashboard)/dashboard/logs/page.js.nft.json +1 -1
  46. package/app/.next-cli-build/server/app/(dashboard)/dashboard/logs/page_client-reference-manifest.js +1 -1
  47. package/app/.next-cli-build/server/app/(dashboard)/dashboard/mcp/page.js.nft.json +1 -1
  48. package/app/.next-cli-build/server/app/(dashboard)/dashboard/mcp/page_client-reference-manifest.js +1 -1
  49. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js.nft.json +1 -1
  50. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page_client-reference-manifest.js +1 -1
  51. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/page.js.nft.json +1 -1
  52. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/page_client-reference-manifest.js +1 -1
  53. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js.nft.json +1 -1
  54. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/combo/[id]/page_client-reference-manifest.js +1 -1
  55. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/web/page.js.nft.json +1 -1
  56. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/web/page_client-reference-manifest.js +1 -1
  57. package/app/.next-cli-build/server/app/(dashboard)/dashboard/memory/page.js.nft.json +1 -1
  58. package/app/.next-cli-build/server/app/(dashboard)/dashboard/memory/page_client-reference-manifest.js +1 -1
  59. package/app/.next-cli-build/server/app/(dashboard)/dashboard/mitm/page.js.nft.json +1 -1
  60. package/app/.next-cli-build/server/app/(dashboard)/dashboard/mitm/page_client-reference-manifest.js +1 -1
  61. package/app/.next-cli-build/server/app/(dashboard)/dashboard/page.js.nft.json +1 -1
  62. package/app/.next-cli-build/server/app/(dashboard)/dashboard/page_client-reference-manifest.js +1 -1
  63. package/app/.next-cli-build/server/app/(dashboard)/dashboard/playground/page.js.nft.json +1 -1
  64. package/app/.next-cli-build/server/app/(dashboard)/dashboard/playground/page_client-reference-manifest.js +1 -1
  65. package/app/.next-cli-build/server/app/(dashboard)/dashboard/privacy/page.js.nft.json +1 -1
  66. package/app/.next-cli-build/server/app/(dashboard)/dashboard/privacy/page_client-reference-manifest.js +1 -1
  67. package/app/.next-cli-build/server/app/(dashboard)/dashboard/profile/page.js.nft.json +1 -1
  68. package/app/.next-cli-build/server/app/(dashboard)/dashboard/profile/page_client-reference-manifest.js +1 -1
  69. package/app/.next-cli-build/server/app/(dashboard)/dashboard/provider-stats/page.js.nft.json +1 -1
  70. package/app/.next-cli-build/server/app/(dashboard)/dashboard/provider-stats/page_client-reference-manifest.js +1 -1
  71. package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/[id]/page.js.nft.json +1 -1
  72. package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/[id]/page_client-reference-manifest.js +1 -1
  73. package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/new/page.js.nft.json +1 -1
  74. package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/new/page_client-reference-manifest.js +1 -1
  75. package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/page.js.nft.json +1 -1
  76. package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/page_client-reference-manifest.js +1 -1
  77. package/app/.next-cli-build/server/app/(dashboard)/dashboard/proxy-pools/page.js.nft.json +1 -1
  78. package/app/.next-cli-build/server/app/(dashboard)/dashboard/proxy-pools/page_client-reference-manifest.js +1 -1
  79. package/app/.next-cli-build/server/app/(dashboard)/dashboard/pxpipe/page.js.nft.json +1 -1
  80. package/app/.next-cli-build/server/app/(dashboard)/dashboard/pxpipe/page_client-reference-manifest.js +1 -1
  81. package/app/.next-cli-build/server/app/(dashboard)/dashboard/quota/page.js.nft.json +1 -1
  82. package/app/.next-cli-build/server/app/(dashboard)/dashboard/quota/page_client-reference-manifest.js +1 -1
  83. package/app/.next-cli-build/server/app/(dashboard)/dashboard/runtime/page.js.nft.json +1 -1
  84. package/app/.next-cli-build/server/app/(dashboard)/dashboard/runtime/page_client-reference-manifest.js +1 -1
  85. package/app/.next-cli-build/server/app/(dashboard)/dashboard/settings/feature-flags/page.js.nft.json +1 -1
  86. package/app/.next-cli-build/server/app/(dashboard)/dashboard/settings/feature-flags/page_client-reference-manifest.js +1 -1
  87. package/app/.next-cli-build/server/app/(dashboard)/dashboard/settings/pricing/page.js.nft.json +1 -1
  88. package/app/.next-cli-build/server/app/(dashboard)/dashboard/settings/pricing/page_client-reference-manifest.js +1 -1
  89. package/app/.next-cli-build/server/app/(dashboard)/dashboard/skill-discovery/page.js.nft.json +1 -1
  90. package/app/.next-cli-build/server/app/(dashboard)/dashboard/skill-discovery/page_client-reference-manifest.js +1 -1
  91. package/app/.next-cli-build/server/app/(dashboard)/dashboard/skills/page.js.nft.json +1 -1
  92. package/app/.next-cli-build/server/app/(dashboard)/dashboard/skills/page_client-reference-manifest.js +1 -1
  93. package/app/.next-cli-build/server/app/(dashboard)/dashboard/token-saver/page.js.nft.json +1 -1
  94. package/app/.next-cli-build/server/app/(dashboard)/dashboard/token-saver/page_client-reference-manifest.js +1 -1
  95. package/app/.next-cli-build/server/app/(dashboard)/dashboard/translator/page.js.nft.json +1 -1
  96. package/app/.next-cli-build/server/app/(dashboard)/dashboard/translator/page_client-reference-manifest.js +1 -1
  97. package/app/.next-cli-build/server/app/(dashboard)/dashboard/usage/page.js.nft.json +1 -1
  98. package/app/.next-cli-build/server/app/(dashboard)/dashboard/usage/page_client-reference-manifest.js +1 -1
  99. package/app/.next-cli-build/server/app/(dashboard)/dashboard/webhooks/page.js.nft.json +1 -1
  100. package/app/.next-cli-build/server/app/(dashboard)/dashboard/webhooks/page_client-reference-manifest.js +1 -1
  101. package/app/.next-cli-build/server/app/.well-known/agent.json/route.js.nft.json +1 -1
  102. package/app/.next-cli-build/server/app/_global-error/page.js.nft.json +1 -1
  103. package/app/.next-cli-build/server/app/_global-error/page_client-reference-manifest.js +1 -1
  104. package/app/.next-cli-build/server/app/_global-error.html +1 -1
  105. package/app/.next-cli-build/server/app/_global-error.rsc +1 -1
  106. package/app/.next-cli-build/server/app/_global-error.segments/_full.segment.rsc +1 -1
  107. package/app/.next-cli-build/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  108. package/app/.next-cli-build/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  109. package/app/.next-cli-build/server/app/_not-found/page.js.nft.json +1 -1
  110. package/app/.next-cli-build/server/app/_not-found/page_client-reference-manifest.js +1 -1
  111. package/app/.next-cli-build/server/app/_not-found.html +1 -1
  112. package/app/.next-cli-build/server/app/_not-found.rsc +5 -5
  113. package/app/.next-cli-build/server/app/_not-found.segments/_full.segment.rsc +5 -5
  114. package/app/.next-cli-build/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +5 -5
  115. package/app/.next-cli-build/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  116. package/app/.next-cli-build/server/app/api/a2a/route.js.nft.json +1 -1
  117. package/app/.next-cli-build/server/app/api/a2a/status/route.js.nft.json +1 -1
  118. package/app/.next-cli-build/server/app/api/agents/[id]/actions/route.js +1 -1
  119. package/app/.next-cli-build/server/app/api/agents/[id]/actions/route.js.nft.json +1 -1
  120. package/app/.next-cli-build/server/app/api/agents/route.js +1 -1
  121. package/app/.next-cli-build/server/app/api/agents/route.js.nft.json +1 -1
  122. package/app/.next-cli-build/server/app/api/auth/login/route.js.nft.json +1 -1
  123. package/app/.next-cli-build/server/app/api/auth/logout/route.js.nft.json +1 -1
  124. package/app/.next-cli-build/server/app/api/auth/oidc/callback/route.js.nft.json +1 -1
  125. package/app/.next-cli-build/server/app/api/auth/oidc/start/route.js.nft.json +1 -1
  126. package/app/.next-cli-build/server/app/api/auth/oidc/test/route.js.nft.json +1 -1
  127. package/app/.next-cli-build/server/app/api/auth/reset-password/route.js.nft.json +1 -1
  128. package/app/.next-cli-build/server/app/api/auth/status/route.js.nft.json +1 -1
  129. package/app/.next-cli-build/server/app/api/batch/[id]/route.js.nft.json +1 -1
  130. package/app/.next-cli-build/server/app/api/batch/route.js.nft.json +1 -1
  131. package/app/.next-cli-build/server/app/api/batches/[id]/cancel/route.js.nft.json +1 -1
  132. package/app/.next-cli-build/server/app/api/batches/[id]/errors/route.js.nft.json +1 -1
  133. package/app/.next-cli-build/server/app/api/batches/[id]/results/route.js.nft.json +1 -1
  134. package/app/.next-cli-build/server/app/api/batches/[id]/retry/route.js.nft.json +1 -1
  135. package/app/.next-cli-build/server/app/api/batches/[id]/route.js.nft.json +1 -1
  136. package/app/.next-cli-build/server/app/api/batches/route.js.nft.json +1 -1
  137. package/app/.next-cli-build/server/app/api/cache/clear/route.js.nft.json +1 -1
  138. package/app/.next-cli-build/server/app/api/cache/stats/route.js.nft.json +1 -1
  139. package/app/.next-cli-build/server/app/api/catalog/opencode/route.js.nft.json +1 -1
  140. package/app/.next-cli-build/server/app/api/cli-tools/all-statuses/route.js +5 -5
  141. package/app/.next-cli-build/server/app/api/cli-tools/all-statuses/route.js.nft.json +1 -1
  142. package/app/.next-cli-build/server/app/api/cli-tools/antigravity-mitm/alias/route.js +2 -2
  143. package/app/.next-cli-build/server/app/api/cli-tools/antigravity-mitm/alias/route.js.nft.json +1 -1
  144. package/app/.next-cli-build/server/app/api/cli-tools/antigravity-mitm/route.js.nft.json +1 -1
  145. package/app/.next-cli-build/server/app/api/cli-tools/claude-settings/route.js +1 -1
  146. package/app/.next-cli-build/server/app/api/cli-tools/claude-settings/route.js.nft.json +1 -1
  147. package/app/.next-cli-build/server/app/api/cli-tools/cline-settings/route.js +2 -2
  148. package/app/.next-cli-build/server/app/api/cli-tools/cline-settings/route.js.nft.json +1 -1
  149. package/app/.next-cli-build/server/app/api/cli-tools/codex-settings/route.js +1 -1
  150. package/app/.next-cli-build/server/app/api/cli-tools/codex-settings/route.js.nft.json +1 -1
  151. package/app/.next-cli-build/server/app/api/cli-tools/copilot-settings/route.js +2 -2
  152. package/app/.next-cli-build/server/app/api/cli-tools/copilot-settings/route.js.nft.json +1 -1
  153. package/app/.next-cli-build/server/app/api/cli-tools/cowork-mcp-registry/route.js +2 -2
  154. package/app/.next-cli-build/server/app/api/cli-tools/cowork-mcp-registry/route.js.nft.json +1 -1
  155. package/app/.next-cli-build/server/app/api/cli-tools/cowork-mcp-tools/route.js +1 -1
  156. package/app/.next-cli-build/server/app/api/cli-tools/cowork-mcp-tools/route.js.nft.json +1 -1
  157. package/app/.next-cli-build/server/app/api/cli-tools/cowork-settings/route.js +1 -1
  158. package/app/.next-cli-build/server/app/api/cli-tools/cowork-settings/route.js.nft.json +1 -1
  159. package/app/.next-cli-build/server/app/api/cli-tools/deepseek-tui-settings/route.js +3 -3
  160. package/app/.next-cli-build/server/app/api/cli-tools/deepseek-tui-settings/route.js.nft.json +1 -1
  161. package/app/.next-cli-build/server/app/api/cli-tools/devin-settings/route.js +1 -1
  162. package/app/.next-cli-build/server/app/api/cli-tools/devin-settings/route.js.nft.json +1 -1
  163. package/app/.next-cli-build/server/app/api/cli-tools/droid-settings/route.js +1 -1
  164. package/app/.next-cli-build/server/app/api/cli-tools/droid-settings/route.js.nft.json +1 -1
  165. package/app/.next-cli-build/server/app/api/cli-tools/grok-build-settings/route.js +2 -2
  166. package/app/.next-cli-build/server/app/api/cli-tools/grok-build-settings/route.js.nft.json +1 -1
  167. package/app/.next-cli-build/server/app/api/cli-tools/hermes-settings/route.js +2 -2
  168. package/app/.next-cli-build/server/app/api/cli-tools/hermes-settings/route.js.nft.json +1 -1
  169. package/app/.next-cli-build/server/app/api/cli-tools/jcode-settings/route.js +2 -2
  170. package/app/.next-cli-build/server/app/api/cli-tools/jcode-settings/route.js.nft.json +1 -1
  171. package/app/.next-cli-build/server/app/api/cli-tools/kilo-settings/route.js +2 -2
  172. package/app/.next-cli-build/server/app/api/cli-tools/kilo-settings/route.js.nft.json +1 -1
  173. package/app/.next-cli-build/server/app/api/cli-tools/oh-my-pi-settings/route.js +2 -2
  174. package/app/.next-cli-build/server/app/api/cli-tools/oh-my-pi-settings/route.js.nft.json +1 -1
  175. package/app/.next-cli-build/server/app/api/cli-tools/openclaw-settings/route.js +2 -2
  176. package/app/.next-cli-build/server/app/api/cli-tools/openclaw-settings/route.js.nft.json +1 -1
  177. package/app/.next-cli-build/server/app/api/cli-tools/opencode-settings/route.js +2 -2
  178. package/app/.next-cli-build/server/app/api/cli-tools/opencode-settings/route.js.nft.json +1 -1
  179. package/app/.next-cli-build/server/app/api/cli-tools/subagents/route.js +1 -1
  180. package/app/.next-cli-build/server/app/api/cli-tools/subagents/route.js.nft.json +1 -1
  181. package/app/.next-cli-build/server/app/api/cloud-agents/status/route.js +1 -1
  182. package/app/.next-cli-build/server/app/api/cloud-agents/status/route.js.nft.json +1 -1
  183. package/app/.next-cli-build/server/app/api/combos/[id]/route.js.nft.json +1 -1
  184. package/app/.next-cli-build/server/app/api/combos/route.js.nft.json +1 -1
  185. package/app/.next-cli-build/server/app/api/dashboard/chat/completions/route.js.nft.json +1 -1
  186. package/app/.next-cli-build/server/app/api/discovery/endpoints/route.js +1 -1
  187. package/app/.next-cli-build/server/app/api/discovery/endpoints/route.js.nft.json +1 -1
  188. package/app/.next-cli-build/server/app/api/discovery/route.js +1 -1
  189. package/app/.next-cli-build/server/app/api/discovery/route.js.nft.json +1 -1
  190. package/app/.next-cli-build/server/app/api/discovery/skills/route.js +1 -1
  191. package/app/.next-cli-build/server/app/api/discovery/skills/route.js.nft.json +1 -1
  192. package/app/.next-cli-build/server/app/api/events/route.js.nft.json +1 -1
  193. package/app/.next-cli-build/server/app/api/headroom/extras/route.js.nft.json +1 -1
  194. package/app/.next-cli-build/server/app/api/headroom/proxy/[...path]/route.js.nft.json +1 -1
  195. package/app/.next-cli-build/server/app/api/headroom/restart/route.js.nft.json +1 -1
  196. package/app/.next-cli-build/server/app/api/headroom/start/route.js.nft.json +1 -1
  197. package/app/.next-cli-build/server/app/api/headroom/status/route.js.nft.json +1 -1
  198. package/app/.next-cli-build/server/app/api/headroom/stop/route.js.nft.json +1 -1
  199. package/app/.next-cli-build/server/app/api/health/route.js.nft.json +1 -1
  200. package/app/.next-cli-build/server/app/api/hermes/autonomy/route.js.nft.json +1 -1
  201. package/app/.next-cli-build/server/app/api/init/route.js.nft.json +1 -1
  202. package/app/.next-cli-build/server/app/api/keys/[id]/route.js.nft.json +1 -1
  203. package/app/.next-cli-build/server/app/api/keys/route.js.nft.json +1 -1
  204. package/app/.next-cli-build/server/app/api/locale/route.js.nft.json +1 -1
  205. package/app/.next-cli-build/server/app/api/mcp/[plugin]/message/route.js.nft.json +1 -1
  206. package/app/.next-cli-build/server/app/api/mcp/[plugin]/sse/route.js.nft.json +1 -1
  207. package/app/.next-cli-build/server/app/api/mcp/route.js +1 -1
  208. package/app/.next-cli-build/server/app/api/mcp/route.js.nft.json +1 -1
  209. package/app/.next-cli-build/server/app/api/mcp/status/route.js.nft.json +1 -1
  210. package/app/.next-cli-build/server/app/api/media-providers/tts/deepgram/voices/route.js.nft.json +1 -1
  211. package/app/.next-cli-build/server/app/api/media-providers/tts/elevenlabs/voices/route.js.nft.json +1 -1
  212. package/app/.next-cli-build/server/app/api/media-providers/tts/inworld/voices/route.js.nft.json +1 -1
  213. package/app/.next-cli-build/server/app/api/media-providers/tts/minimax/voices/route.js.nft.json +1 -1
  214. package/app/.next-cli-build/server/app/api/media-providers/tts/voices/route.js.nft.json +1 -1
  215. package/app/.next-cli-build/server/app/api/memory/[id]/route.js.nft.json +1 -1
  216. package/app/.next-cli-build/server/app/api/memory/health/route.js.nft.json +1 -1
  217. package/app/.next-cli-build/server/app/api/memory/reindex/route.js.nft.json +1 -1
  218. package/app/.next-cli-build/server/app/api/memory/route.js.nft.json +1 -1
  219. package/app/.next-cli-build/server/app/api/memory/search/route.js.nft.json +1 -1
  220. package/app/.next-cli-build/server/app/api/metrics/route.js.nft.json +1 -1
  221. package/app/.next-cli-build/server/app/api/models/alias/route.js.nft.json +1 -1
  222. package/app/.next-cli-build/server/app/api/models/availability/route.js.nft.json +1 -1
  223. package/app/.next-cli-build/server/app/api/models/custom/route.js.nft.json +1 -1
  224. package/app/.next-cli-build/server/app/api/models/disabled/route.js.nft.json +1 -1
  225. package/app/.next-cli-build/server/app/api/models/route.js.nft.json +1 -1
  226. package/app/.next-cli-build/server/app/api/models/test/route.js.nft.json +1 -1
  227. package/app/.next-cli-build/server/app/api/oauth/[provider]/[action]/route.js +1 -1
  228. package/app/.next-cli-build/server/app/api/oauth/[provider]/[action]/route.js.nft.json +1 -1
  229. package/app/.next-cli-build/server/app/api/oauth/codex/bulk-import/route.js +1 -1
  230. package/app/.next-cli-build/server/app/api/oauth/codex/bulk-import/route.js.nft.json +1 -1
  231. package/app/.next-cli-build/server/app/api/oauth/codex/import-token/route.js +1 -1
  232. package/app/.next-cli-build/server/app/api/oauth/codex/import-token/route.js.nft.json +1 -1
  233. package/app/.next-cli-build/server/app/api/oauth/cursor/auto-import/route.js.nft.json +1 -1
  234. package/app/.next-cli-build/server/app/api/oauth/cursor/import/route.js.nft.json +1 -1
  235. package/app/.next-cli-build/server/app/api/oauth/gitlab/pat/route.js.nft.json +1 -1
  236. package/app/.next-cli-build/server/app/api/oauth/iflow/cookie/route.js.nft.json +1 -1
  237. package/app/.next-cli-build/server/app/api/oauth/kiro/api-key/route.js.nft.json +1 -1
  238. package/app/.next-cli-build/server/app/api/oauth/kiro/auto-import/route.js.nft.json +1 -1
  239. package/app/.next-cli-build/server/app/api/oauth/kiro/import/route.js.nft.json +1 -1
  240. package/app/.next-cli-build/server/app/api/oauth/kiro/import-cli-proxy/route.js.nft.json +1 -1
  241. package/app/.next-cli-build/server/app/api/oauth/kiro/social-authorize/route.js.nft.json +1 -1
  242. package/app/.next-cli-build/server/app/api/oauth/kiro/social-exchange/route.js.nft.json +1 -1
  243. package/app/.next-cli-build/server/app/api/pricing/route.js.nft.json +1 -1
  244. package/app/.next-cli-build/server/app/api/provider-nodes/[id]/route.js.nft.json +1 -1
  245. package/app/.next-cli-build/server/app/api/provider-nodes/route.js.nft.json +1 -1
  246. package/app/.next-cli-build/server/app/api/provider-nodes/validate/route.js.nft.json +1 -1
  247. package/app/.next-cli-build/server/app/api/providers/[id]/export/route.js.nft.json +1 -1
  248. package/app/.next-cli-build/server/app/api/providers/[id]/models/route.js.nft.json +1 -1
  249. package/app/.next-cli-build/server/app/api/providers/[id]/onboard/route.js +1 -1
  250. package/app/.next-cli-build/server/app/api/providers/[id]/onboard/route.js.nft.json +1 -1
  251. package/app/.next-cli-build/server/app/api/providers/[id]/route.js.nft.json +1 -1
  252. package/app/.next-cli-build/server/app/api/providers/[id]/test/route.js.nft.json +1 -1
  253. package/app/.next-cli-build/server/app/api/providers/[id]/test-models/route.js.nft.json +1 -1
  254. package/app/.next-cli-build/server/app/api/providers/client/route.js +1 -1
  255. package/app/.next-cli-build/server/app/api/providers/client/route.js.nft.json +1 -1
  256. package/app/.next-cli-build/server/app/api/providers/kilo/free-models/route.js.nft.json +1 -1
  257. package/app/.next-cli-build/server/app/api/providers/route.js.nft.json +1 -1
  258. package/app/.next-cli-build/server/app/api/providers/suggested-models/route.js.nft.json +1 -1
  259. package/app/.next-cli-build/server/app/api/providers/test-batch/route.js.nft.json +1 -1
  260. package/app/.next-cli-build/server/app/api/providers/validate/route.js.nft.json +1 -1
  261. package/app/.next-cli-build/server/app/api/proxy-pools/[id]/route.js.nft.json +1 -1
  262. package/app/.next-cli-build/server/app/api/proxy-pools/[id]/test/route.js.nft.json +1 -1
  263. package/app/.next-cli-build/server/app/api/proxy-pools/cloudflare-deploy/route.js.nft.json +1 -1
  264. package/app/.next-cli-build/server/app/api/proxy-pools/deno-deploy/route.js.nft.json +1 -1
  265. package/app/.next-cli-build/server/app/api/proxy-pools/route.js.nft.json +1 -1
  266. package/app/.next-cli-build/server/app/api/proxy-pools/vercel-deploy/route.js +1 -1
  267. package/app/.next-cli-build/server/app/api/proxy-pools/vercel-deploy/route.js.nft.json +1 -1
  268. package/app/.next-cli-build/server/app/api/pxpipe/health/route.js.nft.json +1 -1
  269. package/app/.next-cli-build/server/app/api/pxpipe/install/route.js.nft.json +1 -1
  270. package/app/.next-cli-build/server/app/api/pxpipe/logs/route.js.nft.json +1 -1
  271. package/app/.next-cli-build/server/app/api/pxpipe/restart/route.js.nft.json +1 -1
  272. package/app/.next-cli-build/server/app/api/pxpipe/start/route.js.nft.json +1 -1
  273. package/app/.next-cli-build/server/app/api/pxpipe/stats/route.js.nft.json +1 -1
  274. package/app/.next-cli-build/server/app/api/pxpipe/status/route.js.nft.json +1 -1
  275. package/app/.next-cli-build/server/app/api/pxpipe/stop/route.js.nft.json +1 -1
  276. package/app/.next-cli-build/server/app/api/saver/stats/route.js.nft.json +1 -1
  277. package/app/.next-cli-build/server/app/api/settings/database/route.js.nft.json +1 -1
  278. package/app/.next-cli-build/server/app/api/settings/proxy-test/route.js.nft.json +1 -1
  279. package/app/.next-cli-build/server/app/api/settings/require-login/route.js.nft.json +1 -1
  280. package/app/.next-cli-build/server/app/api/settings/route.js.nft.json +1 -1
  281. package/app/.next-cli-build/server/app/api/shutdown/route.js.nft.json +1 -1
  282. package/app/.next-cli-build/server/app/api/skill-discovery/[id]/install/route.js.nft.json +1 -1
  283. package/app/.next-cli-build/server/app/api/skill-discovery/[id]/route.js.nft.json +1 -1
  284. package/app/.next-cli-build/server/app/api/skill-discovery/[id]/uninstall/route.js.nft.json +1 -1
  285. package/app/.next-cli-build/server/app/api/skill-discovery/route.js.nft.json +1 -1
  286. package/app/.next-cli-build/server/app/api/skill-discovery/sync/route.js.nft.json +1 -1
  287. package/app/.next-cli-build/server/app/api/tags/route.js.nft.json +1 -1
  288. package/app/.next-cli-build/server/app/api/translator/console-logs/route.js.nft.json +1 -1
  289. package/app/.next-cli-build/server/app/api/translator/console-logs/stream/route.js.nft.json +1 -1
  290. package/app/.next-cli-build/server/app/api/translator/load/route.js.nft.json +1 -1
  291. package/app/.next-cli-build/server/app/api/translator/save/route.js.nft.json +1 -1
  292. package/app/.next-cli-build/server/app/api/translator/send/route.js +1 -1
  293. package/app/.next-cli-build/server/app/api/translator/send/route.js.nft.json +1 -1
  294. package/app/.next-cli-build/server/app/api/translator/translate/route.js +1 -1
  295. package/app/.next-cli-build/server/app/api/translator/translate/route.js.nft.json +1 -1
  296. package/app/.next-cli-build/server/app/api/tunnel/disable/route.js.nft.json +1 -1
  297. package/app/.next-cli-build/server/app/api/tunnel/enable/route.js.nft.json +1 -1
  298. package/app/.next-cli-build/server/app/api/tunnel/status/route.js.nft.json +1 -1
  299. package/app/.next-cli-build/server/app/api/tunnel/tailscale-check/route.js.nft.json +1 -1
  300. package/app/.next-cli-build/server/app/api/tunnel/tailscale-disable/route.js.nft.json +1 -1
  301. package/app/.next-cli-build/server/app/api/tunnel/tailscale-enable/route.js.nft.json +1 -1
  302. package/app/.next-cli-build/server/app/api/tunnel/tailscale-install/route.js +2 -2
  303. package/app/.next-cli-build/server/app/api/tunnel/tailscale-install/route.js.nft.json +1 -1
  304. package/app/.next-cli-build/server/app/api/usage/[connectionId]/codex-reset-credits/route.js +1 -1
  305. package/app/.next-cli-build/server/app/api/usage/[connectionId]/codex-reset-credits/route.js.nft.json +1 -1
  306. package/app/.next-cli-build/server/app/api/usage/[connectionId]/route.js +1 -1
  307. package/app/.next-cli-build/server/app/api/usage/[connectionId]/route.js.nft.json +1 -1
  308. package/app/.next-cli-build/server/app/api/usage/chart/route.js.nft.json +1 -1
  309. package/app/.next-cli-build/server/app/api/usage/history/route.js.nft.json +1 -1
  310. package/app/.next-cli-build/server/app/api/usage/local-snapshot/route.js +1 -1
  311. package/app/.next-cli-build/server/app/api/usage/local-snapshot/route.js.nft.json +1 -1
  312. package/app/.next-cli-build/server/app/api/usage/logs/route.js.nft.json +1 -1
  313. package/app/.next-cli-build/server/app/api/usage/providers/route.js.nft.json +1 -1
  314. package/app/.next-cli-build/server/app/api/usage/request-details/route.js.nft.json +1 -1
  315. package/app/.next-cli-build/server/app/api/usage/request-logs/route.js.nft.json +1 -1
  316. package/app/.next-cli-build/server/app/api/usage/stats/route.js.nft.json +1 -1
  317. package/app/.next-cli-build/server/app/api/usage/stream/route.js.nft.json +1 -1
  318. package/app/.next-cli-build/server/app/api/v1/api/chat/route.js.nft.json +1 -1
  319. package/app/.next-cli-build/server/app/api/v1/audio/speech/route.js.nft.json +1 -1
  320. package/app/.next-cli-build/server/app/api/v1/audio/transcriptions/route.js.nft.json +1 -1
  321. package/app/.next-cli-build/server/app/api/v1/audio/voices/route.js.nft.json +1 -1
  322. package/app/.next-cli-build/server/app/api/v1/chat/completions/route.js.nft.json +1 -1
  323. package/app/.next-cli-build/server/app/api/v1/embeddings/route.js +1 -1
  324. package/app/.next-cli-build/server/app/api/v1/embeddings/route.js.nft.json +1 -1
  325. package/app/.next-cli-build/server/app/api/v1/images/generations/route.js +1 -1
  326. package/app/.next-cli-build/server/app/api/v1/images/generations/route.js.nft.json +1 -1
  327. package/app/.next-cli-build/server/app/api/v1/messages/count_tokens/route.js.nft.json +1 -1
  328. package/app/.next-cli-build/server/app/api/v1/messages/route.js.nft.json +1 -1
  329. package/app/.next-cli-build/server/app/api/v1/models/[kind]/route.js.nft.json +1 -1
  330. package/app/.next-cli-build/server/app/api/v1/models/info/route.js.nft.json +1 -1
  331. package/app/.next-cli-build/server/app/api/v1/models/route.js.nft.json +1 -1
  332. package/app/.next-cli-build/server/app/api/v1/responses/compact/route.js.nft.json +1 -1
  333. package/app/.next-cli-build/server/app/api/v1/responses/route.js.nft.json +1 -1
  334. package/app/.next-cli-build/server/app/api/v1/route.js.nft.json +1 -1
  335. package/app/.next-cli-build/server/app/api/v1/search/route.js +1 -1
  336. package/app/.next-cli-build/server/app/api/v1/search/route.js.nft.json +1 -1
  337. package/app/.next-cli-build/server/app/api/v1/videos/[id]/route.js.nft.json +1 -1
  338. package/app/.next-cli-build/server/app/api/v1/videos/edits/route.js.nft.json +1 -1
  339. package/app/.next-cli-build/server/app/api/v1/videos/extensions/route.js.nft.json +1 -1
  340. package/app/.next-cli-build/server/app/api/v1/videos/generations/route.js.nft.json +1 -1
  341. package/app/.next-cli-build/server/app/api/v1/web/fetch/route.js +1 -1
  342. package/app/.next-cli-build/server/app/api/v1/web/fetch/route.js.nft.json +1 -1
  343. package/app/.next-cli-build/server/app/api/v1beta/models/[...path]/route.js.nft.json +1 -1
  344. package/app/.next-cli-build/server/app/api/v1beta/models/route.js.nft.json +1 -1
  345. package/app/.next-cli-build/server/app/api/version/route.js +1 -1
  346. package/app/.next-cli-build/server/app/api/version/route.js.nft.json +1 -1
  347. package/app/.next-cli-build/server/app/api/version/shutdown/route.js.nft.json +1 -1
  348. package/app/.next-cli-build/server/app/api/version/update/route.js.nft.json +1 -1
  349. package/app/.next-cli-build/server/app/api/webhooks/[id]/route.js.nft.json +1 -1
  350. package/app/.next-cli-build/server/app/api/webhooks/events/route.js.nft.json +1 -1
  351. package/app/.next-cli-build/server/app/api/webhooks/route.js.nft.json +1 -1
  352. package/app/.next-cli-build/server/app/callback/page.js.nft.json +1 -1
  353. package/app/.next-cli-build/server/app/callback/page_client-reference-manifest.js +1 -1
  354. package/app/.next-cli-build/server/app/callback.html +1 -1
  355. package/app/.next-cli-build/server/app/callback.rsc +5 -5
  356. package/app/.next-cli-build/server/app/callback.segments/_full.segment.rsc +5 -5
  357. package/app/.next-cli-build/server/app/callback.segments/_tree.segment.rsc +2 -2
  358. package/app/.next-cli-build/server/app/callback.segments/callback/__PAGE__.segment.rsc +5 -5
  359. package/app/.next-cli-build/server/app/dashboard/a2a.html +1 -1
  360. package/app/.next-cli-build/server/app/dashboard/a2a.rsc +7 -7
  361. package/app/.next-cli-build/server/app/dashboard/a2a.segments/!KGRhc2hib2FyZCk/dashboard/a2a/__PAGE__.segment.rsc +7 -7
  362. package/app/.next-cli-build/server/app/dashboard/a2a.segments/_full.segment.rsc +7 -7
  363. package/app/.next-cli-build/server/app/dashboard/a2a.segments/_tree.segment.rsc +2 -2
  364. package/app/.next-cli-build/server/app/dashboard/activity.html +1 -1
  365. package/app/.next-cli-build/server/app/dashboard/activity.rsc +7 -7
  366. package/app/.next-cli-build/server/app/dashboard/activity.segments/!KGRhc2hib2FyZCk/dashboard/activity/__PAGE__.segment.rsc +7 -7
  367. package/app/.next-cli-build/server/app/dashboard/activity.segments/_full.segment.rsc +7 -7
  368. package/app/.next-cli-build/server/app/dashboard/activity.segments/_tree.segment.rsc +2 -2
  369. package/app/.next-cli-build/server/app/dashboard/agent-skills.html +1 -1
  370. package/app/.next-cli-build/server/app/dashboard/agent-skills.rsc +7 -7
  371. package/app/.next-cli-build/server/app/dashboard/agent-skills.segments/!KGRhc2hib2FyZCk/dashboard/agent-skills/__PAGE__.segment.rsc +7 -7
  372. package/app/.next-cli-build/server/app/dashboard/agent-skills.segments/_full.segment.rsc +7 -7
  373. package/app/.next-cli-build/server/app/dashboard/agent-skills.segments/_tree.segment.rsc +2 -2
  374. package/app/.next-cli-build/server/app/dashboard/api-endpoints.html +1 -1
  375. package/app/.next-cli-build/server/app/dashboard/api-endpoints.rsc +7 -7
  376. package/app/.next-cli-build/server/app/dashboard/api-endpoints.segments/!KGRhc2hib2FyZCk/dashboard/api-endpoints/__PAGE__.segment.rsc +7 -7
  377. package/app/.next-cli-build/server/app/dashboard/api-endpoints.segments/_full.segment.rsc +7 -7
  378. package/app/.next-cli-build/server/app/dashboard/api-endpoints.segments/_tree.segment.rsc +2 -2
  379. package/app/.next-cli-build/server/app/dashboard/basic-chat.html +1 -1
  380. package/app/.next-cli-build/server/app/dashboard/basic-chat.rsc +7 -7
  381. package/app/.next-cli-build/server/app/dashboard/basic-chat.segments/!KGRhc2hib2FyZCk/dashboard/basic-chat/__PAGE__.segment.rsc +7 -7
  382. package/app/.next-cli-build/server/app/dashboard/basic-chat.segments/_full.segment.rsc +7 -7
  383. package/app/.next-cli-build/server/app/dashboard/basic-chat.segments/_tree.segment.rsc +2 -2
  384. package/app/.next-cli-build/server/app/dashboard/batch.html +1 -1
  385. package/app/.next-cli-build/server/app/dashboard/batch.rsc +7 -7
  386. package/app/.next-cli-build/server/app/dashboard/batch.segments/!KGRhc2hib2FyZCk/dashboard/batch/__PAGE__.segment.rsc +7 -7
  387. package/app/.next-cli-build/server/app/dashboard/batch.segments/_full.segment.rsc +7 -7
  388. package/app/.next-cli-build/server/app/dashboard/batch.segments/_tree.segment.rsc +2 -2
  389. package/app/.next-cli-build/server/app/dashboard/cache.html +1 -1
  390. package/app/.next-cli-build/server/app/dashboard/cache.rsc +7 -7
  391. package/app/.next-cli-build/server/app/dashboard/cache.segments/!KGRhc2hib2FyZCk/dashboard/cache/__PAGE__.segment.rsc +7 -7
  392. package/app/.next-cli-build/server/app/dashboard/cache.segments/_full.segment.rsc +7 -7
  393. package/app/.next-cli-build/server/app/dashboard/cache.segments/_tree.segment.rsc +2 -2
  394. package/app/.next-cli-build/server/app/dashboard/cli-tools.html +1 -1
  395. package/app/.next-cli-build/server/app/dashboard/cli-tools.rsc +7 -7
  396. package/app/.next-cli-build/server/app/dashboard/cli-tools.segments/!KGRhc2hib2FyZCk/dashboard/cli-tools/__PAGE__.segment.rsc +7 -7
  397. package/app/.next-cli-build/server/app/dashboard/cli-tools.segments/_full.segment.rsc +7 -7
  398. package/app/.next-cli-build/server/app/dashboard/cli-tools.segments/_tree.segment.rsc +2 -2
  399. package/app/.next-cli-build/server/app/dashboard/cloud-agents.html +1 -1
  400. package/app/.next-cli-build/server/app/dashboard/cloud-agents.rsc +7 -7
  401. package/app/.next-cli-build/server/app/dashboard/cloud-agents.segments/!KGRhc2hib2FyZCk/dashboard/cloud-agents/__PAGE__.segment.rsc +7 -7
  402. package/app/.next-cli-build/server/app/dashboard/cloud-agents.segments/_full.segment.rsc +7 -7
  403. package/app/.next-cli-build/server/app/dashboard/cloud-agents.segments/_tree.segment.rsc +2 -2
  404. package/app/.next-cli-build/server/app/dashboard/combos.html +1 -1
  405. package/app/.next-cli-build/server/app/dashboard/combos.rsc +7 -7
  406. package/app/.next-cli-build/server/app/dashboard/combos.segments/!KGRhc2hib2FyZCk/dashboard/combos/__PAGE__.segment.rsc +7 -7
  407. package/app/.next-cli-build/server/app/dashboard/combos.segments/_full.segment.rsc +7 -7
  408. package/app/.next-cli-build/server/app/dashboard/combos.segments/_tree.segment.rsc +2 -2
  409. package/app/.next-cli-build/server/app/dashboard/costs.html +1 -1
  410. package/app/.next-cli-build/server/app/dashboard/costs.rsc +7 -7
  411. package/app/.next-cli-build/server/app/dashboard/costs.segments/!KGRhc2hib2FyZCk/dashboard/costs/__PAGE__.segment.rsc +7 -7
  412. package/app/.next-cli-build/server/app/dashboard/costs.segments/_full.segment.rsc +7 -7
  413. package/app/.next-cli-build/server/app/dashboard/costs.segments/_tree.segment.rsc +2 -2
  414. package/app/.next-cli-build/server/app/dashboard/discovery.html +1 -1
  415. package/app/.next-cli-build/server/app/dashboard/discovery.rsc +7 -7
  416. package/app/.next-cli-build/server/app/dashboard/discovery.segments/!KGRhc2hib2FyZCk/dashboard/discovery/__PAGE__.segment.rsc +7 -7
  417. package/app/.next-cli-build/server/app/dashboard/discovery.segments/_full.segment.rsc +7 -7
  418. package/app/.next-cli-build/server/app/dashboard/discovery.segments/_tree.segment.rsc +2 -2
  419. package/app/.next-cli-build/server/app/dashboard/endpoint.html +1 -1
  420. package/app/.next-cli-build/server/app/dashboard/endpoint.rsc +7 -7
  421. package/app/.next-cli-build/server/app/dashboard/endpoint.segments/!KGRhc2hib2FyZCk/dashboard/endpoint/__PAGE__.segment.rsc +7 -7
  422. package/app/.next-cli-build/server/app/dashboard/endpoint.segments/_full.segment.rsc +7 -7
  423. package/app/.next-cli-build/server/app/dashboard/endpoint.segments/_tree.segment.rsc +2 -2
  424. package/app/.next-cli-build/server/app/dashboard/free-provider-rankings.html +1 -1
  425. package/app/.next-cli-build/server/app/dashboard/free-provider-rankings.rsc +7 -7
  426. package/app/.next-cli-build/server/app/dashboard/free-provider-rankings.segments/!KGRhc2hib2FyZCk/dashboard/free-provider-rankings/__PAGE__.segment.rsc +7 -7
  427. package/app/.next-cli-build/server/app/dashboard/free-provider-rankings.segments/_full.segment.rsc +7 -7
  428. package/app/.next-cli-build/server/app/dashboard/free-provider-rankings.segments/_tree.segment.rsc +2 -2
  429. package/app/.next-cli-build/server/app/dashboard/free-tiers.html +1 -1
  430. package/app/.next-cli-build/server/app/dashboard/free-tiers.rsc +7 -7
  431. package/app/.next-cli-build/server/app/dashboard/free-tiers.segments/!KGRhc2hib2FyZCk/dashboard/free-tiers/__PAGE__.segment.rsc +7 -7
  432. package/app/.next-cli-build/server/app/dashboard/free-tiers.segments/_full.segment.rsc +7 -7
  433. package/app/.next-cli-build/server/app/dashboard/free-tiers.segments/_tree.segment.rsc +2 -2
  434. package/app/.next-cli-build/server/app/dashboard/guardrails.html +1 -1
  435. package/app/.next-cli-build/server/app/dashboard/guardrails.rsc +7 -7
  436. package/app/.next-cli-build/server/app/dashboard/guardrails.segments/!KGRhc2hib2FyZCk/dashboard/guardrails/__PAGE__.segment.rsc +7 -7
  437. package/app/.next-cli-build/server/app/dashboard/guardrails.segments/_full.segment.rsc +7 -7
  438. package/app/.next-cli-build/server/app/dashboard/guardrails.segments/_tree.segment.rsc +2 -2
  439. package/app/.next-cli-build/server/app/dashboard/health.html +1 -1
  440. package/app/.next-cli-build/server/app/dashboard/health.rsc +7 -7
  441. package/app/.next-cli-build/server/app/dashboard/health.segments/!KGRhc2hib2FyZCk/dashboard/health/__PAGE__.segment.rsc +7 -7
  442. package/app/.next-cli-build/server/app/dashboard/health.segments/_full.segment.rsc +7 -7
  443. package/app/.next-cli-build/server/app/dashboard/health.segments/_tree.segment.rsc +2 -2
  444. package/app/.next-cli-build/server/app/dashboard/logs.html +1 -1
  445. package/app/.next-cli-build/server/app/dashboard/logs.rsc +7 -7
  446. package/app/.next-cli-build/server/app/dashboard/logs.segments/!KGRhc2hib2FyZCk/dashboard/logs/__PAGE__.segment.rsc +7 -7
  447. package/app/.next-cli-build/server/app/dashboard/logs.segments/_full.segment.rsc +7 -7
  448. package/app/.next-cli-build/server/app/dashboard/logs.segments/_tree.segment.rsc +2 -2
  449. package/app/.next-cli-build/server/app/dashboard/mcp.html +1 -1
  450. package/app/.next-cli-build/server/app/dashboard/mcp.rsc +7 -7
  451. package/app/.next-cli-build/server/app/dashboard/mcp.segments/!KGRhc2hib2FyZCk/dashboard/mcp/__PAGE__.segment.rsc +7 -7
  452. package/app/.next-cli-build/server/app/dashboard/mcp.segments/_full.segment.rsc +7 -7
  453. package/app/.next-cli-build/server/app/dashboard/mcp.segments/_tree.segment.rsc +2 -2
  454. package/app/.next-cli-build/server/app/dashboard/media-providers/web.html +1 -1
  455. package/app/.next-cli-build/server/app/dashboard/media-providers/web.rsc +7 -7
  456. package/app/.next-cli-build/server/app/dashboard/media-providers/web.segments/!KGRhc2hib2FyZCk/dashboard/media-providers/web/__PAGE__.segment.rsc +7 -7
  457. package/app/.next-cli-build/server/app/dashboard/media-providers/web.segments/_full.segment.rsc +7 -7
  458. package/app/.next-cli-build/server/app/dashboard/media-providers/web.segments/_tree.segment.rsc +2 -2
  459. package/app/.next-cli-build/server/app/dashboard/memory.html +1 -1
  460. package/app/.next-cli-build/server/app/dashboard/memory.rsc +7 -7
  461. package/app/.next-cli-build/server/app/dashboard/memory.segments/!KGRhc2hib2FyZCk/dashboard/memory/__PAGE__.segment.rsc +7 -7
  462. package/app/.next-cli-build/server/app/dashboard/memory.segments/_full.segment.rsc +7 -7
  463. package/app/.next-cli-build/server/app/dashboard/memory.segments/_tree.segment.rsc +2 -2
  464. package/app/.next-cli-build/server/app/dashboard/mitm.html +1 -1
  465. package/app/.next-cli-build/server/app/dashboard/mitm.rsc +7 -7
  466. package/app/.next-cli-build/server/app/dashboard/mitm.segments/!KGRhc2hib2FyZCk/dashboard/mitm/__PAGE__.segment.rsc +7 -7
  467. package/app/.next-cli-build/server/app/dashboard/mitm.segments/_full.segment.rsc +7 -7
  468. package/app/.next-cli-build/server/app/dashboard/mitm.segments/_tree.segment.rsc +2 -2
  469. package/app/.next-cli-build/server/app/dashboard/playground.html +1 -1
  470. package/app/.next-cli-build/server/app/dashboard/playground.rsc +7 -7
  471. package/app/.next-cli-build/server/app/dashboard/playground.segments/!KGRhc2hib2FyZCk/dashboard/playground/__PAGE__.segment.rsc +7 -7
  472. package/app/.next-cli-build/server/app/dashboard/playground.segments/_full.segment.rsc +7 -7
  473. package/app/.next-cli-build/server/app/dashboard/playground.segments/_tree.segment.rsc +2 -2
  474. package/app/.next-cli-build/server/app/dashboard/privacy.html +1 -1
  475. package/app/.next-cli-build/server/app/dashboard/privacy.rsc +7 -7
  476. package/app/.next-cli-build/server/app/dashboard/privacy.segments/!KGRhc2hib2FyZCk/dashboard/privacy/__PAGE__.segment.rsc +7 -7
  477. package/app/.next-cli-build/server/app/dashboard/privacy.segments/_full.segment.rsc +7 -7
  478. package/app/.next-cli-build/server/app/dashboard/privacy.segments/_tree.segment.rsc +2 -2
  479. package/app/.next-cli-build/server/app/dashboard/profile.html +1 -1
  480. package/app/.next-cli-build/server/app/dashboard/profile.rsc +7 -7
  481. package/app/.next-cli-build/server/app/dashboard/profile.segments/!KGRhc2hib2FyZCk/dashboard/profile/__PAGE__.segment.rsc +7 -7
  482. package/app/.next-cli-build/server/app/dashboard/profile.segments/_full.segment.rsc +7 -7
  483. package/app/.next-cli-build/server/app/dashboard/profile.segments/_tree.segment.rsc +2 -2
  484. package/app/.next-cli-build/server/app/dashboard/provider-stats.html +1 -1
  485. package/app/.next-cli-build/server/app/dashboard/provider-stats.rsc +7 -7
  486. package/app/.next-cli-build/server/app/dashboard/provider-stats.segments/!KGRhc2hib2FyZCk/dashboard/provider-stats/__PAGE__.segment.rsc +7 -7
  487. package/app/.next-cli-build/server/app/dashboard/provider-stats.segments/_full.segment.rsc +7 -7
  488. package/app/.next-cli-build/server/app/dashboard/provider-stats.segments/_tree.segment.rsc +2 -2
  489. package/app/.next-cli-build/server/app/dashboard/providers/new.html +1 -1
  490. package/app/.next-cli-build/server/app/dashboard/providers/new.rsc +7 -7
  491. package/app/.next-cli-build/server/app/dashboard/providers/new.segments/!KGRhc2hib2FyZCk/dashboard/providers/new/__PAGE__.segment.rsc +7 -7
  492. package/app/.next-cli-build/server/app/dashboard/providers/new.segments/_full.segment.rsc +7 -7
  493. package/app/.next-cli-build/server/app/dashboard/providers/new.segments/_tree.segment.rsc +2 -2
  494. package/app/.next-cli-build/server/app/dashboard/providers.html +1 -1
  495. package/app/.next-cli-build/server/app/dashboard/providers.rsc +7 -7
  496. package/app/.next-cli-build/server/app/dashboard/providers.segments/!KGRhc2hib2FyZCk/dashboard/providers/__PAGE__.segment.rsc +7 -7
  497. package/app/.next-cli-build/server/app/dashboard/providers.segments/_full.segment.rsc +7 -7
  498. package/app/.next-cli-build/server/app/dashboard/providers.segments/_tree.segment.rsc +2 -2
  499. package/app/.next-cli-build/server/app/dashboard/proxy-pools.html +1 -1
  500. package/app/.next-cli-build/server/app/dashboard/proxy-pools.rsc +7 -7
  501. package/app/.next-cli-build/server/app/dashboard/proxy-pools.segments/!KGRhc2hib2FyZCk/dashboard/proxy-pools/__PAGE__.segment.rsc +7 -7
  502. package/app/.next-cli-build/server/app/dashboard/proxy-pools.segments/_full.segment.rsc +7 -7
  503. package/app/.next-cli-build/server/app/dashboard/proxy-pools.segments/_tree.segment.rsc +2 -2
  504. package/app/.next-cli-build/server/app/dashboard/pxpipe.html +1 -1
  505. package/app/.next-cli-build/server/app/dashboard/pxpipe.rsc +7 -7
  506. package/app/.next-cli-build/server/app/dashboard/pxpipe.segments/!KGRhc2hib2FyZCk/dashboard/pxpipe/__PAGE__.segment.rsc +7 -7
  507. package/app/.next-cli-build/server/app/dashboard/pxpipe.segments/_full.segment.rsc +7 -7
  508. package/app/.next-cli-build/server/app/dashboard/pxpipe.segments/_tree.segment.rsc +2 -2
  509. package/app/.next-cli-build/server/app/dashboard/quota.html +2 -2
  510. package/app/.next-cli-build/server/app/dashboard/quota.rsc +8 -8
  511. package/app/.next-cli-build/server/app/dashboard/quota.segments/!KGRhc2hib2FyZCk/dashboard/quota/__PAGE__.segment.rsc +8 -8
  512. package/app/.next-cli-build/server/app/dashboard/quota.segments/_full.segment.rsc +8 -8
  513. package/app/.next-cli-build/server/app/dashboard/quota.segments/_tree.segment.rsc +2 -2
  514. package/app/.next-cli-build/server/app/dashboard/runtime.html +1 -1
  515. package/app/.next-cli-build/server/app/dashboard/runtime.rsc +7 -7
  516. package/app/.next-cli-build/server/app/dashboard/runtime.segments/!KGRhc2hib2FyZCk/dashboard/runtime/__PAGE__.segment.rsc +7 -7
  517. package/app/.next-cli-build/server/app/dashboard/runtime.segments/_full.segment.rsc +7 -7
  518. package/app/.next-cli-build/server/app/dashboard/runtime.segments/_tree.segment.rsc +2 -2
  519. package/app/.next-cli-build/server/app/dashboard/settings/feature-flags.html +1 -1
  520. package/app/.next-cli-build/server/app/dashboard/settings/feature-flags.rsc +7 -7
  521. package/app/.next-cli-build/server/app/dashboard/settings/feature-flags.segments/!KGRhc2hib2FyZCk/dashboard/settings/feature-flags/__PAGE__.segment.rsc +7 -7
  522. package/app/.next-cli-build/server/app/dashboard/settings/feature-flags.segments/_full.segment.rsc +7 -7
  523. package/app/.next-cli-build/server/app/dashboard/settings/feature-flags.segments/_tree.segment.rsc +2 -2
  524. package/app/.next-cli-build/server/app/dashboard/settings/pricing.html +1 -1
  525. package/app/.next-cli-build/server/app/dashboard/settings/pricing.rsc +7 -7
  526. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/!KGRhc2hib2FyZCk/dashboard/settings/pricing/__PAGE__.segment.rsc +7 -7
  527. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_full.segment.rsc +7 -7
  528. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_tree.segment.rsc +2 -2
  529. package/app/.next-cli-build/server/app/dashboard/skill-discovery.html +1 -1
  530. package/app/.next-cli-build/server/app/dashboard/skill-discovery.rsc +7 -7
  531. package/app/.next-cli-build/server/app/dashboard/skill-discovery.segments/!KGRhc2hib2FyZCk/dashboard/skill-discovery/__PAGE__.segment.rsc +7 -7
  532. package/app/.next-cli-build/server/app/dashboard/skill-discovery.segments/_full.segment.rsc +7 -7
  533. package/app/.next-cli-build/server/app/dashboard/skill-discovery.segments/_tree.segment.rsc +2 -2
  534. package/app/.next-cli-build/server/app/dashboard/skills.html +1 -1
  535. package/app/.next-cli-build/server/app/dashboard/skills.rsc +7 -7
  536. package/app/.next-cli-build/server/app/dashboard/skills.segments/!KGRhc2hib2FyZCk/dashboard/skills/__PAGE__.segment.rsc +7 -7
  537. package/app/.next-cli-build/server/app/dashboard/skills.segments/_full.segment.rsc +7 -7
  538. package/app/.next-cli-build/server/app/dashboard/skills.segments/_tree.segment.rsc +2 -2
  539. package/app/.next-cli-build/server/app/dashboard/token-saver.html +1 -1
  540. package/app/.next-cli-build/server/app/dashboard/token-saver.rsc +8 -8
  541. package/app/.next-cli-build/server/app/dashboard/token-saver.segments/!KGRhc2hib2FyZCk/dashboard/token-saver/__PAGE__.segment.rsc +8 -8
  542. package/app/.next-cli-build/server/app/dashboard/token-saver.segments/_full.segment.rsc +8 -8
  543. package/app/.next-cli-build/server/app/dashboard/token-saver.segments/_tree.segment.rsc +2 -2
  544. package/app/.next-cli-build/server/app/dashboard/translator.html +1 -1
  545. package/app/.next-cli-build/server/app/dashboard/translator.rsc +7 -7
  546. package/app/.next-cli-build/server/app/dashboard/translator.segments/!KGRhc2hib2FyZCk/dashboard/translator/__PAGE__.segment.rsc +7 -7
  547. package/app/.next-cli-build/server/app/dashboard/translator.segments/_full.segment.rsc +7 -7
  548. package/app/.next-cli-build/server/app/dashboard/translator.segments/_tree.segment.rsc +2 -2
  549. package/app/.next-cli-build/server/app/dashboard/usage.html +1 -1
  550. package/app/.next-cli-build/server/app/dashboard/usage.rsc +7 -7
  551. package/app/.next-cli-build/server/app/dashboard/usage.segments/!KGRhc2hib2FyZCk/dashboard/usage/__PAGE__.segment.rsc +7 -7
  552. package/app/.next-cli-build/server/app/dashboard/usage.segments/_full.segment.rsc +7 -7
  553. package/app/.next-cli-build/server/app/dashboard/usage.segments/_tree.segment.rsc +2 -2
  554. package/app/.next-cli-build/server/app/dashboard/webhooks.html +1 -1
  555. package/app/.next-cli-build/server/app/dashboard/webhooks.rsc +7 -7
  556. package/app/.next-cli-build/server/app/dashboard/webhooks.segments/!KGRhc2hib2FyZCk/dashboard/webhooks/__PAGE__.segment.rsc +7 -7
  557. package/app/.next-cli-build/server/app/dashboard/webhooks.segments/_full.segment.rsc +7 -7
  558. package/app/.next-cli-build/server/app/dashboard/webhooks.segments/_tree.segment.rsc +2 -2
  559. package/app/.next-cli-build/server/app/dashboard.html +1 -1
  560. package/app/.next-cli-build/server/app/dashboard.rsc +7 -7
  561. package/app/.next-cli-build/server/app/dashboard.segments/!KGRhc2hib2FyZCk/dashboard/__PAGE__.segment.rsc +7 -7
  562. package/app/.next-cli-build/server/app/dashboard.segments/_full.segment.rsc +7 -7
  563. package/app/.next-cli-build/server/app/dashboard.segments/_tree.segment.rsc +2 -2
  564. package/app/.next-cli-build/server/app/favicon.ico/route.js.nft.json +1 -1
  565. package/app/.next-cli-build/server/app/index.html +1 -1
  566. package/app/.next-cli-build/server/app/index.rsc +5 -5
  567. package/app/.next-cli-build/server/app/index.segments/__PAGE__.segment.rsc +5 -5
  568. package/app/.next-cli-build/server/app/index.segments/_full.segment.rsc +5 -5
  569. package/app/.next-cli-build/server/app/index.segments/_tree.segment.rsc +2 -2
  570. package/app/.next-cli-build/server/app/landing/page.js.nft.json +1 -1
  571. package/app/.next-cli-build/server/app/landing/page_client-reference-manifest.js +1 -1
  572. package/app/.next-cli-build/server/app/landing.html +1 -1
  573. package/app/.next-cli-build/server/app/landing.rsc +5 -5
  574. package/app/.next-cli-build/server/app/landing.segments/_full.segment.rsc +5 -5
  575. package/app/.next-cli-build/server/app/landing.segments/_tree.segment.rsc +2 -2
  576. package/app/.next-cli-build/server/app/landing.segments/landing/__PAGE__.segment.rsc +5 -5
  577. package/app/.next-cli-build/server/app/login/page.js.nft.json +1 -1
  578. package/app/.next-cli-build/server/app/login/page_client-reference-manifest.js +1 -1
  579. package/app/.next-cli-build/server/app/login.html +1 -1
  580. package/app/.next-cli-build/server/app/login.rsc +6 -6
  581. package/app/.next-cli-build/server/app/login.segments/_full.segment.rsc +6 -6
  582. package/app/.next-cli-build/server/app/login.segments/_tree.segment.rsc +2 -2
  583. package/app/.next-cli-build/server/app/login.segments/login/__PAGE__.segment.rsc +6 -6
  584. package/app/.next-cli-build/server/app/manifest.webmanifest/route.js.nft.json +1 -1
  585. package/app/.next-cli-build/server/app/page.js.nft.json +1 -1
  586. package/app/.next-cli-build/server/app/page_client-reference-manifest.js +1 -1
  587. package/app/.next-cli-build/server/app-paths-manifest.json +9 -9
  588. package/app/.next-cli-build/server/chunks/112.js +1 -1
  589. package/app/.next-cli-build/server/chunks/2638.js +1 -1
  590. package/app/.next-cli-build/server/chunks/2813.js +1 -1
  591. package/app/.next-cli-build/server/chunks/2974.js +1 -1
  592. package/app/.next-cli-build/server/chunks/318.js +1 -1
  593. package/app/.next-cli-build/server/chunks/3755.js +1 -1
  594. package/app/.next-cli-build/server/chunks/4037.js +1 -1
  595. package/app/.next-cli-build/server/chunks/4303.js +1 -1
  596. package/app/.next-cli-build/server/chunks/5243.js +1 -1
  597. package/app/.next-cli-build/server/chunks/5731.js +1 -1
  598. package/app/.next-cli-build/server/chunks/6270.js +1 -1
  599. package/app/.next-cli-build/server/chunks/6664.js +1 -1
  600. package/app/.next-cli-build/server/chunks/7403.js +1 -1
  601. package/app/.next-cli-build/server/chunks/772.js +1 -1
  602. package/app/.next-cli-build/server/chunks/{4018.js → 7937.js} +5 -5
  603. package/app/.next-cli-build/server/chunks/8499.js +1 -1
  604. package/app/.next-cli-build/server/chunks/8537.js +1 -1
  605. package/app/.next-cli-build/server/chunks/862.js +1 -1
  606. package/app/.next-cli-build/server/chunks/869.js +1 -1
  607. package/app/.next-cli-build/server/chunks/8750.js +1 -1
  608. package/app/.next-cli-build/server/instrumentation.js.nft.json +1 -1
  609. package/app/.next-cli-build/server/middleware-build-manifest.js +1 -1
  610. package/app/.next-cli-build/server/middleware.js +4 -4
  611. package/app/.next-cli-build/server/middleware.js.nft.json +1 -1
  612. package/app/.next-cli-build/server/pages/404.html +1 -1
  613. package/app/.next-cli-build/server/pages/500.html +1 -1
  614. package/app/.next-cli-build/server/server-reference-manifest.js +1 -1
  615. package/app/.next-cli-build/server/server-reference-manifest.json +1 -1
  616. package/app/.next-cli-build/static/chunks/{1321-b974e2c6d758f2eb.js → 1321-6b997794bb843da8.js} +1 -1
  617. package/app/.next-cli-build/static/chunks/{7780-8fef40eb7ea5a323.js → 7780-f3508723257e47c1.js} +1 -1
  618. package/app/.next-cli-build/static/chunks/app/(dashboard)/dashboard/cli-tools/{page-8f34a6d3a99ebd37.js → page-f7cbd07558c7b42e.js} +1 -1
  619. package/app/.next-cli-build/static/css/01b1ec05918332c3.css +1 -0
  620. package/app/package.json +79 -78
  621. package/app/src/mitm/server.js +40 -42
  622. package/cli.js +10 -0
  623. package/hooks/opencodeBridgeRuntime.js +77 -0
  624. package/hooks/sqliteRuntime.js +156 -156
  625. package/hooks/trayRuntime.js +107 -107
  626. package/package.json +49 -49
  627. package/vendor/opencode-free-bridge/LICENSE +21 -0
  628. package/vendor/opencode-free-bridge/README.md +762 -0
  629. package/vendor/opencode-free-bridge/THIRD_PARTY_NOTICES.md +33 -0
  630. package/vendor/opencode-free-bridge/UPSTREAM_COMMIT +1 -0
  631. package/vendor/opencode-free-bridge/identity.js +2624 -0
  632. package/vendor/opencode-free-bridge/package.json +54 -0
  633. package/vendor/opencode-free-bridge/panel.js +515 -0
  634. package/vendor/opencode-free-bridge/proxy.js +980 -0
  635. package/vendor/opencode-free-bridge/server.js +3357 -0
  636. package/vendor/opencode-free-bridge/token-budget.js +107 -0
  637. package/vendor/opencode-free-bridge/transport-baseline.json +39 -0
  638. package/app/.next-cli-build/static/css/8ba5c8dbc4e30aa0.css +0 -1
  639. /package/app/.next-cli-build/static/{X4uxXfKI78mMCnlqFsTAr → DryEkcB2pIbzN1YYgNcUS}/_buildManifest.js +0 -0
  640. /package/app/.next-cli-build/static/{X4uxXfKI78mMCnlqFsTAr → DryEkcB2pIbzN1YYgNcUS}/_ssgManifest.js +0 -0
@@ -0,0 +1,3357 @@
1
+ #!/usr/bin/env bun
2
+ const dns = require("dns");
3
+ dns.setDefaultResultOrder(process.env.DNS_RESULT_ORDER || "ipv4first");
4
+ const http = require("http");
5
+ const crypto = require("crypto");
6
+ let VERSION = "1.5.0";
7
+ try {
8
+ VERSION = require("./package.json").version || VERSION;
9
+ } catch {}
10
+ const { effectiveMaxTokens } = require("./token-budget");
11
+
12
+ /**
13
+ * Load a sibling module, or a fallback when the file is not on disk yet.
14
+ *
15
+ * This exists because of a real upgrade path: 2.1.0's self-updater had no
16
+ * `proxy.js`/`panel.js` in its file list, so updating that install to 2.2.0
17
+ * wrote a `server.js` that required modules the updater never fetched. A hard
18
+ * `require` turned that into a crash loop (`Cannot find module './proxy'`)
19
+ * after the restart, with no way back except reinstalling. A missing optional
20
+ * module must degrade to direct egress, never prevent the process from booting.
21
+ *
22
+ * Only a missing *own* module is tolerated. If `proxy.js` exists but requires
23
+ * something absent, that is a broken install and the error must surface.
24
+ */
25
+ function loadLocalModule(name, fallback) {
26
+ try {
27
+ return require(`./${name}`);
28
+ } catch (err) {
29
+ if (err.code !== "MODULE_NOT_FOUND" || !String(err.message).includes(`'./${name}'`)) throw err;
30
+ return fallback;
31
+ }
32
+ }
33
+
34
+ const PROXY_FALLBACK = {
35
+ getProxyUrl: () => "",
36
+ withProxy: (init) => init,
37
+ netFetch: (url, init) => fetch(url, init),
38
+ describe: () => ({
39
+ mode: "direct",
40
+ proxy: null,
41
+ hasCredentials: false,
42
+ source: "none",
43
+ locked: false,
44
+ configPath: null,
45
+ error: "proxy.js belum terpasang; egress langsung. Jalankan ulang install.sh.",
46
+ }),
47
+ setProxyUrl: () => ({
48
+ ok: false,
49
+ error: "proxy.js belum terpasang. Jalankan ulang install.sh atau tunggu perbaikan otomatis.",
50
+ }),
51
+ testProxy: async () => ({ ok: false, error: "proxy.js belum terpasang." }),
52
+ };
53
+
54
+ const PANEL_FALLBACK = {
55
+ renderPanel: () =>
56
+ "<!doctype html><meta charset=\"utf-8\"><title>opencode-free-bridge</title>" +
57
+ "<body style=\"font:16px/1.5 system-ui;max-width:40rem;margin:4rem auto;padding:0 1rem\">" +
58
+ "<h1>Instalasi belum lengkap</h1>" +
59
+ "<p><code>proxy.js</code> dan <code>panel.js</code> tidak ditemukan di direktori ini. " +
60
+ "Bridge tetap berjalan dengan egress langsung. Jalankan ulang " +
61
+ "<code>install.sh</code> untuk memasang panel.</p></body>",
62
+ };
63
+
64
+ // Egress proxy and the panel that configures it. Loaded before anything else
65
+ // touches the network so the first request already honours a saved proxy.
66
+ let proxy = loadLocalModule("proxy", PROXY_FALLBACK);
67
+ let panel = loadLocalModule("panel", PANEL_FALLBACK);
68
+
69
+ /**
70
+ * Local modules this server requires by relative path. The self-updater uses
71
+ * this to refuse an update that would install a `server.js` whose imports are
72
+ * not part of the release, and boot uses it to repair a half-installed tree.
73
+ */
74
+ function localRequires(source) {
75
+ const names = new Set();
76
+ // Strip comments first: the doc comment above names `require("./x")` as an
77
+ // example, and scanning it produced a bogus `x.js` dependency.
78
+ const text = String(source || "")
79
+ .replace(/\/\*[\s\S]*?\*\//g, "")
80
+ .replace(/(^|[^:])\/\/[^\n]*/g, "$1");
81
+ // `require("./x")` and the tolerant `loadLocalModule("x", …)` loader both
82
+ // name a file that must be present for this server to work.
83
+ for (const re of [/\brequire\(\s*["']\.\/([A-Za-z0-9._-]+)["']\s*\)/g, /\bloadLocalModule\(\s*["']([A-Za-z0-9._-]+)["']/g]) {
84
+ let match;
85
+ while ((match = re.exec(text))) {
86
+ if (match[1].endsWith(".json")) continue;
87
+ const name = match[1].endsWith(".js") ? match[1] : `${match[1]}.js`;
88
+ names.add(name);
89
+ }
90
+ }
91
+ return [...names];
92
+ }
93
+
94
+ /**
95
+ * Fetch files that `server.js` requires but that are not on disk.
96
+ *
97
+ * 2.1.0 shipped a self-updater whose file list predated `proxy.js`/`panel.js`,
98
+ * so an update to 2.2.0 wrote a server that could not boot. That install now
99
+ * starts with the fallbacks above; this repairs it on the next boot instead of
100
+ * leaving the panel permanently unavailable. Best-effort: a failure keeps the
101
+ * fallback, never blocks startup.
102
+ */
103
+ async function repairMissingModules(dir = __dirname) {
104
+ const fs = require("fs");
105
+ const path = require("path");
106
+ const required = new Set(localRequires(fs.readFileSync(path.join(dir, "server.js"), "utf8")));
107
+ for (const name of ["proxy.js", "panel.js"]) required.add(name);
108
+ const missing = [...required].filter((f) => !fs.existsSync(path.join(dir, f)));
109
+ const repaired = [];
110
+ const failed = [];
111
+ for (const file of missing) {
112
+ try {
113
+ const res = await fetch(`${UPDATE_BASE_URL}/${file}?cb=${Date.now()}`, {
114
+ headers: { "Cache-Control": "no-cache" },
115
+ signal: AbortSignal.timeout(8000),
116
+ });
117
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
118
+ const text = await res.text();
119
+ if (!text || text.length < 20) throw new Error("empty response");
120
+ new Function(text.replace(/^#!.*\n/, ""));
121
+ const tmp = path.join(dir, `.${file}.repair.${process.pid}`);
122
+ fs.writeFileSync(tmp, text, "utf8");
123
+ fs.renameSync(tmp, path.join(dir, file));
124
+ repaired.push(file);
125
+ } catch (err) {
126
+ failed.push(`${file}: ${err.message}`);
127
+ }
128
+ }
129
+ if (repaired.length && path.resolve(dir) === path.resolve(__dirname)) {
130
+ proxy = loadLocalModule("proxy", PROXY_FALLBACK);
131
+ panel = loadLocalModule("panel", PANEL_FALLBACK);
132
+ console.log(`[opencode-free-bridge] Repaired missing module(s): ${repaired.join(", ")}`);
133
+ } else if (repaired.length) {
134
+ console.log(`[opencode-free-bridge] Repaired missing module(s): ${repaired.join(", ")}`);
135
+ }
136
+ if (failed.length) {
137
+ console.warn(
138
+ `[opencode-free-bridge] Could not repair ${failed.join(", ")}; ` +
139
+ "running with direct egress. Re-run install.sh to fix this install."
140
+ );
141
+ }
142
+ return { repaired, failed };
143
+ }
144
+
145
+ const PORT = parseInt(process.env.PORT || "20129", 10);
146
+ const HOST = process.env.HOST || "127.0.0.1";
147
+ const UPSTREAM_TIMEOUT_MS = parseInt(process.env.UPSTREAM_TIMEOUT_MS || "120000", 10);
148
+ const MAX_BODY_BYTES = parseInt(process.env.MAX_BODY_BYTES || String(10 * 1024 * 1024), 10);
149
+ const GATE_RETRY_COOLDOWN_MS = parseInt(
150
+ process.env.GATE_RETRY_COOLDOWN_MS || "30000",
151
+ 10
152
+ );
153
+ const CORS_ORIGIN = process.env.CORS_ORIGIN !== undefined ? process.env.CORS_ORIGIN : "";
154
+ // Optional egress relay. When set, every upstream inference request is sent to
155
+ // this relay URL instead of directly to opencode.ai, with the real origin and
156
+ // path carried in x-relay-target / x-relay-path (the same header contract
157
+ // 9Router's Cloudflare/Vercel/Deno/Netlify relays speak). The relay performs
158
+ // the final fetch, so upstream sees the relay's egress IP. Empty = direct.
159
+ const UPSTREAM_RELAY_URL = (process.env.UPSTREAM_RELAY_URL || "").trim();
160
+
161
+ /**
162
+ * Origin-only label for `/health`. The configured relay URL may carry
163
+ * credentials or a token in the query string; those must not be handed to any
164
+ * client that can reach the local health endpoint.
165
+ */
166
+ function relayLabel(url) {
167
+ try {
168
+ return new URL(url).origin;
169
+ } catch {
170
+ return "configured";
171
+ }
172
+ }
173
+
174
+ // Wire endpoints. These are *defaults*; the live values come from identity.js,
175
+ // which resolves them from the upstream provider catalog (`opencode.api` in
176
+ // models.opencode.ai/api.json) at refresh time. Reading them per request means
177
+ // a /zen/v1 → /inference/v1 style migration applies without a restart.
178
+ const OPENCODE_RESPONSES_URL =
179
+ process.env.OPENCODE_RESPONSES_URL || "https://opencode.ai/inference/v1/responses";
180
+ const OPENCODE_CHAT_URL =
181
+ process.env.OPENCODE_CHAT_URL || "https://opencode.ai/inference/v1/chat/completions";
182
+ const OPENCODE_MODELS_URL =
183
+ process.env.OPENCODE_MODELS_URL || "https://opencode.ai/inference/v1/models";
184
+ const OPENCODE_RELEASES_ATOM_URL =
185
+ process.env.OPENCODE_RELEASES_ATOM_URL || "https://github.com/anomalyco/opencode/releases.atom";
186
+ const UPDATE_HINT =
187
+ "Update opencode-free-bridge: curl -fsSL https://raw.githubusercontent.com/azrinaldi/opencode-free-bridge/main/install.sh | bash";
188
+ // Self-update (like the CLI's `update: auto`): poll this repo's package.json and
189
+ // replace the installed files when a newer release exists. Enabled by default;
190
+ // AUTO_UPDATE=0 disables it. Files are syntax-checked before being swapped in,
191
+ // and the swap is rename-per-file, so a partial download can never leave a
192
+ // broken install.
193
+ const UPDATE_BASE_URL =
194
+ process.env.OPENCODE_BRIDGE_UPDATE_BASE ||
195
+ "https://raw.githubusercontent.com/azrinaldi/opencode-free-bridge/main";
196
+ const AUTO_UPDATE = process.env.AUTO_UPDATE !== "0" && process.env.AUTO_UPDATE !== "false";
197
+ const UPDATE_FILES = [
198
+ "package.json",
199
+ "identity.js",
200
+ "token-budget.js",
201
+ "verify.js",
202
+ "gate-drill.js",
203
+ "transport-fingerprint.js",
204
+ "transport-baseline.json",
205
+ "tunnel-doctor.sh",
206
+ "proxy.js",
207
+ "panel.js",
208
+ "egress-proxy.js",
209
+ "server.js",
210
+ "THIRD_PARTY_NOTICES.md",
211
+ ];
212
+ let updateInFlight = null;
213
+ let lastUpdateCheck = 0;
214
+ const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
215
+
216
+ /**
217
+ * Compare two `x.y.z` versions. Returns true when `remote` is newer.
218
+ */
219
+ function isNewerVersion(current, remote) {
220
+ const a = String(current).split(".").map(Number);
221
+ const b = String(remote).split(".").map(Number);
222
+ for (let i = 0; i < 3; i++) {
223
+ const av = Number.isFinite(a[i]) ? a[i] : 0;
224
+ const bv = Number.isFinite(b[i]) ? b[i] : 0;
225
+ if (bv !== av) return bv > av;
226
+ }
227
+ return false;
228
+ }
229
+
230
+ /**
231
+ * The CLI's `update: auto`, in miniature: check this repo's published
232
+ * package.json, and when it is newer, download every shipped file into a temp
233
+ * file, syntax-check the JS, then rename them into place and exit so the
234
+ * service manager restarts us. Any failure leaves the running install intact.
235
+ *
236
+ * Deliberately *not* on the request path: it runs alongside model discovery,
237
+ * at most once per UPDATE_CHECK_TTL_MS.
238
+ */
239
+ async function checkAndApplyUpdate() {
240
+ if (!AUTO_UPDATE) return { updated: false, reason: "disabled" };
241
+ if (updateInFlight) return updateInFlight;
242
+ if (Date.now() - lastUpdateCheck < UPDATE_CHECK_TTL_MS) {
243
+ return { updated: false, reason: "fresh" };
244
+ }
245
+ const fs = require("fs");
246
+ const path = require("path");
247
+ if (
248
+ process.env.AUTO_UPDATE !== "1" &&
249
+ process.env.AUTO_UPDATE !== "force" &&
250
+ !process.env.OPENCODE_BRIDGE_UPDATE_BASE &&
251
+ fs.existsSync(path.join(__dirname, ".git"))
252
+ ) {
253
+ return { updated: false, reason: "git-checkout" };
254
+ }
255
+ updateInFlight = (async () => {
256
+ lastUpdateCheck = Date.now();
257
+ const tmpFiles = [];
258
+ const cb = Date.now();
259
+ try {
260
+ const pkgRes = await proxy.netFetch(`${UPDATE_BASE_URL}/package.json?cb=${cb}`, {
261
+ headers: { "Cache-Control": "no-cache" },
262
+ signal: AbortSignal.timeout(8000),
263
+ });
264
+ if (!pkgRes.ok) throw new Error(`HTTP ${pkgRes.status} fetching package.json`);
265
+ const remotePkg = await pkgRes.json();
266
+ const remoteVersion = String(remotePkg?.version || "").trim();
267
+ if (!/^\d+\.\d+\.\d+$/.test(remoteVersion)) throw new Error("invalid remote version");
268
+ if (!isNewerVersion(VERSION, remoteVersion)) {
269
+ return { updated: false, reason: "up-to-date" };
270
+ }
271
+
272
+ for (const file of UPDATE_FILES) {
273
+ const res = await proxy.netFetch(`${UPDATE_BASE_URL}/${file}?cb=${cb}`, {
274
+ headers: { "Cache-Control": "no-cache" },
275
+ signal: AbortSignal.timeout(15000),
276
+ });
277
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${file}`);
278
+ const text = await res.text();
279
+ if (!text || text.length < 20) throw new Error(`empty response for ${file}`);
280
+ if (file.endsWith(".js")) {
281
+ try {
282
+ new Function(text.replace(/^#!.*\n/, ""));
283
+ } catch (err) {
284
+ throw new Error(`syntax check failed for ${file}: ${err.message}`);
285
+ }
286
+ } else if (file.endsWith(".json")) {
287
+ try {
288
+ JSON.parse(text);
289
+ } catch (err) {
290
+ throw new Error(`invalid JSON in ${file}: ${err.message}`);
291
+ }
292
+ } else if (file.endsWith(".sh")) {
293
+ // `new Function` cannot parse bash. Check the shebang here and let
294
+ // `bash -n` parse it below, once it is on disk.
295
+ if (!/^#!.*\b(ba)?sh\b/.test(text)) throw new Error(`${file} is not a shell script`);
296
+ }
297
+ const tmpPath = path.join(__dirname, `.${file}.tmp.${process.pid}`);
298
+ fs.writeFileSync(tmpPath, text, "utf8");
299
+ if (file.endsWith(".sh")) {
300
+ let parsed = true;
301
+ try {
302
+ require("child_process").execFileSync("bash", ["-n", tmpPath], {
303
+ stdio: "ignore",
304
+ timeout: 5000,
305
+ });
306
+ } catch (err) {
307
+ // No bash on this host is not a syntax failure; the shebang check
308
+ // above is all we can do there. Anything else is a real rejection.
309
+ if (!err || err.code !== "ENOENT") {
310
+ try {
311
+ fs.unlinkSync(tmpPath);
312
+ } catch {}
313
+ parsed = false;
314
+ throw new Error(`syntax check failed for ${file}: ${err.message}`);
315
+ }
316
+ }
317
+ if (parsed) fs.chmodSync(tmpPath, 0o755);
318
+ }
319
+ tmpFiles.push({ tmpPath, targetPath: path.join(__dirname, file) });
320
+ }
321
+
322
+ // A release can add a module that an older `UPDATE_FILES` list does not
323
+ // name. 2.1.0 -> 2.2.0 did exactly that: the new server.js required
324
+ // ./proxy and ./panel, the old updater never fetched them, and the
325
+ // restart crash-looped. Read the *incoming* server.js and stage every
326
+ // local module it imports, so the file list in the running process can
327
+ // never be the reason a valid release fails to boot.
328
+ const incoming = tmpFiles.find((f) => path.basename(f.targetPath) === "server.js");
329
+ if (incoming) {
330
+ const declared = new Set(tmpFiles.map((f) => path.basename(f.targetPath)));
331
+ for (const file of localRequires(fs.readFileSync(incoming.tmpPath, "utf8"))) {
332
+ if (declared.has(file) || fs.existsSync(path.join(__dirname, file))) continue;
333
+ const res = await proxy.netFetch(`${UPDATE_BASE_URL}/${file}?cb=${cb}`, {
334
+ headers: { "Cache-Control": "no-cache" },
335
+ signal: AbortSignal.timeout(15000),
336
+ });
337
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching required module ${file}`);
338
+ const text = await res.text();
339
+ if (!text || text.length < 20) throw new Error(`empty response for required module ${file}`);
340
+ try {
341
+ new Function(text.replace(/^#!.*\n/, ""));
342
+ } catch (err) {
343
+ throw new Error(`syntax check failed for required module ${file}: ${err.message}`);
344
+ }
345
+ const tmpPath = path.join(__dirname, `.${file}.tmp.${process.pid}`);
346
+ fs.writeFileSync(tmpPath, text, "utf8");
347
+ tmpFiles.push({ tmpPath, targetPath: path.join(__dirname, file) });
348
+ declared.add(file);
349
+ console.log(`[opencode-free-bridge] Staged newly required module ${file}`);
350
+ }
351
+ }
352
+
353
+ for (const { tmpPath, targetPath } of tmpFiles) fs.renameSync(tmpPath, targetPath);
354
+ console.log(
355
+ `[opencode-free-bridge] Self-updated ${VERSION} → ${remoteVersion}; exiting for service restart.`
356
+ );
357
+ server.close(() => process.exit(0));
358
+ setTimeout(() => process.exit(0), 2000).unref();
359
+ return { updated: true, version: remoteVersion };
360
+ } catch (err) {
361
+ for (const { tmpPath } of tmpFiles) {
362
+ try {
363
+ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
364
+ } catch {}
365
+ }
366
+ console.warn(`[opencode-free-bridge] Self-update skipped: ${err.message}`);
367
+ return { updated: false, error: err.message };
368
+ } finally {
369
+ updateInFlight = null;
370
+ }
371
+ })();
372
+ return updateInFlight;
373
+ }
374
+
375
+ function isFreeTierCliGateError(status, errType, errMsg) {
376
+ if (errType === "FreeTierError" || errType === "UpgradeRequired") return true;
377
+ // 426 is the version-floor handshake: upstream names the minimum client
378
+ // version it will accept. Treat it as a gate signal so the identity layer can
379
+ // adopt that floor and retry.
380
+ if (status === 426) return true;
381
+ if (status !== 403 && status !== 401) return false;
382
+ const m = String(errMsg || "").toLowerCase();
383
+ return (
384
+ m.includes("freetiererror") ||
385
+ m.includes("free tier") ||
386
+ m.includes("free models") ||
387
+ m.includes("opencode cli") ||
388
+ m.includes("only available in opencode") ||
389
+ m.includes("upgrade required") ||
390
+ m.includes("or newer is required")
391
+ );
392
+ }
393
+ const identity = require("./identity");
394
+
395
+ /**
396
+ * Wire endpoints for this request. Environment variables pin them; otherwise
397
+ * the values derived from the upstream provider catalog are used, falling back
398
+ * to the shipped defaults until the first successful refresh. Resolved per
399
+ * call, so a catalog change applies on the next request.
400
+ */
401
+ function resolveEndpoints(modelId = null) {
402
+ const derived = identity.getEndpoints(modelId);
403
+ return {
404
+ responsesUrl: process.env.OPENCODE_RESPONSES_URL || derived.responsesUrl || OPENCODE_RESPONSES_URL,
405
+ chatUrl: process.env.OPENCODE_CHAT_URL || derived.chatUrl || OPENCODE_CHAT_URL,
406
+ modelsUrl: process.env.OPENCODE_MODELS_URL || derived.modelsUrl || OPENCODE_MODELS_URL,
407
+ };
408
+ }
409
+
410
+ // The identity (user-agent, session-id format, header set) is owned by
411
+ // identity.js, which derives it from opencode's public source and refreshes it
412
+ // lazily — on request, never on a timer. `currentUserAgent` mirrors it for the
413
+ // few call sites that need a plain string.
414
+ let currentUserAgent = process.env.OPENCODE_USER_AGENT || identity.getUserAgent();
415
+
416
+ function syncUserAgent() {
417
+ if (!process.env.OPENCODE_USER_AGENT) currentUserAgent = identity.getUserAgent();
418
+ return currentUserAgent;
419
+ }
420
+
421
+ /**
422
+ * Send an inference request to `url`, optionally through the configured egress
423
+ * relay. The relay contract is the one 9Router's deployable relays speak:
424
+ * POST <relay> with `x-relay-target: <origin>` and `x-relay-path: <path>`, and
425
+ * the relay replays method/headers/body at that origin. The origin and path
426
+ * never travel in the URL, so a relay deployed at a bare workers.dev hostname
427
+ * works unchanged.
428
+ *
429
+ * The request body is serialized once by the caller and passed as a string, so
430
+ * every retry path can replay it.
431
+ *
432
+ * A configured egress proxy applies to whichever URL is finally fetched, so a
433
+ * relay deployment can itself be dialled through the proxy.
434
+ *
435
+ * `egressUrl` pins the egress for this call. The chat handler picks one egress
436
+ * per request and reuses it across retries, so a retry that must stay on the IP
437
+ * that answered does not silently move to a different one mid-conversation.
438
+ * Without it, a pool picks per call.
439
+ */
440
+ function upstreamFetch(url, init, egressUrl = null) {
441
+ if (!UPSTREAM_RELAY_URL) return proxy.netFetch(url, init, egressUrl);
442
+ const parsed = new URL(url);
443
+ const headers = { ...(init.headers || {}) };
444
+ headers["x-relay-target"] = parsed.origin;
445
+ headers["x-relay-path"] = parsed.pathname + parsed.search;
446
+ return proxy.netFetch(UPSTREAM_RELAY_URL, { ...init, headers }, egressUrl);
447
+ }
448
+
449
+ /**
450
+ * Send a request through the chosen egress, and move to the next pool entry when
451
+ * the egress itself is unreachable.
452
+ *
453
+ * A dead proxy is not an upstream refusal: nothing reached upstream, so retrying
454
+ * on another entry costs one connection, while returning a 500 to the client
455
+ * wastes the request entirely. This is the failure mode a pool exists to absorb.
456
+ *
457
+ * With one proxy (or none) there is nothing to fail over to, so the transport
458
+ * error is rethrown for the caller's existing handling.
459
+ */
460
+ async function egressFetch(url, init, state) {
461
+ try {
462
+ const res = await upstreamFetch(url, init, state.egressUrl);
463
+ state.transportFailures = 0;
464
+ return res;
465
+ } catch (err) {
466
+ const pool = proxy.describe().pool;
467
+ const canFailover = pool && pool.active && pool.enabled > 1;
468
+ if (!canFailover || !state.egressUrl) throw err;
469
+
470
+ proxy.markFailure(state.egressUrl, { reason: `transport: ${err.message}`.slice(0, 200) });
471
+ const next = proxy.pick({ exclude: state.egressUrl });
472
+ if (!next.url || next.url === state.egressUrl) throw err;
473
+
474
+ console.warn(
475
+ `[opencode-free-bridge] Egress ${
476
+ proxy.displayHost(state.egressUrl) || "direct"
477
+ } unreachable (${err.message}); retrying through ${proxy.displayHost(next.url) || "direct"}`
478
+ );
479
+ state.egress = next;
480
+ state.egressUrl = next.url;
481
+ state.transportFailures = (state.transportFailures || 0) + 1;
482
+ // One hop only. A pool where every entry is down must surface the error
483
+ // instead of walking the whole list inside a single client request.
484
+ return upstreamFetch(url, init, state.egressUrl);
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Record a refusal against the egress that produced it.
490
+ *
491
+ * An empty URL is the `direct` pool entry, which is a real egress, so this
492
+ * cannot test truthiness: only a missing state means "nothing to record".
493
+ */
494
+ function recordEgressFailure(state, { reason, cooldownMs = null } = {}) {
495
+ const url = state && state.egressUrl;
496
+ if (url === null || url === undefined) return;
497
+ const pool = proxy.describe().pool;
498
+ if (!pool || !pool.active) return;
499
+ proxy.markFailure(url, { reason, cooldownMs });
500
+ }
501
+
502
+ function isValidV2UserAgent(ua) {
503
+ const match = String(ua || "")
504
+ .trim()
505
+ .match(/^opencode\/[a-z0-9-]+\/(\d+)\.\d+\.\d+\/[a-z0-9-]+$/i);
506
+ return Boolean(match) && parseInt(match[1], 10) >= 2;
507
+ }
508
+
509
+ /**
510
+ * A User-Agent the free-tier gate accepts, or the identity's own.
511
+ *
512
+ * Measured (clean egress, `read` tool present): a request without a
513
+ * `User-Agent`, with a bare `opencode`, or with `Mozilla/5.0 …` / `curl/8.18.0`
514
+ * is refused with the same 403 FreeTierError that a blocked IP gets. Only the
515
+ * `opencode/<channel>/<semver>/<name>` shape passes, and the version is not
516
+ * checked for freshness (`opencode/9.9.9` was accepted).
517
+ *
518
+ * `OPENCODE_USER_AGENT` is an operator override, so it is honoured — but an
519
+ * override that the gate would reject is reported instead of silently
520
+ * producing a 403 that looks like an allowance problem.
521
+ */
522
+ function gateSafeUserAgent() {
523
+ const override = process.env.OPENCODE_USER_AGENT;
524
+ if (override && /^opencode\/[A-Za-z0-9-]+\/\d+\.\d+\.\d+\/[A-Za-z0-9-]+$/i.test(String(override).trim())) {
525
+ return String(override).trim();
526
+ }
527
+ if (override) {
528
+ console.warn(
529
+ "[opencode-free-bridge] OPENCODE_USER_AGENT does not match the shape the free-tier gate accepts " +
530
+ "(opencode/<channel>/<semver>/<name>); using the derived identity instead"
531
+ );
532
+ }
533
+ const derived = syncUserAgent();
534
+ if (/^opencode\//i.test(derived)) return derived;
535
+ // Last resort: the derived identity itself is malformed. Keep the request
536
+ // shaped as an opencode client rather than sending something the gate refuses.
537
+ return "opencode/latest/0.0.0/cli";
538
+ }
539
+
540
+ // --- ID Generator (matches OpenCode v2 CLI format) ---
541
+ // The shape (charset, total length, multiplier, prefix) comes from identity.js,
542
+ // which derives it from opencode's own `packages/schema/src/identifier.ts`.
543
+ // Kept as a thin wrapper here so existing call sites and tests keep working.
544
+ function createId(descending, timestamp = Date.now()) {
545
+ return identity.createId(descending, timestamp);
546
+ }
547
+
548
+ // Live model list, discovered from upstream at boot and refreshed hourly.
549
+ // No hardcoded fallback: an empty list honestly means "upstream not reached
550
+ // yet", so gateways/harnesses apply their own fallback instead of being
551
+ // served a stale lie.
552
+ let activeModels = [];
553
+ let cachedModelsPayload = '{"object":"list","data":[]}';
554
+ let lastDiscoveryTime = 0;
555
+ const DISCOVERY_TTL_MS = 60 * 60 * 1000; // Auto-refresh every 1 hour
556
+ // Single-flight: concurrent /v1/models hits after TTL expiry share one upstream
557
+ // fetch instead of stampeding (50 parallel GETs = 1 upstream call, not 50).
558
+ let discoveryInFlight = null;
559
+ // CLI version feed is slow (5s timeout) and changes rarely: cache 6h, refresh
560
+ // in parallel so model discovery never waits on it.
561
+ let lastCliVersionCheckTime = 0;
562
+ const CLI_VERSION_TTL_MS = 6 * 60 * 60 * 1000;
563
+
564
+ function rebuildDiscoveryPayloads() {
565
+ cachedModelsPayload = JSON.stringify({ object: "list", data: activeModels });
566
+ }
567
+ rebuildDiscoveryPayloads();
568
+
569
+ function isLocalOrigin(origin) {
570
+ if (typeof origin !== "string" || !origin) return false;
571
+ return /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(origin.trim());
572
+ }
573
+
574
+ const LOOPBACK_HOST_RE = /^(localhost|127(?:\.\d{1,3}){3}|\[::1\]|::1)$/i;
575
+
576
+ /** Hostname from a `Host` header, without the port. IPv6 literals keep brackets off. */
577
+ function hostnameOf(hostHeader) {
578
+ const raw = String(hostHeader || "").trim();
579
+ if (!raw) return "";
580
+ const v6 = raw.match(/^\[([^\]]+)\](?::\d+)?$/);
581
+ if (v6) return v6[1];
582
+ return raw.replace(/:\d+$/, "");
583
+ }
584
+
585
+ /**
586
+ * Admin-surface gate: the panel and `/api/proxy` can change where every
587
+ * outbound request goes, so they must not be reachable from a foreign page or
588
+ * a rebound DNS name.
589
+ *
590
+ * The bridge is a local tool with no authentication. Two browser attacks
591
+ * matter here:
592
+ *
593
+ * - CSRF: any page the operator visits could POST to 127.0.0.1 and point the
594
+ * egress at an attacker's proxy. A form POST cannot set
595
+ * `Content-Type: application/json`, so requiring it removes the simple
596
+ * request; the Origin/Sec-Fetch-Site checks below cover the rest.
597
+ * - DNS rebinding: an attacker domain resolving to 127.0.0.1 makes the
598
+ * browser send `Host: evil.example`, which a loopback-only listener would
599
+ * otherwise accept.
600
+ *
601
+ * `PANEL_ALLOWED_HOSTS` (comma-separated) adds names for a reverse proxy or a
602
+ * deliberate LAN deployment. A bind address other than a wildcard is allowed
603
+ * automatically, so `HOST=192.168.1.5` keeps its own panel working.
604
+ */
605
+ function allowedAdminHosts() {
606
+ const hosts = new Set(["localhost", "127.0.0.1", "::1"]);
607
+ for (const extra of String(process.env.PANEL_ALLOWED_HOSTS || "").split(",")) {
608
+ const trimmed = extra.trim().toLowerCase();
609
+ if (trimmed) hosts.add(trimmed);
610
+ }
611
+ const bound = String(HOST || "").toLowerCase();
612
+ const wildcard = !bound || bound === "0.0.0.0" || bound === "::" || bound === "[::]";
613
+ if (!wildcard) hosts.add(bound);
614
+ if (wildcard) {
615
+ // A wildcard bind is usually reached by one of this machine's own
616
+ // addresses (LAN IP, VPS public IP). Those are safe to accept and make
617
+ // `HOST=0.0.0.0` usable without configuration; a rebound attacker domain
618
+ // is still not in this set. Domain names go in PANEL_ALLOWED_HOSTS.
619
+ const os = require("os");
620
+ for (const list of Object.values(os.networkInterfaces())) {
621
+ for (const addr of list || []) {
622
+ if (addr && addr.address) hosts.add(String(addr.address).toLowerCase());
623
+ }
624
+ }
625
+ }
626
+ return hosts;
627
+ }
628
+
629
+ function adminRequestError(req) {
630
+ const host = hostnameOf(req.headers?.host).toLowerCase();
631
+ if (!host) return "Header Host wajib ada.";
632
+ if (!allowedAdminHosts().has(host)) {
633
+ return `Host ${host} tidak diizinkan. Panel hanya menerima host lokal; tambahkan ke PANEL_ALLOWED_HOSTS bila ini memang disengaja.`;
634
+ }
635
+
636
+ const site = String(req.headers?.["sec-fetch-site"] || "").toLowerCase();
637
+ if (site === "cross-site") {
638
+ return "Permintaan lintas-situs ditolak. Buka panel langsung dari host ini.";
639
+ }
640
+
641
+ // An Origin must match the Host this request was sent to (which covers the
642
+ // panel itself and an SSH/tunnel forward that keeps the browser's origin),
643
+ // or be explicitly allow-listed in CORS_ORIGIN. A loopback origin on a
644
+ // *different* port is another local service, not this panel, so it is not
645
+ // trusted automatically.
646
+ const origin = String(req.headers?.origin || "").trim();
647
+ if (origin) {
648
+ let originHost = "";
649
+ try {
650
+ originHost = new URL(origin).hostname.toLowerCase();
651
+ } catch {
652
+ return `Origin ${origin} tidak valid.`;
653
+ }
654
+ const configured = String(CORS_ORIGIN || "")
655
+ .split(",")
656
+ .map((s) => s.trim())
657
+ .filter(Boolean);
658
+ if (originHost !== host && !configured.includes(origin)) {
659
+ return `Origin ${origin} tidak diizinkan.`;
660
+ }
661
+ }
662
+ return null;
663
+ }
664
+
665
+
666
+ /**
667
+ * Kept for callers and tests that expect the old name. Consults the handshake
668
+ * and derives the rest of the recipe from opencode's source.
669
+ *
670
+ * This is a *poll*: it bypasses the identity freshness guard so the release
671
+ * check happens on its own schedule, but adoption still follows UPDATE_MODE.
672
+ * In `reactive` mode a release that has not moved the gate is recorded (and
673
+ * reported by /health as `identity.available`) without being adopted — forcing
674
+ * it here would silently turn `reactive` into `always` every 6h.
675
+ *
676
+ * The `lastCliVersionCheckTime` guard keeps the handshake read at most once per
677
+ * CLI_VERSION_TTL_MS even when called repeatedly.
678
+ */
679
+ async function refreshCliVersion() {
680
+ const now = Date.now();
681
+ if (now - lastCliVersionCheckTime < CLI_VERSION_TTL_MS) {
682
+ return { updated: false, reason: "fresh" };
683
+ }
684
+ const result = await identity.refresh({ recheck: true });
685
+ syncUserAgent();
686
+ lastCliVersionCheckTime = now;
687
+ return result;
688
+ }
689
+
690
+ // Auto-discovery from OpenCode upstream gateway.
691
+ // Single-flight: concurrent callers share one in-flight fetch.
692
+ // The CLI-version feed refreshes in parallel (fire-and-forget) so a slow
693
+ // 5s feed never delays the model list.
694
+ async function discoverModels() {
695
+ if (discoveryInFlight) return discoveryInFlight;
696
+ discoveryInFlight = (async () => {
697
+ refreshCliVersion().catch(() => {});
698
+ checkAndApplyUpdate().catch(() => {});
699
+ try {
700
+ const res = await proxy.netFetch(resolveEndpoints().modelsUrl, {
701
+ headers: {
702
+ "Authorization": "Bearer public",
703
+ "User-Agent": currentUserAgent,
704
+ },
705
+ signal: AbortSignal.timeout(15000),
706
+ });
707
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
708
+ const json = await res.json();
709
+ const items = json.data || [];
710
+
711
+ // The provider catalog decides what is free — it is the file the CLI reads
712
+ // at boot, and its `cost`/`status` fields are facts about the plan. The
713
+ // gateway's /models list only contributes models the catalog does not
714
+ // describe (a few evaluator entries), because a name is not a price: a
715
+ // model named `-free` that the catalog marks deprecated must drop out, and
716
+ // one the catalog marks free must survive /models omitting it.
717
+ const ownedBy = new Map(items.map((i) => [String(i.id || "").toLowerCase(), i]));
718
+ const kept = [];
719
+ const seen = new Set();
720
+ const keep = (id) => {
721
+ const lower = String(id).toLowerCase();
722
+ if (seen.has(lower)) return;
723
+ seen.add(lower);
724
+ kept.push(id);
725
+ };
726
+
727
+ for (const id of identity.getFreeModels()) keep(id);
728
+ // With the catalog loaded this loop adds nothing today: every id /models
729
+ // offers is either described by the catalog (already ruled on) or is an
730
+ // evaluator entry. It exists for the window before the first catalog read,
731
+ // when the name is the only signal available — a bridge that answers
732
+ // /v1/models with an empty list for the first seconds after boot is worse
733
+ // than one that guesses and corrects itself on the next refresh.
734
+ for (const item of items) {
735
+ const id = String(item.id || "");
736
+ const lower = id.toLowerCase();
737
+ if (lower.startsWith("jev-")) continue; // systemone evaluator models
738
+ const verdict = identity.getCatalogVerdict(lower);
739
+ if (verdict) continue; // the catalog already ruled on this id
740
+ if (!lower.endsWith("-free") && !lower.includes("-contributor") && lower !== "big-pickle") continue;
741
+ keep(id);
742
+ }
743
+
744
+ const freeItems = kept.map((id) => ({
745
+ id,
746
+ object: "model",
747
+ owned_by: ownedBy.get(String(id).toLowerCase())?.owned_by || "opencode",
748
+ }));
749
+
750
+ if (freeItems.length > 0) {
751
+ activeModels = freeItems;
752
+ rebuildDiscoveryPayloads();
753
+ lastDiscoveryTime = Date.now();
754
+ console.log(`[opencode-free-bridge] Auto-discovered ${activeModels.length} free models from upstream gateway.`);
755
+ return;
756
+ }
757
+ } catch (err) {
758
+ console.warn(`[opencode-free-bridge] Auto-discovery warning: ${err.message}. Model list stays empty until upstream answers.`);
759
+ }
760
+ })();
761
+ try {
762
+ return await discoveryInFlight;
763
+ } finally {
764
+ discoveryInFlight = null;
765
+ }
766
+ }
767
+
768
+ function normalizeModel(rawModel) {
769
+ if (typeof rawModel !== "string") return "";
770
+ const stripped = rawModel.trim().replace(/^(opencode\/|oc\/|zen\/|9router\/|openai\/)/i, "").trim();
771
+ if (!stripped) return "";
772
+ // A name that is already a real model id is passed through untouched; only a
773
+ // shorthand is resolved. The catalog's families are consulted first, so a
774
+ // revision that retired keeps resolving to a live sibling without a bridge
775
+ // release; the static table below remains for names the catalog does not
776
+ // group (and for the case where the catalog has not been read yet).
777
+ const lower = stripped.toLowerCase();
778
+ const verdict = identity.getCatalogVerdict(lower);
779
+ // This bridge is free-tier only: it sends `Authorization: Bearer public` and
780
+ // has no way to authenticate a paid request. A paid id would therefore only
781
+ // earn a 401/402 upstream, so it is not passed through — an alias that points
782
+ // at one is allowed to fall through to a free sibling instead.
783
+ if (verdict === "free") return stripped;
784
+ const derived = identity.resolveModelAlias(lower);
785
+ if (derived) return derived;
786
+ // No static table behind this. A shorthand either derives from the catalog's
787
+ // own `family` field or it is passed through as a literal model id. Anything
788
+ // else would be a name this bridge invented, and an invented name can outlive
789
+ // the model it points at.
790
+ return stripped;
791
+ }
792
+
793
+ // Protocol routing: which models want /responses instead of /chat/completions.
794
+ //
795
+ // The shipped default is empty on purpose. It used to seed two `muse-spark`
796
+ // ids, both of which the catalog now marks deprecated, plus a `includes("muse")`
797
+ // fallback — a name rule for a model family that no longer exists. Every live
798
+ // free model speaks the chat lane (catalog: `@ai-sdk/openai-compatible`), and a
799
+ // model that needs the other lane is learned from upstream's own
800
+ // `ModelProtocolUnsupported` reply, which is a fact rather than a guess.
801
+ // `RESPONSES_MODELS` still seeds the set when upstream ships a Responses-only
802
+ // model before the bridge has met it.
803
+ const responsesModelSet = new Set(
804
+ (process.env.RESPONSES_MODELS || "")
805
+ .split(",")
806
+ .map((s) => s.trim().toLowerCase())
807
+ .filter(Boolean)
808
+ );
809
+ const chatModelSet = new Set();
810
+
811
+ function isResponsesModel(modelId) {
812
+ const lower = String(modelId || "").trim().toLowerCase();
813
+ if (!lower) return false;
814
+ if (chatModelSet.has(lower)) return false;
815
+ if (responsesModelSet.has(lower)) return true;
816
+ return false;
817
+ }
818
+
819
+ /** Test hook: forget learned lane assignments (they persist by design). */
820
+ function __resetProtocolLanes() {
821
+ chatModelSet.clear();
822
+ responsesModelSet.clear();
823
+ for (const m of (process.env.RESPONSES_MODELS || "").split(",")) {
824
+ const id = m.trim().toLowerCase();
825
+ if (id) responsesModelSet.add(id);
826
+ }
827
+ }
828
+
829
+ function markProtocolSupport(modelId, useResponses) {
830
+ const lower = String(modelId || "").trim().toLowerCase();
831
+ if (!lower) return;
832
+ if (useResponses) {
833
+ responsesModelSet.add(lower);
834
+ chatModelSet.delete(lower);
835
+ } else {
836
+ chatModelSet.add(lower);
837
+ responsesModelSet.delete(lower);
838
+ }
839
+ }
840
+
841
+ // --- Tool fingerprint (dynamic) -------------------------------------------
842
+ //
843
+ // Upstream /inference/v1 validates the `tools` array in the request body, and
844
+ // this is the layer that has moved most often: first the array had to exist,
845
+ // then it had to contain specific tool names, then (per 9router#4837) the exact
846
+ // schemas. Hardcoding those definitions here meant a code change every time.
847
+ //
848
+ // The definitions now come from identity.js, which derives them at the release
849
+ // commit SHA named by the update handshake:
850
+ // 1. source — `read.ts` / `shell.ts` at that immutable commit
851
+ // 2. DEFAULT_TOOLS — shipped fallback, used until a source read succeeds
852
+ //
853
+ // The names below are kept for the call sites and tests that predate the
854
+ // change; they are read from the live profile, not frozen at load time.
855
+ function getFingerprintNames(scope = null, modelId = null) {
856
+ // Only the names actually sent upstream. A capture carries ~10 tools but
857
+ // `TOOL_PROFILE_SCOPE=core` sends a subset; treating the unsent ones as
858
+ // fingerprints would alias an unrelated client tool (`glob` → `glob_client`)
859
+ // for no reason. The model matters too: the release swaps `edit`+`write` for
860
+ // `patch` on GPT models, so the fingerprint set follows the same rule.
861
+ return identity.getToolList(false, scope, modelId).map((t) => t.function.name);
862
+ }
863
+
864
+ function getFingerprintDefinitions() {
865
+ return identity.getTools();
866
+ }
867
+
868
+ function getCanonicalTools(flat, scope = null, modelId = null) {
869
+ return identity.getToolList(flat, scope, modelId);
870
+ }
871
+
872
+ /**
873
+ * Stable JSON for schema comparison. Key order must not affect equality: two
874
+ * definitions that differ only in property order are the same schema.
875
+ */
876
+ function stableStringify(val) {
877
+ if (!val || typeof val !== "object") return JSON.stringify(val);
878
+ if (Array.isArray(val)) return "[" + val.map(stableStringify).join(",") + "]";
879
+ const keys = Object.keys(val).sort();
880
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(val[k])).join(",") + "}";
881
+ }
882
+
883
+ const EMPTY_MAP = Object.freeze(new Map());
884
+ const paramSigCache = new WeakMap();
885
+ function getParamSig(params) {
886
+ if (!params || typeof params !== "object") return stableStringify(params);
887
+ let sig = paramSigCache.get(params);
888
+ if (sig === undefined) {
889
+ sig = stableStringify(params);
890
+ paramSigCache.set(params, sig);
891
+ }
892
+ return sig;
893
+ }
894
+
895
+ function buildIpThrottleHeaders(freeTierMsg, diagnostic = null) {
896
+ if (!freeTierMsg) return { "Content-Type": "application/json" };
897
+ const retryAfterSec = Math.max(1, Math.ceil((GATE_RETRY_COOLDOWN_MS || 30000) / 1000));
898
+ const headers = {
899
+ "Content-Type": "application/json",
900
+ "Retry-After": String(retryAfterSec),
901
+ // `shape-ok` means the request carried every anchor the gate checks, so the
902
+ // refusal is about this egress. `shape-rejected` means the bridge produced
903
+ // a request the gate refuses — a bridge bug that returns the identical
904
+ // upstream error. Callers that alert on this can tell the two apart.
905
+ "X-OpenCode-Gate": diagnostic && diagnostic.shapeOk === false ? "shape-rejected" : "ip-throttled",
906
+ };
907
+ if (diagnostic && diagnostic.logId) headers["X-OpenCode-Log-Id"] = diagnostic.logId;
908
+ return headers;
909
+ }
910
+
911
+ function resolveIpThrottleStatus(upstreamStatus, freeTierMsg) {
912
+ if (freeTierMsg && process.env.IP_LIMIT_STATUS === "429") return 429;
913
+ return upstreamStatus;
914
+ }
915
+
916
+ /**
917
+ * Is this client-supplied tool already byte-identical to the fingerprint we
918
+ * would inject? If so it can be passed through untouched.
919
+ */
920
+ function isExactFingerprintDef(tool, key) {
921
+ const want = identity.getTools()[key];
922
+ if (!want || !tool || typeof tool !== "object") return false;
923
+ const def = tool.function && typeof tool.function === "object" ? tool.function : tool;
924
+ return (
925
+ def.description === want.description &&
926
+ getParamSig(def.parameters) === getParamSig(want.parameters)
927
+ );
928
+ }
929
+
930
+ function buildPlaceholderNote(appendedNames) {
931
+ if (!Array.isArray(appendedNames) || appendedNames.length === 0) return "";
932
+ const list = appendedNames.map((n) => `\`${n}\``).join(" or ");
933
+ return appendedNames.length > 1
934
+ ? `Do not call the ${list} tools; they are placeholders that do nothing. Use the other tools you have been given instead.`
935
+ : `Do not call the ${list} tool; it is a placeholder that does nothing. Use the other tools you have been given instead.`;
936
+ }
937
+
938
+ function getToolName(tool) {
939
+ if (!tool || typeof tool !== "object") return "";
940
+ if (typeof tool.name === "string") return tool.name.trim();
941
+ if (tool.function && typeof tool.function.name === "string") return tool.function.name.trim();
942
+ return "";
943
+ }
944
+
945
+ /**
946
+ * Merge the client's tools with the fingerprint tools the gate requires.
947
+ *
948
+ * Three cases per required tool:
949
+ * - client already sends it verbatim → keep the client's copy
950
+ * - client sends the same name with a different schema → rename the client's
951
+ * copy (`read_client`) so both survive, and map responses back
952
+ * - client does not send it at all → append the fingerprint definition
953
+ *
954
+ * The definitions are resolved per call from identity.js, so a profile update
955
+ * takes effect on the next request without a restart.
956
+ */
957
+ function prepareToolsWithFingerprint(clientTools = [], flat = false, scope = null, modelId = null) {
958
+ const fingerprintNames = getFingerprintNames(scope, modelId);
959
+ const canonical = getCanonicalTools(flat, scope, modelId);
960
+ const canonicalByName = new Map(
961
+ canonical.map((t) => [flat ? t.name : t.function.name, t])
962
+ );
963
+
964
+ if (!Array.isArray(clientTools) || clientTools.length === 0) {
965
+ return {
966
+ tools: canonical,
967
+ appended: fingerprintNames,
968
+ placeholderNote: buildPlaceholderNote(fingerprintNames),
969
+ aliasToOriginal: EMPTY_MAP,
970
+ originalToAlias: EMPTY_MAP,
971
+ };
972
+ }
973
+
974
+ const aliasToOriginal = new Map();
975
+ const originalToAlias = new Map();
976
+ const normalized = [];
977
+ const takenNames = new Set(clientTools.map(getToolName).filter(Boolean));
978
+ const presentExactFingerprints = new Set();
979
+
980
+ for (const t of clientTools) {
981
+ const name = getToolName(t);
982
+ if (!name) continue;
983
+ const def = t.function && typeof t.function === "object" ? t.function : t;
984
+ const description = def.description || "";
985
+ const parameters = def.parameters || { type: "object", properties: {} };
986
+
987
+ if (fingerprintNames.includes(name)) {
988
+ if (presentExactFingerprints.has(name)) continue;
989
+ if (isExactFingerprintDef(t, name)) {
990
+ presentExactFingerprints.add(name);
991
+ normalized.push(
992
+ flat
993
+ ? { type: "function", name, description, parameters, strict: false }
994
+ : { type: "function", function: { name, description, parameters } }
995
+ );
996
+ continue;
997
+ }
998
+ // Client defined its own tool under a name the gate fingerprints: alias
999
+ // it so the exact fingerprint can still be injected alongside.
1000
+ if (originalToAlias.has(name)) continue;
1001
+ let alias = `${name}_client`;
1002
+ for (let i = 2; takenNames.has(alias); i++) {
1003
+ alias = `${name}_client${i}`;
1004
+ }
1005
+ takenNames.add(alias);
1006
+ aliasToOriginal.set(alias, name);
1007
+ originalToAlias.set(name, alias);
1008
+ normalized.push(
1009
+ flat
1010
+ ? { type: "function", name: alias, description, parameters }
1011
+ : { type: "function", function: { name: alias, description, parameters } }
1012
+ );
1013
+ continue;
1014
+ }
1015
+
1016
+ normalized.push(
1017
+ flat
1018
+ ? { type: "function", name, description, parameters }
1019
+ : { type: "function", function: { name, description, parameters } }
1020
+ );
1021
+ }
1022
+
1023
+ const appended = [];
1024
+ for (const name of fingerprintNames) {
1025
+ if (presentExactFingerprints.has(name)) continue;
1026
+ const def = canonicalByName.get(name);
1027
+ if (!def) continue;
1028
+ normalized.push(def);
1029
+ appended.push(name);
1030
+ }
1031
+
1032
+ return {
1033
+ tools: normalized,
1034
+ appended,
1035
+ placeholderNote:
1036
+ appended.length === fingerprintNames.length
1037
+ ? buildPlaceholderNote(fingerprintNames)
1038
+ : buildPlaceholderNote(appended),
1039
+ aliasToOriginal,
1040
+ originalToAlias,
1041
+ };
1042
+ }
1043
+
1044
+ function prepareResponsesTools(clientTools = [], modelId = null) {
1045
+ return prepareToolsWithFingerprint(clientTools, true, null, modelId).tools;
1046
+ }
1047
+
1048
+ function prepareChatTools(clientTools = [], modelId = null) {
1049
+ return prepareToolsWithFingerprint(clientTools, false, null, modelId).tools;
1050
+ }
1051
+
1052
+ function restoreClientToolName(name, aliasToOriginal) {
1053
+ if (!name || !aliasToOriginal || aliasToOriginal.size === 0) return name;
1054
+ return aliasToOriginal.get(name) || name;
1055
+ }
1056
+
1057
+ function extractContent(content) {
1058
+ if (typeof content === "string") return content;
1059
+ if (Array.isArray(content)) {
1060
+ return content
1061
+ .map((p) => {
1062
+ if (typeof p === "string") return p;
1063
+ if (p?.type === "text" && typeof p.text === "string") return p.text;
1064
+ if (p?.text) return String(p.text);
1065
+ return "";
1066
+ })
1067
+ .filter(Boolean)
1068
+ .join("\n");
1069
+ }
1070
+ return content ? JSON.stringify(content) : "";
1071
+ }
1072
+
1073
+ // First-N-chars text without ever serializing the whole value: strings are
1074
+ // sliced directly, arrays join only text parts up to the limit, objects fall
1075
+ // back to a bounded stringify. Used for the headerless session signature so
1076
+ // a multi-MB system prompt is never JSON.stringified just for a hash.
1077
+ function shortText(content, limit = 1000) {
1078
+ if (typeof content === "string") return content.slice(0, limit);
1079
+ if (Array.isArray(content)) {
1080
+ let out = "";
1081
+ for (const p of content) {
1082
+ if (out.length >= limit) break;
1083
+ if (typeof p === "string") out += p;
1084
+ else if (p?.type === "text" && typeof p.text === "string") out += p.text;
1085
+ else if (p?.text) out += String(p.text);
1086
+ }
1087
+ return out.slice(0, limit);
1088
+ }
1089
+ if (!content) return "";
1090
+ try {
1091
+ return JSON.stringify(content).slice(0, limit);
1092
+ } catch {
1093
+ return String(content).slice(0, limit);
1094
+ }
1095
+ }
1096
+
1097
+ function messageSignatureSample(content, limit = 1000) {
1098
+ if (typeof content === "string") {
1099
+ const len = content.length;
1100
+ if (len <= limit) return `${len}:${content}`;
1101
+ const half = Math.floor(limit / 2);
1102
+ return `${len}:${content.slice(0, half)}..${content.slice(len - half)}`;
1103
+ }
1104
+ if (Array.isArray(content)) {
1105
+ const head = shortText(content, Math.floor(limit / 2));
1106
+ const last = content[content.length - 1];
1107
+ const tail = shortText(last ? [last] : [], Math.floor(limit / 2));
1108
+ return `${content.length}:${head}..${tail}`;
1109
+ }
1110
+ return shortText(content, limit);
1111
+ }
1112
+
1113
+ function transformResponsesContent(content, role = "user") {
1114
+ if (typeof content === "string") return content;
1115
+ if (Array.isArray(content)) {
1116
+ const hasNonText = content.some(
1117
+ (p) =>
1118
+ p &&
1119
+ typeof p === "object" &&
1120
+ (p.type === "image_url" || p.type === "input_image" || p.type === "input_file")
1121
+ );
1122
+ if (!hasNonText) return extractContent(content);
1123
+ const textType = role === "assistant" ? "output_text" : "input_text";
1124
+ const parts = [];
1125
+ for (const p of content) {
1126
+ if (typeof p === "string") {
1127
+ parts.push({ type: textType, text: p });
1128
+ } else if (p && typeof p === "object") {
1129
+ if (p.type === "text" || p.type === "input_text" || p.type === "output_text") {
1130
+ parts.push({ type: textType, text: String(p.text ?? "") });
1131
+ } else if (p.type === "image_url") {
1132
+ const imageUrl = typeof p.image_url === "string" ? p.image_url : p.image_url?.url;
1133
+ if (imageUrl) parts.push({ type: "input_image", image_url: imageUrl });
1134
+ } else if (p.type === "input_image" || p.type === "input_file") {
1135
+ parts.push(p);
1136
+ } else if (p.text) {
1137
+ parts.push({ type: textType, text: String(p.text) });
1138
+ }
1139
+ }
1140
+ }
1141
+ return parts;
1142
+ }
1143
+ return extractContent(content);
1144
+ }
1145
+
1146
+ // Convert OpenAI messages to OpenCode input format (for Responses API)
1147
+ function transformToResponsesInput(messages = [], originalToAlias = null) {
1148
+ const input = [];
1149
+ for (const m of Array.isArray(messages) ? messages : []) {
1150
+ if (!m) continue;
1151
+ if (m.role === "system" || m.role === "developer") {
1152
+ continue;
1153
+ }
1154
+ if (m.role === "tool") {
1155
+ input.push({
1156
+ type: "function_call_output",
1157
+ call_id: m.tool_call_id || "call_unknown",
1158
+ output: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""),
1159
+ });
1160
+ continue;
1161
+ }
1162
+ if (m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
1163
+ if (m.content) {
1164
+ input.push({
1165
+ role: "assistant",
1166
+ content: transformResponsesContent(m.content, "assistant"),
1167
+ });
1168
+ }
1169
+ for (const tc of m.tool_calls) {
1170
+ const fn = tc.function || {};
1171
+ const rawName = fn.name || "unknown";
1172
+ const mappedName =
1173
+ originalToAlias && originalToAlias.has(rawName) ? originalToAlias.get(rawName) : rawName;
1174
+ input.push({
1175
+ type: "function_call",
1176
+ call_id: tc.id || `call_${crypto.randomBytes(8).toString("hex")}`,
1177
+ name: mappedName,
1178
+ arguments: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments || {}),
1179
+ });
1180
+ }
1181
+ continue;
1182
+ }
1183
+ const role = m.role || "user";
1184
+ input.push({ role, content: transformResponsesContent(m.content, role) });
1185
+ }
1186
+ return input;
1187
+ }
1188
+
1189
+ /**
1190
+ * The harness environment block the real CLI appends to every system prompt.
1191
+ *
1192
+ * Measured against the live gateway on 2026-10-25: a request whose system
1193
+ * prompt does not contain this block is answered with `429 FreeUsageLimitError`,
1194
+ * byte-for-byte the message the gateway uses for an exhausted allowance. The
1195
+ * trigger is the `<env>` block with a `Working directory:` line, not the prompt
1196
+ * length and not the model: a 12 KB generic prompt is refused, a 65-byte
1197
+ * `<env>Working directory: /tmp</env>` is accepted, and the same split holds
1198
+ * for big-pickle and mimo. That made a *shape* problem look like a quota
1199
+ * problem — the CLI answered while the bridge did not, in the same minute.
1200
+ *
1201
+ * The CLI builds this from its own session context, so the values here are the
1202
+ * bridge's honest equivalents: its working directory and whether that is a git
1203
+ * repository. Reproducing the block is what makes the bridge's request
1204
+ * indistinguishable from the CLI's on this gate.
1205
+ *
1206
+ * Cached per working directory: the values cannot change while the process runs
1207
+ * (the bridge never chdir's), and probing them costs a `git` subprocess —
1208
+ * measured at 2.5 ms, which is real money on every request.
1209
+ */
1210
+ let harnessEnvBlockCache = { cwd: null, block: "" };
1211
+ function buildHarnessEnvBlock() {
1212
+ const cwd = process.cwd();
1213
+ if (harnessEnvBlockCache.cwd === cwd && harnessEnvBlockCache.block) {
1214
+ return harnessEnvBlockCache.block;
1215
+ }
1216
+ let isRepo = "no";
1217
+ try {
1218
+ const { execFileSync } = require("child_process");
1219
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
1220
+ cwd,
1221
+ encoding: "utf8",
1222
+ stdio: ["ignore", "pipe", "ignore"],
1223
+ timeout: 2000,
1224
+ });
1225
+ isRepo = "yes";
1226
+ } catch {}
1227
+ const block = [
1228
+ "Here is some useful information about the environment you are running in:",
1229
+ "<env>",
1230
+ ` Working directory: ${cwd}`,
1231
+ ` Workspace root folder: ${cwd}`,
1232
+ ` Is directory a git repo: ${isRepo}`,
1233
+ " Platform: " + process.platform,
1234
+ "</env>",
1235
+ ].join("\n");
1236
+ harnessEnvBlockCache = { cwd, block };
1237
+ return block;
1238
+ }
1239
+ /**
1240
+ * Append the harness env block to a system prompt unless the client already
1241
+ * sent one. A client that forwards the CLI's own prompt (or any prompt with an
1242
+ * `<env>` block) keeps it verbatim — only the missing anchor is added.
1243
+ */
1244
+ function withHarnessEnvBlock(systemText) {
1245
+ const text = typeof systemText === "string" ? systemText : "";
1246
+ if (/<env>[\s\S]*?Working directory:/.test(text)) return text;
1247
+ const block = buildHarnessEnvBlock();
1248
+ return text.trim() ? `${text.replace(/\s+$/, "")}\n\n${block}` : block;
1249
+ }
1250
+ function extractSystemInstructions(messages = [], explicitInstructions = "", placeholderNote = "") {
1251
+ const parts = [];
1252
+ if (typeof explicitInstructions === "string" && explicitInstructions.trim()) {
1253
+ parts.push(explicitInstructions.trim());
1254
+ }
1255
+ for (const m of Array.isArray(messages) ? messages : []) {
1256
+ if (!m || (m.role !== "system" && m.role !== "developer")) continue;
1257
+ const text = extractContent(m.content).trim();
1258
+ if (text) parts.push(text);
1259
+ }
1260
+ if (placeholderNote) {
1261
+ parts.push(placeholderNote);
1262
+ }
1263
+ return withHarnessEnvBlock(parts.join("\n\n"));
1264
+ }
1265
+
1266
+ function transformChatMessages(messages = [], originalToAlias = null, placeholderNote = "") {
1267
+ const out = [];
1268
+ for (const m of Array.isArray(messages) ? messages : []) {
1269
+ if (!m) continue;
1270
+ if (
1271
+ originalToAlias &&
1272
+ originalToAlias.size > 0 &&
1273
+ m.role === "assistant" &&
1274
+ Array.isArray(m.tool_calls)
1275
+ ) {
1276
+ out.push({
1277
+ ...m,
1278
+ tool_calls: m.tool_calls.map((tc) => {
1279
+ const fnName = tc?.function?.name;
1280
+ if (fnName && originalToAlias.has(fnName)) {
1281
+ return { ...tc, function: { ...tc.function, name: originalToAlias.get(fnName) } };
1282
+ }
1283
+ return tc;
1284
+ }),
1285
+ });
1286
+ } else {
1287
+ out.push(m);
1288
+ }
1289
+ }
1290
+
1291
+ if (placeholderNote) {
1292
+ const first = out[0];
1293
+ if (first?.role === "system" || first?.role === "developer") {
1294
+ if (typeof first.content === "string") {
1295
+ out[0] = { ...first, content: `${first.content}\n\n${placeholderNote}` };
1296
+ } else if (Array.isArray(first.content)) {
1297
+ out[0] = { ...first, content: [...first.content, { type: "text", text: `\n\n${placeholderNote}` }] };
1298
+ } else {
1299
+ out.unshift({ role: "system", content: placeholderNote });
1300
+ }
1301
+ } else {
1302
+ out.unshift({ role: "system", content: placeholderNote });
1303
+ }
1304
+ }
1305
+
1306
+ // The gateway refuses a request whose system prompt lacks the harness `<env>`
1307
+ // block with the same `429 FreeUsageLimitError` it uses for an exhausted
1308
+ // allowance. See buildHarnessEnvBlock. A client that already forwarded the
1309
+ // CLI's prompt keeps its own block; only the missing anchor is added.
1310
+ const sysIndex = out.findIndex((m) => m?.role === "system" || m?.role === "developer");
1311
+ if (sysIndex === -1) {
1312
+ out.unshift({ role: "system", content: buildHarnessEnvBlock() });
1313
+ } else {
1314
+ const sys = out[sysIndex];
1315
+ if (typeof sys.content === "string") {
1316
+ out[sysIndex] = { ...sys, content: withHarnessEnvBlock(sys.content) };
1317
+ } else if (Array.isArray(sys.content)) {
1318
+ const text = extractContent(sys.content);
1319
+ if (!/<env>[\s\S]*?Working directory:/.test(text)) {
1320
+ out[sysIndex] = {
1321
+ ...sys,
1322
+ content: [...sys.content, { type: "text", text: `\n\n${buildHarnessEnvBlock()}` }],
1323
+ };
1324
+ }
1325
+ } else {
1326
+ out[sysIndex] = { ...sys, content: buildHarnessEnvBlock() };
1327
+ }
1328
+ }
1329
+ return out;
1330
+ }
1331
+
1332
+ function buildUpstreamPayload(body, targetModel, useResponses, scope = null) {
1333
+ const requestedTokens =
1334
+ body.max_output_tokens ?? body.max_completion_tokens ?? body.max_tokens;
1335
+ const maxTokens = effectiveMaxTokens(requestedTokens, targetModel, body);
1336
+ const { tools, placeholderNote, aliasToOriginal, originalToAlias } = prepareToolsWithFingerprint(
1337
+ body.tools,
1338
+ useResponses,
1339
+ scope,
1340
+ targetModel
1341
+ );
1342
+ if (useResponses) {
1343
+ const upstreamBody = {
1344
+ model: targetModel,
1345
+ input: transformToResponsesInput(body.messages, originalToAlias),
1346
+ stream: true,
1347
+ max_output_tokens: maxTokens,
1348
+ tools,
1349
+ tool_choice: "auto",
1350
+ };
1351
+ const instructions = extractSystemInstructions(
1352
+ body.messages,
1353
+ body.instructions,
1354
+ placeholderNote
1355
+ );
1356
+ if (instructions) {
1357
+ upstreamBody.instructions = instructions;
1358
+ }
1359
+ if (typeof body.temperature === "number") upstreamBody.temperature = body.temperature;
1360
+ if (typeof body.top_p === "number") upstreamBody.top_p = body.top_p;
1361
+ return { upstreamBody, aliasToOriginal };
1362
+ }
1363
+
1364
+ // Field names and order match the CLI's own request: `max_completion_tokens`
1365
+ // (not the legacy `max_tokens`), `store: false`, and `tools` before `stream`.
1366
+ // Upstream accepts both spellings today, but sending the CLI's exact shape
1367
+ // removes a difference that costs nothing to erase.
1368
+ const upstreamBody = {
1369
+ model: targetModel,
1370
+ messages: transformChatMessages(body.messages, originalToAlias, placeholderNote),
1371
+ tools,
1372
+ stream: true,
1373
+ stream_options: { include_usage: true },
1374
+ max_completion_tokens: maxTokens,
1375
+ store: false,
1376
+ };
1377
+ if (typeof body.temperature === "number") upstreamBody.temperature = body.temperature;
1378
+ if (typeof body.top_p === "number") upstreamBody.top_p = body.top_p;
1379
+ if (body.stop !== undefined) upstreamBody.stop = body.stop;
1380
+ if (body.response_format !== undefined) upstreamBody.response_format = body.response_format;
1381
+ if (body.reasoning_effort !== undefined) upstreamBody.reasoning_effort = body.reasoning_effort;
1382
+
1383
+ // Upstream v2 rejects tool_choice: "none" with 403 FreeTierError.
1384
+ if (body.tool_choice && body.tool_choice !== "none") {
1385
+ if (
1386
+ typeof body.tool_choice === "object" &&
1387
+ body.tool_choice.function?.name &&
1388
+ originalToAlias.has(body.tool_choice.function.name)
1389
+ ) {
1390
+ upstreamBody.tool_choice = {
1391
+ ...body.tool_choice,
1392
+ function: {
1393
+ ...body.tool_choice.function,
1394
+ name: originalToAlias.get(body.tool_choice.function.name),
1395
+ },
1396
+ };
1397
+ } else {
1398
+ upstreamBody.tool_choice = body.tool_choice;
1399
+ }
1400
+ }
1401
+
1402
+ return { upstreamBody, aliasToOriginal };
1403
+ }
1404
+
1405
+ /**
1406
+ * Mint a fresh session id after the identity changed.
1407
+ *
1408
+ * The id is derived from the recipe, so an id minted *before* a refresh encodes
1409
+ * the shape the gate just rejected. The self-heal path therefore has to replace
1410
+ * it — replaying the stale id would send the same rejected request and fail
1411
+ * identically. The cache entry is dropped for the same reason: otherwise the
1412
+ * next request from this client would get the stale id back.
1413
+ */
1414
+ function regenerateSessionId(req, body, staleId) {
1415
+ for (const [key, entry] of sessionCache) {
1416
+ if (entry.sessionId === staleId) sessionCache.delete(key);
1417
+ }
1418
+ return getPersistentSessionId(req, body);
1419
+ }
1420
+
1421
+ /**
1422
+ * The project id the CLI would send from this checkout.
1423
+ *
1424
+ * Measured against a real CLI capture: the header equals
1425
+ * `sha1("git-remote:github.com/<owner>/<repo>")` — the remote URL with scheme
1426
+ * and `.git` stripped. With no remote the CLI hashes the directory path
1427
+ * (`sha1("directory:<abs path>")`), and an `opencode` file in the directory
1428
+ * overrides both. Derived once and cached: a checkout does not move.
1429
+ */
1430
+ let cachedProjectId = null;
1431
+ function getProjectId() {
1432
+ if (cachedProjectId) return cachedProjectId;
1433
+ try {
1434
+ const fs = require("fs");
1435
+ const path = require("path");
1436
+ const crypto = require("crypto");
1437
+ const cwd = process.cwd();
1438
+
1439
+ // An `opencode` file wins, exactly as it does in the CLI.
1440
+ const marker = path.join(cwd, "opencode");
1441
+ if (fs.existsSync(marker)) {
1442
+ const value = fs.readFileSync(marker, "utf8").trim();
1443
+ if (value) return (cachedProjectId = value);
1444
+ }
1445
+
1446
+ // Git remote, normalised the way the CLI's `url()` helper does it.
1447
+ let remote = "";
1448
+ try {
1449
+ const { execFileSync } = require("child_process");
1450
+ remote = execFileSync("git", ["remote", "get-url", "origin"], {
1451
+ cwd,
1452
+ encoding: "utf8",
1453
+ stdio: ["ignore", "pipe", "ignore"],
1454
+ timeout: 2000,
1455
+ }).trim();
1456
+ } catch {}
1457
+ if (remote) {
1458
+ const normalized = remote
1459
+ .replace(/^[a-z]+:\/\//i, "") // drop scheme
1460
+ .replace(/^git@([^:]+):/i, "$1/") // scp-style
1461
+ .replace(/\.git$/i, "")
1462
+ .replace(/\/+$/, "");
1463
+ if (normalized) {
1464
+ return (cachedProjectId = crypto
1465
+ .createHash("sha1")
1466
+ .update(`git-remote:${normalized}`)
1467
+ .digest("hex"));
1468
+ }
1469
+ }
1470
+
1471
+ return (cachedProjectId = crypto
1472
+ .createHash("sha1")
1473
+ .update(`directory:${cwd}`)
1474
+ .digest("hex"));
1475
+ } catch {
1476
+ return (cachedProjectId = "global");
1477
+ }
1478
+ }
1479
+
1480
+ function buildUpstreamHeaders(req, sessionId) {
1481
+ const downstreamUa = req?.headers?.["user-agent"] || "";
1482
+ const ua = isValidV2UserAgent(downstreamUa) ? downstreamUa : gateSafeUserAgent();
1483
+ const client = req?.headers?.["x-opencode-client"];
1484
+ // `x-opencode-project` is not a constant: the CLI derives it as
1485
+ // `sha1("git-remote:" + host + path)` for a repo, `sha1("directory:" + path)`
1486
+ // otherwise, or the contents of an `opencode` file in the directory. Sending
1487
+ // the literal "global" is a shape the CLI never emits, so it is derived here
1488
+ // the same way — from this bridge's own checkout. A client that supplies the
1489
+ // header still wins.
1490
+ const project = req?.headers?.["x-opencode-project"] || getProjectId();
1491
+ // The gate header set is derived by identity.js from upstream's own request
1492
+ // builder, so a header added upstream starts being sent on the next request
1493
+ // after a refresh. Values this bridge cannot derive are reported, not sent
1494
+ // empty: an empty required header is worse than an absent one.
1495
+ const { headers: gateHeaders } = identity.buildGateHeaders({
1496
+ userAgent: ua,
1497
+ client: client === "desktop" || client === "cli" ? client : "cli",
1498
+ project: String(project),
1499
+ sessionId,
1500
+ });
1501
+ return {
1502
+ "Authorization": "Bearer public",
1503
+ "Content-Type": "application/json",
1504
+ // The CLI sends `*/*` here (it always streams via the body, not the Accept
1505
+ // header). Matching it costs nothing and removes a difference the gate
1506
+ // could one day start looking at.
1507
+ "Accept": "*/*",
1508
+ // W3C trace context, which the CLI emits on every request. The values are
1509
+ // random per request — measured: they carry no relation to the session id —
1510
+ // so this generates them the same way rather than deriving them. Both
1511
+ // headers describe one 128-bit trace with a 64-bit span; `b3` adds the
1512
+ // parent span id.
1513
+ ...buildTraceHeaders(),
1514
+ ...gateHeaders,
1515
+ };
1516
+ }
1517
+
1518
+ /** A fresh W3C trace context, shaped like the CLI's. */
1519
+ function buildTraceHeaders() {
1520
+ const traceId = crypto.randomBytes(16).toString("hex");
1521
+ const spanId = crypto.randomBytes(8).toString("hex");
1522
+ const parentSpanId = crypto.randomBytes(8).toString("hex");
1523
+ return {
1524
+ b3: `${traceId}-${spanId}-1-${parentSpanId}`,
1525
+ traceparent: `00-${traceId}-${spanId}-01`,
1526
+ };
1527
+ }
1528
+
1529
+ /**
1530
+ * Upstream can answer HTTP 200 and then report a failure *inside* the stream
1531
+ * (`data: {"error":{...}}`, e.g. "Streaming response failed: [503] ...").
1532
+ * Left unhandled, a non-streaming client is handed a 200 with `content: null`
1533
+ * and `finish_reason: "stop"` — a fake success. Returns the error text when the
1534
+ * event is an error, otherwise null.
1535
+ */
1536
+ function extractStreamError(ev) {
1537
+ const err = ev?.error;
1538
+ if (!err) return null;
1539
+ if (typeof err === "string") return err;
1540
+ return err.message || err.type || JSON.stringify(err);
1541
+ }
1542
+
1543
+ function extractResponsesFinishReason(ev, hasToolCalls) {
1544
+ const status = ev?.response?.status;
1545
+ const reason = ev?.response?.incomplete_details?.reason;
1546
+ if (ev?.type === "response.incomplete" || status === "incomplete") {
1547
+ if (reason === "content_filter") return "content_filter";
1548
+ return "length";
1549
+ }
1550
+ if (hasToolCalls) return "tool_calls";
1551
+ return "stop";
1552
+ }
1553
+
1554
+ // Persistent session cache — preserves upstream prompt/KV cache across turns (LRU)
1555
+ const sessionCache = new Map();
1556
+ const MAX_SESSIONS = 500;
1557
+ const SESSION_TTL_MS = 3 * 3600 * 1000;
1558
+ let lastFailedGateRetryTime = 0;
1559
+ let lastFailedGateRetryEpoch = -1;
1560
+ // Last IP-level abuse block seen from upstream, if any. Kept apart from the
1561
+ // retry cooldown because the two have different causes and different advice:
1562
+ // the cooldown is a per-IP allowance window, the block is the gateway refusing
1563
+ // this network outright.
1564
+ let lastIpAbuseAt = 0;
1565
+ // The last gate rejection's correlation id and the shape audit that was true
1566
+ // when it happened. `x-opencode-log-id` is the only handle upstream gives for
1567
+ // a rejected request, so it is recorded rather than discarded — it is what a
1568
+ // support request or a bug report needs to be actionable.
1569
+ let lastGateDiagnostic = null;
1570
+ let lastUpstreamError = null;
1571
+
1572
+ /**
1573
+ * Which gate anchors the *actual* outgoing request carried.
1574
+ *
1575
+ * The gate requires a tool named `read` and an `opencode/*` User-Agent with a
1576
+ * session header (see GATE_ANCHOR_TOOL_NAMES / GATE_ANCHOR_HEADER_NAMES in
1577
+ * identity.js). When a 403 comes back, these are what distinguishes "this
1578
+ * bridge sent an unacceptable shape" from "upstream refuses this egress".
1579
+ * Reporting the distinction is the whole point: both produce an identical
1580
+ * FreeTierError body.
1581
+ */
1582
+ function auditGateShape(upstreamBody, headers) {
1583
+ const tools = Array.isArray(upstreamBody?.tools) ? upstreamBody.tools : [];
1584
+ const toolNames = tools.map((t) => t?.function?.name || t?.name).filter(Boolean);
1585
+ // Header names are compared case-insensitively: the audit must not report a
1586
+ // missing anchor just because the same header arrived with different casing.
1587
+ const headerKey = (name) =>
1588
+ Object.keys(headers || {}).find((k) => k.toLowerCase() === name);
1589
+ const hasHeader = (name) => headerKey(name) !== undefined;
1590
+ const ua = String(headers?.[headerKey("user-agent")] || "");
1591
+ const sessionHeaders = ["x-opencode-session", "x-session-affinity", "x-session-id"].filter(
1592
+ hasHeader
1593
+ );
1594
+ return {
1595
+ hasReadTool: toolNames.includes("read"),
1596
+ toolCount: toolNames.length,
1597
+ userAgentOk: /^opencode\/[A-Za-z0-9-]+\/\d+\.\d+\.\d+\/[A-Za-z0-9-]+$/i.test(ua),
1598
+ userAgent: ua,
1599
+ sessionHeaders,
1600
+ };
1601
+ }
1602
+
1603
+ function upstreamLogIdOf(upstreamRes) {
1604
+ return typeof upstreamRes?.headers?.get === "function"
1605
+ ? upstreamRes.headers.get("x-opencode-log-id")
1606
+ : null;
1607
+ }
1608
+
1609
+ /**
1610
+ * Answer an upstream IP-level abuse block.
1611
+ *
1612
+ * Terminal by construction: the block is keyed to the egress network, so the
1613
+ * identity layer is not consulted, no session id is re-minted, and the request
1614
+ * is not replayed — measured (and confirmed by upstream's own issue tracker)
1615
+ * as a network flag that no client shape can clear. The refusal reaches the
1616
+ * client verbatim; `X-OpenCode-Gate: ip-abuse` lets callers and `verify.js`
1617
+ * tell it apart from the shape gate without parsing the message.
1618
+ */
1619
+ function respondIpAbuse(res, status, errType, errMsg, logId, model) {
1620
+ lastIpAbuseAt = Date.now();
1621
+ lastGateDiagnostic = {
1622
+ at: lastIpAbuseAt,
1623
+ kind: "ip-abuse",
1624
+ status,
1625
+ logId: logId || null,
1626
+ message: errMsg,
1627
+ model,
1628
+ };
1629
+ console.warn(
1630
+ `[opencode-free-bridge] Upstream IP-abuse block (${status}): ${errMsg} — not refreshing or retrying; the gateway keys this to this network's IP, so no identity change can clear it` +
1631
+ (logId ? ` [x-opencode-log-id: ${logId}]` : "")
1632
+ );
1633
+ res.writeHead(status, {
1634
+ "Content-Type": "application/json",
1635
+ "X-OpenCode-Gate": "ip-abuse",
1636
+ ...(logId ? { "X-OpenCode-Log-Id": logId } : {}),
1637
+ });
1638
+ return res.end(
1639
+ JSON.stringify({
1640
+ error: {
1641
+ message: errMsg,
1642
+ type: errType || "FreeTierError",
1643
+ code: status,
1644
+ },
1645
+ })
1646
+ );
1647
+ }
1648
+
1649
+ const origSessionCacheClear = sessionCache.clear.bind(sessionCache);
1650
+ sessionCache.clear = () => {
1651
+ lastFailedGateRetryTime = 0;
1652
+ lastFailedGateRetryEpoch = -1;
1653
+ lastIpAbuseAt = 0;
1654
+ lastUpstreamError = null;
1655
+ return origSessionCacheClear();
1656
+ };
1657
+
1658
+ function getPersistentSessionId(req, body) {
1659
+ const headers = req?.headers || {};
1660
+ const rawHeaderKey =
1661
+ headers["x-opencode-session"] ||
1662
+ headers["x-opencode-session-id"] ||
1663
+ headers["x-session-id"] ||
1664
+ headers["session-id"] ||
1665
+ headers["x-conversation-id"] ||
1666
+ headers["x-9r-session-id"];
1667
+ const headerKey = Array.isArray(rawHeaderKey)
1668
+ ? String(rawHeaderKey[0] || "").slice(0, 256)
1669
+ : rawHeaderKey
1670
+ ? String(rawHeaderKey).slice(0, 256)
1671
+ : "";
1672
+ // Fast path (common case): a session header is present, so no message
1673
+ // hashing is needed at all. The conv-signature hash below only runs for
1674
+ // headerless clients, and slices BEFORE stringify so a huge system prompt
1675
+ // never gets serialized just to take its first 1000 chars.
1676
+ let lookupKey = headerKey ? "hdr:" + headerKey : null;
1677
+ if (!lookupKey) {
1678
+ const msgs = body && Array.isArray(body.messages) ? body.messages : [];
1679
+ if (msgs.length > 0) {
1680
+ const sig =
1681
+ messageSignatureSample(msgs[0]?.content, 1000) +
1682
+ "::" +
1683
+ messageSignatureSample(msgs[1]?.content, 1000);
1684
+ if (sig !== "::") {
1685
+ lookupKey =
1686
+ "conv:" + crypto.createHash("sha256").update(sig).digest("hex").slice(0, 20);
1687
+ }
1688
+ }
1689
+ }
1690
+ const now = Date.now();
1691
+ if (lookupKey && sessionCache.has(lookupKey)) {
1692
+ const entry = sessionCache.get(lookupKey);
1693
+ if (now - entry.lastSeen < SESSION_TTL_MS) {
1694
+ // Re-insert to update Map insertion order for O(1) LRU eviction
1695
+ sessionCache.delete(lookupKey);
1696
+ entry.lastSeen = now;
1697
+ sessionCache.set(lookupKey, entry);
1698
+ return entry.sessionId;
1699
+ }
1700
+ sessionCache.delete(lookupKey);
1701
+ }
1702
+ const newSessionId = identity.createSessionId(true);
1703
+ if (lookupKey) {
1704
+ if (sessionCache.size >= MAX_SESSIONS) {
1705
+ for (const [k, v] of sessionCache) {
1706
+ if (now - v.lastSeen >= SESSION_TTL_MS) sessionCache.delete(k);
1707
+ }
1708
+ if (sessionCache.size >= MAX_SESSIONS) {
1709
+ const lruKey = sessionCache.keys().next().value;
1710
+ if (lruKey !== undefined) sessionCache.delete(lruKey);
1711
+ }
1712
+ }
1713
+ sessionCache.set(lookupKey, { sessionId: newSessionId, lastSeen: now });
1714
+ }
1715
+ return newSessionId;
1716
+ }
1717
+
1718
+ /**
1719
+ * `/api/proxy` — read and change the egress proxy; `/api/proxy/test` — prove a
1720
+ * candidate reaches upstream before it is trusted.
1721
+ *
1722
+ * The panel is the only caller, so the contract stays small: JSON in, JSON out,
1723
+ * one error field. Credentials are accepted on write and never echoed on read.
1724
+ */
1725
+ async function handleProxyApi(req, res, pathname) {
1726
+ const send = (status, body) => {
1727
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
1728
+ res.end(JSON.stringify(body));
1729
+ };
1730
+
1731
+ if (pathname === "/api/proxy" && (req.method === "GET" || req.method === "HEAD")) {
1732
+ return send(200, proxy.describe());
1733
+ }
1734
+ if (req.method !== "POST") {
1735
+ res.setHeader("Allow", "GET, POST");
1736
+ return send(405, { error: "Gunakan GET untuk status atau POST untuk mengubah." });
1737
+ }
1738
+
1739
+ let payload = {};
1740
+ try {
1741
+ const text = await readRequestBody(req, 128 * 1024);
1742
+ payload = text ? JSON.parse(text) : {};
1743
+ } catch (err) {
1744
+ return send(err.statusCode === 413 ? 413 : 400, { error: `Body tidak valid: ${err.message}` });
1745
+ }
1746
+ if (!payload || typeof payload !== "object") {
1747
+ return send(400, { error: "Body harus berupa objek JSON." });
1748
+ }
1749
+
1750
+ // Pool management. `entries` replaces the whole pool; `pool: []` clears it.
1751
+ // Kept apart from the single-proxy `url` field so one request cannot mean two
1752
+ // different things, and so an operator can keep a single proxy configured as a
1753
+ // fallback while experimenting with a pool.
1754
+ const hasPool = Object.prototype.hasOwnProperty.call(payload, "entries") ||
1755
+ Object.prototype.hasOwnProperty.call(payload, "pool");
1756
+ if (hasPool && pathname === "/api/proxy") {
1757
+ const spec = Object.prototype.hasOwnProperty.call(payload, "entries")
1758
+ ? { entries: payload.entries, strategy: payload.strategy, cooldownMs: payload.cooldownMs, maxCooldownMs: payload.maxCooldownMs }
1759
+ : payload.pool;
1760
+ const result = proxy.setPool(spec);
1761
+ if (!result.ok) return send(400, { error: result.error });
1762
+ console.log(
1763
+ `[opencode-free-bridge] Egress pool: ${result.pool.enabled}/${result.pool.size} aktif, strategi ${result.pool.strategy}`
1764
+ );
1765
+ return send(200, result);
1766
+ }
1767
+
1768
+ if (pathname === "/api/proxy/test" && Array.isArray(payload.entries)) {
1769
+ const { entries } = payload;
1770
+ if (entries.length === 0) return send(400, { error: "Daftar entries kosong." });
1771
+ // Probe without installing: the caller is evaluating candidates.
1772
+ const results = [];
1773
+ for (const [index, entry] of entries.entries()) {
1774
+ const parsed = proxy.parsePoolEntry(entry, index);
1775
+ if (!parsed.ok) {
1776
+ results.push({ id: `#${index + 1}`, host: null, ok: false, error: parsed.error });
1777
+ continue;
1778
+ }
1779
+ const verdict = await proxy.testProxy(parsed.entry.url, { probeUrl: resolveEndpoints().modelsUrl });
1780
+ results.push({
1781
+ id: parsed.entry.id,
1782
+ label: parsed.entry.label,
1783
+ host: proxy.displayHost(parsed.entry.url),
1784
+ ok: verdict.ok,
1785
+ apiStatus: verdict.apiStatus || null,
1786
+ exitIp: verdict.exitIp || null,
1787
+ latencyMs: verdict.latencyMs || null,
1788
+ error: verdict.error || null,
1789
+ });
1790
+ }
1791
+ const anyOk = results.some((r) => r.ok);
1792
+ return send(anyOk ? 200 : 502, { ok: anyOk, entries: results });
1793
+ }
1794
+
1795
+ if (pathname === "/api/proxy/test" && typeof payload.url === "string") {
1796
+ const result = await proxy.testProxy(payload.url, { probeUrl: resolveEndpoints().modelsUrl });
1797
+ return send(result.ok ? 200 : 502, result);
1798
+ }
1799
+
1800
+ if (typeof payload.url === "string") {
1801
+ const result = proxy.setProxyUrl(payload.url);
1802
+ if (!result.ok) return send(400, { error: result.error });
1803
+ console.log(
1804
+ `[opencode-free-bridge] Egress ${result.mode === "proxy" ? `via proxy ${result.proxy}` : "direct"}`
1805
+ );
1806
+ return send(200, result);
1807
+ }
1808
+
1809
+ return send(400, { error: "Body harus berisi 'url' (satu proxy) atau 'entries' (pool)." });
1810
+ }
1811
+
1812
+ function readRequestBody(req, maxBytes = MAX_BODY_BYTES) {
1813
+ // Early reject: if the client declares a huge body, refuse without reading
1814
+ // a single byte instead of buffering up to the limit first.
1815
+ const declared = parseInt(req?.headers?.["content-length"] || "", 10);
1816
+ if (Number.isFinite(declared) && declared > maxBytes) {
1817
+ const err = new Error(`Request body exceeds limit of ${maxBytes} bytes`);
1818
+ err.statusCode = 413;
1819
+ err.code = "payload_too_large";
1820
+ return Promise.reject(err);
1821
+ }
1822
+ return new Promise((resolve, reject) => {
1823
+ const chunks = [];
1824
+ let totalBytes = 0;
1825
+ let done = false;
1826
+ const cleanup = () => {
1827
+ if (typeof req.off === "function") {
1828
+ req.off("error", onError);
1829
+ req.off("data", onData);
1830
+ req.off("end", onEnd);
1831
+ } else if (typeof req.removeListener === "function") {
1832
+ req.removeListener("error", onError);
1833
+ req.removeListener("data", onData);
1834
+ req.removeListener("end", onEnd);
1835
+ }
1836
+ };
1837
+
1838
+ const onError = (err) => {
1839
+ if (done) return;
1840
+ done = true;
1841
+ cleanup();
1842
+ reject(err);
1843
+ };
1844
+
1845
+ const onData = (chunk) => {
1846
+ if (done) return;
1847
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1848
+ totalBytes += buf.length;
1849
+ if (totalBytes > maxBytes) {
1850
+ done = true;
1851
+ chunks.length = 0;
1852
+ cleanup();
1853
+ if (typeof req.pause === "function") req.pause();
1854
+ const err = new Error(`Request body exceeds limit of ${maxBytes} bytes`);
1855
+ err.statusCode = 413;
1856
+ err.code = "payload_too_large";
1857
+ reject(err);
1858
+ return;
1859
+ }
1860
+ chunks.push(buf);
1861
+ };
1862
+
1863
+ const onEnd = () => {
1864
+ if (done) return;
1865
+ done = true;
1866
+ cleanup();
1867
+ resolve(Buffer.concat(chunks).toString("utf8"));
1868
+ };
1869
+
1870
+ req.on("error", onError);
1871
+ req.on("data", onData);
1872
+ req.on("end", onEnd);
1873
+ });
1874
+ }
1875
+
1876
+ // --- HTTP Server ---
1877
+ function handleRequest(req, res) {
1878
+ req.on("error", (err) => {
1879
+ console.warn("[opencode-free-bridge] request stream error:", err.message);
1880
+ });
1881
+ res.on("error", (err) => {
1882
+ console.warn("[opencode-free-bridge] response stream error:", err.message);
1883
+ });
1884
+
1885
+ const reqOrigin = req.headers?.origin || "";
1886
+ const configuredOrigin = process.env.CORS_ORIGIN !== undefined ? process.env.CORS_ORIGIN : CORS_ORIGIN;
1887
+ if (configuredOrigin === "*") {
1888
+ res.setHeader("Access-Control-Allow-Origin", "*");
1889
+ } else if (configuredOrigin) {
1890
+ const allowed = configuredOrigin.split(",").map((s) => s.trim()).filter(Boolean);
1891
+ if (allowed.includes(reqOrigin)) {
1892
+ res.setHeader("Access-Control-Allow-Origin", reqOrigin);
1893
+ res.setHeader("Vary", "Origin");
1894
+ }
1895
+ } else if (isLocalOrigin(reqOrigin)) {
1896
+ res.setHeader("Access-Control-Allow-Origin", reqOrigin);
1897
+ res.setHeader("Vary", "Origin");
1898
+ }
1899
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1900
+ res.setHeader(
1901
+ "Access-Control-Allow-Headers",
1902
+ "Content-Type, Authorization, X-Requested-With, X-Session-Id, Session-Id, X-Conversation-Id, X-9r-Session-Id, X-OpenCode-Session, X-OpenCode-Session-Id, X-Session-Affinity, X-OpenCode-Client, X-OpenCode-Project"
1903
+ );
1904
+
1905
+ if (req.method === "OPTIONS") {
1906
+ res.writeHead(204);
1907
+ return res.end();
1908
+ }
1909
+
1910
+ const rawUrl = req.url || "/";
1911
+ const qIdx = rawUrl.indexOf("?");
1912
+ const rawPath = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx);
1913
+ const pathname = rawPath.length > 1 && rawPath.endsWith("/") ? rawPath.replace(/\/+$/, "") : rawPath;
1914
+
1915
+ // Panel: the human-facing page. JSON consumers keep using /health, which is
1916
+ // why "/" is no longer an alias for the health document.
1917
+ if ((req.method === "GET" || req.method === "HEAD") && (pathname === "" || pathname === "/")) {
1918
+ const denied = adminRequestError(req);
1919
+ if (denied) {
1920
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
1921
+ return res.end(req.method === "HEAD" ? undefined : denied);
1922
+ }
1923
+ const html = panel.renderPanel(proxy.describe(), { version: VERSION, port: PORT });
1924
+ res.writeHead(200, {
1925
+ "Content-Type": "text/html; charset=utf-8",
1926
+ "Cache-Control": "no-store",
1927
+ "Content-Length": Buffer.byteLength(html),
1928
+ "X-Frame-Options": "DENY",
1929
+ });
1930
+ return res.end(req.method === "HEAD" ? undefined : html);
1931
+ }
1932
+
1933
+ if (pathname === "/api/proxy" || pathname === "/api/proxy/test") {
1934
+ const denied = adminRequestError(req);
1935
+ if (denied) {
1936
+ res.writeHead(403, { "Content-Type": "application/json", "Cache-Control": "no-store" });
1937
+ return res.end(JSON.stringify({ error: denied }));
1938
+ }
1939
+ // Only JSON can change the setting. `text/plain` is a CORS "simple
1940
+ // request", so accepting it would let any page POST here without a
1941
+ // preflight; refusing it is the structural half of the CSRF defence.
1942
+ if (req.method === "POST") {
1943
+ const type = String(req.headers?.["content-type"] || "").split(";")[0].trim().toLowerCase();
1944
+ if (type !== "application/json") {
1945
+ res.writeHead(415, { "Content-Type": "application/json", "Cache-Control": "no-store" });
1946
+ return res.end(JSON.stringify({ error: "Content-Type harus application/json." }));
1947
+ }
1948
+ }
1949
+ // `handleProxyApi` is async; an unexpected throw must become a 500 instead
1950
+ // of an unhandled rejection that leaves the socket hanging.
1951
+ handleProxyApi(req, res, pathname).catch((err) => {
1952
+ if (res.headersSent) return res.destroy();
1953
+ res.writeHead(500, { "Content-Type": "application/json", "Cache-Control": "no-store" });
1954
+ res.end(JSON.stringify({ error: `Kesalahan internal: ${err.message}` }));
1955
+ });
1956
+ return;
1957
+ }
1958
+
1959
+ // Health check
1960
+ if (pathname === "/health") {
1961
+ res.writeHead(200, { "Content-Type": "application/json" });
1962
+ const idState = identity.getState();
1963
+ const proxyState = proxy.describe();
1964
+ const gateThrottled =
1965
+ lastFailedGateRetryTime > 0 &&
1966
+ Date.now() - lastFailedGateRetryTime < GATE_RETRY_COOLDOWN_MS &&
1967
+ lastFailedGateRetryEpoch === (idState.epoch || 0);
1968
+ return res.end(
1969
+ JSON.stringify({
1970
+ status: "ok",
1971
+ provider: "opencode-free-bridge",
1972
+ version: VERSION,
1973
+ runtime: typeof Bun !== "undefined" ? `bun/${Bun.version}` : `node/${process.versions.node}`,
1974
+ discoveredModels: activeModels.length,
1975
+ // Egress in force. `mode` is what actually carries the request: the
1976
+ // pool when one is configured, otherwise a single proxy, otherwise the
1977
+ // relay, otherwise direct. `proxy.mode` only reports the configured
1978
+ // shape, so a relay-only deployment still reads as `relay` here.
1979
+ // Credentials are never included; every host in `proxy` is host:port.
1980
+ egress: {
1981
+ mode:
1982
+ proxyState.mode === "pool"
1983
+ ? "pool"
1984
+ : proxyState.mode === "proxy"
1985
+ ? "proxy"
1986
+ : UPSTREAM_RELAY_URL
1987
+ ? "relay"
1988
+ : "direct",
1989
+ ...(UPSTREAM_RELAY_URL ? { relay: relayLabel(UPSTREAM_RELAY_URL) } : {}),
1990
+ proxy: proxyState,
1991
+ },
1992
+ identity: {
1993
+ userAgent: idState.userAgent,
1994
+ updateMode: idState.updateMode,
1995
+ sourceTag: idState.lastSourceTag,
1996
+ versionFloor: idState.versionFloor,
1997
+ lastCheckAgeMs: idState.lastCheckTime ? Date.now() - idState.lastCheckTime : null,
1998
+ ttlMs: idState.ttlMs,
1999
+ lastError: idState.lastRefreshError,
2000
+ release: idState.release,
2001
+ // Set in reactive mode when a release exists that has not been adopted
2002
+ // because the gate has not moved. `null` once adopted.
2003
+ available: idState.available,
2004
+ // The header names in force, and any the recipe asks for that this
2005
+ // bridge cannot fill — a non-empty `unsentHeaders` means a manual
2006
+ // mapping is needed before the next gate move is followed.
2007
+ headers: idState.headers,
2008
+ unsentHeaders: idState.unsentHeaders,
2009
+ // The values derived from upstream sources: the wire endpoints (from
2010
+ // the provider catalog) and the tool allowlist (from the release's
2011
+ // tool plugin sources). Visible because they are the layers that
2012
+ // change without a bridge release.
2013
+ endpoints: idState.endpoints,
2014
+ toolNames: idState.toolNames,
2015
+ tools: idState.tools,
2016
+ gate: {
2017
+ status: lastIpAbuseAt > 0 ? "ip-blocked" : gateThrottled ? "throttled" : "open",
2018
+ // The gateway keys an abuse block to the egress network, so there is
2019
+ // no known expiry to advertise. `ipBlockedSince` is the last time
2020
+ // upstream returned that refusal.
2021
+ ipBlockedSince: lastIpAbuseAt || null,
2022
+ throttledUntil: gateThrottled
2023
+ ? lastFailedGateRetryTime + GATE_RETRY_COOLDOWN_MS
2024
+ : null,
2025
+ cooldownMs: GATE_RETRY_COOLDOWN_MS,
2026
+ // The shape anchors the gate checks. Reported so a deployment can
2027
+ // confirm it is sending them without waiting for a 403.
2028
+ anchors: {
2029
+ tools: identity.GATE_ANCHOR_TOOL_NAMES.slice(),
2030
+ headers: identity.GATE_ANCHOR_HEADER_NAMES.slice(),
2031
+ },
2032
+ // The most recent gate rejection: what was sent, what was missing,
2033
+ // and upstream's own correlation id. `shapeOk: false` means the
2034
+ // bridge produced a request the gate refuses — not an IP problem.
2035
+ lastRejection: lastGateDiagnostic,
2036
+ lastUpstreamError,
2037
+ },
2038
+ },
2039
+ })
2040
+ );
2041
+ }
2042
+
2043
+ // Model list (triggers async re-discovery when the hourly TTL expired)
2044
+ if (req.method === "GET" && (pathname === "/v1/models" || pathname === "/models")) {
2045
+ if (activeModels.length === 0) {
2046
+ discoverModels()
2047
+ .catch(() => {})
2048
+ .finally(() => {
2049
+ res.writeHead(200, { "Content-Type": "application/json" });
2050
+ res.end(cachedModelsPayload);
2051
+ });
2052
+ return;
2053
+ }
2054
+ if (Date.now() - lastDiscoveryTime > DISCOVERY_TTL_MS) {
2055
+ discoverModels().catch(() => {});
2056
+ }
2057
+ res.writeHead(200, { "Content-Type": "application/json" });
2058
+ return res.end(cachedModelsPayload);
2059
+ }
2060
+
2061
+ // Chat completions
2062
+ if (req.method === "POST" && (pathname === "/v1/chat/completions" || pathname === "/chat/completions")) {
2063
+ (async () => {
2064
+ let bodyText;
2065
+ try {
2066
+ bodyText = await readRequestBody(req);
2067
+ } catch (err) {
2068
+ if (err.statusCode === 413) {
2069
+ res.writeHead(413, { "Content-Type": "application/json", "Connection": "close" });
2070
+ res.end(
2071
+ JSON.stringify({
2072
+ error: {
2073
+ message: err.message,
2074
+ type: "invalid_request_error",
2075
+ code: "payload_too_large",
2076
+ },
2077
+ })
2078
+ );
2079
+ res.once("finish", () => {
2080
+ if (typeof req.destroy === "function") req.destroy();
2081
+ });
2082
+ return;
2083
+ }
2084
+ if (!res.headersSent && !res.writableEnded) {
2085
+ res.writeHead(400, { "Content-Type": "application/json" });
2086
+ res.end(
2087
+ JSON.stringify({
2088
+ error: { message: "Failed to read request body", type: "invalid_request_error" },
2089
+ })
2090
+ );
2091
+ }
2092
+ return;
2093
+ }
2094
+
2095
+ let body;
2096
+ try {
2097
+ body = JSON.parse(bodyText);
2098
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
2099
+ throw new Error("Request body must be a JSON object");
2100
+ }
2101
+ } catch (err) {
2102
+ res.writeHead(400, { "Content-Type": "application/json" });
2103
+ return res.end(
2104
+ JSON.stringify({
2105
+ error: {
2106
+ message: `Invalid JSON body: ${err.message}`,
2107
+ type: "invalid_request_error",
2108
+ code: "invalid_json",
2109
+ },
2110
+ })
2111
+ );
2112
+ }
2113
+
2114
+ const targetModel = normalizeModel(body.model);
2115
+ if (!targetModel) {
2116
+ res.writeHead(400, { "Content-Type": "application/json" });
2117
+ return res.end(
2118
+ JSON.stringify({
2119
+ error: {
2120
+ message: "Missing or empty required parameter: 'model'",
2121
+ type: "invalid_request_error",
2122
+ param: "model",
2123
+ code: "model_required",
2124
+ },
2125
+ })
2126
+ );
2127
+ }
2128
+
2129
+ // Free tier only: this bridge sends `Authorization: Bearer public` and
2130
+ // cannot authenticate anything else, so a model the catalog prices above
2131
+ // zero can only fail upstream. Say that here — an upstream 401 ("no
2132
+ // session") sends the user hunting for a login that does not exist.
2133
+ const modelVerdict = identity.getCatalogVerdict(targetModel);
2134
+ if (modelVerdict && modelVerdict !== "free") {
2135
+ const isRetired = modelVerdict === "deprecated";
2136
+ res.writeHead(404, { "Content-Type": "application/json" });
2137
+ return res.end(
2138
+ JSON.stringify({
2139
+ error: {
2140
+ message: isRetired
2141
+ ? `Model '${targetModel}' has been retired upstream. Free models are listed at /v1/models.`
2142
+ : `Model '${targetModel}' is not on the free tier, and this bridge has no account to run it with. Free models are listed at /v1/models.`,
2143
+ type: "invalid_request_error",
2144
+ param: "model",
2145
+ code: isRetired ? "model_deprecated" : "model_not_free",
2146
+ },
2147
+ })
2148
+ );
2149
+ }
2150
+
2151
+ if (!Array.isArray(body.messages)) {
2152
+ res.writeHead(400, { "Content-Type": "application/json" });
2153
+ return res.end(
2154
+ JSON.stringify({
2155
+ error: {
2156
+ message: "Missing or invalid required parameter: 'messages' must be an array",
2157
+ type: "invalid_request_error",
2158
+ param: "messages",
2159
+ code: "messages_required",
2160
+ },
2161
+ })
2162
+ );
2163
+ }
2164
+
2165
+ const clientStream = Boolean(body.stream);
2166
+
2167
+ // Lazy identity check: no timer runs in the background. The first request
2168
+ // after the TTL kicks off a refresh in the background and proceeds with
2169
+ // the current mask; the next request uses the refreshed one.
2170
+ identity.ensureFresh();
2171
+ syncUserAgent();
2172
+
2173
+ let useResponses = isResponsesModel(targetModel);
2174
+ const sessionId = getPersistentSessionId(req, body);
2175
+ let retryHeaders = null;
2176
+ const upstreamHeaders = buildUpstreamHeaders(req, sessionId);
2177
+
2178
+ const abortController = new AbortController();
2179
+ let timedOut = false;
2180
+ let clientDisconnected = false;
2181
+ const timeoutId = setTimeout(() => {
2182
+ timedOut = true;
2183
+ abortController.abort(new Error("Upstream request timed out"));
2184
+ }, UPSTREAM_TIMEOUT_MS);
2185
+
2186
+ const onClose = () => {
2187
+ if (!res.writableEnded) {
2188
+ clientDisconnected = true;
2189
+ abortController.abort(new Error("Client disconnected"));
2190
+ }
2191
+ };
2192
+ res.on("close", onClose);
2193
+
2194
+ let reader = null;
2195
+ try {
2196
+ let { upstreamBody, aliasToOriginal } = buildUpstreamPayload(body, targetModel, useResponses);
2197
+ let liveEndpoints = resolveEndpoints(targetModel);
2198
+ let upstreamUrl = useResponses ? liveEndpoints.responsesUrl : liveEndpoints.chatUrl;
2199
+
2200
+ // One egress per client request. Everything below — the protocol-lane
2201
+ // retry, the endpoint-migration retry, the 5xx retry, and the gate retry
2202
+ // — reuses it, because upstream ties a retry to the address that saw the
2203
+ // first attempt. Only two things move it: an unreachable proxy (the
2204
+ // request never left) and a gate rejection that names this egress as the
2205
+ // problem. Both are handled where they happen and update this state.
2206
+ const egressState = { egress: proxy.pick(), egressUrl: null, transportFailures: 0 };
2207
+ egressState.egressUrl = egressState.egress.url;
2208
+ const egressUrl = () => egressState.egressUrl;
2209
+
2210
+ let upstreamRes = await egressFetch(upstreamUrl, {
2211
+ method: "POST",
2212
+ headers: upstreamHeaders,
2213
+ body: JSON.stringify(upstreamBody),
2214
+ signal: abortController.signal,
2215
+ }, egressState);
2216
+
2217
+ // If a newly released model requires the opposite protocol lane, learn it
2218
+ // and transparently retry once on the matching lane.
2219
+ if (!upstreamRes.ok && upstreamRes.status === 400) {
2220
+ const errText = await upstreamRes.text();
2221
+ let errJson;
2222
+ try {
2223
+ errJson = JSON.parse(errText);
2224
+ } catch {
2225
+ errJson = { message: errText };
2226
+ }
2227
+ const errType = errJson.error?.type || "";
2228
+ const errMsg = errJson.error?.message || errJson.message || "";
2229
+ if (
2230
+ errType === "ModelProtocolUnsupported" ||
2231
+ errMsg.includes("ModelProtocolUnsupported") ||
2232
+ errMsg.includes("does not support this protocol")
2233
+ ) {
2234
+ useResponses = !useResponses;
2235
+ markProtocolSupport(targetModel, useResponses);
2236
+ ({ upstreamBody, aliasToOriginal } = buildUpstreamPayload(body, targetModel, useResponses));
2237
+ upstreamUrl = useResponses ? liveEndpoints.responsesUrl : liveEndpoints.chatUrl;
2238
+ upstreamRes = await upstreamFetch(upstreamUrl, {
2239
+ method: "POST",
2240
+ headers: upstreamHeaders,
2241
+ body: JSON.stringify(upstreamBody),
2242
+ signal: abortController.signal,
2243
+ }, egressUrl());
2244
+ } else {
2245
+ res.writeHead(400, { "Content-Type": "application/json" });
2246
+ return res.end(
2247
+ JSON.stringify({
2248
+ error: {
2249
+ message: errMsg || "Upstream error 400",
2250
+ type: errType || "upstream_error",
2251
+ code: 400,
2252
+ },
2253
+ })
2254
+ );
2255
+ }
2256
+ }
2257
+
2258
+ // Endpoint migration. The provider catalog is the authority for the wire
2259
+ // base URL, so an upstream path move (the /zen/v1 → /inference/v1 move)
2260
+ // shows up as a 404/410 at the old path — not as a gate rejection. A 404
2261
+ // is otherwise indistinguishable from "this route does not exist", so
2262
+ // re-derive from the catalog and retry once, but only when the derived
2263
+ // URL actually differs. Replaying the same URL on a 404 would loop.
2264
+ // A body can only be read once. When an earlier branch has already
2265
+ // consumed it, the generic error path below must reuse the text instead
2266
+ // of reading an empty stream and reporting "Upstream error <status>".
2267
+ let consumedErrorText = null;
2268
+ if (!upstreamRes.ok && (upstreamRes.status === 404 || upstreamRes.status === 410)) {
2269
+ const staleUrl = upstreamUrl;
2270
+ let moveText = "";
2271
+ try {
2272
+ moveText = await upstreamRes.text();
2273
+ } catch {}
2274
+ consumedErrorText = moveText;
2275
+ // A 410 can also be about the *model*, not the route
2276
+ // (`{"error":{"type":"ModelDeprecated"}}`). Retrying such a request at
2277
+ // another URL would just repeat the same model-level answer, so the
2278
+ // endpoint heal only applies when the error does not name a model.
2279
+ let moveErrType = "";
2280
+ try {
2281
+ const j = JSON.parse(moveText);
2282
+ moveErrType = j?.error?.type || j?.type || "";
2283
+ } catch {}
2284
+ const modelLevelError = /model(deprecated|unavailable|protocolunsupported)/i.test(
2285
+ moveErrType
2286
+ );
2287
+ if (!modelLevelError) {
2288
+ await identity.refresh({ force: true }).catch(() => null);
2289
+ liveEndpoints = resolveEndpoints(targetModel);
2290
+ const derivedUrl = useResponses ? liveEndpoints.responsesUrl : liveEndpoints.chatUrl;
2291
+ if (derivedUrl && derivedUrl !== staleUrl) {
2292
+ console.warn(
2293
+ `[opencode-free-bridge] Upstream endpoint moved (${upstreamRes.status}): ${staleUrl} → ${derivedUrl}; retrying once`
2294
+ );
2295
+ upstreamUrl = derivedUrl;
2296
+ upstreamRes = await upstreamFetch(upstreamUrl, {
2297
+ method: "POST",
2298
+ headers: upstreamHeaders,
2299
+ body: JSON.stringify(upstreamBody),
2300
+ signal: abortController.signal,
2301
+ }, egressUrl());
2302
+ // The text above belonged to the response we just replaced. Reading
2303
+ // it for the new response would report the old error verbatim.
2304
+ consumedErrorText = null;
2305
+ }
2306
+ }
2307
+ }
2308
+
2309
+ // Spent allowance on a pooled egress. A 429 means transport and request
2310
+ // shape were both accepted and only this IP's quota is gone, so the one
2311
+ // useful action is to spend the retry on a different IP instead of
2312
+ // replaying an identical request at an address upstream just refused.
2313
+ // The cooldown is recorded either way, so the next request also moves.
2314
+ if (!upstreamRes.ok && upstreamRes.status === 429) {
2315
+ const poolState = proxy.describe().pool;
2316
+ const canFailover = poolState && poolState.active && poolState.enabled > 1;
2317
+ recordEgressFailure(egressState, {
2318
+ reason: "allowance 429",
2319
+ cooldownMs: poolState ? poolState.cooldownMs * 4 : null,
2320
+ });
2321
+ if (canFailover) {
2322
+ const next = proxy.pick({ exclude: egressState.egressUrl });
2323
+ if (next.url !== egressState.egressUrl) {
2324
+ console.warn(
2325
+ `[opencode-free-bridge] Egress ${
2326
+ proxy.displayHost(egressState.egressUrl) || "direct"
2327
+ } is out of allowance (429); retrying through ${
2328
+ proxy.displayHost(next.url) || "direct"
2329
+ }`
2330
+ );
2331
+ egressState.egress = next;
2332
+ egressState.egressUrl = next.url;
2333
+ upstreamRes = await egressFetch(upstreamUrl, {
2334
+ method: "POST",
2335
+ headers: upstreamHeaders,
2336
+ body: JSON.stringify(upstreamBody),
2337
+ signal: abortController.signal,
2338
+ }, egressState);
2339
+ consumedErrorText = null;
2340
+ // The retry may have landed on a second spent IP. Record it too,
2341
+ // or the next request would pick it again and learn the same thing.
2342
+ if (upstreamRes.status === 429) {
2343
+ recordEgressFailure(egressState, {
2344
+ reason: "allowance 429",
2345
+ cooldownMs: poolState.cooldownMs * 4,
2346
+ });
2347
+ }
2348
+ }
2349
+ }
2350
+ }
2351
+
2352
+ // Transient 5xx self-heal: upstream Workers intermittently drop empty-body
2353
+ // HTTP 500/502/503/504 responses (e.g. cf-placement: remote-IAD) during
2354
+ // free-inference degradation. Retry once with fresh trace/session headers.
2355
+ if (!upstreamRes.ok && upstreamRes.status >= 500 && upstreamRes.status < 600) {
2356
+ const first5xx = upstreamRes.status;
2357
+ const firstLogId =
2358
+ typeof upstreamRes.headers?.get === "function"
2359
+ ? upstreamRes.headers.get("x-opencode-log-id")
2360
+ : null;
2361
+ try {
2362
+ await upstreamRes.text();
2363
+ } catch {}
2364
+ const retry5xxSid = regenerateSessionId(req, body, sessionId);
2365
+ const retry5xxHeaders = buildUpstreamHeaders(req, retry5xxSid);
2366
+ console.warn(
2367
+ `[opencode-free-bridge] Upstream returned ${first5xx}` +
2368
+ (firstLogId ? ` [x-opencode-log-id: ${firstLogId}]` : "") +
2369
+ `; retrying once with fresh session`
2370
+ );
2371
+ upstreamRes = await upstreamFetch(upstreamUrl, {
2372
+ method: "POST",
2373
+ headers: retry5xxHeaders,
2374
+ body: JSON.stringify(upstreamBody),
2375
+ signal: abortController.signal,
2376
+ }, egressUrl());
2377
+ consumedErrorText = null;
2378
+ }
2379
+
2380
+ // Self-healing identity. A gate rejection is identified by the error
2381
+ // *body*, not the status line: a 403 is only the gate when its type or
2382
+ // message says so, while 426 is the version-floor handshake by itself.
2383
+ // The body must therefore be read before the decision — passing empty
2384
+ // strings here made this branch unreachable for 403.
2385
+ if (!upstreamRes.ok) {
2386
+ let errText = consumedErrorText ?? "";
2387
+ if (consumedErrorText === null) {
2388
+ try {
2389
+ errText = await upstreamRes.text();
2390
+ } catch {
2391
+ errText = "";
2392
+ }
2393
+ }
2394
+ let errJson = null;
2395
+ try {
2396
+ errJson = JSON.parse(errText);
2397
+ } catch {
2398
+ errJson = null;
2399
+ }
2400
+ const errType = errJson?.error?.type || errJson?.type || "";
2401
+ const rawMsg =
2402
+ errJson?.error?.message ||
2403
+ errJson?.message ||
2404
+ errText ||
2405
+ `Upstream error ${upstreamRes.status}`;
2406
+
2407
+ // Terminal branch. Upstream names an IP-level abuse block in the
2408
+ // message, and that is a different failure from the shape gate: the
2409
+ // gateway has flagged this network, so refreshing the recipe, minting
2410
+ // a new session id, or replaying the request cannot change the
2411
+ // answer. Measured against the gateway's own reports: the official
2412
+ // CLI gets the same refusal from the same IP. Return it verbatim and
2413
+ // leave the identity layer untouched.
2414
+ if (identity.isIpAbuseRejection(upstreamRes.status, rawMsg)) {
2415
+ // The block is keyed to this network's IP, so this egress is the
2416
+ // problem: park it so the next request leaves through another entry.
2417
+ recordEgressFailure(egressState, { reason: "ip-abuse block" });
2418
+ return respondIpAbuse(
2419
+ res,
2420
+ upstreamRes.status,
2421
+ errType,
2422
+ rawMsg,
2423
+ upstreamLogIdOf(upstreamRes),
2424
+ targetModel
2425
+ );
2426
+ }
2427
+
2428
+ if (!isFreeTierCliGateError(upstreamRes.status, errType, rawMsg)) {
2429
+ const upstreamLogId =
2430
+ typeof upstreamRes.headers?.get === "function"
2431
+ ? upstreamRes.headers.get("x-opencode-log-id")
2432
+ : null;
2433
+ const cfRay =
2434
+ typeof upstreamRes.headers?.get === "function"
2435
+ ? upstreamRes.headers.get("cf-ray")
2436
+ : null;
2437
+ const cfPlacement =
2438
+ typeof upstreamRes.headers?.get === "function"
2439
+ ? upstreamRes.headers.get("cf-placement")
2440
+ : null;
2441
+ if (upstreamRes.status >= 500) {
2442
+ lastUpstreamError = {
2443
+ at: Date.now(),
2444
+ status: upstreamRes.status,
2445
+ logId: upstreamLogId,
2446
+ cfRay,
2447
+ cfPlacement,
2448
+ message: rawMsg,
2449
+ model: targetModel,
2450
+ };
2451
+ console.warn(
2452
+ `[opencode-free-bridge] Upstream server error (${upstreamRes.status}) on model ${targetModel}: ${rawMsg}` +
2453
+ (upstreamLogId ? ` [x-opencode-log-id: ${upstreamLogId}]` : "") +
2454
+ (cfRay ? ` [cf-ray: ${cfRay}]` : "") +
2455
+ (cfPlacement ? ` [cf-placement: ${cfPlacement}]` : "")
2456
+ );
2457
+ }
2458
+ const errHeaders = { "Content-Type": "application/json" };
2459
+ if (upstreamLogId) errHeaders["X-OpenCode-Log-Id"] = upstreamLogId;
2460
+ if (upstreamRes.status >= 500) errHeaders["X-OpenCode-Gate"] = "upstream-error";
2461
+ res.writeHead(upstreamRes.status, errHeaders);
2462
+ return res.end(
2463
+ JSON.stringify({
2464
+ error: {
2465
+ message: rawMsg,
2466
+ type: errType || "upstream_error",
2467
+ code: upstreamRes.status,
2468
+ ...(upstreamLogId ? { log_id: upstreamLogId } : {}),
2469
+ },
2470
+ })
2471
+ );
2472
+ }
2473
+
2474
+ const gateStatus = upstreamRes.status;
2475
+ const verdict = await identity
2476
+ .onGateRejection(gateStatus, errType, rawMsg, false)
2477
+ .catch(() => ({ retry: false, reason: "refresh-error" }));
2478
+
2479
+ // Always retry once on a gate rejection, even when the refresh learned
2480
+ // nothing new. Two inputs to the gate are *minted per request* rather
2481
+ // than read from the recipe — the session id and the tool set — so a
2482
+ // retry with fresh values is a genuinely different request. Measured
2483
+ // upstream behaviour: a session id this bridge did not mint (or one
2484
+ // minted by a rejected recipe) is refused with FreeTierError even
2485
+ // when every header matches, while the same request with a freshly
2486
+ // minted id is accepted.
2487
+ syncUserAgent();
2488
+ // Re-mint the session id. The one minted before this request was
2489
+ // produced by the recipe the gate just rejected; replaying it would
2490
+ // send the same rejected shape and fail identically. The cached
2491
+ // entry is dropped so the next request also gets a correct id.
2492
+ const retrySessionId = regenerateSessionId(req, body, sessionId);
2493
+ const currentEpoch = identity.getState().epoch || 0;
2494
+ const skipCooldownRetry =
2495
+ !verdict.retry &&
2496
+ lastFailedGateRetryTime > 0 &&
2497
+ Date.now() - lastFailedGateRetryTime < GATE_RETRY_COOLDOWN_MS &&
2498
+ lastFailedGateRetryEpoch === currentEpoch;
2499
+
2500
+ if (!skipCooldownRetry) {
2501
+ // A gate rejection on a pooled egress is the one case where the
2502
+ // bridge can act on it locally: the allowance is per IP, so a
2503
+ // different entry is a different allowance. Failed over before the
2504
+ // retry so the retry is a genuinely different request in both shape
2505
+ // and egress. The old entry is put in cooldown; with a single proxy
2506
+ // (or none) `pick()` hands back the same egress and this is a no-op.
2507
+ const pooled = proxy.describe().pool;
2508
+ const canFailover = pooled && pooled.active && pooled.enabled > 1;
2509
+ if (canFailover && egressState.egressUrl) {
2510
+ proxy.markFailure(egressState.egressUrl, {
2511
+ reason: `gate ${gateStatus}`,
2512
+ cooldownMs: gateStatus === 429 ? pooled.cooldownMs * 4 : null,
2513
+ });
2514
+ const next = proxy.pick({ exclude: egressState.egressUrl });
2515
+ if (next.url && next.url !== egressState.egressUrl) {
2516
+ console.warn(
2517
+ `[opencode-free-bridge] Gate refused egress ${
2518
+ proxy.displayHost(egressState.egressUrl) || "direct"
2519
+ } (${gateStatus}); moving to ${proxy.displayHost(next.url) || "direct"} for the retry`
2520
+ );
2521
+ egressState.egress = next;
2522
+ egressState.egressUrl = next.url;
2523
+ }
2524
+ }
2525
+ console.warn(
2526
+ `[opencode-free-bridge] Upstream CLI gate rejected request (${gateStatus}, ${verdict.reason || "unknown"}); refreshing identity and retrying once with full tool set`
2527
+ );
2528
+ ({ upstreamBody, aliasToOriginal } = buildUpstreamPayload(
2529
+ body,
2530
+ targetModel,
2531
+ useResponses,
2532
+ "full"
2533
+ ));
2534
+ // Re-resolve the endpoint *after* the refresh: a catalog change is one
2535
+ // of the things the refresh can learn, and the retry must go to the
2536
+ // new URL — reusing the pre-refresh value would defeat the derivation.
2537
+ liveEndpoints = resolveEndpoints(targetModel);
2538
+ upstreamUrl = useResponses ? liveEndpoints.responsesUrl : liveEndpoints.chatUrl;
2539
+ retryHeaders = buildUpstreamHeaders(req, retrySessionId);
2540
+ upstreamRes = await upstreamFetch(upstreamUrl, {
2541
+ method: "POST",
2542
+ headers: retryHeaders,
2543
+ body: JSON.stringify(upstreamBody),
2544
+ signal: abortController.signal,
2545
+ }, egressUrl());
2546
+ } else {
2547
+ console.warn(
2548
+ `[opencode-free-bridge] Upstream CLI gate rejected request (${gateStatus}); skipping retry because a full-shape retry already failed <${Math.round(GATE_RETRY_COOLDOWN_MS / 1000)}s ago on the current identity (IP cooldown active)`
2549
+ );
2550
+ }
2551
+
2552
+ if (!upstreamRes.ok) {
2553
+ // The retry already used the freshest derived identity (endpoint,
2554
+ // session id, full tool set) — so a repeated FreeTierError is not a
2555
+ // shape problem this bridge can learn its way out of. Measured
2556
+ // 403 FreeTierError has more than one cause, and the message alone
2557
+ // cannot distinguish them:
2558
+ //
2559
+ // 1. transport fingerprint — measured: Node and curl are refused
2560
+ // 100% while Bun succeeds 100% from the same IP in the same
2561
+ // minute. The CLI ships as a Bun binary, so only Bun matches.
2562
+ // 2. per-IP allowance — also returned as 403. Measured: after a
2563
+ // few dozen probes the real CLI v2.0.32 gets the identical 403
2564
+ // from the same IP that answered 200 an hour earlier, and so
2565
+ // does the bridge. The transport had not changed.
2566
+ //
2567
+ // A 429 FreeUsageLimitError is unambiguous: transport passed and
2568
+ // the quota is spent. Observed on a fresh IP while 403 was being
2569
+ // returned elsewhere, which is how the two were told apart.
2570
+ //
2571
+ // Only the runtime can be checked locally, so report that and name
2572
+ // both possibilities instead of asserting the wrong one.
2573
+ // Read the retry's own body before logging. The first attempt's
2574
+ // message describes a shape this bridge has already replaced, so
2575
+ // reporting it here would describe a request nobody sent.
2576
+ let retryText = errText;
2577
+ let retryJson = errJson;
2578
+ if (!skipCooldownRetry) {
2579
+ retryText = "";
2580
+ try {
2581
+ retryText = await upstreamRes.text();
2582
+ } catch {}
2583
+ retryJson = null;
2584
+ try {
2585
+ retryJson = JSON.parse(retryText);
2586
+ } catch {}
2587
+ }
2588
+ const retryType = retryJson?.error?.type || retryJson?.type || errType || "upstream_error";
2589
+ const retryMsg =
2590
+ retryJson?.error?.message || retryJson?.message || retryText || rawMsg;
2591
+ // Report the *retry* status, not the first attempt's: they differ
2592
+ // whenever upstream answers the refreshed shape differently (a 426
2593
+ // floor handshake followed by a 429 capacity answer is common), and
2594
+ // logging the old status misattributes the final verdict.
2595
+ const retryStatus = upstreamRes.status;
2596
+ // The retry can reveal the block even when the first attempt still
2597
+ // carried the shape message (upstream mixes the two while a flag is
2598
+ // being applied). That is still terminal: answer it verbatim and do
2599
+ // not run the shape audit or append an update hint.
2600
+ if (identity.isIpAbuseRejection(retryStatus, retryMsg)) {
2601
+ if (!skipCooldownRetry) {
2602
+ lastFailedGateRetryTime = Date.now();
2603
+ lastFailedGateRetryEpoch = identity.getState().epoch || 0;
2604
+ }
2605
+ recordEgressFailure(egressState, { reason: "ip-abuse block" });
2606
+ return respondIpAbuse(
2607
+ res,
2608
+ retryStatus,
2609
+ retryType,
2610
+ retryMsg,
2611
+ upstreamLogIdOf(upstreamRes),
2612
+ targetModel
2613
+ );
2614
+ }
2615
+ const retryFreeTierMsg = /freetiererror|free tier|free models|only available in opencode/i.test(retryMsg);
2616
+ const onNode = typeof Bun === "undefined";
2617
+ // A 429 means transport and shape both passed and only capacity is
2618
+ // exhausted. Calling that a gate rejection would send the user to
2619
+ // re-check headers that upstream already accepted.
2620
+ const capacity = retryStatus === 429;
2621
+ if (capacity) {
2622
+ lastFailedGateRetryTime = 0;
2623
+ } else if (!skipCooldownRetry && isFreeTierCliGateError(retryStatus, retryType, retryMsg)) {
2624
+ lastFailedGateRetryTime = Date.now();
2625
+ lastFailedGateRetryEpoch = identity.getState().epoch || 0;
2626
+ }
2627
+ // The egress that produced *this* refusal is spent: record it so the
2628
+ // next request picks a different entry instead of relearning the
2629
+ // same throttle. A 429 is an unambiguous allowance answer, so it
2630
+ // earns a longer cooldown than a 403 whose cause the bridge cannot
2631
+ // fully separate from a shape problem.
2632
+ recordEgressFailure(egressState, {
2633
+ reason: capacity ? "allowance 429" : `gate ${retryStatus}`,
2634
+ cooldownMs: capacity ? proxy.describe().pool.cooldownMs * 4 : null,
2635
+ });
2636
+
2637
+ // The gate answers the same `403 FreeTierError` for a blocked egress
2638
+ // and for a request whose shape it refuses, so the status line and
2639
+ // message cannot tell the two apart. What can: whether the request
2640
+ // that was actually sent carried the anchors the gate checks. Audit
2641
+ // that here instead of asserting a cause. Measured anchors: a tool
2642
+ // named `read`, and an `opencode/*` User-Agent with a session header.
2643
+ const shape = auditGateShape(upstreamBody, retryHeaders || upstreamHeaders);
2644
+ const shapeOk = shape.hasReadTool && shape.userAgentOk && shape.sessionHeaders.length > 0;
2645
+ const missing = [];
2646
+ if (!shape.hasReadTool) missing.push("tool `read`");
2647
+ if (!shape.userAgentOk) missing.push("an `opencode/*` User-Agent");
2648
+ if (shape.sessionHeaders.length === 0) missing.push("a session header");
2649
+ // Correlation id for this rejection. Upstream returns it on every
2650
+ // response, accepted or refused; it is the only handle a support
2651
+ // request or a bug report can quote.
2652
+ const upstreamLogId = upstreamLogIdOf(upstreamRes);
2653
+ lastGateDiagnostic = {
2654
+ at: Date.now(),
2655
+ kind: "free-tier-gate",
2656
+ status: retryStatus,
2657
+ logId: upstreamLogId,
2658
+ shape,
2659
+ shapeOk,
2660
+ missing,
2661
+ model: targetModel,
2662
+ };
2663
+
2664
+ const verdictText = capacity
2665
+ ? "upstream accepted the transport and the request shape and answered with capacity pressure; the free-tier allowance or the model's capacity is exhausted. Retry later or switch model"
2666
+ : retryFreeTierMsg
2667
+ ? onNode
2668
+ ? "this process is on Node, whose TLS ClientHello differs from the CLI's (measured: Node 0/6, Bun 6/6 from the same IP). Run the bridge with Bun — `bun server.js` — before suspecting an allowance limit"
2669
+ : shapeOk
2670
+ ? "the request carried every anchor the gate checks, so this is the egress: the per-IP free-tier allowance is spent or upstream refuses this network. The real CLI gets the same 403 from the same IP when it is"
2671
+ : `this bridge produced a shape the gate refuses — missing ${missing.join(", ")}. That is a bridge bug, not an IP allowance: the same 403 is returned for both, which is why it is audited here`
2672
+ : `no further identity change to apply; ${UPDATE_HINT}`;
2673
+ console.warn(
2674
+ `[opencode-free-bridge] Upstream CLI gate rejected ${
2675
+ skipCooldownRetry
2676
+ ? "the request (no retry: cooldown active)"
2677
+ : "the retry with the refreshed identity"
2678
+ } (${retryStatus}${
2679
+ retryStatus !== gateStatus ? `, first attempt ${gateStatus}` : ""
2680
+ }): ${retryMsg} — ${verdictText}` +
2681
+ (upstreamLogId ? ` [x-opencode-log-id: ${upstreamLogId}]` : "")
2682
+ );
2683
+ const outStatus = resolveIpThrottleStatus(upstreamRes.status, retryFreeTierMsg && !capacity);
2684
+ res.writeHead(
2685
+ outStatus,
2686
+ buildIpThrottleHeaders(retryFreeTierMsg && !capacity, {
2687
+ shapeOk,
2688
+ logId: upstreamLogId,
2689
+ })
2690
+ );
2691
+ return res.end(
2692
+ JSON.stringify({
2693
+ error: {
2694
+ // Point at an update only when the shape is plausibly stale.
2695
+ // A budget rejection already carries the newest identity, so
2696
+ // an "update the bridge" hint would send the user the wrong way.
2697
+ message: retryFreeTierMsg ? retryMsg : `${retryMsg} (${UPDATE_HINT})`,
2698
+ type: retryType,
2699
+ code: outStatus,
2700
+ },
2701
+ })
2702
+ );
2703
+ }
2704
+ }
2705
+
2706
+ lastFailedGateRetryTime = 0;
2707
+ lastIpAbuseAt = 0;
2708
+ // This egress carried a request upstream accepted, so its failure
2709
+ // history is stale: clear it so the next pick treats it as healthy.
2710
+ proxy.markSuccess(egressState.egressUrl);
2711
+ const completionId = `chatcmpl-${crypto.randomBytes(12).toString("hex")}`;
2712
+ const created = Math.floor(Date.now() / 1000);
2713
+ reader = upstreamRes.body.getReader();
2714
+ const decoder = new TextDecoder();
2715
+ let buffer = "";
2716
+
2717
+ // ==========================================
2718
+ // PATH A: Client requested streaming (SSE)
2719
+ // ==========================================
2720
+ if (clientStream) {
2721
+ let sseHeadersSent = false;
2722
+ const writeSse = (payload) => {
2723
+ if (!sseHeadersSent) {
2724
+ sseHeadersSent = true;
2725
+ res.writeHead(200, {
2726
+ "Content-Type": "text/event-stream; charset=utf-8",
2727
+ "Cache-Control": "no-cache",
2728
+ "Connection": "keep-alive",
2729
+ });
2730
+ }
2731
+ res.write(payload);
2732
+ };
2733
+ const emitStreamError = (errMessage, errType) => {
2734
+ console.warn(`[opencode-free-bridge] Upstream failed inside the stream: ${errMessage}`);
2735
+ reader.cancel().catch(() => {});
2736
+ if (!sseHeadersSent) {
2737
+ sseHeadersSent = true;
2738
+ res.writeHead(502, { "Content-Type": "application/json" });
2739
+ res.end(
2740
+ JSON.stringify({
2741
+ error: {
2742
+ message: errMessage,
2743
+ type: errType || "upstream_error",
2744
+ code: 502,
2745
+ },
2746
+ })
2747
+ );
2748
+ return;
2749
+ }
2750
+ writeSse(
2751
+ `data: ${JSON.stringify({
2752
+ error: {
2753
+ message: errMessage,
2754
+ type: errType || "upstream_error",
2755
+ code: 502,
2756
+ },
2757
+ })}\n\n`
2758
+ );
2759
+ writeSse("data: [DONE]\n\n");
2760
+ };
2761
+
2762
+ if (useResponses) {
2763
+ const toolIndexMap = new Map();
2764
+ let nextToolIndex = 0;
2765
+ let hasToolCalls = false;
2766
+ let upstreamFinishReason = null;
2767
+ let streamUsage = null;
2768
+ let streamAbortedByError = false;
2769
+
2770
+ const handleResponsesStreamLine = (line) => {
2771
+ if (streamAbortedByError) return;
2772
+ if (!line.startsWith("data: ")) return;
2773
+ const rawJson = line.slice(6).trim();
2774
+ if (!rawJson || rawJson === "[DONE]") return;
2775
+ try {
2776
+ const ev = JSON.parse(rawJson);
2777
+ const inStreamError = extractStreamError(ev);
2778
+ if (inStreamError) {
2779
+ streamAbortedByError = true;
2780
+ emitStreamError(inStreamError, ev?.error?.type);
2781
+ return;
2782
+ }
2783
+ if (ev.type === "response.output_text.delta" && ev.delta) {
2784
+ writeSse(
2785
+ `data: ${JSON.stringify({
2786
+ id: completionId,
2787
+ object: "chat.completion.chunk",
2788
+ created,
2789
+ model: targetModel,
2790
+ choices: [{ index: 0, delta: { content: ev.delta }, finish_reason: null }],
2791
+ })}\n\n`
2792
+ );
2793
+ } else if (
2794
+ (ev.type === "response.reasoning_summary_text.delta" ||
2795
+ ev.type === "response.reasoning_text.delta" ||
2796
+ ev.type === "response.reasoning.delta") &&
2797
+ ev.delta
2798
+ ) {
2799
+ writeSse(
2800
+ `data: ${JSON.stringify({
2801
+ id: completionId,
2802
+ object: "chat.completion.chunk",
2803
+ created,
2804
+ model: targetModel,
2805
+ choices: [{ index: 0, delta: { reasoning_content: ev.delta }, finish_reason: null }],
2806
+ })}\n\n`
2807
+ );
2808
+ } else if (ev.type === "response.output_item.added" && ev.item?.type === "function_call") {
2809
+ hasToolCalls = true;
2810
+ const key =
2811
+ ev.output_index !== undefined
2812
+ ? String(ev.output_index)
2813
+ : ev.item.call_id || ev.item.id || String(nextToolIndex);
2814
+ let toolIdx = toolIndexMap.get(key);
2815
+ if (toolIdx === undefined) {
2816
+ toolIdx = nextToolIndex++;
2817
+ toolIndexMap.set(key, toolIdx);
2818
+ if (ev.item.call_id) toolIndexMap.set(ev.item.call_id, toolIdx);
2819
+ if (ev.item.id) toolIndexMap.set(ev.item.id, toolIdx);
2820
+ if (ev.output_index !== undefined) toolIndexMap.set(String(ev.output_index), toolIdx);
2821
+ }
2822
+ const restoredName = restoreClientToolName(ev.item.name, aliasToOriginal);
2823
+ writeSse(
2824
+ `data: ${JSON.stringify({
2825
+ id: completionId,
2826
+ object: "chat.completion.chunk",
2827
+ created,
2828
+ model: targetModel,
2829
+ choices: [
2830
+ {
2831
+ index: 0,
2832
+ delta: {
2833
+ tool_calls: [
2834
+ {
2835
+ index: toolIdx,
2836
+ id: ev.item.call_id || ev.item.id || `call_${toolIdx}`,
2837
+ type: "function",
2838
+ function: { name: restoredName, arguments: "" },
2839
+ },
2840
+ ],
2841
+ },
2842
+ finish_reason: null,
2843
+ },
2844
+ ],
2845
+ })}\n\n`
2846
+ );
2847
+ } else if (ev.type === "response.function_call_arguments.delta" && ev.delta) {
2848
+ const key = ev.output_index !== undefined ? String(ev.output_index) : ev.item_id || ev.call_id;
2849
+ const toolIdx = key !== undefined && toolIndexMap.has(key) ? toolIndexMap.get(key) : 0;
2850
+ writeSse(
2851
+ `data: ${JSON.stringify({
2852
+ id: completionId,
2853
+ object: "chat.completion.chunk",
2854
+ created,
2855
+ model: targetModel,
2856
+ choices: [
2857
+ {
2858
+ index: 0,
2859
+ delta: {
2860
+ tool_calls: [{ index: toolIdx, function: { arguments: ev.delta } }],
2861
+ },
2862
+ finish_reason: null,
2863
+ },
2864
+ ],
2865
+ })}\n\n`
2866
+ );
2867
+ } else if (ev.type === "response.completed" || ev.type === "response.incomplete") {
2868
+ upstreamFinishReason = extractResponsesFinishReason(ev, hasToolCalls);
2869
+ if (ev.response?.usage) {
2870
+ streamUsage = {
2871
+ prompt_tokens: ev.response.usage.input_tokens || 0,
2872
+ completion_tokens: ev.response.usage.output_tokens || 0,
2873
+ total_tokens: ev.response.usage.total_tokens || 0,
2874
+ };
2875
+ }
2876
+ }
2877
+ } catch {}
2878
+ };
2879
+
2880
+ await consumeSseLines(reader, decoder, handleResponsesStreamLine);
2881
+ if (streamAbortedByError) {
2882
+ if (!res.writableEnded) res.end();
2883
+ return;
2884
+ }
2885
+
2886
+ const finalFinishReason = upstreamFinishReason || (hasToolCalls ? "tool_calls" : "stop");
2887
+ writeSse(
2888
+ `data: ${JSON.stringify({
2889
+ id: completionId,
2890
+ object: "chat.completion.chunk",
2891
+ created,
2892
+ model: targetModel,
2893
+ choices: [{ index: 0, delta: {}, finish_reason: finalFinishReason }],
2894
+ })}\n\n`
2895
+ );
2896
+ if (body.stream_options?.include_usage && streamUsage) {
2897
+ writeSse(
2898
+ `data: ${JSON.stringify({
2899
+ id: completionId,
2900
+ object: "chat.completion.chunk",
2901
+ created,
2902
+ model: targetModel,
2903
+ choices: [],
2904
+ usage: streamUsage,
2905
+ })}\n\n`
2906
+ );
2907
+ }
2908
+ writeSse("data: [DONE]\n\n");
2909
+ return res.end();
2910
+ } else {
2911
+ // Chat Completions API SSE stream
2912
+ const hasAliases = aliasToOriginal.size > 0;
2913
+ let streamAbortedByError = false;
2914
+ const handleChatStreamLine = (line) => {
2915
+ if (streamAbortedByError) return;
2916
+ if (!line.startsWith("data: ")) return;
2917
+ const raw = line.slice(6).trim();
2918
+ if (!raw) return;
2919
+ if (raw === "[DONE]") {
2920
+ writeSse("data: [DONE]\n\n");
2921
+ return;
2922
+ }
2923
+ // A failure inside the stream is not a completion. If no chunk has
2924
+ // been sent yet, return HTTP 502 JSON so any router or harness can
2925
+ // fail over; if chunks were already sent, emit an SSE error event.
2926
+ if (raw.includes('"error"')) try {
2927
+ const maybe = JSON.parse(raw);
2928
+ const streamErr = extractStreamError(maybe);
2929
+ if (streamErr) {
2930
+ streamAbortedByError = true;
2931
+ emitStreamError(streamErr, maybe?.error?.type);
2932
+ return;
2933
+ }
2934
+ } catch {}
2935
+ // Zero-parse fast path: when no client tools are aliased and the chunk
2936
+ // carries no `"reasoning":` field to normalize, forward raw SSE bytes directly.
2937
+ if (!hasAliases && !raw.includes('"reasoning"')) {
2938
+ writeSse(`data: ${raw}\n\n`);
2939
+ return;
2940
+ }
2941
+ try {
2942
+ const chunk = JSON.parse(raw);
2943
+ const delta = chunk.choices?.[0]?.delta;
2944
+ if (delta && delta.reasoning && !delta.reasoning_content) {
2945
+ delta.reasoning_content = delta.reasoning;
2946
+ }
2947
+ if (hasAliases && Array.isArray(delta?.tool_calls)) {
2948
+ for (const tc of delta.tool_calls) {
2949
+ if (tc.function?.name) {
2950
+ tc.function.name = restoreClientToolName(tc.function.name, aliasToOriginal);
2951
+ }
2952
+ }
2953
+ }
2954
+ writeSse(`data: ${JSON.stringify(chunk)}\n\n`);
2955
+ } catch {
2956
+ writeSse(`data: ${raw}\n\n`);
2957
+ }
2958
+ };
2959
+
2960
+ await consumeSseLines(reader, decoder, handleChatStreamLine);
2961
+ if (!res.writableEnded) {
2962
+ if (!sseHeadersSent) writeSse("data: [DONE]\n\n");
2963
+ res.end();
2964
+ }
2965
+ return;
2966
+ }
2967
+ }
2968
+
2969
+ // ==========================================
2970
+ // PATH B: Client requested non-streaming (JSON)
2971
+ // ==========================================
2972
+ let assistantText = "";
2973
+ let reasoningText = "";
2974
+ const toolCalls = [];
2975
+ const nonStreamToolMap = new Map();
2976
+ let usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
2977
+ let upstreamFinishReason = null;
2978
+ let streamError = null;
2979
+
2980
+ const handleNonStreamLine = (line) => {
2981
+ if (!line.startsWith("data: ")) return;
2982
+ const rawJson = line.slice(6).trim();
2983
+ if (!rawJson || rawJson === "[DONE]") return;
2984
+
2985
+ try {
2986
+ const ev = JSON.parse(rawJson);
2987
+
2988
+ // A 200 followed by an in-stream error is a failure, not an empty
2989
+ // completion. Without this the client would receive content: null
2990
+ // with finish_reason "stop".
2991
+ const inStreamError = extractStreamError(ev);
2992
+ if (inStreamError) {
2993
+ streamError = inStreamError;
2994
+ return;
2995
+ }
2996
+
2997
+ if (useResponses) {
2998
+ if (ev.type === "response.output_text.delta" && ev.delta) {
2999
+ assistantText += ev.delta;
3000
+ } else if (
3001
+ (ev.type === "response.reasoning_summary_text.delta" ||
3002
+ ev.type === "response.reasoning_text.delta" ||
3003
+ ev.type === "response.reasoning.delta") &&
3004
+ ev.delta
3005
+ ) {
3006
+ reasoningText += ev.delta;
3007
+ } else if (ev.type === "response.output_item.added" && ev.item?.type === "function_call") {
3008
+ const key =
3009
+ ev.output_index !== undefined
3010
+ ? String(ev.output_index)
3011
+ : ev.item.call_id || ev.item.id || String(toolCalls.length);
3012
+ const tc = {
3013
+ id: ev.item.call_id || ev.item.id || `call_${toolCalls.length}`,
3014
+ type: "function",
3015
+ function: {
3016
+ name: restoreClientToolName(ev.item.name, aliasToOriginal),
3017
+ arguments: "",
3018
+ },
3019
+ };
3020
+ toolCalls.push(tc);
3021
+ nonStreamToolMap.set(key, tc);
3022
+ if (ev.item.call_id) nonStreamToolMap.set(ev.item.call_id, tc);
3023
+ if (ev.item.id) nonStreamToolMap.set(ev.item.id, tc);
3024
+ if (ev.output_index !== undefined) nonStreamToolMap.set(String(ev.output_index), tc);
3025
+ } else if (ev.type === "response.function_call_arguments.delta" && ev.delta) {
3026
+ const key =
3027
+ ev.output_index !== undefined ? String(ev.output_index) : ev.item_id || ev.call_id;
3028
+ const targetTc =
3029
+ key !== undefined && nonStreamToolMap.has(key)
3030
+ ? nonStreamToolMap.get(key)
3031
+ : toolCalls[toolCalls.length - 1];
3032
+ if (targetTc) targetTc.function.arguments += ev.delta;
3033
+ } else if (ev.type === "response.completed" || ev.type === "response.incomplete") {
3034
+ upstreamFinishReason = extractResponsesFinishReason(ev, toolCalls.length > 0);
3035
+ if (ev.response?.usage) {
3036
+ usage = {
3037
+ prompt_tokens: ev.response.usage.input_tokens || 0,
3038
+ completion_tokens: ev.response.usage.output_tokens || 0,
3039
+ total_tokens: ev.response.usage.total_tokens || 0,
3040
+ };
3041
+ }
3042
+ }
3043
+ } else {
3044
+ // Chat completions format parsing
3045
+ const choice = ev.choices?.[0];
3046
+ if (choice?.finish_reason) {
3047
+ upstreamFinishReason = choice.finish_reason;
3048
+ }
3049
+ const delta = choice?.delta;
3050
+ if (delta?.content) assistantText += delta.content;
3051
+ if (delta?.reasoning) reasoningText += delta.reasoning;
3052
+ else if (delta?.reasoning_content) reasoningText += delta.reasoning_content;
3053
+ if (Array.isArray(delta?.tool_calls)) {
3054
+ for (const tc of delta.tool_calls) {
3055
+ const idx = tc.index ?? toolCalls.length;
3056
+ if (!toolCalls[idx]) {
3057
+ toolCalls[idx] = {
3058
+ id: tc.id || `call_${idx}`,
3059
+ type: "function",
3060
+ function: {
3061
+ name: restoreClientToolName(tc.function?.name || "", aliasToOriginal),
3062
+ arguments: "",
3063
+ },
3064
+ };
3065
+ } else {
3066
+ if (tc.id && toolCalls[idx].id.startsWith("call_")) {
3067
+ toolCalls[idx].id = tc.id;
3068
+ }
3069
+ if (tc.function?.name && !toolCalls[idx].function.name) {
3070
+ toolCalls[idx].function.name = restoreClientToolName(
3071
+ tc.function.name,
3072
+ aliasToOriginal
3073
+ );
3074
+ }
3075
+ }
3076
+ if (tc.function?.arguments) {
3077
+ toolCalls[idx].function.arguments += tc.function.arguments;
3078
+ }
3079
+ }
3080
+ }
3081
+ if (ev.usage && typeof ev.usage === "object") {
3082
+ usage = {
3083
+ prompt_tokens: ev.usage.prompt_tokens || 0,
3084
+ completion_tokens: ev.usage.completion_tokens || 0,
3085
+ total_tokens: ev.usage.total_tokens || 0,
3086
+ };
3087
+ }
3088
+ }
3089
+ } catch {}
3090
+ };
3091
+
3092
+ await consumeSseLines(reader, decoder, handleNonStreamLine);
3093
+
3094
+ if (streamError) {
3095
+ console.warn(`[opencode-free-bridge] Upstream failed inside the stream: ${streamError}`);
3096
+ res.writeHead(502, { "Content-Type": "application/json" });
3097
+ return res.end(
3098
+ JSON.stringify({
3099
+ error: { message: streamError, type: "upstream_error", code: 502 },
3100
+ })
3101
+ );
3102
+ }
3103
+
3104
+ const validToolCalls = toolCalls.filter(Boolean);
3105
+ const messageObj = { role: "assistant", content: assistantText || null };
3106
+ if (reasoningText) messageObj.reasoning_content = reasoningText;
3107
+ if (validToolCalls.length > 0) messageObj.tool_calls = validToolCalls;
3108
+
3109
+ let finishReason = upstreamFinishReason || "stop";
3110
+ if (
3111
+ validToolCalls.length > 0 &&
3112
+ finishReason !== "length" &&
3113
+ finishReason !== "content_filter"
3114
+ ) {
3115
+ finishReason = "tool_calls";
3116
+ }
3117
+
3118
+ res.writeHead(200, { "Content-Type": "application/json" });
3119
+ return res.end(
3120
+ JSON.stringify({
3121
+ id: completionId,
3122
+ object: "chat.completion",
3123
+ created,
3124
+ model: targetModel,
3125
+ choices: [
3126
+ {
3127
+ index: 0,
3128
+ message: messageObj,
3129
+ finish_reason: finishReason,
3130
+ },
3131
+ ],
3132
+ usage,
3133
+ })
3134
+ );
3135
+ } catch (err) {
3136
+ if (reader) {
3137
+ reader.cancel().catch(() => {});
3138
+ }
3139
+ if (clientDisconnected || res.writableEnded) {
3140
+ return;
3141
+ }
3142
+ if (timedOut) {
3143
+ if (!res.headersSent) {
3144
+ res.writeHead(504, { "Content-Type": "application/json" });
3145
+ return res.end(
3146
+ JSON.stringify({
3147
+ error: {
3148
+ message: `Upstream request timed out after ${UPSTREAM_TIMEOUT_MS}ms`,
3149
+ type: "timeout_error",
3150
+ code: 504,
3151
+ },
3152
+ })
3153
+ );
3154
+ }
3155
+ return res.end();
3156
+ }
3157
+ console.error("[opencode-free-bridge] exception:", err);
3158
+ if (!res.headersSent) {
3159
+ res.writeHead(500, { "Content-Type": "application/json" });
3160
+ return res.end(
3161
+ JSON.stringify({
3162
+ error: { message: err.message || "Internal server error", type: "server_error" },
3163
+ })
3164
+ );
3165
+ }
3166
+ return res.end();
3167
+ } finally {
3168
+ clearTimeout(timeoutId);
3169
+ res.off("close", onClose);
3170
+ }
3171
+ })();
3172
+ return;
3173
+ }
3174
+
3175
+ res.writeHead(404, { "Content-Type": "application/json" });
3176
+ res.end(JSON.stringify({ error: { message: "Not found", type: "invalid_request_error" } }));
3177
+ }
3178
+
3179
+ async function consumeSseLines(reader, decoder, onLine) {
3180
+ let buffer = "";
3181
+ while (true) {
3182
+ const { done, value } = await reader.read();
3183
+ if (done) {
3184
+ buffer += decoder.decode();
3185
+ if (buffer.trim()) {
3186
+ let start = 0;
3187
+ for (let i = 0; i < buffer.length; i++) {
3188
+ if (buffer.charCodeAt(i) === 10) {
3189
+ const line =
3190
+ i > start && buffer.charCodeAt(i - 1) === 13
3191
+ ? buffer.slice(start, i - 1)
3192
+ : buffer.slice(start, i);
3193
+ onLine(line);
3194
+ start = i + 1;
3195
+ }
3196
+ }
3197
+ if (start < buffer.length) {
3198
+ const tail = buffer.endsWith("\r") ? buffer.slice(start, -1) : buffer.slice(start);
3199
+ onLine(tail);
3200
+ }
3201
+ }
3202
+ break;
3203
+ }
3204
+ buffer += decoder.decode(value, { stream: true });
3205
+ let start = 0;
3206
+ for (let i = 0; i < buffer.length; i++) {
3207
+ if (buffer.charCodeAt(i) === 10) {
3208
+ const line =
3209
+ i > start && buffer.charCodeAt(i - 1) === 13
3210
+ ? buffer.slice(start, i - 1)
3211
+ : buffer.slice(start, i);
3212
+ onLine(line);
3213
+ start = i + 1;
3214
+ }
3215
+ }
3216
+ buffer = start === 0 ? buffer : buffer.slice(start);
3217
+ }
3218
+ }
3219
+
3220
+ const server = http.createServer(handleRequest);
3221
+
3222
+ // Only auto-start when executed directly (`node server.js`), so unit tests can
3223
+ // require("./server") without binding the default port or starting timers.
3224
+ // The transport fingerprint is a property of the HTTP runtime, not of this code.
3225
+ // Measured from one IP in one minute with identical headers and body: the real
3226
+ // CLI and this bridge on Bun both answer 200, while this bridge on Node and curl
3227
+ // answer 403. The CLI ships as a Bun binary, and the gate inspects its
3228
+ // ClientHello. Node therefore cannot serve live traffic, so it is refused at
3229
+ // startup rather than left running and failing every request.
3230
+ const ON_BUN = typeof Bun !== "undefined";
3231
+ const ALLOW_NODE = process.env.ALLOW_NODE === "1";
3232
+
3233
+ function refuseNodeRuntime() {
3234
+ const lines = [
3235
+ "",
3236
+ " opencode-free-bridge requires Bun.",
3237
+ "",
3238
+ " This process is Node, whose TLS transport the free-tier gate refuses:",
3239
+ " measured 0/6 requests answered on Node vs 6/6 on Bun, from the same IP,",
3240
+ " in the same minute, with byte-identical headers and body.",
3241
+ "",
3242
+ " The CLI ships as a Bun binary, so only Bun reproduces its transport.",
3243
+ "",
3244
+ " Install Bun: curl -fsSL https://bun.sh/install | bash",
3245
+ " Then run: bun server.js",
3246
+ "",
3247
+ " For offline work (tests, drills, inspecting /v1/models) set ALLOW_NODE=1.",
3248
+ " Live chat requests will still be refused with 403 by upstream.",
3249
+ "",
3250
+ ];
3251
+ console.error(lines.join("\n"));
3252
+ }
3253
+
3254
+ if (require.main === module) {
3255
+ if (!ON_BUN && !ALLOW_NODE) {
3256
+ refuseNodeRuntime();
3257
+ process.exit(1);
3258
+ }
3259
+ server.listen(PORT, HOST, () => {
3260
+ console.log(`[opencode-free-bridge] v${VERSION} listening on http://${HOST}:${PORT}`);
3261
+ if (!ON_BUN) {
3262
+ console.log(
3263
+ "[opencode-free-bridge] WARNING: ALLOW_NODE=1 — running on Node. Real chat requests will " +
3264
+ "be refused by the free-tier gate with 403. This mode is for offline work against a " +
3265
+ "mocked upstream, not for live traffic."
3266
+ );
3267
+ }
3268
+ });
3269
+ // Repair a half-installed tree (an older self-updater that did not know about
3270
+ // proxy.js/panel.js) after the socket is open, so a slow or absent network
3271
+ // never delays startup. The panel reads the current binding per request, so
3272
+ // it starts working as soon as the repair lands.
3273
+ repairMissingModules().catch(() => {});
3274
+ // Discovery runs in background: /v1/models is live as soon as upstream
3275
+ // answers. Boot never blocks on network.
3276
+ discoverModels().catch(() => {});
3277
+ // Model list rotates on upstream's schedule, so it keeps a timer. The
3278
+ // IDENTITY does not: it is refreshed lazily from the request path (see
3279
+ // identity.ensureFresh), so an idle bridge makes no network calls at all.
3280
+ setInterval(discoverModels, DISCOVERY_TTL_MS).unref();
3281
+ }
3282
+
3283
+ module.exports = {
3284
+ VERSION,
3285
+ // Kept for callers and tests written against the old frozen constants. These
3286
+ // are live views of the resolved profile, not snapshots.
3287
+ get FINGERPRINT_TOOL_NAMES() {
3288
+ return getFingerprintNames();
3289
+ },
3290
+ get FINGERPRINT_TOOL_DEFINITIONS() {
3291
+ return getFingerprintDefinitions();
3292
+ },
3293
+ get CANONICAL_TOOLS_CHAT() {
3294
+ return getCanonicalTools(false);
3295
+ },
3296
+ get CANONICAL_TOOLS_RESPONSES() {
3297
+ return getCanonicalTools(true);
3298
+ },
3299
+ MAX_SESSIONS,
3300
+ SESSION_TTL_MS,
3301
+ sessionCache,
3302
+ createId,
3303
+ isValidV2UserAgent,
3304
+ normalizeModel,
3305
+ isResponsesModel,
3306
+ markProtocolSupport,
3307
+ __resetProtocolLanes,
3308
+ extractContent,
3309
+ transformToResponsesInput,
3310
+ transformChatMessages,
3311
+ prepareToolsWithFingerprint,
3312
+ prepareResponsesTools,
3313
+ prepareChatTools,
3314
+ restoreClientToolName,
3315
+ buildUpstreamPayload,
3316
+ isNewerVersion,
3317
+ checkAndApplyUpdate,
3318
+ resolveEndpoints,
3319
+ buildUpstreamHeaders,
3320
+ getProjectId,
3321
+ extractResponsesFinishReason,
3322
+ extractStreamError,
3323
+ getPersistentSessionId,
3324
+ discoverModels,
3325
+ handleRequest,
3326
+ server,
3327
+ shortText,
3328
+ messageSignatureSample,
3329
+ transformResponsesContent,
3330
+ extractSystemInstructions,
3331
+ isLocalOrigin,
3332
+ isFreeTierCliGateError,
3333
+ consumeSseLines,
3334
+ readRequestBody,
3335
+ refreshCliVersion,
3336
+ identity,
3337
+ __getUserAgent: () => currentUserAgent,
3338
+ __syncUserAgent: syncUserAgent,
3339
+ __getGateDiagnostic: () => lastGateDiagnostic,
3340
+ __getLastUpstreamError: () => lastUpstreamError,
3341
+ __auditGateShape: auditGateShape,
3342
+ gateSafeUserAgent,
3343
+ DISCOVERY_TTL_MS,
3344
+ CLI_VERSION_TTL_MS,
3345
+ __setLastDiscoveryTime: (v) => { lastDiscoveryTime = v; },
3346
+ __setActiveModels: (m) => {
3347
+ activeModels = Array.isArray(m) ? m : [];
3348
+ rebuildDiscoveryPayloads();
3349
+ },
3350
+ __getDiscoveryState: () => ({ inFlight: !!discoveryInFlight, lastDiscoveryTime, activeModels }),
3351
+ __setLastCliVersionCheckTime: (v) => { lastCliVersionCheckTime = v; },
3352
+ __getCliVersionState: () => ({ lastCliVersionCheckTime }),
3353
+ __setLastUpdateCheck: (v) => { lastUpdateCheck = v; },
3354
+ __localRequires: localRequires,
3355
+ __repairMissingModules: repairMissingModules,
3356
+ __adminRequestError: adminRequestError,
3357
+ };