@wenathlan/devthink 2.0.13 → 2.0.15

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 (444) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/accounts.ts +2087 -0
  3. package/agent.ts +2331 -0
  4. package/antigravity.ts +2139 -0
  5. package/apifreeze.ts +4146 -0
  6. package/auth.ts +642 -0
  7. package/background.ts +16837 -0
  8. package/bridge.ts +2080 -0
  9. package/build.ts +54 -0
  10. package/bun.ts +26 -0
  11. package/capture.ts +1614 -0
  12. package/checksecrets.ts +72 -0
  13. package/cjs.ts +9 -0
  14. package/cli.ts +3500 -0
  15. package/commands.ts +1101 -0
  16. package/companion.ts +450 -0
  17. package/compatibility.ts +37 -0
  18. package/config.ts +3137 -0
  19. package/constants.ts +943 -0
  20. package/core.ts +1473 -0
  21. package/crossbrowser.ts +1359 -0
  22. package/dashdone.ts +605 -0
  23. package/data.ts +943 -0
  24. package/database.ts +133 -0
  25. package/debug.ts +850 -0
  26. package/deno.ts +27 -0
  27. package/devthink.ts +707 -0
  28. package/dist/accounts.d.ts +409 -0
  29. package/dist/agent.d.ts +837 -0
  30. package/dist/antigravity.d.ts +380 -0
  31. package/dist/apifreeze.d.ts +113 -0
  32. package/dist/auth.d.ts +224 -0
  33. package/{bridge.d.ts → dist/bridge.d.ts} +1 -1
  34. package/dist/capture.d.ts +595 -0
  35. package/dist/cjs.d.ts +6 -0
  36. package/{cli.js → dist/cli.js} +2 -2
  37. package/dist/commands.d.ts +310 -0
  38. package/dist/compatibility.d.ts +17 -0
  39. package/dist/config.d.ts +1853 -0
  40. package/dist/constants.d.ts +637 -0
  41. package/dist/core.d.ts +361 -0
  42. package/dist/data.d.ts +330 -0
  43. package/dist/database.d.ts +24 -0
  44. package/dist/debug.d.ts +270 -0
  45. package/dist/engine.d.ts +74 -0
  46. package/dist/environments.d.ts +244 -0
  47. package/dist/evidence.d.ts +277 -0
  48. package/dist/export.d.ts +311 -0
  49. package/dist/fingerprint.d.ts +594 -0
  50. package/dist/flow.d.ts +300 -0
  51. package/dist/gates.d.ts +103 -0
  52. package/dist/identity.d.ts +51 -0
  53. package/dist/ids.d.ts +7 -0
  54. package/dist/llm.d.ts +652 -0
  55. package/dist/models.d.ts +1807 -0
  56. package/dist/net.d.ts +325 -0
  57. package/dist/neutral.d.ts +41 -0
  58. package/dist/oauth.d.ts +703 -0
  59. package/dist/page.d.ts +1166 -0
  60. package/dist/perf.d.ts +481 -0
  61. package/dist/plan.d.ts +134 -0
  62. package/dist/project.d.ts +395 -0
  63. package/dist/providers.d.ts +37 -0
  64. package/dist/quota.d.ts +453 -0
  65. package/dist/recovery.d.ts +621 -0
  66. package/dist/request.d.ts +1492 -0
  67. package/dist/run.d.ts +779 -0
  68. package/dist/runtime.d.ts +212 -0
  69. package/dist/search.d.ts +375 -0
  70. package/dist/security.d.ts +534 -0
  71. package/dist/serve.d.ts +136 -0
  72. package/dist/session.d.ts +296 -0
  73. package/dist/storage.d.ts +53 -0
  74. package/dist/streaming.d.ts +1119 -0
  75. package/dist/swarm.d.ts +328 -0
  76. package/dist/tools.d.ts +170 -0
  77. package/dist/types.d.ts +8357 -0
  78. package/dist/utils.d.ts +82 -0
  79. package/dist/version.d.ts +7 -0
  80. package/dist/versionregistry.d.ts +406 -0
  81. package/dist/views.d.ts +579 -0
  82. package/dist/workbenchsession.d.ts +69 -0
  83. package/dist/workflow.d.ts +649 -0
  84. package/docs/readme.md +92 -0
  85. package/docs/schemas/antigravity.json +192 -0
  86. package/engine.ts +1524 -0
  87. package/environments.ts +1005 -0
  88. package/evidence.ts +643 -0
  89. package/export.ts +889 -0
  90. package/fingerprint.ts +1458 -0
  91. package/flow.ts +1190 -0
  92. package/gates.ts +307 -0
  93. package/gateway.ts +34 -0
  94. package/hardening.ts +191 -0
  95. package/headless.ts +369 -0
  96. package/http.ts +2110 -0
  97. package/identity.ts +246 -0
  98. package/ids.ts +26 -0
  99. package/index.ts +369 -0
  100. package/llm.ts +2682 -0
  101. package/mcp.ts +1835 -0
  102. package/{memory.js → memory.ts} +3834 -2310
  103. package/models.ts +3805 -0
  104. package/modes.ts +43 -0
  105. package/net.ts +1214 -0
  106. package/neutral.ts +362 -0
  107. package/node.ts +26 -0
  108. package/oauth.ts +2216 -0
  109. package/offscreen.ts +170 -0
  110. package/pack.ts +956 -0
  111. package/package.json +175 -82
  112. package/page.ts +7824 -0
  113. package/pagebridge.ts +1116 -0
  114. package/perf.ts +957 -0
  115. package/plan.ts +672 -0
  116. package/plugin.ts +76 -0
  117. package/{policy.js → policy.ts} +5073 -4679
  118. package/prisma.config.ts +27 -0
  119. package/progress.ts +1308 -0
  120. package/project.ts +1291 -0
  121. package/protocol.ts +4187 -0
  122. package/providers.ts +246 -0
  123. package/quota.ts +1887 -0
  124. package/recovery.ts +2433 -0
  125. package/request.ts +4482 -0
  126. package/run.ts +2135 -0
  127. package/runtime.ts +526 -0
  128. package/search.ts +1760 -0
  129. package/security.ts +1673 -0
  130. package/serve.ts +419 -0
  131. package/server.ts +5538 -0
  132. package/session.ts +1162 -0
  133. package/storage.ts +182 -0
  134. package/streaming.ts +4101 -0
  135. package/swarm.ts +884 -0
  136. package/sync.ts +56 -0
  137. package/tests/agentcert.test.ts +128 -0
  138. package/tests/agentfleet.test.ts +214 -0
  139. package/tests/agentreview.test.ts +241 -0
  140. package/tests/agentstream.test.ts +335 -0
  141. package/tests/agentwork.test.ts +356 -0
  142. package/tests/apifreeze.test.ts +290 -0
  143. package/tests/apimap.test.ts +112 -0
  144. package/tests/approvalgate.test.ts +175 -0
  145. package/tests/artifactmanifest.test.ts +204 -0
  146. package/tests/attentionfeed.test.ts +163 -0
  147. package/tests/backgroundruns.test.ts +100 -0
  148. package/tests/batchscheduling.test.ts +131 -0
  149. package/tests/blackboard.test.ts +238 -0
  150. package/tests/bridge.test.ts +1088 -0
  151. package/tests/browsercoverage.test.ts +134 -0
  152. package/tests/browserpolyfills.test.ts +124 -0
  153. package/tests/build.mjs +1260 -0
  154. package/tests/clientauth.test.ts +223 -0
  155. package/tests/clitools.test.ts +578 -0
  156. package/tests/confirmgates.test.ts +190 -0
  157. package/tests/containerpack.test.ts +143 -0
  158. package/tests/controlflow.test.ts +2120 -0
  159. package/tests/coordination.test.ts +345 -0
  160. package/tests/copyscreen.test.ts +139 -0
  161. package/tests/core.test.ts +417 -0
  162. package/tests/costcert.test.ts +95 -0
  163. package/tests/crossbrowser.test.ts +115 -0
  164. package/tests/cspaudit.test.ts +79 -0
  165. package/tests/dashdone.test.ts +615 -0
  166. package/tests/datagrid.test.ts +137 -0
  167. package/tests/doccheck.test.ts +107 -0
  168. package/tests/dombatch.test.ts +109 -0
  169. package/tests/emulation.test.ts +282 -0
  170. package/tests/environments.test.ts +259 -0
  171. package/tests/evidenceviews.test.ts +88 -0
  172. package/tests/exporttools.test.ts +185 -0
  173. package/tests/firefoxprep.test.ts +134 -0
  174. package/tests/flowlibrary.test.ts +242 -0
  175. package/tests/flowrun.test.ts +303 -0
  176. package/tests/forensics.test.ts +439 -0
  177. package/tests/gateway.test.ts +939 -0
  178. package/tests/headlesslib.test.ts +171 -0
  179. package/tests/httpstream.test.ts +277 -0
  180. package/tests/immutablelog.test.ts +157 -0
  181. package/tests/inboundguard.test.ts +177 -0
  182. package/tests/lazyload.test.ts +72 -0
  183. package/tests/librarymodes.test.ts +623 -0
  184. package/tests/llm.test.ts +696 -0
  185. package/tests/manifest.test.ts +226 -0
  186. package/tests/maskinputs.test.ts +150 -0
  187. package/tests/matrixverify.test.ts +111 -0
  188. package/tests/mavenpack.test.ts +75 -0
  189. package/tests/mcpserver.test.ts +704 -0
  190. package/tests/memory.test.ts +4155 -0
  191. package/tests/memorycare.test.ts +230 -0
  192. package/tests/migrateplan.test.ts +349 -0
  193. package/tests/minimization.test.ts +414 -0
  194. package/tests/modelroute.test.ts +112 -0
  195. package/tests/multiagent.test.ts +440 -0
  196. package/tests/nativebridge.test.ts +807 -0
  197. package/tests/navigation.test.ts +284 -0
  198. package/tests/nugetpack.test.ts +83 -0
  199. package/tests/orchestration.test.ts +449 -0
  200. package/tests/originpolicy.test.ts +405 -0
  201. package/tests/outputcompare.test.ts +97 -0
  202. package/tests/package.test.ts +91 -0
  203. package/tests/pagebridge.test.ts +5909 -0
  204. package/tests/pentest.test.ts +75 -0
  205. package/tests/perfrecords.test.ts +257 -0
  206. package/tests/permdiff.test.ts +85 -0
  207. package/tests/phishguard.test.ts +128 -0
  208. package/tests/pickerviews.test.ts +155 -0
  209. package/tests/pipelines.test.ts +381 -0
  210. package/tests/planlint.test.ts +240 -0
  211. package/tests/planreview.test.ts +383 -0
  212. package/tests/platformtargets.test.ts +56 -0
  213. package/tests/policy.test.ts +8284 -0
  214. package/tests/portability.test.ts +96 -0
  215. package/tests/progress.test.ts +1036 -0
  216. package/tests/promptlibrary.test.ts +116 -0
  217. package/tests/protocol.test.ts +4793 -0
  218. package/tests/publishingpipeline.test.ts +314 -0
  219. package/tests/quickactions.test.ts +164 -0
  220. package/tests/readiness.test.ts +78 -0
  221. package/tests/recipes.test.ts +163 -0
  222. package/tests/redactshots.test.ts +216 -0
  223. package/tests/registernvidiakeys.ts +51 -0
  224. package/tests/relayserve.test.ts +304 -0
  225. package/tests/releasenotes.test.ts +276 -0
  226. package/tests/resilience.test.ts +467 -0
  227. package/tests/resourceaware.test.ts +187 -0
  228. package/tests/runbudget.test.ts +125 -0
  229. package/tests/runhistory.test.ts +299 -0
  230. package/tests/runreplay.test.ts +113 -0
  231. package/tests/runresume.test.ts +184 -0
  232. package/tests/runstate.test.ts +288 -0
  233. package/tests/runtimeadapters.test.ts +251 -0
  234. package/tests/safariskeleton.test.ts +115 -0
  235. package/tests/sandboxframe.test.ts +196 -0
  236. package/tests/sbom.test.ts +105 -0
  237. package/tests/secretvault.test.ts +188 -0
  238. package/tests/selcache.test.ts +111 -0
  239. package/tests/sessioninterface.test.ts +791 -0
  240. package/tests/sessions.test.ts +591 -0
  241. package/tests/siteprefs.test.ts +228 -0
  242. package/tests/snapshotdelta.test.ts +165 -0
  243. package/tests/statusviews.test.ts +237 -0
  244. package/tests/stepmeter.test.ts +200 -0
  245. package/tests/streamparse.test.ts +163 -0
  246. package/tests/stylepruning.test.ts +90 -0
  247. package/tests/surfaces.test.ts +282 -0
  248. package/tests/sweep.test.ts +78 -0
  249. package/tests/syncbridge.test.ts +164 -0
  250. package/tests/taskqueue.test.ts +206 -0
  251. package/tests/toolcalls.test.ts +336 -0
  252. package/tests/toolcatalog.test.ts +201 -0
  253. package/tests/torture-agent.test.ts +706 -0
  254. package/tests/torture-auth.test.ts +461 -0
  255. package/tests/torture-commands.test.ts +895 -0
  256. package/tests/torture-data.test.ts +923 -0
  257. package/tests/torture-environments.test.ts +285 -0
  258. package/tests/torture-export.test.ts +263 -0
  259. package/tests/torture-http.test.ts +1379 -0
  260. package/tests/torture-llm.test.ts +808 -0
  261. package/tests/torture-memory.test.ts +1084 -0
  262. package/tests/torture-plan.test.ts +829 -0
  263. package/tests/torture-run.test.ts +626 -0
  264. package/tests/torture-session.test.ts +928 -0
  265. package/tests/torture-swarm.test.ts +1245 -0
  266. package/tests/torture-tools.test.ts +964 -0
  267. package/tests/torture-workflow.test.ts +2354 -0
  268. package/tests/tourviews.test.ts +52 -0
  269. package/tests/transparency.test.ts +147 -0
  270. package/tests/trigger.test.ts +941 -0
  271. package/tests/v1sunset.test.ts +428 -0
  272. package/tests/virtlist.test.ts +94 -0
  273. package/tests/vision.test.ts +540 -0
  274. package/tests/vsixpack.test.ts +145 -0
  275. package/tests/webapi.test.ts +523 -0
  276. package/tests/workflow.test.ts +1785 -0
  277. package/tests/workfloweditor.test.ts +759 -0
  278. package/tests/xpipack.test.ts +128 -0
  279. package/tools.ts +734 -0
  280. package/types.ts +9908 -0
  281. package/umd.ts +9 -0
  282. package/utils.ts +249 -0
  283. package/version.ts +8 -0
  284. package/versionregistry.ts +1310 -0
  285. package/views.ts +2153 -0
  286. package/vitest.config.ts +12 -0
  287. package/web/App.tsx +78 -0
  288. package/web/README.md +47 -0
  289. package/web/capacitor.config.ts +46 -0
  290. package/web/const.ts +19 -0
  291. package/web/control.shell.tsx +66 -0
  292. package/web/dashboardpage.ts +554 -0
  293. package/web/db.ts +493 -0
  294. package/web/design.html +541 -0
  295. package/web/gatewayview/Gateway.tsx +345 -0
  296. package/web/gatewayview/config.ts +1042 -0
  297. package/web/gatewayview/definition.ts +174 -0
  298. package/web/icons.ts +15 -0
  299. package/{manifest.json → web/manifest.json} +1 -1
  300. package/web/optionspage.ts +691 -0
  301. package/web/popup.ts +1651 -0
  302. package/web/schema.prisma +176 -0
  303. package/web/sidepanel.ts +7941 -0
  304. package/web/sitemanifest.json +13 -0
  305. package/web/terminal-ui.tsx +622 -0
  306. package/web/transparencypage.ts +541 -0
  307. package/web/vite.config.ts +39 -0
  308. package/web/workspace.ts +23 -0
  309. package/workbenchmemory.ts +104 -0
  310. package/workbenchsession.ts +304 -0
  311. package/workflow.ts +4495 -0
  312. package/bridge.d.ts.map +0 -1
  313. package/bridge.js +0 -1877
  314. package/bun.d.ts.map +0 -1
  315. package/bun.js +0 -52329
  316. package/caps/background.json +0 -712
  317. package/caps/cli.json +0 -360
  318. package/caps/library.json +0 -3611
  319. package/caps/mcp.json +0 -126
  320. package/caps/pagebridge.json +0 -377
  321. package/caps/popup.json +0 -51
  322. package/caps/sidepanel.json +0 -522
  323. package/checksums.txt +0 -142
  324. package/cli.d.ts.map +0 -1
  325. package/companion.d.ts.map +0 -1
  326. package/companion.js +0 -389
  327. package/crossbrowser.d.ts.map +0 -1
  328. package/crossbrowser.js +0 -1191
  329. package/dashdone.d.ts.map +0 -1
  330. package/dashdone.js +0 -368
  331. package/deno.d.ts.map +0 -1
  332. package/deno.js +0 -52325
  333. package/devthink.umd.js +0 -52346
  334. package/fixtures/bun.ts +0 -7
  335. package/fixtures/cjs.js +0 -6
  336. package/fixtures/deno.ts +0 -6
  337. package/fixtures/example-org-pagestate.json +0 -22
  338. package/fixtures/importers/automa-workflow.json +0 -18
  339. package/fixtures/importers/selenium-side.json +0 -20
  340. package/fixtures/importers/tabular-plan.csv +0 -7
  341. package/fixtures/importers/uivision-macro.json +0 -10
  342. package/fixtures/importers/v1-plan.json +0 -13
  343. package/fixtures/mcp-client.mjs +0 -14
  344. package/fixtures/pages/checkout.html +0 -192
  345. package/fixtures/pages/dashboard.html +0 -110
  346. package/fixtures/pages/feed.html +0 -113
  347. package/fixtures/pages/product-grid.html +0 -150
  348. package/fixtures/plans/form-inventory-plan.json +0 -11
  349. package/fixtures/plans/release-notes-plan.json +0 -11
  350. package/fixtures/recipes/agentscompete.json +0 -11
  351. package/fixtures/recipes/agentsforms.json +0 -11
  352. package/fixtures/recipes/agentsmonitor.json +0 -11
  353. package/fixtures/recipes/agentsreview.json +0 -29
  354. package/fixtures/recipes/agentswarm.json +0 -11
  355. package/fixtures/recipes/fillcalendar.json +0 -12
  356. package/fixtures/recipes/fillcheckout.json +0 -13
  357. package/fixtures/recipes/filldependentdropdowns.json +0 -12
  358. package/fixtures/recipes/fillfromcsv.json +0 -13
  359. package/fixtures/recipes/fillloginform.json +0 -12
  360. package/fixtures/recipes/fillmultistepwizard.json +0 -13
  361. package/fixtures/recipes/fillregistration.json +0 -12
  362. package/fixtures/recipes/fillsearchfilters.json +0 -12
  363. package/fixtures/recipes/filltypeahead.json +0 -11
  364. package/fixtures/recipes/monitoralerts.json +0 -12
  365. package/fixtures/recipes/monitoravailability.json +0 -11
  366. package/fixtures/recipes/monitornetworkquiet.json +0 -12
  367. package/fixtures/recipes/monitorpagechanges.json +0 -11
  368. package/fixtures/recipes/monitorpricechange.json +0 -11
  369. package/fixtures/recipes/monitorschedule.json +0 -12
  370. package/fixtures/recipes/scrapedynamicprices.json +0 -13
  371. package/fixtures/recipes/scrapeimagegallery.json +0 -12
  372. package/fixtures/recipes/scrapeinfinitefeed.json +0 -13
  373. package/fixtures/recipes/scrapenestedlists.json +0 -12
  374. package/fixtures/recipes/scrapeproductgrid.json +0 -12
  375. package/fixtures/recipes/scrapereviews.json +0 -12
  376. package/fixtures/recipes/scrapesearchresults.json +0 -12
  377. package/fixtures/recipes/scrapetablepagination.json +0 -12
  378. package/fixtures/recipes/testaccessibility.json +0 -34
  379. package/fixtures/recipes/testconsoleerrors.json +0 -12
  380. package/fixtures/recipes/testdeeplinks.json +0 -12
  381. package/fixtures/recipes/testformvalidation.json +0 -13
  382. package/fixtures/recipes/testlayoutshift.json +0 -12
  383. package/fixtures/recipes/testlinkcheck.json +0 -12
  384. package/fixtures/recipes/testloadtiming.json +0 -12
  385. package/fixtures/recipes/testvisualdiff.json +0 -12
  386. package/fixtures/workflows/changelog-digest-workflow.json +0 -13
  387. package/fixtures/workflows/form-survey-workflow.json +0 -13
  388. package/gallery.json +0 -706
  389. package/hardening.d.ts.map +0 -1
  390. package/hardening.js +0 -188
  391. package/headless.d.ts.map +0 -1
  392. package/headless.js +0 -11522
  393. package/http.d.ts.map +0 -1
  394. package/http.js +0 -2165
  395. package/index.cjs +0 -54682
  396. package/index.d.ts.map +0 -1
  397. package/index.js +0 -52310
  398. package/index.neutral.js +0 -46343
  399. package/mcp.d.ts.map +0 -1
  400. package/mcp.js +0 -20515
  401. package/memory.d.ts.map +0 -1
  402. package/nativehost.template.json +0 -10
  403. package/node.cjs +0 -54686
  404. package/node.d.ts.map +0 -1
  405. package/pack.d.ts.map +0 -1
  406. package/pack.js +0 -805
  407. package/policy.d.ts.map +0 -1
  408. package/progress.d.ts.map +0 -1
  409. package/progress.js +0 -645
  410. package/protocol.d.ts.map +0 -1
  411. package/protocol.js +0 -8334
  412. package/schemas/audit.schema.json +0 -226
  413. package/schemas/capability.schema.json +0 -113
  414. package/schemas/envelope.schema.json +0 -162
  415. package/schemas/memory.schema.json +0 -51
  416. package/schemas/observation.schema.json +0 -113
  417. package/schemas/plan.schema.json +0 -208
  418. package/schemas/progress.schema.json +0 -126
  419. package/schemas/proposal.schema.json +0 -68
  420. package/schemas/session.schema.json +0 -82
  421. package/schemas/tool.schema.json +0 -94
  422. package/server.d.ts.map +0 -1
  423. package/server.js +0 -28877
  424. package/umd-example.html +0 -18
  425. package/umd.d.ts.map +0 -1
  426. /package/{bun.d.ts → dist/bun.d.ts} +0 -0
  427. /package/{cli.d.ts → dist/cli.d.ts} +0 -0
  428. /package/{companion.d.ts → dist/companion.d.ts} +0 -0
  429. /package/{crossbrowser.d.ts → dist/crossbrowser.d.ts} +0 -0
  430. /package/{dashdone.d.ts → dist/dashdone.d.ts} +0 -0
  431. /package/{deno.d.ts → dist/deno.d.ts} +0 -0
  432. /package/{hardening.d.ts → dist/hardening.d.ts} +0 -0
  433. /package/{headless.d.ts → dist/headless.d.ts} +0 -0
  434. /package/{http.d.ts → dist/http.d.ts} +0 -0
  435. /package/{index.d.ts → dist/index.d.ts} +0 -0
  436. /package/{mcp.d.ts → dist/mcp.d.ts} +0 -0
  437. /package/{memory.d.ts → dist/memory.d.ts} +0 -0
  438. /package/{node.d.ts → dist/node.d.ts} +0 -0
  439. /package/{pack.d.ts → dist/pack.d.ts} +0 -0
  440. /package/{policy.d.ts → dist/policy.d.ts} +0 -0
  441. /package/{progress.d.ts → dist/progress.d.ts} +0 -0
  442. /package/{protocol.d.ts → dist/protocol.d.ts} +0 -0
  443. /package/{server.d.ts → dist/server.d.ts} +0 -0
  444. /package/{umd.d.ts → dist/umd.d.ts} +0 -0
