dsh-mobile-plugin 0.5.27 → 0.5.29

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 (350) hide show
  1. package/extensions/catalog.json +10 -1
  2. package/extensions/magpie/agentcompact.js +104 -0
  3. package/extensions/magpie/app.css +4902 -0
  4. package/extensions/magpie/app.js +24474 -0
  5. package/extensions/magpie/autobackup.js +185 -0
  6. package/extensions/magpie/autoresetown.js +151 -0
  7. package/extensions/magpie/balfound.js +38 -0
  8. package/extensions/magpie/boot.js +23 -0
  9. package/extensions/magpie/cfgblocked.js +39 -0
  10. package/extensions/magpie/checkinnow.js +111 -0
  11. package/extensions/magpie/claudemode.js +92 -0
  12. package/extensions/magpie/compat.js +41 -0
  13. package/extensions/magpie/context.css +433 -0
  14. package/extensions/magpie/context.js +1576 -0
  15. package/extensions/magpie/ctxtext.js +223 -0
  16. package/extensions/magpie/fonts.js +42 -0
  17. package/extensions/magpie/i18n.js +18942 -0
  18. package/extensions/magpie/icons/ai21.svg +1 -0
  19. package/extensions/magpie/icons/ai302-color.svg +1 -0
  20. package/extensions/magpie/icons/ai360-color.svg +1 -0
  21. package/extensions/magpie/icons/aihubmix-color.svg +1 -0
  22. package/extensions/magpie/icons/aionui.svg +1 -0
  23. package/extensions/magpie/icons/air.svg +4 -0
  24. package/extensions/magpie/icons/alibaba-color.svg +1 -0
  25. package/extensions/magpie/icons/alibabacloud-color.svg +1 -0
  26. package/extensions/magpie/icons/alma.png +0 -0
  27. package/extensions/magpie/icons/anthropic.svg +1 -0
  28. package/extensions/magpie/icons/antigravity-color.svg +1 -0
  29. package/extensions/magpie/icons/anyscale-color.svg +1 -0
  30. package/extensions/magpie/icons/app-alma.png +0 -0
  31. package/extensions/magpie/icons/app-cc-switch.png +0 -0
  32. package/extensions/magpie/icons/apple-touch-icon.png +0 -0
  33. package/extensions/magpie/icons/aside.svg +28 -0
  34. package/extensions/magpie/icons/astrbot.svg +1 -0
  35. package/extensions/magpie/icons/atomcode.png +0 -0
  36. package/extensions/magpie/icons/aws.svg +1 -0
  37. package/extensions/magpie/icons/azure-color.svg +1 -0
  38. package/extensions/magpie/icons/azureai-color.svg +1 -0
  39. package/extensions/magpie/icons/baichuan-color.svg +1 -0
  40. package/extensions/magpie/icons/baidu-color.svg +1 -0
  41. package/extensions/magpie/icons/baiducloud-color.svg +1 -0
  42. package/extensions/magpie/icons/bailian-color.svg +1 -0
  43. package/extensions/magpie/icons/baseten.svg +1 -0
  44. package/extensions/magpie/icons/bedrock-color.svg +1 -0
  45. package/extensions/magpie/icons/burncloud-color.svg +1 -0
  46. package/extensions/magpie/icons/bytedance-color.svg +1 -0
  47. package/extensions/magpie/icons/cerebras.svg +1 -0
  48. package/extensions/magpie/icons/chatglm-color.svg +1 -0
  49. package/extensions/magpie/icons/cherryin-color.svg +1 -0
  50. package/extensions/magpie/icons/chutes.svg +1 -0
  51. package/extensions/magpie/icons/cindy.png +0 -0
  52. package/extensions/magpie/icons/claude-color.svg +1 -0
  53. package/extensions/magpie/icons/claudecode-color.svg +1 -0
  54. package/extensions/magpie/icons/cline.svg +1 -0
  55. package/extensions/magpie/icons/cloudflare-color.svg +1 -0
  56. package/extensions/magpie/icons/codebuddy-color.svg +1 -0
  57. package/extensions/magpie/icons/codex-color.svg +1 -0
  58. package/extensions/magpie/icons/cohere-color.svg +1 -0
  59. package/extensions/magpie/icons/cometapi-color.svg +1 -0
  60. package/extensions/magpie/icons/commandcode.svg +1 -0
  61. package/extensions/magpie/icons/copilot-color.svg +1 -0
  62. package/extensions/magpie/icons/coze.svg +1 -0
  63. package/extensions/magpie/icons/crush.png +0 -0
  64. package/extensions/magpie/icons/curl.svg +1 -0
  65. package/extensions/magpie/icons/cursor.svg +1 -0
  66. package/extensions/magpie/icons/deepinfra-color.svg +1 -0
  67. package/extensions/magpie/icons/deepmind-color.svg +1 -0
  68. package/extensions/magpie/icons/deepseek-color.svg +1 -0
  69. package/extensions/magpie/icons/deveco-color.svg +12 -0
  70. package/extensions/magpie/icons/devin.svg +1 -0
  71. package/extensions/magpie/icons/doubao-color.svg +1 -0
  72. package/extensions/magpie/icons/empryo-color.svg +1 -0
  73. package/extensions/magpie/icons/factory.svg +1 -0
  74. package/extensions/magpie/icons/favicon.svg +1 -0
  75. package/extensions/magpie/icons/featherless-color.svg +1 -0
  76. package/extensions/magpie/icons/fireworks-color.svg +1 -0
  77. package/extensions/magpie/icons/friendli.svg +1 -0
  78. package/extensions/magpie/icons/fx.svg +1 -0
  79. package/extensions/magpie/icons/gemini-color.svg +1 -0
  80. package/extensions/magpie/icons/geminicli-color.svg +1 -0
  81. package/extensions/magpie/icons/gemma-color.svg +1 -0
  82. package/extensions/magpie/icons/giteeai.svg +1 -0
  83. package/extensions/magpie/icons/github.svg +1 -0
  84. package/extensions/magpie/icons/githubcopilot.svg +1 -0
  85. package/extensions/magpie/icons/glama.svg +1 -0
  86. package/extensions/magpie/icons/gmicloud.svg +1 -0
  87. package/extensions/magpie/icons/google-color.svg +1 -0
  88. package/extensions/magpie/icons/googlecloud-color.svg +1 -0
  89. package/extensions/magpie/icons/goose.svg +1 -0
  90. package/extensions/magpie/icons/grok.svg +1 -0
  91. package/extensions/magpie/icons/groq.svg +1 -0
  92. package/extensions/magpie/icons/hanako.png +0 -0
  93. package/extensions/magpie/icons/hermes.svg +1 -0
  94. package/extensions/magpie/icons/huawei-color.svg +1 -0
  95. package/extensions/magpie/icons/huaweicloud-color.svg +1 -0
  96. package/extensions/magpie/icons/huggingface-color.svg +1 -0
  97. package/extensions/magpie/icons/hunyuan-color.svg +1 -0
  98. package/extensions/magpie/icons/hyperbolic-color.svg +1 -0
  99. package/extensions/magpie/icons/icon-512.png +0 -0
  100. package/extensions/magpie/icons/iflytekcloud-color.svg +1 -0
  101. package/extensions/magpie/icons/inference.svg +1 -0
  102. package/extensions/magpie/icons/infinigence-color.svg +1 -0
  103. package/extensions/magpie/icons/internlm-color.svg +1 -0
  104. package/extensions/magpie/icons/jcode.svg +1 -0
  105. package/extensions/magpie/icons/jina.svg +1 -0
  106. package/extensions/magpie/icons/kilo.svg +1 -0
  107. package/extensions/magpie/icons/kilocode.svg +1 -0
  108. package/extensions/magpie/icons/kimi-code-desktop.png +0 -0
  109. package/extensions/magpie/icons/kimi.svg +1 -0
  110. package/extensions/magpie/icons/kiro-color.svg +1 -0
  111. package/extensions/magpie/icons/kling-color.svg +1 -0
  112. package/extensions/magpie/icons/lambda.svg +1 -0
  113. package/extensions/magpie/icons/leptonai-color.svg +1 -0
  114. package/extensions/magpie/icons/liquid.svg +1 -0
  115. package/extensions/magpie/icons/liveagent-color.svg +1 -0
  116. package/extensions/magpie/icons/llmapi.svg +1 -0
  117. package/extensions/magpie/icons/lmstudio.svg +1 -0
  118. package/extensions/magpie/icons/lody.png +0 -0
  119. package/extensions/magpie/icons/longcat.svg +1 -0
  120. package/extensions/magpie/icons/magpie.svg +1 -0
  121. package/extensions/magpie/icons/meta-color.svg +1 -0
  122. package/extensions/magpie/icons/metaai-color.svg +1 -0
  123. package/extensions/magpie/icons/microsoft-color.svg +1 -0
  124. package/extensions/magpie/icons/mimocode.svg +1 -0
  125. package/extensions/magpie/icons/minimax-color.svg +1 -0
  126. package/extensions/magpie/icons/mistermorph-color.svg +1 -0
  127. package/extensions/magpie/icons/mistral-color.svg +1 -0
  128. package/extensions/magpie/icons/mlx-serve.png +0 -0
  129. package/extensions/magpie/icons/modelscope-color.svg +1 -0
  130. package/extensions/magpie/icons/monica-color.svg +1 -0
  131. package/extensions/magpie/icons/moonshot.svg +1 -0
  132. package/extensions/magpie/icons/morph-color.svg +1 -0
  133. package/extensions/magpie/icons/mothx.png +0 -0
  134. package/extensions/magpie/icons/nebius.svg +1 -0
  135. package/extensions/magpie/icons/newapi-color.svg +1 -0
  136. package/extensions/magpie/icons/nousresearch.svg +1 -0
  137. package/extensions/magpie/icons/nova-color.svg +1 -0
  138. package/extensions/magpie/icons/novita-color.svg +1 -0
  139. package/extensions/magpie/icons/nvidia-color.svg +1 -0
  140. package/extensions/magpie/icons/ollama.svg +1 -0
  141. package/extensions/magpie/icons/omlx.svg +1 -0
  142. package/extensions/magpie/icons/omo.svg +1 -0
  143. package/extensions/magpie/icons/omp.svg +1 -0
  144. package/extensions/magpie/icons/openai.svg +1 -0
  145. package/extensions/magpie/icons/openchamber.svg +11 -0
  146. package/extensions/magpie/icons/openclaw.svg +1 -0
  147. package/extensions/magpie/icons/opencode.svg +1 -0
  148. package/extensions/magpie/icons/openmausbot.svg +1 -0
  149. package/extensions/magpie/icons/openrouter.svg +1 -0
  150. package/extensions/magpie/icons/openwebui.svg +1 -0
  151. package/extensions/magpie/icons/parasail.svg +1 -0
  152. package/extensions/magpie/icons/penguin-color.svg +1 -0
  153. package/extensions/magpie/icons/perplexity-color.svg +1 -0
  154. package/extensions/magpie/icons/phala-color.svg +1 -0
  155. package/extensions/magpie/icons/pi.svg +1 -0
  156. package/extensions/magpie/icons/pipellm-color.svg +5 -0
  157. package/extensions/magpie/icons/poe.svg +1 -0
  158. package/extensions/magpie/icons/ppio-color.svg +1 -0
  159. package/extensions/magpie/icons/prime-agent.svg +1 -0
  160. package/extensions/magpie/icons/qiniu-color.svg +1 -0
  161. package/extensions/magpie/icons/qoder.svg +1 -0
  162. package/extensions/magpie/icons/qwen-color.svg +1 -0
  163. package/extensions/magpie/icons/qwenpaw.svg +1 -0
  164. package/extensions/magpie/icons/raycast.svg +1 -0
  165. package/extensions/magpie/icons/reasonix-color.svg +48 -0
  166. package/extensions/magpie/icons/reka.svg +1 -0
  167. package/extensions/magpie/icons/replicate.svg +1 -0
  168. package/extensions/magpie/icons/roocode.svg +1 -0
  169. package/extensions/magpie/icons/rtk.png +0 -0
  170. package/extensions/magpie/icons/sambanova-color.svg +1 -0
  171. package/extensions/magpie/icons/sensenova-color.svg +1 -0
  172. package/extensions/magpie/icons/siliconcloud-color.svg +1 -0
  173. package/extensions/magpie/icons/snow.png +0 -0
  174. package/extensions/magpie/icons/snowflake-color.svg +1 -0
  175. package/extensions/magpie/icons/sophnet-color.svg +1 -0
  176. package/extensions/magpie/icons/spark-color.svg +1 -0
  177. package/extensions/magpie/icons/stepfun-color.svg +1 -0
  178. package/extensions/magpie/icons/straico-color.svg +1 -0
  179. package/extensions/magpie/icons/submodel-color.svg +1 -0
  180. package/extensions/magpie/icons/t3code.svg +4 -0
  181. package/extensions/magpie/icons/targon-color.svg +1 -0
  182. package/extensions/magpie/icons/tencent-color.svg +1 -0
  183. package/extensions/magpie/icons/tencentcloud-color.svg +1 -0
  184. package/extensions/magpie/icons/tiangong-color.svg +1 -0
  185. package/extensions/magpie/icons/together-color.svg +1 -0
  186. package/extensions/magpie/icons/trae.svg +1 -0
  187. package/extensions/magpie/icons/typesafe.png +0 -0
  188. package/extensions/magpie/icons/upstage-color.svg +1 -0
  189. package/extensions/magpie/icons/venice-color.svg +1 -0
  190. package/extensions/magpie/icons/vercel.svg +1 -0
  191. package/extensions/magpie/icons/vertexai-color.svg +1 -0
  192. package/extensions/magpie/icons/vllm-color.svg +1 -0
  193. package/extensions/magpie/icons/volcengine-color.svg +1 -0
  194. package/extensions/magpie/icons/voyage-color.svg +1 -0
  195. package/extensions/magpie/icons/vscode.svg +1 -0
  196. package/extensions/magpie/icons/wenxin-color.svg +1 -0
  197. package/extensions/magpie/icons/windsurf.svg +1 -0
  198. package/extensions/magpie/icons/workbuddy-color.svg +1 -0
  199. package/extensions/magpie/icons/workersai-color.svg +1 -0
  200. package/extensions/magpie/icons/xai.svg +1 -0
  201. package/extensions/magpie/icons/xiaomimimo.svg +1 -0
  202. package/extensions/magpie/icons/xinference-color.svg +1 -0
  203. package/extensions/magpie/icons/yi.svg +1 -0
  204. package/extensions/magpie/icons/yuanbao-color.svg +1 -0
  205. package/extensions/magpie/icons/yylx.svg +5 -0
  206. package/extensions/magpie/icons/zai.svg +1 -0
  207. package/extensions/magpie/icons/zcode.png +0 -0
  208. package/extensions/magpie/icons/zed.svg +1 -0
  209. package/extensions/magpie/icons/zenmux.svg +1 -0
  210. package/extensions/magpie/icons/zeroone.svg +1 -0
  211. package/extensions/magpie/icons/zhipu-color.svg +1 -0
  212. package/extensions/magpie/icons/zoo-code.png +0 -0
  213. package/extensions/magpie/index.html +572 -0
  214. package/extensions/magpie/kernel.js +274 -0
  215. package/extensions/magpie/keyspend.js +63 -0
  216. package/extensions/magpie/layout.js +26 -0
  217. package/extensions/magpie/manifest.json +37 -0
  218. package/extensions/magpie/navlamp.js +47 -0
  219. package/extensions/magpie/omarchy.css +72 -0
  220. package/extensions/magpie/omarchy.js +46 -0
  221. package/extensions/magpie/passerrors.js +21 -0
  222. package/extensions/magpie/pluginpages.js +405 -0
  223. package/extensions/magpie/plugins/gateway/client.js +1194 -0
  224. package/extensions/magpie/plugins/gateway/keys.js +972 -0
  225. package/extensions/magpie/plugins/gateway/settings.js +459 -0
  226. package/extensions/magpie/plugins/library/client.js +6788 -0
  227. package/extensions/magpie/plugins/library/i18n.js +3228 -0
  228. package/extensions/magpie/plugins/library/library.css +848 -0
  229. package/extensions/magpie/plugins/otel/i18n.js +48 -0
  230. package/extensions/magpie/plugins/otel/page.js +189 -0
  231. package/extensions/magpie/plugins/redact/client.js +46 -0
  232. package/extensions/magpie/plugins/redact/demo.js +461 -0
  233. package/extensions/magpie/plugins/redact/i18n.js +432 -0
  234. package/extensions/magpie/plugins/redact/page.js +985 -0
  235. package/extensions/magpie/plugins/redact/redact.css +385 -0
  236. package/extensions/magpie/plugins/redact/settings.js +248 -0
  237. package/extensions/magpie/plugins/redact/xq_dark.jpg +0 -0
  238. package/extensions/magpie/plugins/redact/xq_dark.webm +0 -0
  239. package/extensions/magpie/plugins/redact/xq_light.jpg +0 -0
  240. package/extensions/magpie/plugins/redact/xq_light.webm +0 -0
  241. package/extensions/magpie/plugins/routing/client.js +8593 -0
  242. package/extensions/magpie/plugins/routing/i18n.js +2696 -0
  243. package/extensions/magpie/plugins/routing/routing.css +1111 -0
  244. package/extensions/magpie/plugins/search/settings.js +460 -0
  245. package/extensions/magpie/plugins/sessions/client.js +1622 -0
  246. package/extensions/magpie/plugins/sessions/i18n.js +360 -0
  247. package/extensions/magpie/plugins/sessions/sessions.css +181 -0
  248. package/extensions/magpie/plugins/sessions/settings.js +100 -0
  249. package/extensions/magpie/plugins/usage/client.js +150 -0
  250. package/extensions/magpie/plugins/usage/i18n.js +984 -0
  251. package/extensions/magpie/plugins/usage/page.js +4307 -0
  252. package/extensions/magpie/plugins.css +319 -0
  253. package/extensions/magpie/plugins.js +1926 -0
  254. package/extensions/magpie/profilerename.js +27 -0
  255. package/extensions/magpie/purposes.js +2 -0
  256. package/extensions/magpie/quitask.js +79 -0
  257. package/extensions/magpie/quotaall.js +142 -0
  258. package/extensions/magpie/ratewindows.js +123 -0
  259. package/extensions/magpie/registry.js +87 -0
  260. package/extensions/magpie/resetask.js +137 -0
  261. package/extensions/magpie/ruleedit.js +53 -0
  262. package/extensions/magpie/scrollends.js +91 -0
  263. package/extensions/magpie/settingshints.js +75 -0
  264. package/extensions/magpie/signsheets.js +86 -0
  265. package/extensions/magpie/start.js +26 -0
  266. package/extensions/magpie/syncfiles.js +216 -0
  267. package/extensions/magpie/theme.js +29 -0
  268. package/extensions/magpie/traywindows.js +41 -0
  269. package/extensions/magpie/webfonts.js +130 -0
  270. package/extensions/magpie/whatsnew.js +252 -0
  271. package/extensions/magpie.zip +0 -0
  272. package/knowledge/apps/baijiahao.md +30 -0
  273. package/knowledge/apps/bilibili.md +1 -0
  274. package/knowledge/apps/dedao.md +33 -0
  275. package/knowledge/apps/feishu.md +35 -0
  276. package/knowledge/apps/gmail.md +33 -0
  277. package/knowledge/apps/jike.md +31 -0
  278. package/knowledge/apps/kuaishou.md +34 -0
  279. package/knowledge/apps/linkedin.md +33 -0
  280. package/knowledge/apps/qqmail.md +30 -0
  281. package/knowledge/apps/telegram.md +27 -0
  282. package/knowledge/apps/toutiao.md +32 -0
  283. package/knowledge/apps/uber-eats.md +26 -0
  284. package/knowledge/apps/uber.md +27 -0
  285. package/knowledge/apps/whatsapp.md +27 -0
  286. package/knowledge/apps/x.md +32 -0
  287. package/knowledge/apps/xiaohongshu.md +1 -0
  288. package/knowledge/apps/zhihu.md +1 -0
  289. package/knowledge/sites/bilibili.com/SITE.md +50 -0
  290. package/knowledge/sites/bilibili.com/references/view-fields.md +17 -0
  291. package/knowledge/sites/github.com/SITE.md +51 -0
  292. package/knowledge/sites/github.com/references/repo-fields.md +13 -0
  293. package/knowledge/sites/x.com/SITE.md +47 -0
  294. package/knowledge/sites/xiaohongshu.com/SITE.md +48 -0
  295. package/knowledge/sites/xiaohongshu.com/references/note-fields.md +18 -0
  296. package/knowledge/sites/youtube.com/SITE.md +49 -0
  297. package/knowledge/sites/youtube.com/references/innertube-fields.md +14 -0
  298. package/knowledge/sites/zhihu.com/SITE.md +47 -0
  299. package/knowledge/sites/zhihu.com/references/api-fields.md +21 -0
  300. package/lib/mobile/extension-catalog.js +41 -1
  301. package/lib/mobile/extension-data.js +224 -0
  302. package/lib/mobile/extension-dev.js +3 -1
  303. package/lib/mobile/file-ops.js +252 -0
  304. package/lib/mobile/history-derive.js +80 -4
  305. package/lib/mobile/host-bridge/endpoints.js +48 -6
  306. package/lib/mobile/host-bridge/event-synthesis.js +33 -6
  307. package/lib/mobile/jobs-control.js +118 -0
  308. package/lib/mobile/memory.js +635 -0
  309. package/lib/mobile/model-badges.js +77 -0
  310. package/lib/mobile/pet-tools.js +50 -4
  311. package/lib/mobile/pets.js +214 -18
  312. package/lib/mobile/phone/access.js +85 -0
  313. package/lib/mobile/phone/evidence.js +228 -0
  314. package/lib/mobile/phone/gate.js +290 -0
  315. package/lib/mobile/phone/grounding.js +314 -0
  316. package/lib/mobile/phone/guard.js +465 -0
  317. package/lib/mobile/phone/irreversible.js +88 -8
  318. package/lib/mobile/phone/knowledge-tools.js +665 -0
  319. package/lib/mobile/phone/learn.js +329 -0
  320. package/lib/mobile/phone/redact.js +237 -0
  321. package/lib/mobile/phone/risk.js +95 -0
  322. package/lib/mobile/phone/routes.js +28 -1
  323. package/lib/mobile/phone/service.js +23 -0
  324. package/lib/mobile/phone/site-commands.js +271 -0
  325. package/lib/mobile/phone/step-summary.js +201 -0
  326. package/lib/mobile/phone/stop-summary.js +235 -0
  327. package/lib/mobile/phone/tool-groups.js +125 -0
  328. package/lib/mobile/phone/tools.js +589 -0
  329. package/lib/mobile/phone/untrusted.js +65 -0
  330. package/lib/mobile/phone-knowledge.js +20 -4
  331. package/lib/mobile/schedule-edit.js +500 -0
  332. package/lib/mobile/schedule.js +5 -0
  333. package/lib/mobile/send-continuation.js +52 -0
  334. package/lib/mobile/session-models.js +27 -31
  335. package/lib/mobile/session-send.js +12 -12
  336. package/lib/mobile/skill-import.js +338 -0
  337. package/lib/mobile/skill-store.js +524 -0
  338. package/lib/routes/mobile-api.js +42 -0
  339. package/package.json +7 -6
  340. package/skills/skill-authoring/SKILL.md +46 -0
  341. package/skills/skill-authoring/references/examples.md +82 -0
  342. package/skills/skill-authoring/references/frontmatter-and-fields.md +46 -0
  343. package/skills/skill-authoring/scripts/check_skill.py +167 -0
  344. package/skills/skill-authoring/scripts/manifest.json +3 -0
  345. package/knowledge/sites/bilibili.com.md +0 -27
  346. package/knowledge/sites/github.com.md +0 -27
  347. package/knowledge/sites/x.com.md +0 -28
  348. package/knowledge/sites/xiaohongshu.com.md +0 -27
  349. package/knowledge/sites/youtube.com.md +0 -28
  350. package/knowledge/sites/zhihu.com.md +0 -27