package/antigravity.ts ADDED
@@ -0,0 +1,2139 @@
1
+ /* ── Merged: the grand merge section ── the correlated antigravity provider plugin logics of the merged repository interned here, one surface without duplicate variations ── */
2
+ /**
3
+ * @file antigravity.ts — the antigravity provider plugin of the merged repository.
4
+ * @description
5
+ * Third-person observer: the observer analyses how Gemini CLI, OmniRoute, 9router and
6
+ * pi-antigravity-auth bypass the Project ID and reproduces exactly the same behaviour
7
+ * in a library-first, root-first shape.
8
+ *
9
+ * Responsabilidades (the merged plugin contract):
10
+ * - Total Project ID bypass acting exactly like the Gemini CLI: when
11
+ * account.projectId is absent the observer calls resolveProjectId which runs
12
+ * loadCodeAssist without a project (metadata only), onboardUser when needed
13
+ * (FREE tier), LRO polling, never demanding a project from the user. The
14
+ * in-memory cache carries a truncated SHA256 hash so a heap snapshot never
15
+ * leaks the token.
16
+ * - Endpoint wrapping {model, project, request} the Gemini CLI way
17
+ * (CodeAssistServer.createCodeAssistContentGenerator), straight to
18
+ * cloudcode-pa.googleapis.com, no localhost v1 proxy, no generativelanguage.
19
+ * - The complete model list through 25/08/2026: gemini-3.6, 3.5, 3.1, 3,
20
+ * 2.5, claude 4.5/4.6 thinking, gpt-oss-120b, preview variants plus the
21
+ * antigravity-* aliases.
22
+ * - Dual quota (Antigravity + Gemini CLI), cli_first, pid_offset_enabled
23
+ * 0-1000, google_search grounding.
24
+ * - Hooks: the fetch interceptor cascade 403/404/5xx with 0-80ms jitter,
25
+ * strip x-goog-user-project (fix #1830), skip sandbox for
26
+ * gemini-3.6/3.5/preview (fix #233), the third-person observer, node:*
27
+ * builtins + global fetch only.
28
+ */
29
+
30
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
31
+ import * as fsp from "node:fs/promises";
32
+ import { join, resolve, dirname } from "node:path";
33
+ import { homedir, platform as osPlatform, arch as osArch, release as osRelease } from "node:os";
34
+ import { randomInt, createHash, randomBytes } from "node:crypto";
35
+ // merged siblings (base names only)
36
+ import {
37
+ getGlobalSignatureCache,
38
+ getGlobalPreservationStore,
39
+ isValidThinkingSignature,
40
+ stripInvalidSignatures,
41
+ } from "./recovery.js";
42
+ import * as oauthMod from "./oauth.js";
43
+ import * as accountsMod from "./accounts.js";
44
+ import {
45
+ resolveOAuthIdentity,
46
+ identityFromAccountType,
47
+ getAntigravityCliHeaders,
48
+ ANTIGRAVITY_CLI_OAUTH_SCOPES,
49
+ } from "./oauth.js";
50
+ import { getVersion } from "./versionregistry.js";
51
+ // v2.1.14 dedupe: stateless constants imported from their single owners
52
+ // (constants.ts / models.ts) instead of local copies. Runtime functions and
53
+ // stateful caches stay local to this Layer-1 entry by design.
54
+ // v2.1.15 Phase A: every model-identification family (catalog lists, routing
55
+ // classifiers, thinking tables, endpoint orders, image-gen helpers) is
56
+ // imported from models.ts — THE single owner — and project discovery comes
57
+ // from project.ts — THE single project owner; the former local copies were
58
+ // deleted and re-exported under their historical names.
59
+ import {
60
+ ANTIGRAVITY_VERSION_FALLBACK,
61
+ CLIENT_METADATA_STRING,
62
+ CLOUDCODE_BASE_URL,
63
+ CONTENT_TIMEOUT_MS,
64
+ FALLBACK_PROJECT_ID,
65
+ GEMINI_CLI_USER_AGENT_PLAIN,
66
+ PLUGIN_VERSION,
67
+ } from "./constants.js";
68
+ // v2.1.16 cleanup: FORBIDDEN_HEADERS / FETCH_TIMEOUT_MS / GEMINI_CLI_SCOPES
69
+ // were imported but never used in the file body — they stay reachable through
70
+ // the re-export block below, so the dead import bindings are gone.
71
+ export type PluginOptions = {
72
+ cli_first?: boolean;
73
+ pid_offset_enabled?: boolean;
74
+ /** true/false forces grounding on/off; "auto" enables it for Gemini (non-Claude) models only. */
75
+ google_search_enabled?: boolean | "auto";
76
+ quota_refresh_interval_minutes?: number;
77
+ rotation_strategy?: "round-robin" | "sticky";
78
+ debug?: boolean;
79
+ /** Prefix tool descriptions with hardening hints for Claude models. */
80
+ claude_tool_hardening?: boolean;
81
+ /** Keep thinking blocks/signatures in transformed output instead of dropping them. */
82
+ keep_thinking?: boolean;
83
+ /** Skip accounts whose used-percentage is at or above this threshold (0-100). */
84
+ soft_quota_threshold_percent?: number;
85
+ /** How long a soft-quota mark lasts, in minutes; "auto" resolves to 15. */
86
+ soft_quota_cache_ttl_minutes?: number | "auto";
87
+ /** Suppress toast() notifications. */
88
+ quiet_mode?: boolean;
89
+ };
90
+
91
+ // v2.1.15 Phase C: the Account interface moved to accounts.js (account store
92
+ // owner) — imported type-only (erased at runtime).
93
+
94
+ // v2.1.15 Phase B: generic utilities (fnv hash family, jitter) come from
95
+ // core.js — THE owner; identity masquerade (dynamic UA, antigravity headers)
96
+ // comes from fingerprint.js — THE owner.
97
+ import { fnv1a32, fnv1a32Hex, getJitter } from "./core.js";
98
+ import { buildAntagravityHeaders, getDynamicUserAgent } from "./fingerprint.js";
99
+ import { X_GOOG_API_CLIENT_GEMINI_CLI } from "./fingerprint.js";
100
+ import {
101
+ ALL_MODELS_2026,
102
+ ANTIGRAVITY_VERSION_MIN,
103
+ CODE_ASSIST_ENDPOINTS,
104
+ CONTEXT_WINDOW_CLAUDE,
105
+ CONTEXT_WINDOW_GEMINI,
106
+ CONTEXT_WINDOW_GPT_OSS,
107
+ ENDPOINT_ORDER_GEMINI_CLI,
108
+ MODELS_2026_PROVIDER,
109
+ OUTPUT_LIMIT_CLAUDE,
110
+ OUTPUT_LIMIT_GEMINI,
111
+ OUTPUT_LIMIT_GPT_OSS,
112
+ THINKING_BUDGET_MAP,
113
+ THINKING_LEVELS,
114
+ buildImageGenConfig,
115
+ getEndpointsForModel,
116
+ isGeminiCLIOnlyModel,
117
+ isImageGenModel,
118
+ mapModelToGroup,
119
+ } from "./models.js";
120
+ import type { ThinkingLevel } from "./models.js";
121
+ import { resolveProjectId } from "./project.js";
122
+ // v2.1.15 Phase D: the live request pipeline (transformRequest, the Cloud Code
123
+ // builders, fetchWithCascade and the method paths) lives ONLY in request.ts;
124
+ // the fetch-cascade recovery helpers live ONLY in recovery.ts.
125
+ import {
126
+ buildAntigravityRequest,
127
+ buildGeminiCLIRequest,
128
+ fetchWithCascade,
129
+ transformRequest,
130
+ STREAM_METHOD,
131
+ GENERATE_METHOD,
132
+ LOAD_METHOD,
133
+ ONBOARD_METHOD,
134
+ } from "./request.js";
135
+ import type { BuildOptions, BuildResult } from "./request.js";
136
+ import { detectFetchErrorType, fetchSessionRecovery, preserveValidatedSignature } from "./recovery.js";
137
+ import { shouldSkipSandbox } from "./models.js";
138
+ import { QUOTA_METHOD } from "./quota.js";
139
+
140
+ import type { Account } from "./accounts.js";
141
+ export type { Account };
142
+ import type { ResolveProjectIdOptions, ResolveResult } from "./project.js";
143
+
144
+ // v2.1.14 dedupe: re-export the consolidated constants so this module's
145
+ // public surface is unchanged.
146
+ export {
147
+ ANTIGRAVITY_VERSION_FALLBACK,
148
+ CONTENT_TIMEOUT_MS,
149
+ FALLBACK_PROJECT_ID,
150
+ FORBIDDEN_HEADERS,
151
+ FETCH_TIMEOUT_MS,
152
+ GEMINI_CLI_SCOPES,
153
+ PLUGIN_VERSION,
154
+ } from "./constants.js";
155
+ export { fnv1a32, fnv1a32Hex, getJitter } from "./core.js";
156
+ export { buildAntagravityHeaders, getDynamicUserAgent } from "./fingerprint.js";
157
+ export {
158
+ ALL_MODELS_2026,
159
+ ANTIGRAVITY_VERSION_MIN,
160
+ CODE_ASSIST_ENDPOINTS,
161
+ ENDPOINT_ORDER,
162
+ ENDPOINT_ORDER_GEMINI_CLI,
163
+ GEMINI_CLI_ONLY_MODELS,
164
+ MODELS_2026_BASE,
165
+ MODELS_2026_ANTIGRAVITY_ALIAS,
166
+ THINKING_BUDGET_MAP,
167
+ THINKING_LEVELS,
168
+ buildImageGenConfig,
169
+ getEndpointsForModel,
170
+ isGeminiCLIOnlyModel,
171
+ isImageGenModel,
172
+ mapModelToGroup,
173
+ } from "./models.js";
174
+ export { MODELS_2026_PROVIDER as MODELS_2026 } from "./models.js";
175
+ export type { ModelId2026, ThinkingLevel } from "./models.js";
176
+ export { resolveProjectId } from "./project.js";
177
+ export type { ResolveProjectIdOptions, ResolveResult } from "./project.js";
178
+
179
+ // Node types (no DOM lib) do not expose the RequestInfo name globally; derive it from fetch itself.
180
+ type RequestInfo = Parameters<typeof fetch>[0];
181
+
182
+ /**
183
+ * Linear-time trailing-slash strip (regex-free; safe on uncontrolled input —
184
+ * the former /\/+$/.replace was polynomial and flagged as ReDoS-prone).
185
+ */
186
+ function stripTrailingSlashes(value: string): string {
187
+ let end = value.length;
188
+ while (end > 0 && value.charCodeAt(end - 1) === 47 /* "/" */) end--;
189
+ return end === value.length ? value : value.slice(0, end);
190
+ }
191
+
192
+ // --- optional internal modules (they themselves only use node:* + fetch) ---
193
+ // The observer tries to reuse them when present, but v2 is self-contained to guarantee bypass logic.
194
+ let __AccountManagerCtor: any = null;
195
+ try {
196
+ // dynamic import via top-level await not allowed here, will lazy import in init
197
+ } catch {
198
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Constants — frozen, deterministic
203
+ // ---------------------------------------------------------------------------
204
+
205
+ export const PLUGIN_ID = "devthink" as const;
206
+ // PLUGIN_VERSION is imported from constants.ts (single owner) and re-exported above.
207
+
208
+ /** Public alias kept for consumers that expect `VERSION` instead of `PLUGIN_VERSION`. */
209
+ export const VERSION: string = PLUGIN_VERSION;
210
+
211
+ /**
212
+ * Gemini CLI identification fallback headers used when the auth module does
213
+ * not expose getGeminiHeaders(). v2.1.16 single-owner fix: hoisted from the
214
+ * two byte-identical inline copies (opencode auth loader + chat.headers
215
+ * spoof) — every value comes from its owner: the plain UA (no model segment)
216
+ * and the Client-Metadata string from constants.js, the gl-node snapshot
217
+ * from fingerprint.js. The "gccl/0.9.2" composite segment is not exported
218
+ * by any owner module, so the composite X-Goog-Api-Client is assembled
219
+ * exactly once here (plugin.ts's single local definition, shared by both
220
+ * sites — fixes the internal duplication found by audit 16-B).
221
+ */
222
+ const FALLBACK_GEMINI_HEADERS: Record<string, string> = {
223
+ "User-Agent": GEMINI_CLI_USER_AGENT_PLAIN,
224
+ "X-Goog-Api-Client": `${X_GOOG_API_CLIENT_GEMINI_CLI} gccl/0.9.2 ${X_GOOG_API_CLIENT_GEMINI_CLI}`,
225
+ "Client-Metadata": CLIENT_METADATA_STRING,
226
+ };
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Gemini CLI bypass credentials — exact identification used by the official CLI
230
+ // (merged from legacy plugin.ts / plugin(1).ts; v2.1.14: values deduped to the
231
+ // single owner constants.ts GEMINI_CLI_OAUTH_CLIENT_ID/SECRET)
232
+ // ---------------------------------------------------------------------------
233
+
234
+ // v2.1.15 Phase C: GEMINI_CLI_OAUTH_CLIENT_ID/SECRET were aliases of the
235
+ // constants.js GEMINI_CLI_OAUTH_CLIENT_ID/SECRET pair — the aliases are
236
+ // replaced by direct imports (single owner, single name).
237
+
238
+ /** Short aliases for the Gemini CLI bypass credentials. */
239
+ // v2.1.15 Phase C: the CLIENT_ID/CLIENT_SECRET aliases (gemini-cli pair)
240
+ // were deleted — auth.js CLIENT_ID/CLIENT_SECRET is the antigravity-cli
241
+ // client pair; the gemini-cli pair is GEMINI_CLI_OAUTH_CLIENT_ID/SECRET.
242
+
243
+ // v2.1.15 Phase A: ALL_MODELS_2026 (flat routing catalog), the ALL_MODELS
244
+ // alias, isImageGenModel and buildImageGenConfig moved to models.ts —
245
+ // imported and re-exported at the top of this file.
246
+
247
+ // v2.1.15 Phase D: the Cloud Code method paths (STREAM_METHOD, GENERATE_METHOD,
248
+ // LOAD_METHOD, ONBOARD_METHOD) moved to request.ts; QUOTA_METHOD moved to
249
+ // quota.ts — imported from the owners at the top of this file.
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Helpers — FNV, jitter, UA, headers, cache
253
+ // ---------------------------------------------------------------------------
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Legacy header builders (merged from plugin.ts / plugin(1).ts) — kept for
257
+ // consumers that import them directly; bypass flows above remain canonical.
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /** Gemini CLI identification headers — THE bypass secret for CLI-pool models. */
261
+
262
+ /** Legacy Antigravity desktop identification headers variant. */
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Notifications + error classification + retry-once recovery + thinking
266
+ // signature preservation (merged from plugin(3)/plugin(4)/plugin(5) flows,
267
+ // backed by the recovery module caches).
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /** Module-level quiet flag toggled from PluginOptions.quiet_mode. */
271
+ let __quietMode = false;
272
+
273
+ /**
274
+ * User-facing notification glue (v2.1.15 Phase D: renamed from the former
275
+ * toast() — recovery.js owns the toast name with its sink-based notifier).
276
+ * No-op when quiet_mode is enabled.
277
+ */
278
+ export function notifyUser(message: string): void {
279
+ if (__quietMode) return;
280
+ try {
281
+ console.log(`[m[devthink] ${message}`);
282
+ } catch {
283
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
284
+ }
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Account persistence — minimal inline implementation + reuse AccountManager if present
289
+ // ---------------------------------------------------------------------------
290
+
291
+ function resolveConfigDir(): string {
292
+ const env = process.env.OPENCODE_CONFIG_DIR?.trim() ?? "";
293
+ if (env) return resolve(env);
294
+ return join(homedir(), ".config", "opencode");
295
+ }
296
+ function resolveAccountsPath(custom?: string): string {
297
+ if (custom) return resolve(custom);
298
+ return join(resolveConfigDir(), "antigravity-accounts.json");
299
+ }
300
+
301
+ async function loadAccountsInline(filePath?: string): Promise<Account[]> {
302
+ const fp = resolveAccountsPath(filePath);
303
+ try {
304
+ const content = await fsp.readFile(fp, "utf8");
305
+ const parsed = JSON.parse(content);
306
+ if (Array.isArray(parsed)) return parsed as Account[];
307
+ if (Array.isArray(parsed.accounts)) return parsed.accounts as Account[];
308
+ if (parsed.accounts && typeof parsed.accounts === "object") return Object.values(parsed.accounts) as Account[];
309
+ return [];
310
+ } catch {
311
+ return [];
312
+ }
313
+ }
314
+
315
+ async function saveAccountsInline(accounts: Account[], filePath?: string): Promise<void> {
316
+ const fp = resolveAccountsPath(filePath);
317
+ const dir = dirname(fp);
318
+ try {
319
+ await fsp.mkdir(dir, { recursive: true, mode: 0o755 });
320
+ } catch {
321
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
322
+ }
323
+ const wrapper = { version: 3, accounts, updatedAt: Date.now() };
324
+ const tmp = `${fp}.tmp.${randomInt(100000, 999999)}`;
325
+ try {
326
+ await fsp.writeFile(tmp, JSON.stringify(wrapper, null, 2), { mode: 0o600 });
327
+ await fsp.rename(tmp, fp);
328
+ try {
329
+ await fsp.chmod(fp, 0o600);
330
+ } catch {
331
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
332
+ }
333
+ } catch {
334
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
335
+ }
336
+ }
337
+
338
+ // Minimal AccountManager fallback that reuses inline loader + round-robin
339
+ class InlineAccountManager {
340
+ private accounts: Account[] = [];
341
+ private rotationIndex = 0;
342
+ private loaded = false;
343
+ private filePath: string;
344
+
345
+ constructor(filePath?: string) {
346
+ this.filePath = resolveAccountsPath(filePath);
347
+ }
348
+
349
+ async load(): Promise<void> {
350
+ if (this.loaded) return;
351
+ this.accounts = await loadAccountsInline(this.filePath);
352
+ this.loaded = true;
353
+ }
354
+ async save(): Promise<void> {
355
+ await saveAccountsInline(this.accounts, this.filePath);
356
+ }
357
+
358
+ list(): Account[] {
359
+ return [...this.accounts];
360
+ }
361
+
362
+ async getNextAccount(strategy: "round-robin" | "sticky" = "round-robin"): Promise<Account | null> {
363
+ await this.load();
364
+ const healthy = this.accounts.filter(
365
+ (a) => !a.disabled && (!a.quotaExhaustedUntil || Date.now() >= a.quotaExhaustedUntil),
366
+ );
367
+ if (healthy.length === 0) return this.accounts[0] ?? null;
368
+ if (strategy === "sticky" && healthy.length > 0) return healthy[0]!;
369
+ const idx = this.rotationIndex % healthy.length;
370
+ const acc = healthy[idx]!;
371
+ this.rotationIndex = (this.rotationIndex + 1) % healthy.length;
372
+ return acc;
373
+ }
374
+
375
+ async addAccount(a: Account): Promise<void> {
376
+ await this.load();
377
+ const existingIdx = this.accounts.findIndex((x) => x.email.toLowerCase() === a.email.toLowerCase());
378
+ if (existingIdx >= 0) this.accounts[existingIdx] = { ...this.accounts[existingIdx], ...a, updatedAt: Date.now() };
379
+ else this.accounts.push({ ...a, createdAt: Date.now(), updatedAt: Date.now() });
380
+ await this.save();
381
+ }
382
+
383
+ async markQuotaExhausted(email: string, durationMs = 20 * 60 * 1000): Promise<void> {
384
+ const acc = this.accounts.find((a) => a.email.toLowerCase() === email.toLowerCase());
385
+ if (acc) {
386
+ acc.quotaExhaustedUntil = Date.now() + durationMs;
387
+ await this.save();
388
+ }
389
+ }
390
+
391
+ async refreshAccount(email: string): Promise<{ accessToken?: string } | null> {
392
+ // try oauth module if present
393
+ try {
394
+ const acc = this.accounts.find((a) => a.email.toLowerCase() === email.toLowerCase());
395
+ if (!acc?.refreshToken) return null;
396
+ if (typeof oauthMod.refreshAccessToken === "function") {
397
+ // refresh with the OAuth client the account authenticated with
398
+ const identity = resolveOAuthIdentity(identityFromAccountType((acc as any).type));
399
+ const tokens = await oauthMod.refreshAccessToken(acc.refreshToken, {
400
+ clientId: identity.clientId,
401
+ clientSecret: identity.clientSecret,
402
+ });
403
+ if (tokens?.access_token) {
404
+ acc.accessToken = tokens.access_token;
405
+ acc.expiry = Date.now() + (tokens.expires_in ?? 3600) * 1000;
406
+ await this.save();
407
+ return { accessToken: tokens.access_token };
408
+ }
409
+ }
410
+ } catch {
411
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
412
+ }
413
+ return null;
414
+ }
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Quota — minimal dual pool + shouldSkipAccount
419
+ // ---------------------------------------------------------------------------
420
+
421
+ /**
422
+ * Quota-pool classifier for the inline quota manager.
423
+ * v2.1.16 single-owner audit: GENUINELY DIVERGENT from models.js
424
+ * mapModelToGroup (the ROUTING classifier) and therefore kept local —
425
+ * mapModelToGroup routes non-3.x gemini ids (e.g. gemini-2.5-*) to the
426
+ * "antigravity" endpoint family, while this quota view bills EVERY gemini
427
+ * model against the "gemini-cli" Cloud Code Assist quota pool. Merging the
428
+ * two would change which pool a gemini-2.5 quota exhaustion marks, so the
429
+ * divergence is documented instead of merged.
430
+ */
431
+ function mapModelToQuotaGroup(model: string): string {
432
+ const lower = (model ?? "").toLowerCase();
433
+ if (lower.includes("claude") || lower.includes("gpt-oss")) return "antigravity";
434
+ if (lower.includes("gemini")) return "gemini-cli";
435
+ return "antigravity";
436
+ }
437
+
438
+ class InlineQuotaManager {
439
+ private exhausted: Map<string, { until: number; group?: string }> = new Map();
440
+
441
+ shouldSkipAccount(email: string, model: string): boolean {
442
+ const key = `${email.toLowerCase()}:${mapModelToQuotaGroup(model)}`;
443
+ const entry = this.exhausted.get(key) ?? this.exhausted.get(email.toLowerCase());
444
+ if (!entry) return false;
445
+ if (Date.now() >= entry.until) {
446
+ this.exhausted.delete(key);
447
+ this.exhausted.delete(email.toLowerCase());
448
+ return false;
449
+ }
450
+ return true;
451
+ }
452
+
453
+ markExhausted(email: string, model: string, durationMs = 20 * 60 * 1000, group?: string): void {
454
+ const g = group ?? mapModelToQuotaGroup(model);
455
+ this.exhausted.set(`${email.toLowerCase()}:${g}`, { until: Date.now() + durationMs, group: g });
456
+ }
457
+
458
+ isQuotaError(status: number, bodyText?: string): boolean {
459
+ if (status === 429) return true;
460
+ if (!bodyText) return false;
461
+ const lower = bodyText.toLowerCase();
462
+ return (
463
+ lower.includes("quota") ||
464
+ lower.includes("exhausted") ||
465
+ lower.includes("limit") ||
466
+ lower.includes("resource_exhausted")
467
+ );
468
+ }
469
+ }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // Streaming helpers — SSE tolerant parser + OpenAI/Anthropic transformers minimal
473
+ // ---------------------------------------------------------------------------
474
+
475
+ const SSE_DONE = "[DONE]";
476
+
477
+ function createSSEParser() {
478
+ let buffer = "";
479
+ return {
480
+ parseChunk(chunk: string): { data: string; event?: string | undefined }[] {
481
+ buffer += chunk;
482
+ const events: { data: string; event?: string | undefined }[] = [];
483
+ let idx;
484
+ while ((idx = buffer.indexOf("\n\n")) >= 0) {
485
+ const block = buffer.slice(0, idx);
486
+ buffer = buffer.slice(idx + 2);
487
+ const lines = block.split("\n");
488
+ let data = "";
489
+ let event = "";
490
+ for (const line of lines) {
491
+ if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).trimStart();
492
+ else if (line.startsWith("event:")) event = line.slice(6).trim();
493
+ else if (line.trim() === "") continue;
494
+ else data += (data ? "\n" : "") + line;
495
+ }
496
+ if (data) events.push({ data, event: event || undefined });
497
+ }
498
+ return events;
499
+ },
500
+ flush(): { data: string; event?: string | undefined }[] {
501
+ if (!buffer.trim()) return [];
502
+ const b = buffer;
503
+ buffer = "";
504
+ const lines = b.split("\n");
505
+ let data = "";
506
+ let event = "";
507
+ for (const line of lines) {
508
+ if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).trimStart();
509
+ else if (line.startsWith("event:")) event = line.slice(6).trim();
510
+ else if (line.trim()) data += (data ? "\n" : "") + line;
511
+ }
512
+ return data ? [{ data, event: event || undefined }] : [];
513
+ },
514
+ };
515
+ }
516
+
517
+ function normalizeGeminiPayload(raw: string): any[] {
518
+ const out: any[] = [];
519
+ const trimmed = raw.trim();
520
+ if (!trimmed || trimmed === SSE_DONE) return out;
521
+ let parsed: any;
522
+ try {
523
+ parsed = JSON.parse(trimmed);
524
+ } catch {
525
+ // sometimes wrapped as "{"response":...}"? try tolerant
526
+ return out;
527
+ }
528
+ // cloudcode-pa returns {response: {candidates:[{content:{parts:[...]}}]}} or direct
529
+ const unwrap = (obj: any): any[] => {
530
+ if (!obj) return [];
531
+ if (obj.response) return unwrap(obj.response);
532
+ if (Array.isArray(obj.candidates)) return obj.candidates;
533
+ if (obj.candidates) return unwrap(obj.candidates);
534
+ if (obj.content) return [obj];
535
+ return [obj];
536
+ };
537
+ const candidates = unwrap(parsed);
538
+ for (const cand of candidates) {
539
+ const content = cand.content ?? cand;
540
+ const parts = content.parts ?? content.content?.parts ?? [];
541
+ if (!Array.isArray(parts)) continue;
542
+ for (const part of parts) out.push(part);
543
+ }
544
+ // also handle case where parsed is already part
545
+ if (candidates.length === 0 && (parsed.text || parsed.functionCall || parsed.thought)) out.push(parsed);
546
+ return out;
547
+ }
548
+
549
+ function transformToOpenAIChunk(parts: any[], model: string, isFirst: boolean, isLast: boolean): any {
550
+ // minimal OpenAI delta
551
+ let text = "";
552
+ const toolCalls: any[] = [];
553
+ let thought = "";
554
+ for (const p of parts) {
555
+ if (p.text) text += p.text;
556
+ if (p.functionCall)
557
+ toolCalls.push({
558
+ id: `call_${fnv1a32Hex(JSON.stringify(p.functionCall)).slice(0, 8)}`,
559
+ type: "function",
560
+ function: { name: p.functionCall.name, arguments: JSON.stringify(p.functionCall.args ?? {}) },
561
+ });
562
+ if (p.thought) thought += p.thought;
563
+ }
564
+ const delta: any = { role: isFirst ? "assistant" : undefined, content: text || undefined };
565
+ if (toolCalls.length > 0) delta.tool_calls = toolCalls;
566
+ const chunk: any = {
567
+ id: `chatcmpl-${fnv1a32Hex(model + Date.now()).slice(0, 12)}`,
568
+ object: "chat.completion.chunk",
569
+ created: Math.floor(Date.now() / 1000),
570
+ model,
571
+ choices: [{ index: 0, delta, finish_reason: isLast ? "stop" : null }],
572
+ };
573
+ return chunk;
574
+ }
575
+
576
+ // ---------------------------------------------------------------------------
577
+ // Main plugin class — bypass Project ID exactly as Gemini CLI
578
+ // ---------------------------------------------------------------------------
579
+
580
+ /**
581
+ * Extracts the model id from a Gemini-style URL
582
+ * (.../models/{model}:method?...) using a linear scan — no regex on
583
+ * uncontrolled input. Returns undefined when the URL carries no model.
584
+ */
585
+ function extractModelFromUrl(urlStr: string): string | undefined {
586
+ const marker = "/models/";
587
+ const i = urlStr.indexOf(marker);
588
+ if (i < 0) return undefined;
589
+ const rest = urlStr.slice(i + marker.length);
590
+ const colon = rest.indexOf(":");
591
+ const query = rest.indexOf("?");
592
+ let end = colon >= 0 ? colon : query >= 0 ? query : rest.length;
593
+ if (end < 0) end = rest.length;
594
+ const m = rest.slice(0, end);
595
+ if (!m) return undefined;
596
+ try {
597
+ return decodeURIComponent(m);
598
+ } catch {
599
+ return m;
600
+ }
601
+ }
602
+
603
+ export class AntigravityPlugin {
604
+ private accountManager: any;
605
+ private quotaManager: InlineQuotaManager;
606
+ private options: PluginOptions;
607
+ private version: string = ANTIGRAVITY_VERSION_FALLBACK;
608
+ private initialized = false;
609
+
610
+ constructor(options: PluginOptions = {}) {
611
+ this.options = { claude_tool_hardening: true, keep_thinking: false, soft_quota_threshold_percent: 90, ...options };
612
+ __quietMode = !!this.options.quiet_mode;
613
+ this.quotaManager = new InlineQuotaManager();
614
+ // try load external AccountManager for advanced persistence, fallback inline
615
+ try {
616
+ // will be lazy initialized in init()
617
+ this.accountManager = new InlineAccountManager();
618
+ } catch {
619
+ this.accountManager = new InlineAccountManager();
620
+ }
621
+ }
622
+
623
+ async init(): Promise<void> {
624
+ if (this.initialized) return;
625
+ try {
626
+ this.version = await getVersion().catch(() => ANTIGRAVITY_VERSION_FALLBACK);
627
+ } catch {
628
+ this.version = ANTIGRAVITY_VERSION_FALLBACK;
629
+ }
630
+
631
+ // attempt to use real AccountManager if exists (has richer refresh)
632
+ try {
633
+ const ExtMgr = (accountsMod as any).AccountManager;
634
+ if (ExtMgr) {
635
+ const mgr = new ExtMgr();
636
+ await (mgr as any).load?.().catch(() => {});
637
+ // prefer the richer auth module manager when it has accounts or robin-hood selection
638
+ if (((mgr as any).accounts && (mgr as any).accounts.length > 0) || typeof (mgr as any).getNext === "function")
639
+ this.accountManager = mgr;
640
+ else {
641
+ // keep inline but copy accounts if external has
642
+ await this.accountManager.load().catch(() => {});
643
+ }
644
+ } else {
645
+ await this.accountManager.load().catch(() => {});
646
+ }
647
+ } catch {
648
+ await this.accountManager.load().catch(() => {});
649
+ }
650
+
651
+ this.initialized = true;
652
+ }
653
+
654
+ private getUserAgent(): string {
655
+ try {
656
+ return getDynamicUserAgent(this.version);
657
+ } catch {
658
+ return `antigravity/${this.version} linux/x64`;
659
+ }
660
+ }
661
+
662
+ private shouldHandleModel(model: string): boolean {
663
+ if (!model) return false;
664
+ const lower = model.toLowerCase();
665
+ return (
666
+ lower.includes("antigravity") ||
667
+ lower.includes("gemini") ||
668
+ lower.includes("claude") ||
669
+ lower.includes("gpt-oss") ||
670
+ MODELS_2026_PROVIDER.some((m) => lower.includes(m.toLowerCase()))
671
+ );
672
+ }
673
+
674
+ private async getAccountForModel(model: string): Promise<Account | null> {
675
+ const preferCliFirst = this.options.cli_first ?? false;
676
+ const strategy = this.options.rotation_strategy ?? "round-robin";
677
+ // soft quota: pass threshold through to managers that support it (auth.js getNext)
678
+ const softThreshold = this.options.soft_quota_threshold_percent ?? 90;
679
+ const pickNext = async (strat: string): Promise<any | null> => {
680
+ try {
681
+ if (typeof (this.accountManager as any).getNext === "function")
682
+ return await (this.accountManager as any).getNext(strat, softThreshold);
683
+ } catch {
684
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
685
+ }
686
+ try {
687
+ return (await (this.accountManager as any).getNextAccount?.(strat)) ?? null;
688
+ } catch {
689
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
690
+ }
691
+ try {
692
+ return (await (this.accountManager as any).getNextAccount(strat)) ?? null;
693
+ } catch {
694
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
695
+ }
696
+ return null;
697
+ };
698
+ // dual quota: if cli_first and gemini model, prefer gemini-cli pool first (preserve antigravity for claude)
699
+ // observer implements as round-robin with skip
700
+ let lastErr: any = null;
701
+ for (let i = 0; i < 10; i++) {
702
+ try {
703
+ const candidate = await pickNext(strategy);
704
+ if (!candidate) break;
705
+ if (this.quotaManager.shouldSkipAccount(candidate.email, model)) continue;
706
+
707
+ // cli_first: if gemini and candidate type is antigravity, try to find gemini-cli first? simple heuristic
708
+ if (preferCliFirst && model.toLowerCase().includes("gemini")) {
709
+ // if candidate is claude-only? we still allow but prefer gemini-cli: skip antigravity if another healthy exists
710
+ const isAntigravityOnly = candidate.type === "antigravity" && isGeminiCLIOnlyModel(model);
711
+ // If gemini-cli only model, we should not skip antigravity but note that fallback project would 403 — try next once
712
+ if (isAntigravityOnly) {
713
+ // look ahead one more account in case multi-account
714
+ const nextCandidate = await pickNext("round-robin");
715
+ if (nextCandidate && !this.quotaManager.shouldSkipAccount(nextCandidate.email, model)) {
716
+ return nextCandidate as Account;
717
+ }
718
+ }
719
+ }
720
+
721
+ return candidate as Account;
722
+ } catch (e) {
723
+ lastErr = e;
724
+ continue;
725
+ }
726
+ }
727
+ // fallback any
728
+ try {
729
+ const anyAcc = await pickNext(strategy);
730
+ return anyAcc as Account | null;
731
+ } catch {
732
+ return null;
733
+ }
734
+ }
735
+
736
+ private async refreshIfNeeded(account: Account): Promise<string> {
737
+ if (account.accessToken && account.expiry && Date.now() < account.expiry - 5 * 60 * 1000)
738
+ return account.accessToken;
739
+ try {
740
+ const refreshed = (await (this.accountManager as any).refreshAccount?.(account.email).catch(() => null)) ?? null;
741
+ if (refreshed?.accessToken) return refreshed.accessToken;
742
+ if ((refreshed as any)?.access_token) return (refreshed as any).access_token;
743
+ } catch {
744
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
745
+ }
746
+ try {
747
+ if (typeof oauthMod.refreshAccessToken === "function" && account.refreshToken) {
748
+ // refresh with the OAuth client the account authenticated with
749
+ const identity = resolveOAuthIdentity(
750
+ identityFromAccountType((account as any).type ?? (account as any).oauthClientKey),
751
+ );
752
+ const tokens = await oauthMod.refreshAccessToken(account.refreshToken, {
753
+ clientId: identity.clientId,
754
+ clientSecret: identity.clientSecret,
755
+ });
756
+ if (tokens?.access_token) {
757
+ account.accessToken = tokens.access_token;
758
+ account.expiry = Date.now() + (tokens.expires_in ?? 3600) * 1000;
759
+ await this.accountManager.save?.().catch(() => {});
760
+ return tokens.access_token;
761
+ }
762
+ }
763
+ } catch {
764
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
765
+ }
766
+ if (account.accessToken) return account.accessToken;
767
+ throw new Error(`no access token for ${account.email}`);
768
+ }
769
+
770
+ /**
771
+ * handleFetch — intercepta fetch do opencode provider google.
772
+ * Observer third-person: faz exatamente como Gemini CLI:
773
+ * - se account.projectId ausente, chama resolveProjectId que faz loadCodeAssist SEM project, onboardUser se necessário, nunca exige project do usuário.
774
+ * - wrapping {model, project, request} direto cloudcode-pa, sem localhost v1 proxy.
775
+ * - cascade 403/404/5xx, strip x-goog-user-project, skip sandbox para gemini-3.6/3.5/preview.
776
+ * - dual quota + cli_first + pid_offset.
777
+ */
778
+ async handleFetch(originalFetch: typeof fetch, input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
779
+ await this.init();
780
+
781
+ const urlStr =
782
+ typeof input === "string" ? input : input instanceof URL ? input.toString() : ((input as Request).url ?? "");
783
+ const method = (
784
+ init?.method ??
785
+ (typeof input !== "string" && !(input instanceof URL) ? (input as Request).method : "POST") ??
786
+ "POST"
787
+ ).toUpperCase();
788
+ const bodyStr = (init?.body as string) ?? "";
789
+
790
+ // only intercept POST to google / generative or our own transformed calls
791
+ // opencode typically calls fetch to provider endpoint; body contains model
792
+ let parsedBody: any = null;
793
+ let model: string | undefined;
794
+ let messages: any[] | undefined;
795
+ let tools: any[] | undefined;
796
+ let system: any | undefined;
797
+ let thinkingLevel: string | undefined;
798
+ let generationConfig: any | undefined;
799
+ let isAlreadyWrapped = false;
800
+
801
+ try {
802
+ if (bodyStr) {
803
+ parsedBody = JSON.parse(bodyStr);
804
+ model = parsedBody?.model ?? parsedBody?.request?.model ?? parsedBody?.modelId ?? "";
805
+ // Gemini-NATIVE SDK body (@ai-sdk/google and friends): {contents, systemInstruction?,
806
+ // tools?, toolConfig?, generationConfig?} at the ROOT with the model ONLY in the URL.
807
+ // Normalize in place into the wrapped {model, project, request} shape so the
808
+ // isAlreadyWrapped path below preserves the native request verbatim.
809
+ if (
810
+ !Array.isArray(parsedBody?.messages) &&
811
+ Array.isArray(parsedBody?.contents) &&
812
+ parsedBody?.request === undefined
813
+ ) {
814
+ const urlModel = extractModelFromUrl(urlStr);
815
+ const native: any = parsedBody;
816
+ parsedBody.request = {
817
+ contents: native.contents,
818
+ ...(native.systemInstruction !== undefined ? { systemInstruction: native.systemInstruction } : {}),
819
+ ...(native.tools !== undefined ? { tools: native.tools } : {}),
820
+ ...(native.toolConfig !== undefined ? { toolConfig: native.toolConfig } : {}),
821
+ ...(native.generationConfig !== undefined ? { generationConfig: native.generationConfig } : {}),
822
+ };
823
+ parsedBody.project = "__devthink_native__"; // marker so the wrapped check below passes
824
+ if (!parsedBody.model) parsedBody.model = urlModel ?? "";
825
+ model = parsedBody.model;
826
+ }
827
+ // detect if already in cloudcode envelope {model, project, request}
828
+ if (parsedBody?.project && parsedBody?.request && parsedBody?.model) {
829
+ isAlreadyWrapped = true;
830
+ model = parsedBody.model;
831
+ } else {
832
+ messages = parsedBody?.messages ?? parsedBody?.request?.contents ?? undefined;
833
+ tools = parsedBody?.tools ?? parsedBody?.request?.tools ?? undefined;
834
+ system =
835
+ parsedBody?.system ?? parsedBody?.systemInstruction ?? parsedBody?.request?.systemInstruction ?? undefined;
836
+ thinkingLevel =
837
+ parsedBody?.thinkingLevel ?? parsedBody?.thinking_level ?? parsedBody?.request?.thinkingLevel ?? undefined;
838
+ generationConfig = parsedBody?.generationConfig ?? parsedBody?.request?.generationConfig ?? undefined;
839
+ }
840
+ }
841
+ } catch {
842
+ // not json, skip intercept
843
+ }
844
+
845
+ // if not our model, passthrough
846
+ if (!isAlreadyWrapped && (!model || !this.shouldHandleModel(model))) {
847
+ return originalFetch(input, init);
848
+ }
849
+
850
+ const effectiveModel = String(model ?? "").trim();
851
+ if (!effectiveModel) return originalFetch(input, init);
852
+
853
+ // account selection dual quota
854
+ const selectedAccount = await this.getAccountForModel(effectiveModel);
855
+ if (!selectedAccount) {
856
+ return new Response(
857
+ JSON.stringify({
858
+ error: `no antigravity accounts available for model ${effectiveModel} — run opencode auth login`,
859
+ }),
860
+ { status: 401, headers: { "Content-Type": "application/json" } },
861
+ );
862
+ }
863
+ let account: Account = selectedAccount;
864
+
865
+ let accessToken: string;
866
+ try {
867
+ accessToken = await this.refreshIfNeeded(account);
868
+ } catch (e: any) {
869
+ return new Response(JSON.stringify({ error: `failed to refresh token for ${account.email}: ${e.message}` }), {
870
+ status: 401,
871
+ });
872
+ }
873
+
874
+ // bypass total Project ID — exactly Gemini CLI.
875
+ // NOTE: the FALLBACK project id is treated as "not discovered": accounts
876
+ // provisioned with the fallback never ran loadCodeAssist/onboardUser, so we
877
+ // resolve here (loadCodeAssist -> onboardUser free-tier -> load) to discover
878
+ // the account's REAL cloudaicompanionProject (verified live 2026-08-26: the
879
+ // fallback project yields 403 PERMISSION_DENIED on streamGenerateContent).
880
+ let projectId = (account as any).projectId?.trim() ?? "";
881
+ if (!projectId || projectId === FALLBACK_PROJECT_ID) {
882
+ try {
883
+ const resolved = await resolveProjectId({
884
+ accessToken,
885
+ cachedProjectId: undefined,
886
+ email: account.email,
887
+ metadataSaver: async (pid: string) => {
888
+ (account as any).projectId = pid;
889
+ // save to manager
890
+ try {
891
+ await this.accountManager.save?.();
892
+ } catch {
893
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
894
+ }
895
+ },
896
+ userAgent: this.getUserAgent(),
897
+ });
898
+ projectId = resolved.projectId;
899
+ } catch (e: any) {
900
+ // fallback still returns project id, but if throw -> use blinded fallback with warning
901
+ projectId = FALLBACK_PROJECT_ID;
902
+ console.warn(
903
+ `[m[devthink] resolveProjectId failed for ${account.email}, using fallback ${FALLBACK_PROJECT_ID}: ${e.message}`,
904
+ );
905
+ }
906
+ }
907
+
908
+ // transform request if not already wrapped
909
+ // google_search_enabled:"auto" → grounding only for Gemini (non-Claude) models
910
+ const searchOpt = this.options.google_search_enabled ?? true;
911
+ const searchEnabled =
912
+ typeof searchOpt === "string"
913
+ ? searchOpt === "auto"
914
+ ? effectiveModel.toLowerCase().includes("gemini") && !effectiveModel.toLowerCase().includes("claude")
915
+ : false
916
+ : !!searchOpt;
917
+
918
+ let buildOpts: BuildOptions;
919
+ if (isAlreadyWrapped) {
920
+ // already {model, project, request} — ensure project is our resolved one
921
+ const existingRequest = parsedBody.request ?? {};
922
+ buildOpts = {
923
+ model: effectiveModel,
924
+ messages: existingRequest.contents ? [{ role: "user", content: "" }] : messages, // dummy, will be overridden since we already have request
925
+ tools,
926
+ system,
927
+ thinkingLevel,
928
+ generationConfig,
929
+ accessToken,
930
+ projectId,
931
+ userAgent: this.getUserAgent(),
932
+ antigravityVersion: this.version,
933
+ pidOffsetEnabled: this.options.pid_offset_enabled ?? false,
934
+ googleSearchEnabled: searchEnabled,
935
+ claudeToolHardening: this.options.claude_tool_hardening,
936
+ keepThinking: this.options.keep_thinking,
937
+ };
938
+ // override inner via direct usage of parsedBody.request for preserving exact content if provided
939
+ // but still rebuild to ensure deterministic ids
940
+ buildOpts.messages = undefined; // we will inject directly below
941
+ } else {
942
+ buildOpts = {
943
+ model: effectiveModel,
944
+ messages,
945
+ tools,
946
+ system,
947
+ thinkingLevel,
948
+ generationConfig,
949
+ accessToken,
950
+ projectId,
951
+ userAgent: this.getUserAgent(),
952
+ antigravityVersion: this.version,
953
+ pidOffsetEnabled: this.options.pid_offset_enabled ?? false,
954
+ googleSearchEnabled: searchEnabled,
955
+ claudeToolHardening: this.options.claude_tool_hardening,
956
+ keepThinking: this.options.keep_thinking,
957
+ };
958
+ }
959
+
960
+ // image generation: strip tools and merge image gen config into the request
961
+ if (isImageGenModel(effectiveModel)) {
962
+ buildOpts.tools = [];
963
+ }
964
+
965
+ // Decide whether to use gemini-cli builder (prod-only) or antigravity generic
966
+ const isCliOnly = isGeminiCLIOnlyModel(effectiveModel) || shouldSkipSandbox(effectiveModel);
967
+ const builder =
968
+ (this.options.cli_first &&
969
+ effectiveModel.toLowerCase().includes("gemini") &&
970
+ !effectiveModel.toLowerCase().includes("claude")) ||
971
+ isCliOnly
972
+ ? buildGeminiCLIRequest
973
+ : buildAntigravityRequest;
974
+
975
+ let buildResult: BuildResult;
976
+ try {
977
+ buildResult = builder(buildOpts);
978
+
979
+ if (isImageGenModel(effectiveModel)) {
980
+ buildResult.body.request.generationConfig = {
981
+ ...(buildResult.body.request?.generationConfig ?? {}),
982
+ ...buildImageGenConfig(),
983
+ };
984
+ }
985
+
986
+ // If original was already wrapped (or Gemini-native normalized above),
987
+ // preserve its request content but use our project and ids.
988
+ if (isAlreadyWrapped && parsedBody?.request) {
989
+ // Keep original contents etc to avoid double transformation
990
+ const originalReq = parsedBody.request;
991
+ const builtReq = buildResult.body.request ?? {};
992
+ // generationConfig: the USER's fields win (never force parameters) —
993
+ // only the thinking config we synthesized is merged on top.
994
+ const builtGen: Record<string, any> = builtReq.generationConfig ?? {};
995
+ const origGen: Record<string, any> = originalReq.generationConfig ?? {};
996
+ const mergedGen: Record<string, any> = { ...origGen };
997
+ for (const [k, v] of Object.entries(builtGen)) {
998
+ if (k === "thinkingConfig") {
999
+ mergedGen.thinkingConfig = { ...((origGen.thinkingConfig as Record<string, any>) ?? {}), ...v };
1000
+ } else if (mergedGen[k] === undefined) {
1001
+ mergedGen[k] = v;
1002
+ }
1003
+ }
1004
+ // tools: keep the original declarations, add only what the builder
1005
+ // injected (googleSearch) when missing.
1006
+ const mergedTools: any[] = Array.isArray(originalReq.tools) ? [...originalReq.tools] : [];
1007
+ if (Array.isArray(builtReq.tools)) {
1008
+ for (const t of builtReq.tools) {
1009
+ if (t && t.googleSearch) {
1010
+ if (!mergedTools.some((x) => x && x.googleSearch)) mergedTools.push(t);
1011
+ } else if (t && Array.isArray(t.functionDeclarations) && t.functionDeclarations.length > 0) {
1012
+ if (!mergedTools.some((x) => x && x.functionDeclarations)) mergedTools.push(t);
1013
+ }
1014
+ }
1015
+ }
1016
+ buildResult.body.request = {
1017
+ ...originalReq,
1018
+ generationConfig: mergedGen,
1019
+ tools: mergedTools.length > 0 ? mergedTools : originalReq.tools,
1020
+ };
1021
+ // update model/project remains
1022
+ buildResult.body.model = effectiveModel;
1023
+ buildResult.body.project = projectId;
1024
+ }
1025
+ } catch (e: any) {
1026
+ return new Response(JSON.stringify({ error: `failed to build antigravity request: ${e.message}` }), {
1027
+ status: 500,
1028
+ });
1029
+ }
1030
+
1031
+ // cascade fetch with retry 403/404/5xx + strip forbidden headers
1032
+ const exec = async (): Promise<Response> => {
1033
+ let lastRes: Response | null = null;
1034
+ let lastError: Error | null = null;
1035
+
1036
+ // attempt up to 2 account rotations on 403/429
1037
+ for (let accountAttempt = 0; accountAttempt < 2; accountAttempt++) {
1038
+ try {
1039
+ const response = await fetchWithCascade(buildResult, { timeoutMs: CONTENT_TIMEOUT_MS });
1040
+
1041
+ // handle quota exhausted
1042
+ if (response.status === 429 || (response.headers.get("content-type")?.includes("json") && false)) {
1043
+ // read body text for quota check but need clone
1044
+ const cloned = response.clone();
1045
+ const txt = await cloned.text().catch(() => "");
1046
+ if (this.quotaManager.isQuotaError(response.status, txt)) {
1047
+ this.quotaManager.markExhausted(account.email, effectiveModel, 60 * 60 * 1000);
1048
+ await (this.accountManager as any).markQuotaExhausted?.(account.email, 60 * 60 * 1000).catch(() => {});
1049
+ notifyUser(`account ${account.email} rate-limited, rotating`);
1050
+ // soft quota: percent-bearing bodies mark a shorter cooldown window
1051
+ const low = txt.toLowerCase();
1052
+ if (low.includes("percent") || low.includes("%")) {
1053
+ const ttlMinOpt = this.options.soft_quota_cache_ttl_minutes;
1054
+ const softMs = (ttlMinOpt === "auto" || ttlMinOpt === undefined ? 15 : ttlMinOpt) * 60 * 1000;
1055
+ (this.accountManager as any).markSoftQuota?.(account.email, softMs);
1056
+ }
1057
+ // rotate account
1058
+ const nextAcc = await this.getAccountForModel(effectiveModel);
1059
+ if (nextAcc && nextAcc.email !== account.email) {
1060
+ account = nextAcc;
1061
+ try {
1062
+ accessToken = await this.refreshIfNeeded(account);
1063
+ } catch {
1064
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1065
+ }
1066
+ // re-resolve projectId if missing for new account
1067
+ let newPid = (account as any).projectId?.trim() ?? "";
1068
+ if (!newPid) {
1069
+ try {
1070
+ const resolved = await resolveProjectId({
1071
+ accessToken,
1072
+ email: account.email,
1073
+ metadataSaver: async (pid: string) => {
1074
+ (account as any).projectId = pid;
1075
+ try {
1076
+ await this.accountManager.save?.();
1077
+ } catch {
1078
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1079
+ }
1080
+ },
1081
+ userAgent: this.getUserAgent(),
1082
+ });
1083
+ newPid = resolved.projectId;
1084
+ } catch {
1085
+ newPid = FALLBACK_PROJECT_ID;
1086
+ }
1087
+ }
1088
+ // rebuild with new token/project
1089
+ buildOpts.accessToken = accessToken;
1090
+ buildOpts.projectId = newPid;
1091
+ buildResult = builder(buildOpts);
1092
+ continue;
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ // success or non-retryable error — return response, but transform if needed
1098
+ lastRes = response;
1099
+
1100
+ // if response is not ok and retryable, cascade already handled inside fetchWithCascade loop — but if final is 403/404 here, try next endpoint account rotation
1101
+ if (response.status === 403 || response.status === 404 || response.status >= 500) {
1102
+ lastError = new Error(`retryable status ${response.status}`);
1103
+ // try next account rotation once
1104
+ if (accountAttempt === 0) {
1105
+ const nextAcc = await this.getAccountForModel(effectiveModel);
1106
+ if (nextAcc && nextAcc.email !== account.email) {
1107
+ account = nextAcc;
1108
+ try {
1109
+ accessToken = await this.refreshIfNeeded(account);
1110
+ } catch {
1111
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1112
+ }
1113
+ buildOpts.accessToken = accessToken;
1114
+ buildOpts.projectId = (account as any).projectId ?? projectId;
1115
+ buildResult = builder(buildOpts);
1116
+ continue;
1117
+ }
1118
+ }
1119
+ }
1120
+
1121
+ return response;
1122
+ } catch (e: any) {
1123
+ lastError = e instanceof Error ? e : new Error(String(e));
1124
+ // try next account if error retryable
1125
+ if (accountAttempt === 0) {
1126
+ const nextAcc = await this.getAccountForModel(effectiveModel);
1127
+ if (nextAcc && nextAcc.email !== account.email) {
1128
+ account = nextAcc;
1129
+ try {
1130
+ accessToken = await this.refreshIfNeeded(account);
1131
+ } catch {
1132
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1133
+ }
1134
+ buildOpts.accessToken = accessToken;
1135
+ buildOpts.projectId = (account as any).projectId ?? projectId;
1136
+ buildResult = builder(buildOpts);
1137
+ continue;
1138
+ }
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ if (lastRes) return lastRes;
1144
+ throw lastError ?? new Error("all account attempts failed");
1145
+ };
1146
+
1147
+ let response: Response | null = null;
1148
+ try {
1149
+ response = await exec();
1150
+ } catch (e: any) {
1151
+ // session recovery: classify, notify, sanitize messages, retry once
1152
+ const errType = detectFetchErrorType(e);
1153
+ let retried = false;
1154
+ if (errType !== "unknown") {
1155
+ notifyUser(`recoverable ${errType} error, retrying once`);
1156
+ try {
1157
+ const recoveredMessages = fetchSessionRecovery(errType, messages);
1158
+ if (Array.isArray(recoveredMessages)) messages = recoveredMessages;
1159
+ if (!isAlreadyWrapped) {
1160
+ buildOpts.messages = messages;
1161
+ buildResult = builder(buildOpts);
1162
+ }
1163
+ response = await exec();
1164
+ retried = true;
1165
+ } catch {
1166
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1167
+ }
1168
+ }
1169
+ if (!retried) {
1170
+ // try fallback to other pool if cli_first enabled
1171
+ if (this.options.cli_first && !isCliOnly) {
1172
+ try {
1173
+ const fallbackBuild = buildGeminiCLIRequest({
1174
+ ...buildOpts,
1175
+ endpointOverrides: [CODE_ASSIST_ENDPOINTS.PROD],
1176
+ });
1177
+ response = await fetchWithCascade(fallbackBuild);
1178
+ } catch {
1179
+ return new Response(JSON.stringify({ error: `antigravity request failed after retries: ${e.message}` }), {
1180
+ status: 500,
1181
+ headers: { "Content-Type": "application/json" },
1182
+ });
1183
+ }
1184
+ } else {
1185
+ return new Response(JSON.stringify({ error: `antigravity request failed after retries: ${e.message}` }), {
1186
+ status: 500,
1187
+ headers: { "Content-Type": "application/json" },
1188
+ });
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ if (!response) throw new Error("antigravity request failed: no response");
1194
+
1195
+ // transform SSE to OpenAI-compatible if needed — detect client expects event-stream?
1196
+ const contentType = response.headers.get("content-type") ?? "";
1197
+ // Only transform SUCCESS responses as SSE — Google sends 4xx/5xx error JSON
1198
+ // with Content-Type text/event-stream too (verified live), so status alone
1199
+ // must gate the transform or the real error message gets swallowed into an
1200
+ // empty chunk and the user never sees "not available in your location".
1201
+ const isSSE =
1202
+ response.ok &&
1203
+ (contentType.includes("text/event-stream") ||
1204
+ response.url.includes("alt=sse") ||
1205
+ effectiveModel.includes("gemini") ||
1206
+ effectiveModel.includes("claude"));
1207
+
1208
+ // If caller originally asked for OpenAI SSE (common in opencode), we transform cloudcode SSE → OpenAI SSE lines
1209
+ // Observer notes: return text/event-stream with data: {...}\n\n lines
1210
+ if (isSSE && response.body) {
1211
+ const isAnthropic = parsedBody?.anthropic_version !== undefined || urlStr.toLowerCase().includes("anthropic");
1212
+
1213
+ const parser = createSSEParser();
1214
+ const decoder = new TextDecoder();
1215
+ const encoder = new TextEncoder();
1216
+ // keep_thinking: emit reasoning parts; otherwise drop thought-only parts
1217
+ const keepThinking = !!this.options.keep_thinking;
1218
+ const sessionIdForPreserve = buildResult.sessionId;
1219
+
1220
+ const originalStream = response.body as ReadableStream<Uint8Array>;
1221
+
1222
+ /** Emits one raw Gemini part: preserves thinking signatures, honors keepThinking. */
1223
+ const emitPart = (part: any): boolean => {
1224
+ if (!part) return false;
1225
+ if (part.thought === true || typeof part.thoughtSignature === "string") {
1226
+ const thinkingText =
1227
+ typeof part.thought === "string" ? part.thought : typeof part.text === "string" ? part.text : "";
1228
+ if (thinkingText && typeof part.thoughtSignature === "string") {
1229
+ preserveValidatedSignature(thinkingText, part.thoughtSignature, {
1230
+ sessionId: sessionIdForPreserve,
1231
+ modelId: effectiveModel,
1232
+ });
1233
+ }
1234
+ if (!keepThinking) return false; // drop reasoning-only part unless keep_thinking
1235
+ }
1236
+ return true;
1237
+ };
1238
+
1239
+ const transformedStream = new ReadableStream({
1240
+ async start(controller) {
1241
+ const reader = originalStream.getReader();
1242
+ let first = true;
1243
+ try {
1244
+ while (true) {
1245
+ const { done, value } = await reader.read();
1246
+ if (done) break;
1247
+ const text = decoder.decode(value, { stream: true });
1248
+ const events = parser.parseChunk(text);
1249
+ for (const ev of events) {
1250
+ const payloads = normalizeGeminiPayload(ev.data);
1251
+ for (const part of payloads) {
1252
+ if (!emitPart(part)) continue;
1253
+ const chunkObj = transformToOpenAIChunk([part], effectiveModel, first, false);
1254
+ first = false;
1255
+ const line = `data: ${JSON.stringify(chunkObj)}\n\n`;
1256
+ controller.enqueue(encoder.encode(line));
1257
+ }
1258
+ // if event data is [DONE] close
1259
+ if (ev.data.trim() === SSE_DONE) {
1260
+ controller.enqueue(encoder.encode(`data: ${SSE_DONE}\n\n`));
1261
+ }
1262
+ }
1263
+ }
1264
+ // flush
1265
+ const remaining = parser.flush();
1266
+ for (const ev of remaining) {
1267
+ const payloads = normalizeGeminiPayload(ev.data);
1268
+ for (const part of payloads) {
1269
+ if (!emitPart(part)) continue;
1270
+ const chunkObj = transformToOpenAIChunk([part], effectiveModel, first, false);
1271
+ first = false;
1272
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunkObj)}\n\n`));
1273
+ }
1274
+ }
1275
+ // final chunk with stop
1276
+ const finalChunk = {
1277
+ id: `chatcmpl-${fnv1a32Hex(effectiveModel + Date.now()).slice(0, 12)}`,
1278
+ object: "chat.completion.chunk",
1279
+ created: Math.floor(Date.now() / 1000),
1280
+ model: effectiveModel,
1281
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
1282
+ };
1283
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`));
1284
+ controller.enqueue(encoder.encode(`data: ${SSE_DONE}\n\n`));
1285
+ controller.close();
1286
+ } catch (e) {
1287
+ controller.error(e);
1288
+ }
1289
+ },
1290
+ });
1291
+
1292
+ return new Response(transformedStream, {
1293
+ status: response.status,
1294
+ headers: {
1295
+ "Content-Type": "text/event-stream",
1296
+ "Cache-Control": "no-cache",
1297
+ Connection: "keep-alive",
1298
+ "X-Antigravity-Model": effectiveModel,
1299
+ "X-Antigravity-Project": projectId,
1300
+ },
1301
+ });
1302
+ }
1303
+
1304
+ // non-streaming: try to transform JSON to OpenAI-compatible
1305
+ try {
1306
+ const text = await response.text();
1307
+ let json: any;
1308
+ try {
1309
+ json = JSON.parse(text);
1310
+ } catch {
1311
+ json = { text };
1312
+ }
1313
+
1314
+ // if already OpenAI shape, just return
1315
+ if (json.choices || json.content) {
1316
+ return new Response(JSON.stringify(json), {
1317
+ status: response.status,
1318
+ headers: { "Content-Type": "application/json" },
1319
+ });
1320
+ }
1321
+
1322
+ // unwrap Gemini candidate to text
1323
+ const candidates = json.response?.candidates ?? json.candidates ?? [];
1324
+ let combinedText = "";
1325
+ for (const cand of candidates) {
1326
+ const parts = cand.content?.parts ?? [];
1327
+ for (const p of parts) if (p.text) combinedText += p.text;
1328
+ }
1329
+ // if we got text, wrap as OpenAI
1330
+ if (combinedText) {
1331
+ const openAI = {
1332
+ id: `chatcmpl-${fnv1a32Hex(effectiveModel + Date.now()).slice(0, 12)}`,
1333
+ object: "chat.completion",
1334
+ created: Math.floor(Date.now() / 1000),
1335
+ model: effectiveModel,
1336
+ choices: [{ index: 0, message: { role: "assistant", content: combinedText }, finish_reason: "stop" }],
1337
+ };
1338
+ return new Response(JSON.stringify(openAI), { status: 200, headers: { "Content-Type": "application/json" } });
1339
+ }
1340
+
1341
+ return new Response(JSON.stringify(json), {
1342
+ status: response.status,
1343
+ headers: { "Content-Type": "application/json" },
1344
+ });
1345
+ } catch {
1346
+ return response;
1347
+ }
1348
+ }
1349
+
1350
+ getPluginDefinition() {
1351
+ const self = this;
1352
+ return {
1353
+ id: PLUGIN_ID,
1354
+ version: PLUGIN_VERSION,
1355
+ name: "Antigravity Auth Next",
1356
+ description:
1357
+ "Enable Opencode to authenticate against Antigravity via OAuth — supports models 25/08/2026 (gemini-3.6/3.5/3.1/3/2.5, claude-opus-4-6-thinking, gpt-oss-120b), dual quota, auto-recovery, direct cloudcode-pa (no localhost v1), bypass Project ID as Gemini CLI via loadCodeAssist+onboardUser",
1358
+ async onInit() {
1359
+ await self.init();
1360
+ },
1361
+ hooks: {
1362
+ "tool.execute.before": async (ctx: any) => ctx,
1363
+ "tool.execute.after": async (ctx: any) => ctx,
1364
+ fetch: async (input: RequestInfo | URL, init?: RequestInit, next: typeof fetch = fetch) => {
1365
+ return self.handleFetch(next, input, init);
1366
+ },
1367
+ },
1368
+ auth: {
1369
+ provider: "google",
1370
+ /**
1371
+ * opencode credential loader (merged from legacy plugin.ts): supplies the
1372
+ * active account OAuth credentials to opencode without re-running login.
1373
+ */
1374
+ async loader() {
1375
+ await self.init();
1376
+ try {
1377
+ const m = self.accountManager;
1378
+ const list = (m as any).list?.() ?? (m as any).accounts ?? [];
1379
+ const active = Array.isArray(list)
1380
+ ? (list.find((a: any) => a?.enabled !== false && a?.disabled !== true) ?? list[0] ?? null)
1381
+ : null;
1382
+ if (!active) return null;
1383
+ let token = active.accessToken ?? active.access_token;
1384
+ const expiry = active.expiry ?? active.expiryDate ?? 0;
1385
+ if (!token || Date.now() > expiry - 60000) {
1386
+ if (typeof (m as any).refreshIfNeeded === "function") {
1387
+ try {
1388
+ token = await (m as any).refreshIfNeeded(active.email);
1389
+ } catch {
1390
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1391
+ }
1392
+ }
1393
+ if (!token && active.refreshToken) {
1394
+ try {
1395
+ const t = await oauthMod.refreshToken(active.refreshToken);
1396
+ token = t.access_token;
1397
+ } catch {
1398
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1399
+ }
1400
+ }
1401
+ }
1402
+ if (!token) return null;
1403
+ return {
1404
+ type: "oauth",
1405
+ accessToken: token,
1406
+ refreshToken: active.refreshToken,
1407
+ projectId: active.projectId || FALLBACK_PROJECT_ID,
1408
+ email: active.email,
1409
+ };
1410
+ } catch {
1411
+ return null;
1412
+ }
1413
+ },
1414
+ async login() {
1415
+ const identity = resolveOAuthIdentity(((self as any).options?.oauth_identity as string) ?? "auto");
1416
+ const result = await oauthMod.startOAuthFlow({
1417
+ clientId: identity.clientId,
1418
+ clientSecret: identity.clientSecret,
1419
+ scopes: identity.scopesStr,
1420
+ });
1421
+ if (!(result as any)?.tokens?.refresh_token && !(result as any)?.refresh_token)
1422
+ throw new Error("OAuth failed no refresh_token");
1423
+ const tokens = (result as any).tokens ?? result;
1424
+ const userInfo = (result as any).userInfo ?? {};
1425
+ const email = userInfo.email ?? `user-${Date.now()}@gmail.com`;
1426
+ const refresh = tokens.refresh_token ?? (result as any).refresh_token;
1427
+ const access = tokens.access_token ?? (result as any).access_token;
1428
+ // try add via AccountManager
1429
+ try {
1430
+ const ExtMgr = (accountsMod as any).AccountManager;
1431
+ if (ExtMgr) {
1432
+ const mgr = new ExtMgr();
1433
+ await mgr.load();
1434
+ (await (mgr as any).addAccount?.({
1435
+ email,
1436
+ refreshToken: refresh,
1437
+ accessToken: access,
1438
+ oauthClientKey: identity.identity,
1439
+ oauthClientConfig: { clientId: identity.clientId, clientSecret: identity.clientSecret },
1440
+ type: identity.identity,
1441
+ } as any)) ??
1442
+ (await (mgr as any)
1443
+ .add?.({ email, refreshToken: refresh, accessToken: access } as any)
1444
+ .catch(() => {}));
1445
+ await mgr.save();
1446
+ }
1447
+ } catch {
1448
+ // fallback inline
1449
+ const inline = new InlineAccountManager();
1450
+ await inline.load();
1451
+ await inline.addAccount({ email, refreshToken: refresh, accessToken: access, type: identity.identity });
1452
+ }
1453
+ return { email };
1454
+ },
1455
+ async list() {
1456
+ await self.init();
1457
+ try {
1458
+ const m = self.accountManager;
1459
+ return (m as any).list?.() ?? (m as any).accounts ?? [];
1460
+ } catch {
1461
+ return [];
1462
+ }
1463
+ },
1464
+ async logout(email?: string) {
1465
+ try {
1466
+ const ExtMgr = (accountsMod as any).AccountManager;
1467
+ if (ExtMgr) {
1468
+ const mgr = new ExtMgr();
1469
+ await mgr.load();
1470
+ if (email) await (mgr as any).remove?.(email);
1471
+ else await (mgr as any).clear?.();
1472
+ await mgr.save();
1473
+ }
1474
+ } catch {
1475
+ const inline = new InlineAccountManager();
1476
+ await inline.load();
1477
+ if (!email) {
1478
+ await saveAccountsInline([]);
1479
+ } else {
1480
+ const accs = await loadAccountsInline();
1481
+ await saveAccountsInline(accs.filter((a) => a.email.toLowerCase() !== email.toLowerCase()));
1482
+ }
1483
+ }
1484
+ },
1485
+ /**
1486
+ * Enable/disable toggle (merged from plugin(5)): disabled accounts are
1487
+ * skipped by rotation until re-enabled.
1488
+ */
1489
+ async enable(email: string, en = true) {
1490
+ await self.init();
1491
+ try {
1492
+ if (typeof (self.accountManager as any)?.enable === "function") {
1493
+ await (self.accountManager as any).enable(email, en);
1494
+ return;
1495
+ }
1496
+ } catch {
1497
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1498
+ }
1499
+ try {
1500
+ const accs = await loadAccountsInline();
1501
+ for (const a of accs) {
1502
+ if (a.email.toLowerCase() === email.toLowerCase()) (a as any).disabled = !en;
1503
+ }
1504
+ await saveAccountsInline(accs);
1505
+ } catch {
1506
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1507
+ }
1508
+ },
1509
+ },
1510
+ config: {
1511
+ schema: {
1512
+ oauth_identity: {
1513
+ type: "string",
1514
+ enum: ["auto", "antigravity-cli", "gemini-cli"],
1515
+ default: "auto",
1516
+ description:
1517
+ "OAuth client identity for NEW logins: 'auto'/'antigravity-cli' masquerades as the Antigravity CLI (recommended — its client onboards the current free tier), 'gemini-cli' keeps the legacy Gemini CLI client. Stored accounts always refresh with the client they authenticated with.",
1518
+ },
1519
+ cli_first: {
1520
+ type: "boolean",
1521
+ default: false,
1522
+ description:
1523
+ "Route Gemini models to Gemini CLI quota first (preserve Antigravity quota for Claude) — dual quota",
1524
+ },
1525
+ pid_offset_enabled: {
1526
+ type: "boolean",
1527
+ default: false,
1528
+ description: "Enable PID offset trick 0-1000 randomization for user_prompt_id (9router anti-fingerprint)",
1529
+ },
1530
+ google_search_enabled: {
1531
+ type: ["boolean", "string"],
1532
+ default: true,
1533
+ description: "Enable google_search grounding; true/false force, 'auto' enables for Gemini models only",
1534
+ },
1535
+ quota_refresh_interval_minutes: { type: "number", default: 15 },
1536
+ rotation_strategy: { type: "string", enum: ["round-robin", "sticky"], default: "round-robin" },
1537
+ claude_tool_hardening: {
1538
+ type: "boolean",
1539
+ default: true,
1540
+ description: "Prefix tool descriptions with hardening hints for Claude models",
1541
+ },
1542
+ keep_thinking: {
1543
+ type: "boolean",
1544
+ default: false,
1545
+ description: "Keep thinking blocks/signatures in output instead of dropping reasoning parts",
1546
+ },
1547
+ soft_quota_threshold_percent: {
1548
+ type: "number",
1549
+ default: 90,
1550
+ description: "Skip accounts at or above this used-percentage during rotation",
1551
+ },
1552
+ soft_quota_cache_ttl_minutes: {
1553
+ type: ["number", "string"],
1554
+ default: "auto",
1555
+ description: "Soft-quota mark duration in minutes; 'auto' resolves to 15",
1556
+ },
1557
+ quiet_mode: { type: "boolean", default: false, description: "Suppress toast() notifications" },
1558
+ },
1559
+ defaults: {
1560
+ cli_first: false,
1561
+ pid_offset_enabled: false,
1562
+ google_search_enabled: true,
1563
+ quota_refresh_interval_minutes: 15,
1564
+ rotation_strategy: "round-robin",
1565
+ claude_tool_hardening: true,
1566
+ keep_thinking: false,
1567
+ soft_quota_threshold_percent: 90,
1568
+ soft_quota_cache_ttl_minutes: "auto",
1569
+ quiet_mode: false,
1570
+ },
1571
+ },
1572
+ models: MODELS_2026_PROVIDER.map((id) => ({
1573
+ id,
1574
+ name: id,
1575
+ provider: "google",
1576
+ // v2.1.16 single-owner fix: context/output limits come from models.js
1577
+ // (THE owner). gpt-oss intentionally corrected 131072/32768 ->
1578
+ // 200000/64000 to match the models.ts catalog
1579
+ // (CONTEXT_WINDOW_GPT_OSS / OUTPUT_LIMIT_GPT_OSS); claude/gemini values
1580
+ // are byte-identical to the former inline numbers.
1581
+ context: id.toLowerCase().includes("claude")
1582
+ ? CONTEXT_WINDOW_CLAUDE
1583
+ : id.toLowerCase().includes("gpt-oss")
1584
+ ? CONTEXT_WINDOW_GPT_OSS
1585
+ : CONTEXT_WINDOW_GEMINI,
1586
+ output: id.toLowerCase().includes("claude")
1587
+ ? OUTPUT_LIMIT_CLAUDE
1588
+ : id.toLowerCase().includes("gpt-oss")
1589
+ ? OUTPUT_LIMIT_GPT_OSS
1590
+ : OUTPUT_LIMIT_GEMINI,
1591
+ // api pool routing metadata (from ALL_MODELS_2026 merge)
1592
+ api: mapModelToGroup(id),
1593
+ modalities: { input: ["text", "image", "pdf"], output: ["text"] },
1594
+ })),
1595
+ };
1596
+ }
1597
+ }
1598
+
1599
+ // singleton for opencode
1600
+ let pluginInstance: AntigravityPlugin | null = null;
1601
+
1602
+ export function getPlugin(options?: PluginOptions): AntigravityPlugin {
1603
+ if (!pluginInstance) pluginInstance = new AntigravityPlugin(options);
1604
+ return pluginInstance;
1605
+ }
1606
+
1607
+ /** Legacy definition export (pre-1.18 shape) kept for library consumers. */
1608
+ export const plugin = getPlugin().getPluginDefinition();
1609
+
1610
+ // =============================================================================
1611
+ // OPENCODE PLUGIN MODULE BRIDGE (opencode >= 1.18 contract)
1612
+ // =============================================================================
1613
+ // opencode loads the plugin default export and requires the shape
1614
+ // { id?: string, server: (input, options?) => Promise<Hooks> } - the loader
1615
+ // rejects anything else with "must default export an object with server()".
1616
+ // The bridge below adapts AntigravityPlugin to that contract:
1617
+ //
1618
+ // auth hook - provider "google" with one oauth method. authorize()
1619
+ // starts the PKCE + local-callback flow (the very same
1620
+ // Gemini CLI masquerade flow the CLI `login` command uses)
1621
+ // and returns the Google consent URL; opencode renders it,
1622
+ // and the returned callback() resolves with the exchanged
1623
+ // tokens and persists the account in the robin-hood store.
1624
+ // auth.loader - supplies the active account credentials without a login.
1625
+ // chat.headers - injects the Gemini CLI identification (User-Agent,
1626
+ // X-Goog-Api-Client, Client-Metadata) on every request:
1627
+ // the dual-bypass second face.
1628
+ // tool hooks - instrumentation passthrough kept from the legacy shape.
1629
+ //
1630
+ // The bridge reuses only node:* builtins plus the local modules - no new
1631
+ // dependencies, no bundling requirements.
1632
+
1633
+ /**
1634
+ * Persists an OAuth account using the richest store available (the same
1635
+ * write path as the legacy auth.login hook).
1636
+ */
1637
+ async function persistOAuthAccount(
1638
+ email: string,
1639
+ refresh: string,
1640
+ access: string,
1641
+ expiresIn: number,
1642
+ identity?: {
1643
+ identity: string;
1644
+ clientId: string;
1645
+ clientSecret: string;
1646
+ scopes?: readonly string[];
1647
+ },
1648
+ ): Promise<void> {
1649
+ const inst = getPlugin();
1650
+ try {
1651
+ await (inst as any).init?.();
1652
+ } catch {
1653
+ /* best effort */
1654
+ }
1655
+ try {
1656
+ const m = (inst as any).accountManager;
1657
+ if (m && typeof m.addAccount === "function") {
1658
+ await m.addAccount({
1659
+ email,
1660
+ refreshToken: refresh,
1661
+ accessToken: access,
1662
+ expiryDate: Date.now() + expiresIn * 1000,
1663
+ projectId: "", // left empty on purpose: the first request runs
1664
+ // resolveProjectId (loadCodeAssist + onboardUser
1665
+ // free-tier) and persists the account's REAL
1666
+ // cloudaicompanionProject — the hardcoded fallback
1667
+ // yields 403 PERMISSION_DENIED on generateContent.
1668
+ enabled: true,
1669
+ // identity the account authenticated with — refresh uses the same
1670
+ // OAuth client (v3 store: oauthClientKey; inline store: type)
1671
+ ...(identity
1672
+ ? {
1673
+ oauthClientKey: identity.identity,
1674
+ oauthClientConfig: { clientId: identity.clientId, clientSecret: identity.clientSecret },
1675
+ scopes: identity.scopes ? [...identity.scopes] : undefined,
1676
+ type: identity.identity,
1677
+ }
1678
+ : {}),
1679
+ });
1680
+ return;
1681
+ }
1682
+ } catch {
1683
+ /* fall through to the inline store */
1684
+ }
1685
+ const inline = new InlineAccountManager();
1686
+ await inline.load();
1687
+ await inline.addAccount({
1688
+ email,
1689
+ refreshToken: refresh,
1690
+ accessToken: access,
1691
+ ...(identity ? { type: identity.identity } : {}),
1692
+ } as any);
1693
+ }
1694
+
1695
+ /**
1696
+ * Detects which opencode.json provider block belongs to the provider plugin by looking at
1697
+ * MODEL IDS ONLY (never the provider name): any provider whose models include
1698
+ * `antigravity-*` prefixed ids or Google `gemini-*` ids is a provider consumer,
1699
+ * whatever the user named it ("google", "antigravity", "casas-bahia", ...).
1700
+ * Deterministic priority: antigravity-* models > gemini-* models; exact ids
1701
+ * "google" / "antigravity" win ties; file order breaks remaining ties.
1702
+ * Returns null when no config/provider qualifies (caller falls back "google").
1703
+ */
1704
+ export function detectOpencodeProviderId(configDir?: string): string | null {
1705
+ const candidates: string[] = [];
1706
+ // OPENCODE_CONFIG (opencode >= 1.18) points at the exact config FILE - the
1707
+ // strongest signal there is, so it goes first (plus its dir's canonical
1708
+ // names, in case the file itself is named differently).
1709
+ const envFile = process.env.OPENCODE_CONFIG?.trim();
1710
+ if (envFile) {
1711
+ const absFile = resolve(envFile);
1712
+ candidates.push(absFile, join(dirname(absFile), "opencode.json"), join(dirname(absFile), "config.json"));
1713
+ }
1714
+ const envDir = configDir ?? process.env.OPENCODE_CONFIG_DIR?.trim();
1715
+ if (envDir) candidates.push(join(resolve(envDir), "opencode.json"), join(resolve(envDir), "config.json"));
1716
+ candidates.push(
1717
+ join(process.cwd(), "opencode.json"),
1718
+ join(process.cwd(), ".opencode", "opencode.json"),
1719
+ join(homedir(), ".config", "opencode", "opencode.json"),
1720
+ join(homedir(), ".config", "opencode", "config.json"),
1721
+ join(homedir(), ".opencode", "opencode.json"),
1722
+ );
1723
+ let cfg: any = null;
1724
+ for (const p of candidates) {
1725
+ try {
1726
+ if (!existsSync(p)) continue;
1727
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
1728
+ if (parsed && typeof parsed === "object") {
1729
+ cfg = parsed;
1730
+ break;
1731
+ }
1732
+ } catch {
1733
+ /* unreadable candidate - try next */
1734
+ }
1735
+ }
1736
+ const providers = cfg?.provider;
1737
+ if (!providers || typeof providers !== "object") return null;
1738
+ let best: { id: string; score: number } | null = null;
1739
+ for (const [id, block] of Object.entries(providers as Record<string, any>)) {
1740
+ if (!block || typeof block !== "object") continue;
1741
+ const models = block.models;
1742
+ let score = 0;
1743
+ if (models && typeof models === "object") {
1744
+ for (const modelId of Object.keys(models)) {
1745
+ const l = modelId.toLowerCase();
1746
+ if (l.startsWith("antigravity-")) {
1747
+ score = Math.max(score, 100);
1748
+ break;
1749
+ }
1750
+ if (l.startsWith("gemini-")) score = Math.max(score, 60);
1751
+ }
1752
+ }
1753
+ const lid = id.toLowerCase();
1754
+ if (score <= 0) {
1755
+ // v2.1.20 — two STANDARD provider ids are recognized even without
1756
+ // the provider model ids: "antigravity" (ours) and "google" (Google's).
1757
+ // They score below any provider that actually carries
1758
+ // antigravity-*/gemini-* models, so real model blocks always win.
1759
+ if (lid === "antigravity") score = 50;
1760
+ else if (lid === "google") score = 40;
1761
+ else continue;
1762
+ }
1763
+ if (lid === "google") score += 10;
1764
+ else if (lid === "antigravity") score += 5;
1765
+ if (!best || score > best.score) best = { id, score };
1766
+ }
1767
+ return best ? best.id : null;
1768
+ }
1769
+
1770
+ // =============================================================================
1771
+ // OPENCODE AUTH ENTRIES (opencode >= 1.18 contract)
1772
+ // =============================================================================
1773
+ // `opencode auth login` (and the TUI "Connect a provider" dialog) list one
1774
+ // entry per plugin auth hook, keyed by auth.provider. the plugin registers THREE
1775
+ // entries from a single plugin reference (see server.ts, the ./server
1776
+ // package entry):
1777
+ // - provider "google" — the standard Google entry
1778
+ // - provider "antigravity" — the standard Antigravity entry
1779
+ // - the CUSTOM provider detected by MODEL IDS in opencode.json
1780
+ // ("casasbahia", "my-google", ... — whatever the user named it)
1781
+ // Every entry shares the same credential loader (SDK options with the live
1782
+ // OAuth token + identification headers + the cloudcode-pa fetch translator)
1783
+ // and the same OAuth method (Antigravity CLI masquerade, PKCE, automatic
1784
+ // loopback callback — no code copy/paste). The exchanged credential is
1785
+ // stored under the provider that actually carries the provider models, so
1786
+ // streaming works no matter which entry the user picked.
1787
+
1788
+ /**
1789
+ * Builds the shared credential loader: opencode merges the return straight
1790
+ * into the provider SDK options (baseURL + apiKey = the live OAuth access
1791
+ * token + the identity identification headers + a fetch override wired to
1792
+ * the plugin handleFetch translator, which rewrites every chat request into
1793
+ * the Antigravity/Gemini-CLI cloudcode-pa envelope).
1794
+ */
1795
+ function buildOpencodeAuthLoader(inst: AntigravityPlugin, legacyAuth: any) {
1796
+ return async (): Promise<Record<string, any> | null> => {
1797
+ try {
1798
+ const legacyInfo = (await legacyAuth.loader?.()) as any;
1799
+ if (!legacyInfo?.accessToken) return null;
1800
+ // request fingerprint of the active account's identity — the
1801
+ // antigravity-cli client presents its own UA/api-client pair, the
1802
+ // gemini-cli family keeps the GeminiCLI fingerprint
1803
+ const activeAccount = (() => {
1804
+ try {
1805
+ const list = (inst as any).accountManager?.list?.() ?? (inst as any).accountManager?.accounts ?? [];
1806
+ return Array.isArray(list) ? (list.find((a: any) => a?.email === legacyInfo.email) ?? list[0] ?? null) : null;
1807
+ } catch {
1808
+ return null;
1809
+ }
1810
+ })();
1811
+ const isAgCli =
1812
+ identityFromAccountType((activeAccount as any)?.type ?? (activeAccount as any)?.oauthClientKey) ===
1813
+ "antigravity-cli";
1814
+ const headers = isAgCli
1815
+ ? getAntigravityCliHeaders()
1816
+ : ((oauthMod as any).getGeminiHeaders?.() ?? FALLBACK_GEMINI_HEADERS);
1817
+ return {
1818
+ // v2.1.16 single-owner fix: cloudcode-pa base URL from constants.js
1819
+ // (CLOUDCODE_BASE_URL owner) instead of a local literal.
1820
+ baseURL: CLOUDCODE_BASE_URL,
1821
+ apiKey: legacyInfo.accessToken,
1822
+ headers,
1823
+ fetch: (input: any, init?: any) => (inst as any).handleFetch(fetch, input, init),
1824
+ };
1825
+ } catch {
1826
+ return null;
1827
+ }
1828
+ };
1829
+ }
1830
+
1831
+ /**
1832
+ * Resolves the provider id the OAuth credential is stored under: the
1833
+ * detected provider (model ids first) — otherwise the entry's own provider,
1834
+ * except that "antigravity" is only kept when the provider will actually
1835
+ * exist (user config or the zero-config injection); with the injection
1836
+ * disabled and nothing configured the credential falls back to "google",
1837
+ * which always exists in opencode's models.dev database (a credential whose
1838
+ * provider exists nowhere crashes opencode's auth-loader merge).
1839
+ */
1840
+ function resolveCredentialTarget(fallbackProvider: string): string {
1841
+ const detected = detectOpencodeProviderId();
1842
+ if (detected) return detected;
1843
+ if (fallbackProvider === "antigravity") {
1844
+ const injectionEnabled =
1845
+ ((getPlugin() as any)?.options?.inject_antigravity_provider as boolean | undefined) !== false;
1846
+ return injectionEnabled ? "antigravity" : "google";
1847
+ }
1848
+ return fallbackProvider;
1849
+ }
1850
+
1851
+ /**
1852
+ * Builds the shared OAuth method list: authorize() starts the PKCE +
1853
+ * local-callback flow (the Antigravity CLI masquerade — the very same
1854
+ * identity the CLI `login` command uses) and returns the Google consent
1855
+ * URL; the returned callback() resolves with the exchanged tokens, persists
1856
+ * the account in the robin-hood store and reports the provider the
1857
+ * credential is stored under (model-id detection first, the entry's own
1858
+ * provider id as fallback — so a credential picked on the "google" entry
1859
+ * still lands on the custom provider that carries the provider models).
1860
+ */
1861
+ function buildOpencodeAuthMethods(fallbackProvider: string) {
1862
+ return [
1863
+ {
1864
+ type: "oauth" as const,
1865
+ label: "Login with Google (Antigravity - Gemini CLI bypass)",
1866
+
1867
+ /**
1868
+ * Starts the OAuth flow and returns the Google consent URL plus
1869
+ * the callback that resolves once the tokens are exchanged.
1870
+ * opencode renders the URL; after the user signs in and grants
1871
+ * the consent, Google redirects to the local 127.0.0.1 callback
1872
+ * server started here.
1873
+ */
1874
+ authorize: async (): Promise<{
1875
+ url: string;
1876
+ instructions: string;
1877
+ method: "auto";
1878
+ callback: () => Promise<
1879
+ | {
1880
+ type: "success";
1881
+ provider?: string | undefined;
1882
+ refresh: string;
1883
+ access: string;
1884
+ expires: number;
1885
+ accountId?: string | undefined;
1886
+ }
1887
+ | { type: "failed" }
1888
+ >;
1889
+ }> => {
1890
+ // OAuth identity for NEW logins: the Antigravity CLI client
1891
+ // (default since 2.1.8 — the gemini-cli individuals tier was
1892
+ // retired; the antigravity-cli client onboards the "Antigravity"
1893
+ // free tier and its loopback redirect is accepted by Google,
1894
+ // which keeps the whole flow automatic: no code copy/paste).
1895
+ const inst = getPlugin();
1896
+ const identity = resolveOAuthIdentity(((inst as any)?.options?.oauth_identity as string) ?? "auto");
1897
+ const state = randomBytes(32).toString("base64url");
1898
+ const pkce = identity.usesPKCE ? ((oauthMod as any).generatePKCE?.() ?? null) : null;
1899
+ const srv = await (oauthMod as any).createOAuthServerAsync({
1900
+ callbackPath: "/oauth2callback",
1901
+ expectedState: state,
1902
+ });
1903
+ const redirectUri: string = srv.callbackUrl;
1904
+ const authUrl: string = (oauthMod as any).buildAuthUrl(pkce, redirectUri, {
1905
+ state,
1906
+ accessType: "offline",
1907
+ scopes: identity.scopesStr,
1908
+ clientId: identity.clientId,
1909
+ ...(identity.usesPKCE ? { prompt: "consent" as const } : {}),
1910
+ });
1911
+
1912
+ return {
1913
+ url: authUrl,
1914
+ instructions: `Sign in with your Google account and authorize ${identity.appLabel}. the plugin masquerades as the ${identity.identity} client and stores the tokens locally for the robin-hood rotation.`,
1915
+ method: "auto",
1916
+ callback: async () => {
1917
+ try {
1918
+ const { code } = await srv.waitForCallback();
1919
+ const tokens = await (oauthMod as any).exchangeCodeForTokens(
1920
+ code,
1921
+ pkce ? pkce.verifier : null,
1922
+ redirectUri,
1923
+ { clientId: identity.clientId, clientSecret: identity.clientSecret },
1924
+ );
1925
+ const refresh: string | undefined = tokens.refresh_token;
1926
+ const access: string | undefined = tokens.access_token;
1927
+ const expires: number = Date.now() + (tokens.expires_in ?? 3600) * 1000;
1928
+ if (!refresh || !access) {
1929
+ return { type: "failed" as const };
1930
+ }
1931
+ let email = "";
1932
+ try {
1933
+ const userInfo = await (oauthMod as any).getUserInfo(access);
1934
+ email = (userInfo?.email ?? "").toLowerCase();
1935
+ } catch {
1936
+ /* userinfo is optional for the credential */
1937
+ }
1938
+ await persistOAuthAccount(
1939
+ email || `user-${Date.now()}@gmail.com`,
1940
+ refresh,
1941
+ access,
1942
+ tokens.expires_in ?? 3600,
1943
+ identity,
1944
+ );
1945
+ return {
1946
+ type: "success" as const,
1947
+ provider: resolveCredentialTarget(fallbackProvider),
1948
+ refresh,
1949
+ access,
1950
+ expires,
1951
+ accountId: email || undefined,
1952
+ };
1953
+ } catch {
1954
+ return { type: "failed" as const };
1955
+ } finally {
1956
+ try {
1957
+ await srv.close();
1958
+ } catch {
1959
+ /* server already down */
1960
+ }
1961
+ }
1962
+ },
1963
+ };
1964
+ },
1965
+ },
1966
+ ];
1967
+ }
1968
+
1969
+ /**
1970
+ * Builds a complete opencode AuthHook for one provider id (shared loader +
1971
+ * the OAuth methods). Used by the ./server entry for the standard "google"
1972
+ * and "antigravity" entries and for the custom provider detected by model
1973
+ * ids — all three surface in `opencode auth login` with an OAuth option.
1974
+ */
1975
+ export function buildOpencodeAuthHook(providerId: string): Record<string, any> {
1976
+ const inst = getPlugin();
1977
+ const def = inst.getPluginDefinition();
1978
+ const legacyAuth: any = def.auth ?? {};
1979
+ return {
1980
+ // Recognition is by MODEL IDS in opencode.json, never by a fixed name:
1981
+ // the user may name the provider anything ("google", "casas-bahia", ...)
1982
+ // as long as it carries antigravity-* / gemini-* model ids. The
1983
+ // standard entries "google" and "antigravity" work with zero config.
1984
+ provider: providerId,
1985
+ loader: buildOpencodeAuthLoader(inst, legacyAuth),
1986
+ methods: buildOpencodeAuthMethods(providerId),
1987
+ };
1988
+ }
1989
+
1990
+ /**
1991
+ * Standard-entry safety net: ensures a provider "antigravity" ALWAYS exists
1992
+ * in the config (npm @ai-sdk/google + the provider model catalog) unless the
1993
+ * user defined their own "antigravity" block or disabled the provider.
1994
+ * This is one of the TWO standard entries ("google" is the other — it always
1995
+ * exists in opencode's models.dev database): the "Antigravity" entry of
1996
+ * `opencode auth login` is immediately usable, and credentials stored under
1997
+ * "antigravity" (e.g. from a zero-config login on another project) always
1998
+ * find their provider — opencode crashes on auth-loader credentials whose
1999
+ * provider exists neither in models.dev nor in the config. ADD-ONLY and
2000
+ * in-memory: the user's file is never touched and an explicit
2001
+ * "antigravity" block always wins.
2002
+ */
2003
+ export function injectAntigravityProvider(cfg: any, enabled = true): void {
2004
+ try {
2005
+ if (!enabled || !cfg || typeof cfg !== "object") return;
2006
+ let providers = cfg.provider;
2007
+ if (!providers || typeof providers !== "object" || Array.isArray(providers)) {
2008
+ providers = {};
2009
+ cfg.provider = providers;
2010
+ }
2011
+ if (providers.antigravity) return; // the user already defined it — never override
2012
+ const disabled = Array.isArray(cfg.disabled_providers) ? cfg.disabled_providers.map(String) : [];
2013
+ if (disabled.includes("antigravity")) return; // explicitly disabled
2014
+ const models: Record<string, any> = {};
2015
+ for (const m of ALL_MODELS_2026) models[m.id] = {};
2016
+ providers.antigravity = {
2017
+ npm: "@ai-sdk/google",
2018
+ name: "Antigravity",
2019
+ options: {},
2020
+ models,
2021
+ };
2022
+ } catch {
2023
+ /* never break the host config */
2024
+ }
2025
+ }
2026
+
2027
+ /**
2028
+ * The opencode server entry: initializes the plugin singleton and returns
2029
+ * the hooks object implementing the 1.18.x contract — header injection,
2030
+ * the zero-config antigravity provider injection and the auth entry for
2031
+ * the CUSTOM provider detected by model ids. The standard "google" and
2032
+ * "antigravity" auth entries are registered by server.ts (the ./server
2033
+ * package entry) and are never duplicated here.
2034
+ */
2035
+ export async function opencodeServer(_input?: unknown, options?: PluginOptions): Promise<Record<string, any>> {
2036
+ const inst = getPlugin(options ?? {});
2037
+ try {
2038
+ await (inst as any).init?.();
2039
+ } catch {
2040
+ /* plugin init is best-effort; the hooks degrade gracefully */
2041
+ }
2042
+ const def = inst.getPluginDefinition();
2043
+ const legacyAuth: any = def.auth ?? {};
2044
+ const legacyHooks: any = def.hooks ?? {};
2045
+
2046
+ // v2.1.13 — provider scoping for the identification spoof: only requests
2047
+ // whose PROVIDER is a provider-family entry (the detected custom provider plus the
2048
+ // standard "google" / "antigravity" ids) may receive the Google
2049
+ // identification headers. Model-id substrings alone are NOT enough: real
2050
+ // configs carry e.g. "openai/gpt-oss-120b" under nvidia/openrouter, and
2051
+ // those third-party requests must never be polluted with the spoof.
2052
+ const devthinkProviderIds = new Set(
2053
+ ["google", "antigravity", detectOpencodeProviderId()]
2054
+ .filter((x): x is string => typeof x === "string" && x.length > 0)
2055
+ .map((x) => x.toLowerCase()),
2056
+ );
2057
+
2058
+ const hooks: Record<string, any> = {
2059
+ // keep the valid legacy hooks (tool instrumentation); the legacy "fetch"
2060
+ // hook is not part of the 1.18 contract and is intentionally dropped
2061
+ // here - the identification spoof moves to chat.headers below.
2062
+ ...(legacyHooks["tool.execute.before"] ? { "tool.execute.before": legacyHooks["tool.execute.before"] } : {}),
2063
+ ...(legacyHooks["tool.execute.after"] ? { "tool.execute.after": legacyHooks["tool.execute.after"] } : {}),
2064
+
2065
+ /**
2066
+ * Gemini CLI identification spoof on chat requests for provider-handled
2067
+ * models: gated to MAENE PROVIDER IDS (detected custom provider + the
2068
+ * standard "google"/"antigravity" entries — see devthinkProviderIds above)
2069
+ * AND a matching MODEL ID (antigravity-*, gemini-*, gpt-oss): User-Agent
2070
+ * GeminiCLI/0.57.0, X-Goog-Api-Client gl-node/..., Client-Metadata
2071
+ * ideType=IDE_UNSPECIFIED. Unrelated providers (nvidia/openrouter/...)
2072
+ * never receive the spoof even when their model ids contain the
2073
+ * substrings ("openai/gpt-oss-120b" etc.).
2074
+ */
2075
+ "chat.headers": async (
2076
+ _input: {
2077
+ sessionID: string;
2078
+ agent: string;
2079
+ model?: { providerID: string; modelID: string };
2080
+ provider?: { info?: { options?: Record<string, any> } };
2081
+ },
2082
+ output: { headers: Record<string, string> },
2083
+ ): Promise<void> => {
2084
+ try {
2085
+ const modelId = String(_input?.model?.modelID ?? "").toLowerCase();
2086
+ if (!modelId) return;
2087
+ const providerId = String(_input?.model?.providerID ?? "").toLowerCase();
2088
+ if (!devthinkProviderIds.has(providerId)) return; // never pollute unrelated providers with the spoof
2089
+ const handled = modelId.includes("antigravity") || modelId.includes("gemini") || modelId.includes("gpt-oss");
2090
+ if (!handled) return;
2091
+ const spoof = (oauthMod as any).getGeminiHeaders?.() ?? FALLBACK_GEMINI_HEADERS;
2092
+ Object.assign(output.headers, spoof);
2093
+ } catch {
2094
+ /* header injection never breaks the request */
2095
+ }
2096
+ },
2097
+
2098
+ /**
2099
+ * config hook (opencode >= 1.18): runs before opencode reads
2100
+ * cfg.provider — the documented injection point. Used only for the
2101
+ * zero-config antigravity provider; the user's file is never touched
2102
+ * on disk and any real provider block (custom name included) takes
2103
+ * precedence. Disable with the plugin option
2104
+ * ["devthink", { "inject_antigravity_provider": false }].
2105
+ */
2106
+ config: async (input: any): Promise<void> => {
2107
+ injectAntigravityProvider(input, (options as any)?.inject_antigravity_provider !== false);
2108
+ },
2109
+ };
2110
+
2111
+ // Auth for the CUSTOM provider detected by MODEL IDS in opencode.json
2112
+ // (any name the user chose). The standard "google" and "antigravity"
2113
+ // entries are registered by server.ts (the ./server package entry) —
2114
+ // never duplicated here.
2115
+ const detected = detectOpencodeProviderId();
2116
+ const customCandidate =
2117
+ detected ?? (typeof legacyAuth.provider === "string" && legacyAuth.provider ? legacyAuth.provider : null);
2118
+ if (customCandidate && customCandidate !== "google" && customCandidate !== "antigravity") {
2119
+ hooks.auth = buildOpencodeAuthHook(customCandidate);
2120
+ }
2121
+ return hooks;
2122
+ }
2123
+
2124
+ /**
2125
+ * The opencode PluginModule (V1 shape): what loaders that read package.json
2126
+ * `main` directly get (library consumers, older opencode builds). It always
2127
+ * carries exactly one auth entry — the detected provider with the "google"
2128
+ * fallback — so single-hook loaders keep working.
2129
+ */
2130
+ const opencodePluginModule = {
2131
+ id: "devthink",
2132
+ server: async (input?: unknown, options?: PluginOptions) => {
2133
+ const hooks = await opencodeServer(input, options);
2134
+ if (!hooks.auth) hooks.auth = buildOpencodeAuthHook(detectOpencodeProviderId() ?? "google");
2135
+ return hooks;
2136
+ },
2137
+ } as const;
2138
+
2139
+ export default opencodePluginModule;