@@ -0,0 +1,972 @@
1
+ // Caller keys use the same account list and edit controls as provider keys.
2
+ let gatewayKeys = null, gatewayKeyDraft = null, gatewayKeysBusy = false;
3
+ // gatewayOwnDraft is the value typed for a new key of the user's own (one
4
+ // its clients already send, love1sbug on X); "" lets magpie make one
5
+ let gatewayOwnDraft = "";
6
+ // gatewayAccountNames maps a key's stored account entry ("<provider>/<id>",
7
+ // #905) to how it is shown; what no provider has now is shown as it is kept
8
+ let gatewayAccountNames = {};
9
+ let gatewayKeysRead = 0;
10
+ // gatewayKeysErr is why the list couldn't be read, said in its place until
11
+ // a read works: a redraw of the page (the providers' poll) keeps it
12
+ let gatewayKeysErr = "";
13
+ // gatewayLimit is the key whose limit editor is open, and its draft
14
+ // (#585): what is picked in it is sent only by its Save.
15
+ let gatewayLimit = null;
16
+ let connectURL = "", connectKeyID = "", connectSecret = null;
17
+ // connectWant and connectKeyWant are the address and gateway key picked in
18
+ // Gateway's Connect, kept across reloads, restarts and updates (coeo91 on
19
+ // Discord: the LAN address went back to 127.0.0.1); "" for a key is This
20
+ // computer
21
+ let connectWant = "", connectKeyWant = "";
22
+ try {
23
+ connectWant = localStorage.getItem("magpie.connectAddress") || "";
24
+ connectKeyWant = localStorage.getItem("magpie.connectKey") || "";
25
+ } catch {}
26
+ function keepPick(name, v) {
27
+ try { localStorage.setItem(name, v); } catch {}
28
+ }
29
+
30
+ function connectPick(id, label, text, options, value, choose) {
31
+ const b = el("button", "val");
32
+ b.id = id;
33
+ b.type = "button";
34
+ b.setAttribute("aria-label", t(label));
35
+ b.append(el("code", "", text), svg(CHEV, 11, 1.6));
36
+ b.onclick = () => {
37
+ if (b.classList.contains("open")) return closeProtoMenu();
38
+ openProtoMenu(b, options, value, choose, label);
39
+ };
40
+ return b;
41
+ }
42
+
43
+ async function selectConnectKey(id) {
44
+ connectKeyID = id;
45
+ connectSecret = null;
46
+ renderConnect();
47
+ if (!id || !providers?.gateway.lan) return;
48
+ const key = gatewayKeys?.find((k) => k.id === id && !k.off);
49
+ if (!key) return;
50
+ try {
51
+ const out = await api("caller-keys/copy-key", { key: id });
52
+ if (connectKeyID !== id || gatewayKeys?.find((k) => k.id === id) !== key) return;
53
+ connectSecret = { id, secret: out.secret };
54
+ if (view === "gateway") renderConnect();
55
+ } catch (e) { status(t(e.message), "err"); }
56
+ }
57
+ async function loadGatewayKeys() {
58
+ const read = ++gatewayKeysRead;
59
+ try {
60
+ const out = await api("caller-keys");
61
+ if (read !== gatewayKeysRead) return;
62
+ // A fresh list also refreshes a selected credential, which may have rotated.
63
+ connectSecret = null;
64
+ gatewayKeys = out.keys || [];
65
+ gatewayKeysErr = "";
66
+ renderGatewayKeys();
67
+ if (view === "gateway" && providers) selectConnectKey(connectKeyID);
68
+ } catch (e) {
69
+ if (read !== gatewayKeysRead) return;
70
+ // a list read before stays: only one never read is said unreadable
71
+ if (gatewayKeys !== null) return status(t(e.message), "err");
72
+ gatewayKeysErr = e.message;
73
+ renderGatewayKeys();
74
+ }
75
+ }
76
+
77
+ // refreshGatewayKeys reads the keys again for what each has used of its
78
+ // limits, which only a visit to the page read before: left open, a reset
79
+ // key's line said $0.00 however much was spent after it (Young on Discord).
80
+ // It runs as the page is drawn again (the window back, a Refresh) and on
81
+ // a timer while the page is in sight; the list is drawn again only when
82
+ // it changed, and not while a key is being added, renamed or its limit
83
+ // edited, nor while an action is on its way.
84
+ async function refreshGatewayKeys() {
85
+ if (gatewayKeys === null || gatewayKeysBusy) return;
86
+ const read = ++gatewayKeysRead;
87
+ let out;
88
+ try { out = await api("caller-keys"); } catch { return; }
89
+ if (read !== gatewayKeysRead || gatewayKeysBusy) return;
90
+ const keys = out.keys || [];
91
+ if (JSON.stringify(keys) === JSON.stringify(gatewayKeys)) return;
92
+ gatewayKeys = keys;
93
+ gatewayAccountNames = out.accountNames || gatewayAccountNames;
94
+ if (gatewayKeyDraft === null && gatewayLimit === null && !$("#gatewayKeys .rename-in")) renderGatewayKeys();
95
+ }
96
+ // the timer: only while the Gateway page is in sight, as a look with
97
+ // nothing to do wakes the web content process all the same (#3185)
98
+ let gatewayKeysTick = 0;
99
+ function gatewayKeysTicks() {
100
+ const page = $("#view-gateway");
101
+ if (!page || page.hidden || document.hidden) { clearInterval(gatewayKeysTick); gatewayKeysTick = 0; return; }
102
+ gatewayKeysTick ||= setInterval(() => {
103
+ if ($("#view-gateway").hidden || document.hidden) return gatewayKeysTicks();
104
+ if (view === "gateway" && gatewayKeys?.some((k) => !k.off && keyUsed(k).length)) refreshGatewayKeys();
105
+ }, 15e3);
106
+ }
107
+
108
+ async function gatewayKeyAction(action, body) {
109
+ if (gatewayKeysBusy) return null;
110
+ gatewayKeysBusy = true;
111
+ gatewayKeysRead++;
112
+ try {
113
+ const out = await api("caller-keys/" + action, body);
114
+ gatewayKeysRead++;
115
+ connectSecret = null;
116
+ gatewayKeys = out.keys || [];
117
+ gatewayAccountNames = out.accountNames || gatewayAccountNames;
118
+ gatewayKeyDraft = null;
119
+ if (action === "limit-key" || !gatewayKeys.some((k) => k.id === gatewayLimit?.id)) gatewayLimit = null;
120
+ renderGatewayKeys();
121
+ if (view === "gateway" && providers) selectConnectKey(connectKeyID);
122
+ return out;
123
+ } catch (e) {
124
+ status(t(e.message), "err");
125
+ return null;
126
+ } finally {
127
+ gatewayKeysBusy = false;
128
+ }
129
+ }
130
+
131
+ function gatewayRename(k) {
132
+ const b = el("button", "n rename", k.name);
133
+ b.title = t("Rename");
134
+ b.onclick = () => {
135
+ const i = input(k.name, t("Name"));
136
+ i.className = "rename-in";
137
+ i.setAttribute("aria-label", t("Name"));
138
+ let finished = false;
139
+ const done = async (save) => {
140
+ if (finished) return;
141
+ finished = true;
142
+ // Keep the other controls in place when blur precedes their click.
143
+ i.replaceWith(b);
144
+ if (save && i.value.trim() !== k.name) await gatewayKeyAction("rename-key", { key: k.id, name: i.value });
145
+ };
146
+ i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") done(true); else if (e.key === "Escape") done(false); };
147
+ i.onblur = () => done(true);
148
+ b.replaceWith(i);
149
+ i.focus({ preventScroll: true });
150
+ };
151
+ return b;
152
+ }
153
+
154
+ function gatewayKeyForm() {
155
+ const box = el("div", "acc adding");
156
+ const name = input(gatewayKeyDraft, t("Key name, e.g. Laptop"));
157
+ name.setAttribute("aria-label", t("Gateway key name"));
158
+ name.oninput = () => { gatewayKeyDraft = name.value; };
159
+ const own = input(gatewayOwnDraft, t("Your own key (optional)"), "password");
160
+ own.setAttribute("aria-label", t("Gateway key value"));
161
+ own.title = t("Leave empty and magpie makes a key. Or enter one your clients already send, from another gateway, so they keep working.");
162
+ own.autocomplete = "off";
163
+ own.oninput = () => { gatewayOwnDraft = own.value; };
164
+ const add = el("button", "text primary", t("Create"));
165
+ const go = async () => {
166
+ add.disabled = true;
167
+ const mine = own.value.trim() !== "";
168
+ const out = await gatewayKeyAction("add-key", mine ? { name: name.value, secret: own.value } : { name: name.value });
169
+ if (!out) { add.disabled = false; return; }
170
+ gatewayOwnDraft = "";
171
+ status(t(mine ? "Gateway key created. Clients that send this key reach magpie now." : "Gateway key created. Use Copy on its row to connect a client."), "ok");
172
+ };
173
+ add.onclick = go;
174
+ const cancelled = () => { gatewayKeyDraft = null; gatewayOwnDraft = ""; renderGatewayKeys(); };
175
+ for (const i of [name, own]) i.onkeydown = (e) => {
176
+ e.stopPropagation();
177
+ if (e.key === "Enter" && !add.disabled) go();
178
+ else if (e.key === "Escape") cancelled();
179
+ };
180
+ const cancel = el("button", "text", t("Cancel"));
181
+ cancel.onclick = cancelled;
182
+ const fields = el("div", "kf");
183
+ fields.append(name, own);
184
+ const bar = el("div", "kb");
185
+ bar.append(el("span", "grow"), cancel, add);
186
+ box.append(fields, bar);
187
+ queueMicrotask(() => name.focus({ preventScroll: true }));
188
+ return box;
189
+ }
190
+
191
+ function copyCallerKeyBtn(k) {
192
+ const cp = copyBtn("", t("Gateway key"));
193
+ cp.setAttribute("aria-label", t("Copy gateway key"));
194
+ cp.onclick = async () => {
195
+ try {
196
+ const out = await api("caller-keys/copy-key", { key: k.id });
197
+ await copy(out.secret, t("Gateway key"), cp);
198
+ } catch (e) { status(t(e.message), "err"); }
199
+ };
200
+ return cp;
201
+ }
202
+
203
+ function renderGatewayKeys() {
204
+ // a drag in progress keeps its rows: the list is drawn when it ends
205
+ if (gatewayKeysArranging) { gatewayKeysRenderPending = true; return; }
206
+ const box = $("#gatewayKeys");
207
+ $("#gatewayKeysBlock").hidden = !providers?.gateway.lan;
208
+ box.replaceChildren();
209
+ $("#addGatewayKey").onclick = () => { gatewayKeyDraft = ""; renderGatewayKeys(); };
210
+ // keys: a badge left unset folds away until the row is pointed at, as
211
+ // on a provider's keys, so nothing invisible holds a place in the row
212
+ const list = el("div", "accts gw-keys");
213
+ if (gatewayKeys === null) {
214
+ if (gatewayKeysErr) list.append(el("div", "none", t(gatewayKeysErr))), box.append(list);
215
+ return;
216
+ }
217
+ const rows = [];
218
+ for (const k of gatewayKeys) {
219
+ const row = el("div", "acc" + (k.off ? " off" : " in-use"));
220
+ rows.push(row);
221
+ row.dataset.key = k.id;
222
+ const tick = el("button", "dot tick");
223
+ tick.title = t(k.off ? "Enable key" : "Disable key");
224
+ tick.setAttribute("aria-label", tick.title);
225
+ if (!k.off) tick.append(svg(CHECK, 10, 2.2));
226
+ tick.onclick = () => gatewayKeyAction(k.off ? "on-key" : "off-key", { key: k.id });
227
+ // the copy sits by the key it copies (the owner: stranded among the
228
+ // badges it read as nobody's); Rotate and Remove wait in the row's menu
229
+ row.append(tick, gatewayRename(k), el("span", "plan mono", k.masked), copyCallerKeyBtn(k), el("span", "grow"), ...(k.browser ? [gatewayBrowserTag()] : k.readOnly ? [gatewayReadOnlyTag()] : []), gatewayModelsBadge(k), ...gatewayMCPBadge(k), gatewayQuotasBadge(k), gatewayLimitBadge(k), gatewayKeyMore(k));
230
+ const under = gatewayLimit?.id === k.id ? [gatewayLimitEditor(k)] : keyUsed(k).map((u) => gatewayLimitLine(k, u));
231
+ if (under.length) row.classList.add("with-limit"), row.append(...under);
232
+ list.append(row);
233
+ }
234
+ if (gatewayKeyDraft !== null) list.append(gatewayKeyForm());
235
+ else if (!gatewayKeys.length) list.append(el("div", "none", t("No gateway keys yet")));
236
+ box.append(list);
237
+ gatewayKeysArrange(list, rows);
238
+ const page = gatewayKeyPage();
239
+ if (page) box.append(page);
240
+ }
241
+
242
+ // gatewayKeysArrange: the keys are listed in the order the user puts them
243
+ // in (alextjy on Discord), dragged by a row anywhere off its buttons, or
244
+ // moved with Alt+↑/↓ on it, as the search APIs are (searchArrange); the
245
+ // grip that says so shows on the row under the pointer. The order is kept
246
+ // with the keys (move-key), so `magpie gateway-key list`, Connect's key
247
+ // picker and the Requests page's key filter list them the same way. The
248
+ // new order shows at once, and the list is read again when it can't be
249
+ // saved.
250
+ let gatewayKeysArranging = false, gatewayKeysRenderPending = false;
251
+ function gatewayKeysArranged() {
252
+ gatewayKeysArranging = false;
253
+ if (gatewayKeysRenderPending) { gatewayKeysRenderPending = false; renderGatewayKeys(); }
254
+ }
255
+ function gatewayKeysArrange(list, rows) {
256
+ if (rows.length < 2) return;
257
+ list.classList.add("reorderable");
258
+ const move = async (row, to) => {
259
+ const from = rows.indexOf(row);
260
+ if (gatewayKeysBusy || to < 0 || to >= rows.length || to === from) return;
261
+ const id = row.dataset.key, focus = document.activeElement === row;
262
+ const next = [...gatewayKeys];
263
+ next.splice(to, 0, ...next.splice(from, 1));
264
+ gatewayKeys = next;
265
+ renderGatewayKeys();
266
+ const refocus = () => { if (focus) $(`#gatewayKeys .acc[data-key="${CSS.escape(id)}"]`)?.focus({ preventScroll: true }); };
267
+ refocus();
268
+ if (await gatewayKeyAction("move-key", { key: id, index: to })) status(t("Gateway key order saved"), "ok");
269
+ else await loadGatewayKeys(); // not saved: the order as it is kept
270
+ refocus();
271
+ };
272
+ for (const row of rows) {
273
+ const name = row.querySelector(".n")?.textContent || "";
274
+ row.tabIndex = 0;
275
+ row.setAttribute("role", "group");
276
+ row.setAttribute("aria-label", name + " · " + t("Drag to reorder · Alt+↑/↓ to move"));
277
+ row.title = t("Drag to reorder · Alt+↑/↓ to move");
278
+ row.prepend(el("span", "grip"));
279
+ row.addEventListener("click", (e) => {
280
+ if (row.dataset.dragged) { e.preventDefault(); e.stopImmediatePropagation(); }
281
+ }, true);
282
+ row.onkeydown = (e) => {
283
+ if (e.target !== row || !e.altKey || !["ArrowUp", "ArrowDown"].includes(e.key) || gatewayKeysArranging) return;
284
+ e.preventDefault(); e.stopPropagation();
285
+ move(row, rows.indexOf(row) + (e.key === "ArrowUp" ? -1 : 1));
286
+ };
287
+ row.onpointerdown = (e) => {
288
+ if (gatewayKeysArranging || gatewayKeysBusy || e.target.closest("input, textarea, select, a, button, .key-limit-ed")) return;
289
+ gatewayKeysArranging = dragRows(e, row, row, list, rows, (to) => move(row, to), () => { closeProtoMenu(); closeAgentMenu(); },
290
+ gatewayKeysArranged);
291
+ };
292
+ }
293
+ }
294
+
295
+ // the key page (the gateway's /me): where a key's holder, with their key
296
+ // alone, sees what it has used and may use and the setup for their agents
297
+ // (zytszone on X: a department shares one magpie, whose app its people
298
+ // can't be given). Its address is the one other machines reach.
299
+ function gatewayKeyPage() {
300
+ const g = providers?.gateway, lan = gwFar(g);
301
+ const at = g?.publicURL || (lan.includes(connectURL) ? connectURL : lan[0]);
302
+ if (!at || !gatewayKeys?.length) return null;
303
+ const url = at.replace(/\/+$/, "") + "/me";
304
+ const p = el("div", "usage-note gw-key-page");
305
+ p.append(el("span", "", t("Key holders see their usage and setup at")), " ", el("code", "", url), copyBtn(url, t("Key page")));
306
+ return p;
307
+ }
308
+
309
+ const ROTATE_GLYPH = "M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v3.25h-3.25";
310
+ const REMOVE_GLYPH = "M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5M7 7v4M9 7v4";
311
+ const MCP_GLYPH = "M6 2.5v3M10 2.5v3M4.5 5.5h7V8a3.5 3.5 0 0 1-7 0zM8 11.5v2";
312
+ const POWER_GLYPH = "M8 2.5v5M4.8 4.6a4.75 4.75 0 1 0 6.4 0";
313
+ function gatewayKeyMore(k) {
314
+ const more = el("button", "copy gw-key-more");
315
+ more.type = "button";
316
+ more.title = t("Key actions");
317
+ more.setAttribute("aria-label", t("Key actions"));
318
+ more.setAttribute("aria-haspopup", "menu");
319
+ more.append(svg(DOTS, 14, 2));
320
+ more.onclick = (e) => {
321
+ e.stopPropagation();
322
+ if (more.classList.contains("open")) return closeAgentMenu();
323
+ // off and on again here too, by name, beside Remove (wei_VC on X:
324
+ // the tick alone wasn't seen as a switch, so a key was removed and
325
+ // added again)
326
+ openRowMenu(more, [
327
+ { name: k.off ? "Enable key" : "Disable key", icon: POWER_GLYPH, run: () => gatewayKeyAction(k.off ? "on-key" : "off-key", { key: k.id }) },
328
+ // read-only (Steven on Discord: a desk display's key reads quotas
329
+ // and usage, and uses nothing); the sharing key is given to agents.
330
+ // Magpie Browser's key connects the extension alone, and stays so:
331
+ // it is in a zip in Downloads
332
+ ...(k.browser ? [] : [k.readOnly
333
+ ? { name: "Give full access", icon: EYE_GLYPH, run: () => gatewayKeyAccess(k, false) }
334
+ : { name: "Make read-only", icon: EYE_GLYPH, run: () => gatewayKeyAccess(k, true), off: k.lan, why: k.lan ? t("The sharing key is given to agents, so it can't be read-only") : "" }]),
335
+ // the MCP servers and tools it may use (wakaka on Discord); the
336
+ // Browser key connects the extension alone
337
+ ...(k.browser ? [] : [{ name: "MCP servers", icon: MCP_GLYPH, run: () => gatewayMCPPick(k, more) }]),
338
+ { name: "Rotate key", icon: ROTATE_GLYPH, run: () => askGatewayKey(k, true) },
339
+ { name: "Remove", icon: REMOVE_GLYPH, sep: true, run: () => askGatewayKey(k, false) },
340
+ ]);
341
+ };
342
+ return more;
343
+ }
344
+
345
+ const EYE_GLYPH = "M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8zM8 9.9a1.9 1.9 0 1 0 0-3.8 1.9 1.9 0 0 0 0 3.8z";
346
+ async function gatewayKeyAccess(k, readOnly) {
347
+ const out = await gatewayKeyAction("access-key", { key: k.id, readOnly });
348
+ if (out) status(t(readOnly ? "{name} is read-only" : "{name} has full access", { name: k.name }), "ok");
349
+ }
350
+ // a read-only key says so in its row, always: it reads quotas and usage
351
+ // and uses no model
352
+ function gatewayReadOnlyTag() {
353
+ const tag = el("span", "amodels key-readonly set", t("Read only"));
354
+ tag.title = t("This key only reads quotas and usage. It can't use models or MCP servers, or change anything.");
355
+ return tag;
356
+ }
357
+
358
+ // Magpie Browser's key (the one the extension's zip was given) says so in
359
+ // its row: it connects the extension and nothing else
360
+ function gatewayBrowserTag() {
361
+ const tag = el("span", "amodels key-readonly key-browser set", t("Browser only"));
362
+ tag.title = t("This key connects the Magpie Browser extension and nothing else. It can't use models, MCP servers or stats.");
363
+ return tag;
364
+ }
365
+
366
+ function askGatewayKey(k, rotate) {
367
+ const ed = el("div", "editor");
368
+ const head = el("div", "ehead");
369
+ head.append(el("b", "", t(rotate ? "Rotate gateway key?" : "Remove gateway key?")));
370
+ ed.append(head, el("p", "lib-confirm", t(rotate
371
+ ? "Clients using {name} will need the new key. Its name and usage history stay the same."
372
+ : "Clients using {name} will lose access from other computers. Its usage history is kept.", { name: k.name })));
373
+ // a value of one's own in place of the one magpie makes (isaac_bai on
374
+ // Discord: one easy to remember and pass on)
375
+ let own = null;
376
+ if (rotate) {
377
+ own = input("", t("Your own key (optional)"), "password");
378
+ own.setAttribute("aria-label", t("Gateway key value"));
379
+ own.title = t("Leave empty and magpie makes a key. Or enter one your clients already send, from another gateway, so they keep working.");
380
+ own.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") go.click(); else if (e.key === "Escape") closeConfirmAsk(); };
381
+ ed.append(el("label", "", t("Gateway key")), own);
382
+ }
383
+ const bar = el("div", "bar");
384
+ const cancel = el("button", "text", t("Cancel"));
385
+ cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); };
386
+ const go = el("button", "text primary", t(rotate ? "Rotate key" : "Remove"));
387
+ go.onclick = async (e) => {
388
+ e.stopPropagation();
389
+ if (go.disabled) return;
390
+ go.disabled = true;
391
+ const mine = !!own?.value.trim();
392
+ const out = await gatewayKeyAction(rotate ? "rotate-key" : "remove-key", mine ? { key: k.id, secret: own.value } : { key: k.id });
393
+ if (!out) { go.disabled = false; return; }
394
+ closeConfirmAsk();
395
+ if (rotate) status(t(mine ? "Gateway key changed. Clients that send this key reach magpie now." : "Gateway key rotated. Update clients on other computers."), "ok");
396
+ };
397
+ bar.append(el("span", "grow"), cancel, go);
398
+ ed.append(bar);
399
+ confirmAsk = ed;
400
+ openModal(ed);
401
+ $("#modal").classList.add("lib");
402
+ cancel.focus({ preventScroll: true });
403
+ }
404
+
405
+ // ---- the models a gateway key may use (#882), and the accounts (#905) ----
406
+
407
+ // accountShown is a stored account entry ("<provider>/<id>", #905) as the
408
+ // badge says it: by who is signed in, the id itself when no provider has it.
409
+ const accountShown = (a) => gatewayAccountNames[a] || a;
410
+
411
+ // the key whose models menu waits on its list: the badge drawn last opens
412
+ // it, so a redraw of the keys while it loads (a rename's reply, the
413
+ // providers' poll) doesn't drop the click
414
+ let keyModelsLoading = null;
415
+
416
+ // gatewayModelsBadge picks the models and the accounts a key may use, in
417
+ // the app's menu: "All models" on hover when it may use any, else which
418
+ // it may, always shown. A pick is sent when the menu closes.
419
+ function gatewayModelsBadge(k) {
420
+ const ms = k.models || [];
421
+ const as = k.accounts || [];
422
+ const b = el("button", "amodels key-models" + (ms.length || as.length ? " set" : ""));
423
+ b.type = "button";
424
+ b.textContent = !ms.length && !as.length ? t("All models")
425
+ : ms.length ? (ms.length === 1 && !as.length ? ms[0] : t("{n} models", { n: ms.length }))
426
+ : (as.length === 1 ? accountShown(as[0]) : t("{n} accounts", { n: as.length }));
427
+ // an account picked and no model holds the key to the accounts'
428
+ // providers' models (access.HeldModels, Linka on Discord): it is not
429
+ // let off to every other provider's
430
+ b.title = (ms.length ? t("Only these models: {list}", { list: ms.join(", ") }) : t("Pick the models this key may use"))
431
+ + (as.length ? "\n" + t("Only these accounts: {list}", { list: as.map(accountShown).join(", ") })
432
+ + "\n" + (ms.length ? t("A provider with no account picked is used on every account; to keep the key off it, pick only other providers' models")
433
+ : t("No model is picked, so this key may use only the picked accounts' providers' models")) : "");
434
+ b.setAttribute("aria-label", t("Models this key may use"));
435
+ b.setAttribute("aria-haspopup", "menu");
436
+ b.setAttribute("aria-expanded", "false");
437
+ const open = (models, accounts) => {
438
+ if (!b.isConnected) return;
439
+ const opts = [{ v: "", name: "All models", note: "Any model and any account, now and later" }];
440
+ const seen = new Set();
441
+ // the accounts and keys the key may be held to (#905), each right
442
+ // after its provider's models, not one block at the end: an account
443
+ // by who is signed in, a key by its fingerprint
444
+ const accountsSet = new Set();
445
+ const ofProvider = new Map();
446
+ // the list holds the key only to the accounts of the providers it
447
+ // names (#905): a provider none of whose accounts is picked is used on
448
+ // every one, and its unticked accounts say so (#2077: unticking all of
449
+ // Codex's left Codex's models served)
450
+ const named = new Set(as.map((v) => v.split("/")[0].toLowerCase()));
451
+ // a provider the key's models leave out is used on none of its
452
+ // accounts: with no model picked, every provider no account is
453
+ // picked of (access.HeldModels)
454
+ const modelsReach = (pid) => ms.some((m) => {
455
+ const head = m.split("/")[0].toLowerCase();
456
+ return head === pid || head.includes("*");
457
+ });
458
+ for (const a of accounts || []) {
459
+ accountsSet.add(a.id);
460
+ const pid = String(a.provider).toLowerCase();
461
+ const note = as.length && !named.has(pid)
462
+ ? (ms.length && modelsReach(pid)
463
+ ? t("Used: no {provider} account is picked", { provider: a.providerName })
464
+ : t("Not used: no {provider} model or account is picked", { provider: a.providerName }))
465
+ : a.plan ? a.providerName + " · " + a.plan : a.providerName;
466
+ const o = { v: a.id, name: a.name, note, literalName: true };
467
+ ofProvider.set(a.provider, [...(ofProvider.get(a.provider) || []), o]);
468
+ }
469
+ let listed = ""; // the provider whose models the list is in
470
+ const endBlock = () => {
471
+ for (const o of ofProvider.get(listed) || []) opts.push(o);
472
+ ofProvider.delete(listed);
473
+ listed = "";
474
+ };
475
+ for (const m of models || []) {
476
+ // a routing group the key names is its with every member in it
477
+ // (Magic_zero on Discord); the groups come first
478
+ if (m.group) {
479
+ endBlock();
480
+ if (!seen.has("group")) {
481
+ seen.add("group");
482
+ opts.push({ v: "group/*", name: "Every routing group", note: "group/*" });
483
+ }
484
+ opts.push({ v: m.id, name: m.name, note: m.id, literalName: true });
485
+ continue;
486
+ }
487
+ if (m.provider !== listed) {
488
+ endBlock();
489
+ listed = m.provider;
490
+ }
491
+ if (!seen.has(m.provider)) {
492
+ seen.add(m.provider);
493
+ opts.push({ v: m.provider + "/*", name: t("Every {provider} model", { provider: m.providerName }), note: m.provider + "/*", literalName: true });
494
+ }
495
+ // a decision model (Jev, Clef…) no agent lists: a routing group
496
+ // asks it whatever the key may use, and the key may ask it itself
497
+ // (/v1/systemone, another magpie's list) only when it is picked
498
+ // (Kautism on Discord)
499
+ opts.push({ v: m.id, name: m.name, note: m.decides ? m.id + " · " + t("Decision model: a routing group asks it whatever this key may use; pick it so the key may ask it itself") : m.id, literalName: true });
500
+ }
501
+ endBlock();
502
+ // a provider with accounts and no model in the list (one kept
503
+ // unlisted, or serving none now) keeps its accounts after the models
504
+ for (const os of ofProvider.values()) opts.push(...os);
505
+ // what the CLI kept that the list hasn't, a pattern, a model gone or
506
+ // an account signed out: behind every live entry, a model gone
507
+ // before an account signed out
508
+ for (const v of ms) if (!opts.some((o) => o.v === v)) opts.push({ v, name: v, note: "Not served now", literalName: true });
509
+ for (const v of as) if (!accountsSet.has(v)) { accountsSet.add(v); opts.push({ v, name: v, note: "Not signed in now", literalName: true }); }
510
+ openProtoMenu(b, opts, [...ms, ...as], (picked) => {
511
+ // one action at a time: gatewayKeyAction is busy while one is sent.
512
+ // The accounts are sent only when they changed: a model picked says
513
+ // nothing of the key's accounts.
514
+ (async () => {
515
+ const pickedModels = picked.filter((v) => !accountsSet.has(v));
516
+ const pickedAccounts = picked.filter((v) => accountsSet.has(v));
517
+ const out = await gatewayKeyAction("models-key", { key: k.id, models: pickedModels });
518
+ if (out) {
519
+ status(t(pickedModels.length ? "{name} may use only the models picked"
520
+ : pickedAccounts.length ? "{name} may use only the picked accounts' models" : "{name} may use every model", { name: k.name }), "ok");
521
+ }
522
+ if (pickedAccounts.length !== as.length || pickedAccounts.some((v) => !as.includes(v))) {
523
+ await gatewayKeyAction("accounts-key", { key: k.id, accounts: pickedAccounts });
524
+ }
525
+ })();
526
+ }, "Models this key may use", "sess-menu", "right");
527
+ };
528
+ b.onclick = async (e) => {
529
+ e.preventDefault();
530
+ e.stopPropagation();
531
+ if (b.classList.contains("open")) return closeProtoMenu();
532
+ const loading = keyModelsLoading = { id: k.id, open };
533
+ let models = [], accounts = [];
534
+ try { ({ models, accounts } = await api("caller-keys/models")); } catch (err) {
535
+ if (keyModelsLoading === loading) keyModelsLoading = null;
536
+ status(t(err.message), "err");
537
+ return;
538
+ }
539
+ if (keyModelsLoading !== loading) return;
540
+ keyModelsLoading = null;
541
+ loading.open(models, accounts);
542
+ };
543
+ if (keyModelsLoading?.id === k.id) keyModelsLoading.open = open;
544
+ return b;
545
+ }
546
+
547
+ // gatewayMCPPick picks the MCP servers and tools a key may use (wakaka
548
+ // on Discord), as gatewayModelsBadge picks its models: a server whole, or
549
+ // some of its tools, or none. It opens from the row's menu, and from the
550
+ // key's MCP badge once it holds some.
551
+ async function gatewayMCPPick(k, anchor) {
552
+ const ms = k.mcp || [];
553
+ let list = [];
554
+ try { ({ servers: list } = await api("caller-keys/mcp")); } catch (err) { status(t(err.message), "err"); return; }
555
+ if (!anchor.isConnected) return;
556
+ const opts = [{ v: "", name: "All MCP servers", note: "" }, { v: "-", name: "No MCP server", note: "" }];
557
+ for (const sv of list || []) {
558
+ opts.push({ v: sv.name, name: sv.title || sv.name, note: sv.title && sv.title !== sv.name ? sv.name : "", literalName: true });
559
+ for (const tool of sv.tools || []) opts.push({ v: sv.name + "/" + tool, name: tool, note: sv.name + "/" + tool, literalName: true });
560
+ }
561
+ // what the CLI kept that the list hasn't: a server gone, or a tool it
562
+ // no longer lists
563
+ for (const v of ms) if (!opts.some((o) => o.v === v)) opts.push({ v, name: v, note: "Not served now", literalName: true });
564
+ openProtoMenu(anchor, opts, [...ms], (picked) => {
565
+ // "No MCP server" just ticked is none; left ticked beside a server
566
+ // ticked since, the server
567
+ const mcp = picked.includes("-") && !ms.includes("-") ? ["-"] : picked.length > 1 ? picked.filter((v) => v !== "-") : picked;
568
+ (async () => {
569
+ const out = await gatewayKeyAction("mcp-key", { key: k.id, mcp });
570
+ if (out) status(t(!mcp.length ? "{name} may use every MCP server" : mcp[0] === "-" ? "{name} may use no MCP server" : "{name} may use only the MCP servers and tools picked", { name: k.name }), "ok");
571
+ })();
572
+ }, "MCP servers this key may use", "sess-menu", "right");
573
+ }
574
+
575
+ // gatewayMCPBadge says which MCP servers a key held to some may use; a
576
+ // key that may use any has none (the row's menu sets it)
577
+ function gatewayMCPBadge(k) {
578
+ const ms = k.mcp || [];
579
+ if (!ms.length) return [];
580
+ const b = el("button", "amodels key-mcp set");
581
+ b.type = "button";
582
+ const servers = new Set(ms.map((v) => v.split("/")[0]));
583
+ b.textContent = ms[0] === "-" ? t("No MCP server") : ms.length === 1 ? ms[0] : t("{n} MCP servers", { n: servers.size });
584
+ b.title = ms[0] === "-" ? t("No MCP server") : t("Only these MCP servers and tools: {list}", { list: ms.join(", ") });
585
+ b.setAttribute("aria-label", t("MCP servers this key may use"));
586
+ b.setAttribute("aria-haspopup", "menu");
587
+ b.setAttribute("aria-expanded", "false");
588
+ b.onclick = (e) => {
589
+ e.preventDefault();
590
+ e.stopPropagation();
591
+ if (b.classList.contains("open")) return closeProtoMenu();
592
+ gatewayMCPPick(k, b);
593
+ };
594
+ return [b];
595
+ }
596
+
597
+ // ---- the quotas a gateway key is told (#2216) ----
598
+
599
+ // gatewayQuotasBadge turns on or off a key's being told the quotas of what
600
+ // it may use: a key held to a budget, models or accounts is told none of
601
+ // this magpie's quotas (#1981) unless it is on, and then only the cards of
602
+ // the providers whose every model it may use, of the accounts it may use.
603
+ // "Quotas" on hover when off, "Quotas shown" always when on.
604
+ function gatewayQuotasBadge(k) {
605
+ const b = el("button", "amodels key-quotas" + (k.quotas ? " set" : ""));
606
+ b.type = "button";
607
+ b.textContent = t(k.quotas ? "Quotas shown" : "Quotas");
608
+ b.title = t(k.quotas
609
+ ? "This key is told the quotas of the providers whose every model it may use, and of the accounts it may use, account names included. Click to stop."
610
+ : "A key held to a budget, models or accounts is told no quotas. Click to tell it the quotas of the providers whose every model it may use, and of the accounts it may use, account names included.");
611
+ b.setAttribute("aria-label", t("Show this key its quotas"));
612
+ b.setAttribute("aria-pressed", k.quotas ? "true" : "false");
613
+ b.onclick = async (e) => {
614
+ e.preventDefault();
615
+ const out = await gatewayKeyAction("quotas-key", { key: k.id, quotas: !k.quotas });
616
+ if (out) status(t(k.quotas ? "{name} is told no quotas" : "{name} is told the quotas of what it may use", { name: k.name }), "ok");
617
+ };
618
+ return b;
619
+ }
620
+
621
+ // ---- a gateway key's own limit (#585) ----
622
+
623
+ // "days" is every N days from when the limit is set or reset (#1509),
624
+ // "hours" every N hours (#2102), a subscription's 5-hour window's pace
625
+ const limitPeriods = ["day", "week", "month", "days", "hours"];
626
+ const limitPer = { day: "per day", week: "per week", month: "per month", days: "Every N days", hours: "Every N hours" };
627
+ const cyclic = (period) => period === "days" || period === "hours";
628
+ // perWords is a limit's window as the badge says it: "per week", "every
629
+ // 10 days", "every 5 hours"
630
+ const perWords = (period, days, hours) => period === "days"
631
+ ? (days === 1 ? t("every 1 day") : t("every {n} days", { n: days }))
632
+ : period === "hours" ? (hours === 1 ? t("every 1 hour") : t("every {n} hours", { n: hours }))
633
+ : t(limitPer[period] || period);
634
+ // limitDays reads a cycle's days: a whole number from 1 to 3650, else NaN
635
+ function limitDays(v) {
636
+ const s = String(v ?? "").trim();
637
+ return /^\d{1,4}$/.test(s) && +s >= 1 && +s <= 3650 ? +s : NaN;
638
+ }
639
+ // limitHours reads a cycle's hours: a whole number from 1 to 8760, else NaN
640
+ function limitHours(v) {
641
+ const s = String(v ?? "").trim();
642
+ return /^\d{1,4}$/.test(s) && +s >= 1 && +s <= 8760 ? +s : NaN;
643
+ }
644
+
645
+ // limitTokens reads a count as typed: 2000000, 2,000,000, 500k, 2M, 1.5m,
646
+ // 100万, 1亿; "" for none, NaN for what isn't one.
647
+ function limitTokens(v) {
648
+ const s = String(v ?? "").trim().toLowerCase().replace(/[,\s_]/g, "");
649
+ if (!s) return 0;
650
+ const m = s.match(/^(\d+(?:\.\d+)?)(k|m|b|万|亿)?$/);
651
+ if (!m) return NaN;
652
+ const mult = { k: 1e3, m: 1e6, b: 1e9, "万": 1e4, "亿": 1e8 }[m[2]] || 1;
653
+ return Math.round(parseFloat(m[1]) * mult);
654
+ }
655
+ function limitCost(v) {
656
+ const s = String(v ?? "").trim().replace(/^[$$]/, "");
657
+ if (!s) return 0;
658
+ return /^\d+(\.\d+)?$/.test(s) ? parseFloat(s) : NaN;
659
+ }
660
+ const usd = (n) => "$" + (n >= 100 ? n.toFixed(0) : n.toFixed(2));
661
+ // limitModels names the models a limit counts (zola_xynb on X), "" when
662
+ // it counts every call of the key
663
+ const limitModels = (ms) => !ms?.length ? "" : ms.length === 1 ? ms[0] : t("{n} models", { n: ms.length });
664
+ const sameModels = (a, b) => (a || []).length === (b || []).length && (a || []).every((m) => (b || []).includes(m));
665
+
666
+ // keyLimits are a key's limits, its first and the rest (#2227), and
667
+ // keyUsed what it has used of each, in the same order.
668
+ const keyLimits = (k) => k.limit ? [k.limit, ...(k.moreLimits || [])] : [];
669
+ const keyUsed = (k) => k.usedAll || (k.used ? [k.used] : []);
670
+ // limitDraft is the editor's draft of a key's nth limit, or of a new one
671
+ // at n past the last.
672
+ function limitDraft(k, n) {
673
+ const l = keyLimits(k)[n] || {};
674
+ const taken = keyLimits(k).map((x) => x.period);
675
+ // a new one in a window the key has no limit in yet: a week beside 5 hours
676
+ const period = l.period || (n ? ["week", "hours", "day", "month"].find((p) => !taken.includes(p)) || "days" : "day");
677
+ return { id: k.id, n, period, days: l.days ? String(l.days) : "", hours: l.hours ? String(l.hours) : (n && period === "hours" ? "5" : ""), tokens: l.tokens ? String(l.tokens) : "", cost: l.cost ? String(l.cost) : "", cacheReads: !!l.cacheReads, models: [...(l.models || [])] };
678
+ }
679
+ // bindingUsed is the limit holding a key most: of those spent, the one
680
+ // that resets last, as it is refused until then; else the first
681
+ function bindingUsed(k) {
682
+ const all = keyUsed(k), spent = all.filter((u) => u.spent);
683
+ return spent.length ? spent.reduce((a, b) => (new Date(b.reset) > new Date(a.reset) ? b : a)) : all[0];
684
+ }
685
+
686
+ // gatewayLimitBadge opens a key's limit editor: "Limit" on hover when it
687
+ // has none, else what it has used, "1.2M / 2M · day", always shown; with
688
+ // several limits, the one holding it most and how many more there are.
689
+ function gatewayLimitBadge(k) {
690
+ const u = bindingUsed(k), more = keyUsed(k).length - 1;
691
+ const b = el("button", "amodels limit" + (u ? " set" : "") + (u?.spent ? " spent" : "") + (gatewayLimit?.id === k.id ? " open" : ""));
692
+ b.type = "button";
693
+ if (!u) b.textContent = t("Limit");
694
+ else {
695
+ const parts = [];
696
+ if (u.tokenLimit) parts.push(fmtN(u.tokens) + " / " + fmtN(u.tokenLimit));
697
+ if (u.costLimit) parts.push(usd(u.cost) + " / " + usd(u.costLimit));
698
+ b.textContent = (u.spent ? t("Spent") + " · " : "") + parts.join(" · ") + " · " + perWords(u.period, u.days, u.hours)
699
+ + (u.models?.length ? " · " + limitModels(u.models) : "")
700
+ + (more > 0 ? " · " + t(more === 1 ? "+1 limit" : "+{n} limits", { n: more }) : "");
701
+ }
702
+ b.title = t(u ? "Change this key's limit" : "Set a limit for this key");
703
+ b.setAttribute("aria-label", b.title);
704
+ b.setAttribute("aria-expanded", gatewayLimit?.id === k.id ? "true" : "false");
705
+ b.onclick = (e) => {
706
+ e.preventDefault();
707
+ if (gatewayLimit?.id === k.id) gatewayLimit = null;
708
+ else gatewayLimit = limitDraft(k, Math.max(0, keyUsed(k).indexOf(u)));
709
+ renderGatewayKeys();
710
+ };
711
+ return b;
712
+ }
713
+
714
+ // gatewayLimitLine says what a limited key has used of a limit u, has
715
+ // left and when it resets, under its row; with several, each says its
716
+ // window first.
717
+ function gatewayLimitLine(k, u) {
718
+ const line = el("div", "key-limit" + (u.spent ? " spent" : ""));
719
+ const parts = [];
720
+ if (keyUsed(k).length > 1) parts.push(perWords(u.period, u.days, u.hours));
721
+ if (u.models?.length) parts.push(limitModels(u.models));
722
+ if (u.tokenLimit) parts.push(t("{used} of {limit} tokens · {left} left", { used: fmtN(u.tokens), limit: fmtN(u.tokenLimit), left: fmtN(u.tokensLeft) }));
723
+ if (u.costLimit) parts.push(t("{used} of {limit} estimated · {left} left", { used: usd(u.cost), limit: usd(u.costLimit), left: usd(u.costLeft) }));
724
+ const at = new Date(u.reset);
725
+ parts.push(t(u.spent ? "refused until {when}" : "resets {when}", { when: resetClock(at) }));
726
+ if (u.inFlight) parts.push(t(u.inFlight === 1 ? "1 in flight" : "{n} in flight", { n: u.inFlight }));
727
+ line.textContent = parts.join(" · ");
728
+ line.title = (u.models?.length ? u.models.join(", ") + "\n" : "")
729
+ + t("Counted from {start}; resets {when}", { start: new Date(u.start).toLocaleString(intlLang()), when: at.toLocaleString(intlLang()) })
730
+ + (u.unpriced ? "\n" + t("{n} calls without a known price aren't in the cost", { n: u.unpriced }) : "");
731
+ return line;
732
+ }
733
+
734
+ // gatewayLimitEditor stages a key's limit: nothing is sent until Save.
735
+ function gatewayLimitEditor(k) {
736
+ const d = gatewayLimit;
737
+ const box = el("div", "acct-models key-limit-ed");
738
+ box.onclick = (e) => e.stopPropagation();
739
+ const limits = keyLimits(k), n = d.n || 0, used = keyUsed(k)[n];
740
+ const kept = limits[n] || {};
741
+ const changed = () => d.period !== (kept.period || "day") || limitTokens(d.tokens) !== (kept.tokens || 0)
742
+ || limitCost(d.cost) !== (kept.cost || 0) || d.cacheReads !== !!kept.cacheReads
743
+ || (d.period === "days" && limitDays(d.days) !== kept.days)
744
+ || (d.period === "hours" && limitHours(d.hours) !== kept.hours)
745
+ || !sameModels(d.models, kept.models);
746
+ const fields = el("div", "klf");
747
+ // the app's own menu, not a native select
748
+ const period = el("button", "sess-pick klf-period");
749
+ period.type = "button";
750
+ period.setAttribute("aria-label", t("Limit window"));
751
+ period.setAttribute("aria-haspopup", "menu");
752
+ period.setAttribute("aria-expanded", "false");
753
+ const paintPeriod = () => {
754
+ period.dataset.value = d.period;
755
+ period.replaceChildren(el("span", "", t(limitPer[d.period])), svg(CHEV, 11, 1.6));
756
+ fields.classList.toggle("with-days", cyclic(d.period));
757
+ dl.hidden = d.period !== "days";
758
+ hl.hidden = d.period !== "hours";
759
+ };
760
+ // the days of a cycle, shown for "every N days"
761
+ const days = input(d.days, "10");
762
+ days.setAttribute("aria-label", t("Days in a cycle"));
763
+ days.inputMode = "numeric";
764
+ const dl = el("label", "klf-field klf-days");
765
+ dl.append(el("span", "klf-name", t("Days")), days);
766
+ // the hours of a cycle, shown for "every N hours"
767
+ const hours = input(d.hours, "5");
768
+ hours.setAttribute("aria-label", t("Hours in a cycle"));
769
+ hours.inputMode = "numeric";
770
+ const hl = el("label", "klf-field klf-days klf-hours");
771
+ hl.append(el("span", "klf-name", t("Hours")), hours);
772
+ paintPeriod();
773
+ const tokens = input(d.tokens, t("No token cap"));
774
+ tokens.setAttribute("aria-label", t("Token limit"));
775
+ tokens.inputMode = "decimal";
776
+ const cost = input(d.cost, t("No cost cap"));
777
+ cost.setAttribute("aria-label", t("Estimated cost limit, US$"));
778
+ cost.inputMode = "decimal";
779
+ const tl = el("label", "klf-field");
780
+ tl.append(el("span", "klf-name", t("Tokens")), tokens);
781
+ const cl = el("label", "klf-field");
782
+ cl.append(el("span", "klf-name", t("Estimated cost, US$")), cost);
783
+ const pl = el("label", "klf-field");
784
+ pl.append(el("span", "klf-name", t("Window")), period);
785
+ // the models the limit counts (zola_xynb on X): none picked is every
786
+ // call of the key, as a limit set before there was a pick
787
+ const models = el("button", "sess-pick klf-pick klf-models-pick");
788
+ models.type = "button";
789
+ models.setAttribute("aria-label", t("Models this limit counts"));
790
+ models.setAttribute("aria-haspopup", "menu");
791
+ models.setAttribute("aria-expanded", "false");
792
+ const paintModels = () => {
793
+ models.title = d.models.join(", ");
794
+ models.replaceChildren(el("span", "", limitModels(d.models) || t("All models")), svg(CHEV, 11, 1.6));
795
+ };
796
+ paintModels();
797
+ const ml = el("label", "klf-field klf-models");
798
+ ml.append(el("span", "klf-name", t("Models")), models);
799
+ fields.append(pl, dl, hl, tl, cl, ml);
800
+ // a key's limits (#2227), each a tab, when it has more than one or one
801
+ // is being added: the editor is of one at a time
802
+ let which = null;
803
+ if (limits.length > 1 || n >= limits.length && limits.length) {
804
+ which = el("div", "klf-which");
805
+ which.setAttribute("role", "tablist");
806
+ which.setAttribute("aria-label", t("This key's limits"));
807
+ const tabs = limits.map((l, i) => ({ i, name: perWords(l.period, l.days, l.hours) + (l.models?.length ? " · " + limitModels(l.models) : "") }));
808
+ if (n >= limits.length) tabs.push({ i: n, name: t("New limit") });
809
+ for (const x of tabs) {
810
+ const tab = el("button", "klf-tab" + (x.i === n ? " on" : ""), x.name);
811
+ tab.type = "button";
812
+ tab.setAttribute("role", "tab");
813
+ tab.setAttribute("aria-selected", String(x.i === n));
814
+ tab.onclick = (e) => {
815
+ e.stopPropagation();
816
+ if (x.i === n) return;
817
+ gatewayLimit = limitDraft(k, x.i);
818
+ renderGatewayKeys();
819
+ };
820
+ which.append(tab);
821
+ }
822
+ }
823
+ const cache = el("label", "klf-check");
824
+ const cb = el("input");
825
+ cb.type = "checkbox";
826
+ cb.checked = d.cacheReads;
827
+ cache.append(cb, el("span", "", t("Count cache reads too")));
828
+ const hint = el("div", "hint", t("Tokens are each call's uncached input, output and cache writes. The cost is an estimate at the Usage page's prices, not a bill. Windows are calendar ones in this computer's time (a week from Monday), or every N days or N hours from when the limit is set or reset. Requests from this computer without a key aren't limited."));
829
+ const err = el("div", "hint klf-err");
830
+ const unsaved = el("span", "hint munsaved", t("unsaved"));
831
+ unsaved.title = t("Made when you Save; Cancel drops it");
832
+ const save = el("button", "text primary", t("Save"));
833
+ const update = () => {
834
+ const bad = d.period === "days" && Number.isNaN(limitDays(d.days)) ? t("Days is a whole number from 1 to 3650")
835
+ : d.period === "hours" && Number.isNaN(limitHours(d.hours)) ? t("Hours is a whole number from 1 to 8760")
836
+ : Number.isNaN(limitTokens(d.tokens)) ? t("Tokens is a count, like 2000000 or 2M")
837
+ : Number.isNaN(limitCost(d.cost)) ? t("The cost is US dollars, like 5 or 2.50") : "";
838
+ err.textContent = bad;
839
+ err.hidden = !bad;
840
+ unsaved.hidden = !changed();
841
+ // a limit being added has a cap to save
842
+ save.disabled = !!bad || !changed() || (n >= limits.length && !limitTokens(d.tokens) && !limitCost(d.cost));
843
+ };
844
+ period.onclick = (e) => {
845
+ e.preventDefault();
846
+ e.stopPropagation();
847
+ if (period.classList.contains("open")) return closeProtoMenu();
848
+ openProtoMenu(period, limitPeriods.map((p) => ({ v: p, name: t(limitPer[p]), note: "", literalName: true })), d.period, (v) => {
849
+ d.period = v;
850
+ paintPeriod();
851
+ update();
852
+ if (v === "days") days.focus({ preventScroll: true });
853
+ if (v === "hours") hours.focus({ preventScroll: true });
854
+ }, "Limit window", "sess-menu");
855
+ };
856
+ models.onclick = async (e) => {
857
+ e.preventDefault();
858
+ e.stopPropagation();
859
+ if (models.classList.contains("open")) return closeProtoMenu();
860
+ let list = [];
861
+ try { ({ models: list } = await api("caller-keys/models")); } catch (err) { status(t(err.message), "err"); return; }
862
+ if (!models.isConnected || gatewayLimit !== d) return;
863
+ const opts = [{ v: "", name: "All models", note: "" }];
864
+ const seen = new Set();
865
+ // a routing group isn't one: what it picks is counted by the model
866
+ // that served the call
867
+ for (const m of list || []) {
868
+ if (m.group) continue;
869
+ if (!seen.has(m.provider)) {
870
+ seen.add(m.provider);
871
+ opts.push({ v: m.provider + "/*", name: t("Every {provider} model", { provider: m.providerName }), note: m.provider + "/*", literalName: true });
872
+ }
873
+ opts.push({ v: m.id, name: m.name, note: m.id, literalName: true });
874
+ }
875
+ for (const v of d.models) if (!opts.some((o) => o.v === v)) opts.push({ v, name: v, note: "Not served now", literalName: true });
876
+ openProtoMenu(models, opts, [...d.models], (picked) => {
877
+ d.models = picked;
878
+ paintModels();
879
+ update();
880
+ }, "Models this limit counts", "sess-menu");
881
+ };
882
+ days.oninput = () => { d.days = days.value; update(); };
883
+ hours.oninput = () => { d.hours = hours.value; update(); };
884
+ tokens.oninput = () => { d.tokens = tokens.value; update(); };
885
+ cost.oninput = () => { d.cost = cost.value; update(); };
886
+ cb.onchange = () => { d.cacheReads = cb.checked; update(); };
887
+ const close = () => { gatewayLimit = null; renderGatewayKeys(); };
888
+ for (const i of [days, hours, tokens, cost]) i.onkeydown = (e) => {
889
+ e.stopPropagation();
890
+ if (e.key === "Escape") close();
891
+ else if (e.key === "Enter" && !save.disabled) save.click();
892
+ };
893
+ const cancel = el("button", "text", t("Cancel"));
894
+ cancel.onclick = close;
895
+ save.onclick = async () => {
896
+ if (save.disabled) return;
897
+ save.disabled = true;
898
+ const tk = limitTokens(d.tokens), c = limitCost(d.cost);
899
+ const limit = tk || c ? { period: d.period, tokens: tk, cost: c, cacheReads: d.cacheReads } : null;
900
+ if (limit && d.models.length) limit.models = d.models;
901
+ if (limit && d.period === "days") limit.days = limitDays(d.days);
902
+ if (limit && d.period === "hours") limit.hours = limitHours(d.hours);
903
+ // which one only when there are several: one is the key's only limit
904
+ const out = await gatewayKeyAction("limit-key", which ? { key: k.id, limit, index: n } : { key: k.id, limit });
905
+ if (!out) { update(); return; }
906
+ status(t(limit ? "Limit saved for {name}" : "Limit removed for {name}", { name: k.name }), "ok");
907
+ };
908
+ const foot = el("div", "acm-foot");
909
+ foot.append(cache, el("span", "grow"), unsaved);
910
+ if (limits.length && n < limits.length && limits.length < 5) {
911
+ // one more limit in a window of its own (#2227): 5 hours and a week,
912
+ // as a subscription's; a request passes only while all have some left
913
+ const more = el("button", "text", t("Add a limit"));
914
+ more.title = t("Another limit in a window of its own; a request passes only while every limit has something left");
915
+ more.onclick = (e) => { e.stopPropagation(); gatewayLimit = limitDraft(k, limits.length); renderGatewayKeys(); };
916
+ foot.append(more);
917
+ }
918
+ if (n < limits.length) {
919
+ const off = el("button", "text", t(limits.length > 1 ? "Remove this limit" : "No limit"));
920
+ off.title = t("Clears the caps; Save makes it");
921
+ off.onclick = () => { d.tokens = ""; d.cost = ""; tokens.value = ""; cost.value = ""; update(); };
922
+ foot.append(off);
923
+ }
924
+ foot.append(cancel, save);
925
+ if (which) box.append(which);
926
+ box.append(fields, hint, err);
927
+ if (used) {
928
+ // count from 0 again, tokens and cost together (#1509)
929
+ const zero = el("button", "text klf-reset", t("Reset usage"));
930
+ zero.title = t("Count this key's use from 0 again, tokens and cost together");
931
+ zero.onclick = (e) => { e.stopPropagation(); askResetLimit(k, n); };
932
+ const line = el("div", "klf-used");
933
+ line.append(gatewayLimitLine(k, used), zero);
934
+ box.append(line);
935
+ }
936
+ box.append(foot);
937
+ update();
938
+ return box;
939
+ }
940
+
941
+ // askResetLimit confirms a key's Reset (#1509): what it has used goes back
942
+ // to 0, tokens and cost together; an N-day cycle starts again from now, a
943
+ // calendar window keeps its end.
944
+ function askResetLimit(k, n) {
945
+ const u = keyUsed(k)[n] || {};
946
+ const ed = el("div", "editor");
947
+ const head = el("div", "ehead");
948
+ head.append(el("b", "", t("Reset {name}'s usage?", { name: k.name })));
949
+ ed.append(head, el("p", "lib-confirm", u.period === "days"
950
+ ? t("The tokens and cost {name} has used go back to 0 together, and a new {n}-day cycle starts now.", { name: k.name, n: u.days })
951
+ : u.period === "hours" ? t("The tokens and cost {name} has used go back to 0 together, and a new {n}-hour cycle starts now.", { name: k.name, n: u.hours })
952
+ : t("The tokens and cost {name} has used go back to 0 together. Calls so far in this window stop counting; it still resets {when}.", { name: k.name, when: resetClock(new Date(u.reset)) })));
953
+ const bar = el("div", "bar");
954
+ const cancel = el("button", "text", t("Cancel"));
955
+ cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); };
956
+ const go = el("button", "text primary", t("Reset usage"));
957
+ go.onclick = async (e) => {
958
+ e.stopPropagation();
959
+ if (go.disabled) return;
960
+ go.disabled = true;
961
+ const out = await gatewayKeyAction("reset-limit-key", keyUsed(k).length > 1 ? { key: k.id, index: n } : { key: k.id });
962
+ if (!out) { go.disabled = false; return; }
963
+ closeConfirmAsk();
964
+ status(t("Usage reset for {name}", { name: k.name }), "ok");
965
+ };
966
+ bar.append(el("span", "grow"), cancel, go);
967
+ ed.append(bar);
968
+ confirmAsk = ed;
969
+ openModal(ed);
970
+ $("#modal").classList.add("lib");
971
+ cancel.focus({ preventScroll: true });
972
+ }