@chalky/chalky 0.1.3 → 0.1.5

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 (103) hide show
  1. package/README.md +10 -6
  2. package/{app → agent}/SYSTEM.md +16 -11
  3. package/agent/extensions/app/README.md +68 -0
  4. package/{app/extensions/ui → agent/extensions/app}/agents.js +22 -16
  5. package/agent/extensions/app/guide.md +5 -0
  6. package/agent/extensions/app/index.ts +66 -0
  7. package/{app/extensions/ui/skills/agent-apps → agent/extensions/app/skills/app-agents}/SKILL.md +17 -18
  8. package/agent/extensions/board/browser.js +359 -0
  9. package/agent/extensions/board/guide.md +4 -0
  10. package/agent/extensions/board/index.ts +100 -0
  11. package/agent/extensions/card/README.md +88 -0
  12. package/agent/extensions/card/blocks/Button.js +34 -0
  13. package/{app/extensions/ui/components → agent/extensions/card/blocks}/Column.js +2 -2
  14. package/agent/extensions/card/blocks/Markdown.js +37 -0
  15. package/{app/extensions/ui/components/Card.js → agent/extensions/card/blocks/Panel.js} +3 -3
  16. package/{app/extensions/ui/components → agent/extensions/card/blocks}/Row.js +2 -2
  17. package/{app/extensions/ui/components → agent/extensions/card/blocks}/TextField.js +3 -3
  18. package/{app/extensions/ui → agent/extensions/card}/browser.js +155 -128
  19. package/agent/extensions/card/guide.md +13 -0
  20. package/agent/extensions/card/index.ts +370 -0
  21. package/agent/extensions/command/browser.js +13 -0
  22. package/agent/extensions/command/guide.md +2 -0
  23. package/agent/extensions/command/index.ts +94 -0
  24. package/agent/extensions/doodle/README.md +57 -0
  25. package/agent/extensions/doodle/browser.js +1013 -0
  26. package/agent/extensions/doodle/guide.md +22 -0
  27. package/agent/extensions/doodle/index.ts +274 -0
  28. package/agent/extensions/file/browser.js +143 -0
  29. package/agent/extensions/file/guide.md +5 -0
  30. package/{app/extensions/files → agent/extensions/file}/index.ts +34 -16
  31. package/{app/extensions/frames → agent/extensions/frame}/README.md +13 -13
  32. package/{app/extensions/frames → agent/extensions/frame}/browser.js +24 -19
  33. package/agent/extensions/frame/guide.md +3 -0
  34. package/agent/extensions/frame/index.ts +81 -0
  35. package/{app/extensions/ui_history → agent/extensions/history}/browser.js +18 -18
  36. package/{app/extensions/ui_history → agent/extensions/history}/index.ts +32 -33
  37. package/{app → agent}/extensions/mcp/browser.js +1 -1
  38. package/agent/extensions/mcp/guide.md +3 -0
  39. package/{app → agent}/extensions/mcp/index.ts +2 -4
  40. package/agent/extensions/model/browser.js +363 -0
  41. package/{app → agent}/extensions/model/index.ts +8 -4
  42. package/agent/extensions/model/login.ts +215 -0
  43. package/agent/extensions/note/README.md +35 -0
  44. package/agent/extensions/note/browser.js +356 -0
  45. package/agent/extensions/note/guide.md +8 -0
  46. package/agent/extensions/note/index.ts +192 -0
  47. package/{app → agent}/extensions/page/index.ts +2 -2
  48. package/{app → agent}/extensions/page/page.html +55 -49
  49. package/agent/extensions/prompt/index.ts +38 -0
  50. package/{app → agent}/extensions/reload/index.ts +3 -3
  51. package/{app → agent}/extensions/screen/browser.js +4 -4
  52. package/{app → agent}/extensions/screen/index.ts +11 -11
  53. package/{app/extensions/steps → agent/extensions/step}/index.ts +1 -1
  54. package/{app/extensions/swarm → agent/extensions/team}/README.md +19 -19
  55. package/{app/extensions/swarm → agent/extensions/team}/guide.md +4 -4
  56. package/{app/extensions/swarm → agent/extensions/team}/index.ts +41 -66
  57. package/{app/extensions/versions → agent/extensions/version}/browser.js +10 -10
  58. package/{app/extensions/versions → agent/extensions/version}/index.ts +39 -54
  59. package/{app → agent}/extensions/viewer/README.md +4 -4
  60. package/{app → agent}/extensions/viewer/browser.js +8 -8
  61. package/agent/extensions/viewer/guide.md +3 -0
  62. package/{app → agent}/extensions/viewer/index.ts +22 -17
  63. package/agent/extensions/voice/browser.js +72 -0
  64. package/agent/extensions/voice/index.ts +39 -0
  65. package/{app/extensions/ui → agent/extensions/voice}/voice.js +5 -5
  66. package/agent/settings.json +8 -0
  67. package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/SKILL.md +4 -4
  68. package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/scripts/check.mjs +1 -1
  69. package/{app/extensions/ui/skills/ui-design → agent/skills/design}/SKILL.md +14 -14
  70. package/agent/skills/extension-map/SKILL.md +56 -0
  71. package/{app → agent}/skills/write-skill/SKILL.md +1 -1
  72. package/bin/chalky.mjs +3 -3
  73. package/package.json +8 -8
  74. package/server.ts +162 -63
  75. package/app/extensions/appdata/README.md +0 -10
  76. package/app/extensions/appdata/guide.md +0 -4
  77. package/app/extensions/appdata/index.ts +0 -47
  78. package/app/extensions/doodle/README.md +0 -15
  79. package/app/extensions/doodle/browser.js +0 -388
  80. package/app/extensions/doodle/guide.md +0 -6
  81. package/app/extensions/doodle/index.ts +0 -54
  82. package/app/extensions/files/browser.js +0 -96
  83. package/app/extensions/files/guide.md +0 -7
  84. package/app/extensions/frames/guide.md +0 -7
  85. package/app/extensions/frames/index.ts +0 -76
  86. package/app/extensions/mcp/guide.md +0 -5
  87. package/app/extensions/point/README.md +0 -22
  88. package/app/extensions/point/browser.js +0 -245
  89. package/app/extensions/point/guide.md +0 -6
  90. package/app/extensions/point/index.ts +0 -97
  91. package/app/extensions/prompt/index.ts +0 -19
  92. package/app/extensions/ui/README.md +0 -69
  93. package/app/extensions/ui/components/App.js +0 -28
  94. package/app/extensions/ui/components/Button.js +0 -28
  95. package/app/extensions/ui/components/Markdown.js +0 -37
  96. package/app/extensions/ui/guide.md +0 -7
  97. package/app/extensions/ui/index.ts +0 -271
  98. package/app/extensions/viewer/guide.md +0 -5
  99. package/app/mcp.json +0 -3
  100. package/app/skills/plugin-map/SKILL.md +0 -49
  101. package/app/ui/apps/.keep +0 -0
  102. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Table.js +0 -0
  103. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Text.js +0 -0
@@ -0,0 +1,363 @@
1
+ // The page side of the model extension (sign-ins and the picker): /login and /logout open a dialog in the middle of the page, as /mcp does
2
+ // (a click on the backdrop, Esc or ✕ closes it; closing it during a sign-in cancels the sign-in).
3
+ // list Sign in (pi's providers) and custom endpoints, each "connected" / "sign in again" (GET /login)
4
+ // sign-in what pi asks for, as it asks (event "model"): a link to open, a code to type, a choice, a box to paste
5
+ // into; ✕ cancels
6
+ // custom name, API type, base URL, key (optional), model names as chips (Enter or "," after each; a paste is
7
+ // split); "Ask the endpoint" lists its models to pick from
8
+ // logout the saved logins, each with Log out / Remove
9
+ // model (/model, and after connecting) the usable models, grouped by provider; "Use for" all agents or one agent
10
+ // (an agent's own choice wins; "Same as all agents" drops it). A tick shows which agents use a model now
11
+ // The server does the work (index.ts); keys typed here go to it once and never come back. CHALKY_LOGIN=off: no commands.
12
+ export default function (page) {
13
+ const APIS = { "openai-completions": "OpenAI", "openai-responses": "OpenAI Responses", "anthropic-messages": "Anthropic",
14
+ "google-generative-ai": "Google" }; // the API the endpoint speaks
15
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
16
+
17
+ const style = document.createElement("style");
18
+ style.textContent = `
19
+ #model-sheet { width: min(560px, calc(100vw - 32px)); max-height: 75vh; padding: 0; box-sizing: border-box; color: var(--text);
20
+ background: var(--glass-strong); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
21
+ border: 1px solid var(--edge); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; font-size: 13px; }
22
+ #model-sheet[open] { display: flex; flex-direction: column; }
23
+ #model-sheet::backdrop { background: rgba(0, 0, 0, .08); }
24
+ #model-sheet:focus { outline: none; }
25
+ #model-sheet .body > .btns:last-child { position: sticky; bottom: -16px; margin: 0 -14px -16px; padding: 10px 14px;
26
+ background: var(--glass-strong); border-top: 1px solid var(--line); } /* Use / Save stay in view */
27
+ #model-sheet .head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); font-weight: 600; }
28
+ #model-sheet .head button { border: 0; background: none; padding: 0; color: var(--accent); font: inherit; font-weight: 400; cursor: pointer; }
29
+ #model-sheet .head .x { margin-left: auto; color: var(--muted); font-size: 15px; }
30
+ #model-sheet .body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 14px 16px; display: grid; gap: 12px; align-content: start;
31
+ grid-auto-rows: max-content; } /* rows keep their height: the body scrolls, not each list */
32
+ #model-sheet .group { font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin: 4px 0 -6px; }
33
+ #model-sheet .rows { display: grid; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
34
+ #model-sheet .row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; cursor: pointer; }
35
+ #model-sheet .row + .row { border-top: 1px solid var(--line); }
36
+ #model-sheet .row:hover { background: var(--hover); }
37
+ #model-sheet .row .how { margin-left: auto; font-size: 11px; color: var(--muted); white-space: nowrap; }
38
+ #model-sheet .dot { width: 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; flex: none;
39
+ background: var(--hover); color: var(--soft); font-weight: 600; font-size: 11px; }
40
+ #model-sheet .chip { font-size: 10.5px; padding: 1px 7px; border-radius: 99px; white-space: nowrap; margin-left: auto; }
41
+ #model-sheet .chip.ok { background: rgba(52, 199, 89, 0.14); color: #1f8a3e; }
42
+ #model-sheet .chip.expired { background: rgba(255, 159, 10, 0.16); color: #b25e00; }
43
+ #model-sheet label { display: grid; gap: 5px; font-size: 12px; color: var(--soft); }
44
+ #model-sheet input, #model-sheet select { font: inherit; color: var(--text); background: rgba(255, 255, 255, 0.9); border: 1px solid var(--line);
45
+ border-radius: 9px; padding: 7px 10px; outline: none; min-width: 0; }
46
+ #model-sheet input:focus, #model-sheet select:focus, #model-sheet .tags:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.12); }
47
+ #model-sheet .mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; }
48
+ #model-sheet .two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
49
+ #model-sheet .btns { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
50
+ #model-sheet .btn { border: 0; border-radius: 9px; padding: 7px 14px; font: 500 13px inherit; font-family: inherit; cursor: pointer;
51
+ background: var(--hover); color: var(--text); text-decoration: none; }
52
+ #model-sheet .btn.primary { background: var(--accent); color: #fff; }
53
+ #model-sheet .btn.small { padding: 3px 10px; font-size: 12px; }
54
+ #model-sheet .btn:disabled { opacity: 0.5; cursor: default; }
55
+ #model-sheet .hint { font-size: 11.5px; color: var(--muted); }
56
+ #model-sheet .row .hint { white-space: nowrap; }
57
+ #model-sheet .error { color: var(--danger); font-size: 12.5px; }
58
+ #model-sheet .wait { display: flex; gap: 10px; align-items: center; color: var(--soft); }
59
+ #model-sheet .spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(0, 113, 227, 0.15); border-top-color: var(--accent);
60
+ animation: login-spin 1s linear infinite; flex: none; }
61
+ @keyframes login-spin { to { transform: rotate(360deg) } }
62
+ #model-sheet .code { font: 600 26px ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: 0.12em; text-align: center; padding: 14px;
63
+ border-radius: 10px; background: rgba(255, 255, 255, 0.9); border: 1px dashed var(--line); user-select: all; }
64
+ #model-sheet details summary { cursor: pointer; color: var(--accent); font-size: 12.5px; }
65
+ #model-sheet details[open] summary { margin-bottom: 8px; }
66
+ #model-sheet .tags { display: flex; flex-wrap: wrap; gap: 6px; background: rgba(255, 255, 255, 0.9); border: 1px solid var(--line); border-radius: 9px; padding: 5px; }
67
+ #model-sheet .tags input { border: 0; box-shadow: none; padding: 3px 4px; flex: 1; min-width: 120px; background: none; }
68
+ #model-sheet .tag { font: 12px ui-monospace, "SF Mono", Menlo, monospace; background: rgba(0, 113, 227, 0.1); border-radius: 6px;
69
+ padding: 3px 4px 3px 8px; display: inline-flex; gap: 2px; align-items: center; }
70
+ #model-sheet .tag button { border: 0; background: none; color: var(--muted); cursor: pointer; padding: 0 4px; font-size: 11px; }
71
+ #model-sheet .found { max-height: 150px; overflow-y: auto; display: flex; flex-wrap: wrap; gap: 4px; }
72
+ #model-sheet .found button { border: 1px solid var(--line); background: none; border-radius: 6px; padding: 2px 7px; cursor: pointer;
73
+ font: 11.5px ui-monospace, "SF Mono", Menlo, monospace; color: var(--soft); }
74
+ #model-sheet .found button:hover { background: var(--hover); color: var(--text); }
75
+ #model-sheet .for { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; color: var(--soft); }
76
+ #model-sheet .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
77
+ #model-sheet .seg button { border: 0; background: none; padding: 3px 10px; font: inherit; font-size: 12px; cursor: pointer; color: var(--text); }
78
+ #model-sheet .seg button.on { background: var(--accent); color: #fff; }
79
+ #model-sheet .radio { width: 14px; height: 14px; border-radius: 50%; border: 1.5px solid var(--muted); flex: none; box-sizing: border-box; }
80
+ #model-sheet .row.on .radio { border: 4.5px solid var(--accent); }
81
+ #model-sheet .model-id { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; overflow-wrap: anywhere; }`;
82
+ document.head.append(style);
83
+
84
+ const sheet = document.createElement("dialog");
85
+ sheet.id = "model-sheet";
86
+ document.body.append(sheet);
87
+ sheet.onclick = (e) => { if (e.target === sheet) sheet.close(); }; // a click on the backdrop closes it
88
+ sheet.onclose = () => { // ✕, Esc, the backdrop, Done
89
+ if (view === "signin") page.send({ type: "model_cancel" });
90
+ view = signing = form = null;
91
+ };
92
+
93
+ let view = null; // "list" | "signin" | "custom" | "logout" | "done"
94
+ let signing = null; // the provider being signed in to
95
+ let form = null; // the custom form's state: {id, editing, api, baseUrl, models: [], found: []}
96
+
97
+ function open(next) {
98
+ if (!sheet.open) sheet.showModal();
99
+ next();
100
+ }
101
+ const close = () => sheet.close();
102
+
103
+ function draw(title, body, back) {
104
+ sheet.innerHTML = `<div class="head">${back ? `<button class="back">‹ Back</button>` : ""}${esc(title)}<button class="x" title="Close">✕</button></div>
105
+ <div class="body">${body}</div>`;
106
+ sheet.querySelector(".x").onclick = close;
107
+ if (back) sheet.querySelector(".back").onclick = back;
108
+ return sheet.querySelector(".body");
109
+ }
110
+ const chip = (status) => status === "connected" ? `<span class="chip ok">connected</span>`
111
+ : status === "expired" ? `<span class="chip expired">sign in again</span>` : "";
112
+ const fetchList = () => fetch("/model/logins").then((r) => r.ok ? r.json() : Promise.reject(new Error(`/model/logins: ${r.status}`)));
113
+
114
+ // ---- the list
115
+ async function showList() {
116
+ view = "list";
117
+ const body = draw("Connect a model", `<div class="wait"><span class="spin"></span>Checking your logins…</div>`);
118
+ let data;
119
+ try { data = await fetchList(); } catch (e) { body.innerHTML = `<div class="error">${esc(e.message)}</div>`; return; }
120
+ if (view !== "list") return;
121
+ body.innerHTML = `<input class="search" placeholder="Search…" aria-label="Search">
122
+ <div class="group">Sign in</div>
123
+ <div class="rows signin">${data.providers.map((p) => `<div class="row" data-id="${esc(p.id)}" data-name="${esc(p.name)}">
124
+ <span class="dot">${esc(p.name[0])}</span>${esc(p.name)}${p.subscription ? "" : `<span class="hint">not a subscription</span>`}
125
+ ${chip(p.status) || `<span class="how">sign in</span>`}</div>`).join("")}</div>
126
+ <div class="group">Custom endpoints</div>
127
+ <div class="rows custom">${data.custom.map((c) => `<div class="row" data-id="${esc(c.id)}">
128
+ <span class="dot">${esc(c.id[0].toUpperCase())}</span>${esc(c.id)} <span class="hint">${esc(c.models.length)} model${c.models.length === 1 ? "" : "s"}</span>
129
+ ${chip(c.status) || `<span class="how">edit</span>`}</div>`).join("")}
130
+ <div class="row new"><span class="dot">+</span>Custom endpoint<span class="how">URL · key · models</span></div></div>`;
131
+ const search = body.querySelector(".search");
132
+ search.oninput = () => {
133
+ const q = search.value.toLowerCase();
134
+ for (const r of body.querySelectorAll(".row:not(.new)")) r.style.display = `${r.dataset.id} ${r.dataset.name ?? ""}`.toLowerCase().includes(q) ? "" : "none";
135
+ };
136
+ search.focus();
137
+ for (const r of body.querySelectorAll(".signin .row")) r.onclick = () => startSignIn(r.dataset.id, r.dataset.name);
138
+ for (const r of body.querySelectorAll(".custom .row:not(.new)"))
139
+ r.onclick = () => showCustom(data.custom.find((c) => c.id === r.dataset.id));
140
+ body.querySelector(".row.new").onclick = () => showCustom();
141
+ }
142
+
143
+ // ---- sign in: the sheet shows what pi asks for, as it asks
144
+ function startSignIn(id, name) {
145
+ view = "signin";
146
+ signing = { id, name };
147
+ const body = draw(name, `<div class="link"></div><div class="wait"><span class="spin"></span><span class="msg">Starting…</span></div>
148
+ <div class="ask"></div><div class="error"></div>`, () => { page.send({ type: "model_cancel" }); showList(); });
149
+ body.dataset.provider = id;
150
+ page.send({ type: "model_login", provider: id });
151
+ }
152
+ function onSignIn(ev) {
153
+ const body = sheet.querySelector(".body");
154
+ if (view !== "signin" || !body) return;
155
+ const msg = body.querySelector(".msg"), link = body.querySelector(".link"), ask = body.querySelector(".ask");
156
+ if (ev.step === "notify") {
157
+ const e = ev.event;
158
+ if (e.type === "auth_url") {
159
+ link.innerHTML = `<div class="btns" style="justify-content:flex-start"><a class="btn primary" target="_blank" rel="noopener"
160
+ href="${esc(e.url)}">Open the ${esc(signing.name)} sign-in page ↗</a></div>`;
161
+ msg.textContent = "Waiting for you to finish in the other tab…";
162
+ } else if (e.type === "device_code") {
163
+ link.innerHTML = `<div class="hint" style="text-align:center">Go to <a target="_blank" rel="noopener" href="${esc(e.verificationUri)}">${esc(e.verificationUri)}</a> and enter</div>
164
+ <div class="code">${esc(e.userCode)}</div>
165
+ <div class="btns" style="justify-content:center"><button class="btn copy">Copy code</button>
166
+ <a class="btn primary" target="_blank" rel="noopener" href="${esc(e.verificationUri)}">Open ↗</a></div>`;
167
+ link.querySelector(".copy").onclick = () => navigator.clipboard?.writeText(e.userCode);
168
+ msg.textContent = "Waiting…";
169
+ } else msg.textContent = e.message; // progress, info
170
+ }
171
+ if (ev.step === "prompt") {
172
+ const p = ev.prompt;
173
+ if (p.type === "select") {
174
+ ask.innerHTML = `<div class="hint">${esc(p.message)}</div><div class="rows">${p.options.map((o) =>
175
+ `<div class="row" data-id="${esc(o.id)}">${esc(o.label)}</div>`).join("")}</div>`;
176
+ for (const r of ask.querySelectorAll(".row")) r.onclick = () => { ask.innerHTML = ""; page.send({ type: "model_answer", text: r.dataset.id }); };
177
+ msg.textContent = "Choose one:";
178
+ return;
179
+ }
180
+ // text, secret, manual_code: a box to type or paste into. manual_code goes with a link already open: tucked away
181
+ const field = `<label>${esc(p.message)}<input class="mono" type="${p.type === "secret" ? "password" : "text"}"
182
+ placeholder="${esc(p.placeholder ?? "")}"></label><div class="btns"><button class="btn primary">Continue</button></div>`;
183
+ ask.innerHTML = p.type === "manual_code" ? `<details><summary>The page after signing in didn't load?</summary>${field}</details>` : field;
184
+ const input = ask.querySelector("input"), go = () => { page.send({ type: "model_answer", text: input.value }); ask.innerHTML = ""; msg.textContent = "Checking…"; };
185
+ ask.querySelector(".btn").onclick = go;
186
+ input.onkeydown = (e) => { if (e.key === "Enter") go(); };
187
+ if (p.type !== "manual_code") input.focus();
188
+ }
189
+ if (ev.step === "prompt_done") ask.innerHTML = "";
190
+ }
191
+
192
+ // ---- custom endpoint
193
+ function showCustom(c) {
194
+ view = "custom";
195
+ form = { editing: !!c, found: [], models: [...(c?.models ?? [])] };
196
+ const body = draw(c ? c.id : "Custom endpoint", `
197
+ <div class="two"><label>Name<input class="id" value="${esc(c?.id ?? "")}" placeholder="openrouter" ${c ? "disabled" : ""}></label>
198
+ <label>Speaks<select class="api">${Object.entries(APIS).map(([v, l]) => `<option value="${v}" ${v === (c?.api ?? "openai-completions") ? "selected" : ""}>${l}</option>`).join("")}</select></label></div>
199
+ <label>Base URL<input class="url mono" value="${esc(c?.baseUrl ?? "")}" placeholder="https://openrouter.ai/api/v1"></label>
200
+ <label><span>API key <span class="hint">(optional${c ? "; leave empty to keep the saved one" : ""})</span></span><input class="key mono" type="password" autocomplete="off"></label>
201
+ <label><span>Models <span class="hint">(Enter or "," after each; paste several at once)</span></span><div class="tags"><input class="add mono" placeholder="model name"></div></label>
202
+ <div class="found"></div>
203
+ <div class="error"></div>
204
+ <div class="btns" style="justify-content:space-between"><button class="btn ask">Ask the endpoint</button><button class="btn primary save">Save</button></div>`,
205
+ showList);
206
+ const tags = body.querySelector(".tags"), add = body.querySelector(".add"), found = body.querySelector(".found");
207
+ const drawTags = () => {
208
+ for (const t of tags.querySelectorAll(".tag")) t.remove();
209
+ for (const m of form.models) {
210
+ const t = document.createElement("span");
211
+ t.className = "tag";
212
+ t.innerHTML = `${esc(m)}<button title="Remove">✕</button>`;
213
+ t.querySelector("button").onclick = (e) => { e.preventDefault(); form.models = form.models.filter((x) => x !== m); drawTags(); drawFound(); };
214
+ tags.insertBefore(t, add);
215
+ }
216
+ };
217
+ const drawFound = () => { // the endpoint's own names, narrowed by what is being typed; click one to add it
218
+ const q = add.value.trim().toLowerCase();
219
+ const left = form.found.filter((m) => !form.models.includes(m) && m.toLowerCase().includes(q));
220
+ found.innerHTML = left.slice(0, 200).map((m) => `<button data-m="${esc(m)}">${esc(m)}</button>`).join("")
221
+ + (left.length > 200 ? `<span class="hint">${left.length - 200} more: type to narrow</span>` : "");
222
+ for (const b of found.querySelectorAll("button")) b.onclick = () => addNames([b.dataset.m]);
223
+ };
224
+ const addNames = (names) => {
225
+ for (const n of names.map((s) => s.trim()).filter(Boolean)) if (!form.models.includes(n)) form.models.push(n);
226
+ add.value = "";
227
+ drawTags(); drawFound();
228
+ };
229
+ add.onkeydown = (e) => {
230
+ if (e.key === "Enter" || e.key === ",") { e.preventDefault(); addNames(add.value.split(",")); }
231
+ else if (e.key === "Backspace" && !add.value && form.models.length) { form.models.pop(); drawTags(); drawFound(); }
232
+ };
233
+ add.oninput = drawFound;
234
+ add.onpaste = (e) => { e.preventDefault(); addNames((e.clipboardData.getData("text") || "").split(/[\s,]+/)); };
235
+ tags.onclick = () => add.focus();
236
+ drawTags();
237
+ const value = (sel) => body.querySelector(sel).value.trim();
238
+ body.querySelector(".ask").onclick = () => {
239
+ body.querySelector(".error").textContent = "";
240
+ found.innerHTML = `<span class="hint">Asking…</span>`;
241
+ page.send({ type: "model_probe", id: c?.id, api: value(".api"), baseUrl: value(".url"), key: value(".key") });
242
+ };
243
+ body.querySelector(".save").onclick = () => {
244
+ if (add.value.trim()) addNames(add.value.split(",")); // names typed but not yet added
245
+ body.querySelector(".error").textContent = "";
246
+ body.querySelector(".save").disabled = true;
247
+ page.send({ type: "model_custom", id: value(".id"), api: value(".api"), baseUrl: value(".url"), key: value(".key"), models: form.models });
248
+ };
249
+ (c ? add : body.querySelector(".id")).focus();
250
+ }
251
+
252
+ // ---- logout
253
+ async function showLogout() {
254
+ view = "logout";
255
+ const body = draw("Saved logins", `<div class="wait"><span class="spin"></span>Loading…</div>`);
256
+ let data;
257
+ try { data = await fetchList(); } catch (e) { body.innerHTML = `<div class="error">${esc(e.message)}</div>`; return; }
258
+ if (view !== "logout") return;
259
+ const rows = [...data.providers.filter((p) => p.status).map((p) => ({ id: p.id, name: p.name, how: p.status === "expired" ? "expired" : "signed in", act: "Log out" })),
260
+ ...data.custom.map((c) => ({ id: c.id, name: `${c.id} (custom)`, how: c.status ? "key" : "no key", act: "Remove" }))];
261
+ body.innerHTML = rows.length ? `<div class="rows">${rows.map((r) => `<div class="row" data-id="${esc(r.id)}"><span class="dot">${esc(r.name[0].toUpperCase())}</span>
262
+ ${esc(r.name)}<span class="how">${r.how}</span><button class="btn small">${r.act}</button></div>`).join("")}</div>
263
+ <div class="hint">Log out removes the login from this machine (agent/auth.json). Remove also deletes the endpoint.</div>`
264
+ : `<div class="hint">No saved logins. Type /login to add one.</div>`;
265
+ for (const b of body.querySelectorAll(".row .btn")) b.onclick = () => {
266
+ b.disabled = true;
267
+ page.send({ type: "model_logout", provider: b.closest(".row").dataset.id });
268
+ };
269
+ }
270
+
271
+ // ---- choose the model
272
+ async function showModels(title = "Choose the model") {
273
+ view = "model";
274
+ const body = draw(title, `<div class="wait"><span class="spin"></span>Loading models…</div>`);
275
+ let data;
276
+ try { data = await fetch("/model/choices").then((r) => r.ok ? r.json() : Promise.reject(new Error(`/model/choices: ${r.status}`))); }
277
+ catch (e) { body.innerHTML = `<div class="error">${esc(e.message)}</div>`; return; }
278
+ if (view !== "model") return;
279
+ const key = (m) => m ? `${m.provider}/${m.id}` : "same";
280
+ const firstAgentModel = data.agents[0]?.model;
281
+ form = { scope: "all", pick: key(data.default ?? firstAgentModel) };
282
+ const label = (k) => k === "same" ? "the all-agents model" : k.slice(k.indexOf("/") + 1);
283
+ function render() {
284
+ const agent = data.agents.find((a) => a.name === form.scope);
285
+ const users = (k) => data.agents.filter((a) => key(a.model) === k).map((a) => a.name);
286
+ body.innerHTML = `<div class="for">Use for <span class="seg"><button data-for="all" class="${form.scope === "all" ? "on" : ""}">All agents</button>${data.agents.map((a) =>
287
+ `<button data-for="${esc(a.name)}" class="${form.scope === a.name ? "on" : ""}">${esc(a.name)}</button>`).join("")}</span></div>
288
+ <div class="hint">${agent ? `Only ${esc(agent.name)}; the others keep theirs.` : "Every agent that hasn't picked its own."}</div>
289
+ <input class="search" placeholder="Search models…" aria-label="Search models">
290
+ ${agent ? `<div class="rows"><div class="row ${form.pick === "same" ? "on" : ""}" data-k="same"><span class="radio"></span>Same as all agents
291
+ <span class="how">${esc(data.default ? data.default.id : "MODEL_BASE_URL")}</span></div></div>` : ""}
292
+ ${data.groups.map((g) => `<div class="group">${esc(g.name)}</div><div class="rows">${g.models.map((m) => {
293
+ const k = `${g.provider}/${m.id}`, who = users(k);
294
+ return `<div class="row ${form.pick === k ? "on" : ""}" data-k="${esc(k)}" data-q="${esc(`${g.name} ${m.id} ${m.name}`.toLowerCase())}"><span class="radio"></span>
295
+ <span class="model-id">${esc(m.id)}</span><span class="how">${who.map((n) => `${esc(n)} ✓`).join(" ")}${who.length && m.context ? " · " : ""}${m.context ? `${Math.round(m.context / 1000)}k` : ""}</span></div>`;
296
+ }).join("")}</div>`).join("") || `<div class="hint">No models yet: type /login to connect one.</div>`}
297
+ <div class="error"></div>
298
+ <div class="btns"><button class="btn primary use">Use ${esc(label(form.pick))}</button></div>`;
299
+ for (const b of body.querySelectorAll(".seg button")) b.onclick = () => {
300
+ form.scope = b.dataset.for;
301
+ const a = data.agents.find((x) => x.name === form.scope);
302
+ form.pick = a ? (a.own ? key(a.model) : "same") : key(data.default ?? firstAgentModel);
303
+ render();
304
+ };
305
+ for (const r of body.querySelectorAll(".row[data-k]")) r.onclick = () => {
306
+ form.pick = r.dataset.k;
307
+ for (const x of body.querySelectorAll(".row.on")) x.classList.remove("on");
308
+ r.classList.add("on");
309
+ body.querySelector(".use").textContent = `Use ${label(form.pick)}`;
310
+ };
311
+ const search = body.querySelector(".search");
312
+ search.oninput = () => {
313
+ const q = search.value.toLowerCase();
314
+ for (const r of body.querySelectorAll(".row[data-q]")) r.style.display = r.dataset.q.includes(q) ? "" : "none";
315
+ };
316
+ body.querySelector(".use").onclick = () => {
317
+ const [provider, ...rest] = form.pick === "same" ? [] : form.pick.split("/");
318
+ if (form.scope === "all" && !provider) return;
319
+ form.said = form.scope === "all" ? `all agents now use ${rest.join("/")}`
320
+ : provider ? `${form.scope} now uses ${rest.join("/")}` : `${form.scope} is back on the all-agents model`;
321
+ body.querySelector(".use").disabled = true;
322
+ page.send({ type: "model_set", for: form.scope, provider, id: rest.join("/") || undefined });
323
+ };
324
+ }
325
+ render();
326
+ }
327
+
328
+ // ---- what the server says
329
+ page.on("model", (ev) => {
330
+ if (!sheet.open) return;
331
+ const err = sheet.querySelector(".error");
332
+ if (ev.step === "error") {
333
+ if (err) err.textContent = ev.text;
334
+ sheet.querySelector(".save")?.removeAttribute("disabled");
335
+ const msg = sheet.querySelector(".msg");
336
+ if (view === "signin" && msg) { msg.textContent = "Didn't work:"; sheet.querySelector(".spin")?.remove(); }
337
+ if (view === "custom") sheet.querySelector(".found").innerHTML = "";
338
+ if (view === "model") { sheet.querySelector(".use")?.removeAttribute("disabled"); form.said = null; }
339
+ return;
340
+ }
341
+ if (ev.step === "done") {
342
+ page.note(`connected to ${ev.provider}`);
343
+ return showModels(`Connected to ${ev.provider} ✓`);
344
+ }
345
+ if (ev.step === "cancelled" && view === "signin") return showList();
346
+ if (ev.step === "models" && view === "custom") {
347
+ form.found = ev.models;
348
+ sheet.querySelector(".add").dispatchEvent(new Event("input"));
349
+ if (!ev.models.length) sheet.querySelector(".found").innerHTML = `<span class="hint">The endpoint listed no models: type their names.</span>`;
350
+ return;
351
+ }
352
+ if (ev.step === "changed" && view === "logout") return showLogout();
353
+ if (ev.step === "changed" && view === "model" && form?.said) { page.note(form.said); return close(); }
354
+ onSignIn(ev);
355
+ });
356
+
357
+ fetch("/model/on").then((r) => { // CHALKY_LOGIN=off: the server has no sign-ins, and the page no commands
358
+ if (!r.ok) return;
359
+ page.command("login", () => open(showList), "Connect a model: sign in with a subscription, or a custom endpoint");
360
+ page.command("logout", () => open(showLogout), "Remove a saved login");
361
+ page.command("model", () => open(() => showModels()), "Choose the model, for all agents or one");
362
+ });
363
+ }
@@ -1,10 +1,14 @@
1
- // The model: one OpenAI-compatible endpoint (in Chalky: keyproxy -> LiteLLM), as the Python agent's llm plugin.
2
- // MODEL_BASE_URL (up to /v1), MODEL_API_KEY, MODEL_NAME (a LiteLLM route, "default"). pi does the calls,
3
- // streaming, retries, thinking and compaction; this only tells it where. The host selects provider "chalky".
4
- // MODEL_CONTEXT_WINDOW: the model's context size; pi compacts when a request would leave less than 16384 tokens of it.
1
+ // The model the agents use: the one picked on the page (login.ts: sign-ins, custom endpoints, the picker; browser.js:
2
+ // /login, /logout, /model), else the endpoint set in the environment (here): one OpenAI-compatible endpoint (in
3
+ // Chalky: keyproxy -> LiteLLM). MODEL_BASE_URL (up to /v1), MODEL_API_KEY, MODEL_NAME (a LiteLLM route, "default").
4
+ // pi does the calls, streaming, retries, thinking and compaction; this only tells it where. The host selects provider
5
+ // "chalky" when nothing was picked (server.ts). MODEL_CONTEXT_WINDOW: the model's context size; pi compacts when a
6
+ // request would leave less than 16384 tokens of it.
5
7
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import login from "./login.ts";
6
9
 
7
10
  export default function (pi: ExtensionAPI) {
11
+ login(pi); // CHALKY_LOGIN=off (a cloud deployment): no sign-ins or picker, only this endpoint
8
12
  if (!process.env.MODEL_BASE_URL) return; // pi then says no model is configured on each message
9
13
  pi.registerProvider("chalky", {
10
14
  baseUrl: process.env.MODEL_BASE_URL,
@@ -0,0 +1,215 @@
1
+ // The model extension's sign-ins and model picker (index.ts loads this; browser.js: the page's /login, /logout, /model):
2
+ // connect models from the page and pick one. pi's own login does the work, the call its
3
+ // terminal /login makes (modelRuntime.login): a sign-in (OAuth: pi opens the provider's page and waits on a callback
4
+ // server, or asks for the address to be pasted), or a custom endpoint (any OpenAI-, Anthropic- or Google-compatible
5
+ // URL, its model names, an optional key). pi renews sign-ins itself; an expired one shows as pi's own error.
6
+ // Where things go: tokens and keys in agent/auth.json (pi's; never versioned), custom endpoints in agent/models.json
7
+ // (pi's; no secrets), this install's device id (OpenAI's sign-in asks for one) and the model choices in data/model.json:
8
+ // {deviceId, default: {provider, id} (all agents), agents: {name: {provider, id}} (one agent's own; it wins)}.
9
+ // server.ts asks host.api.model.modelFor(name, session) when it starts an agent: its own choice, else the all-agents
10
+ // one, else nothing (then the MODEL_BASE_URL endpoint, index.ts); a choice whose login is gone is skipped.
11
+ // CHALKY_LOGIN=off (a cloud deployment, where the backend fixes the endpoint): none of this loads, and server.ts doesn't ask.
12
+ // GET /model/on 204 (so the page knows sign-ins are on)
13
+ // GET /model/choices {groups: [{provider, name, models: [{id, name, context}]}] (usable now), default, agents: [{name, model, own}]}
14
+ // GET /model/logins {providers: [{id, name, subscription, status}], custom: [{id, baseUrl, api, models, status}]}
15
+ // status: "connected" | "expired" | undefined; each call checks the stored sign-ins (pi renews one that is due)
16
+ // page messages model_login {provider}, model_answer {text}, model_cancel,
17
+ // model_custom {id, api, baseUrl, key?, models: [names]}, model_probe {baseUrl, api, key?, id?},
18
+ // model_logout {provider}, model_set {for: "all" | <agent>, provider?, id?} (for an agent, no provider: back to all agents')
19
+ // event "model" {step: "notify", event} (pi: open this link / enter this code / waiting) | {step: "prompt", prompt}
20
+ // (pi asks: the page answers with model_answer) | {step: "prompt_done"} (pi stopped asking: the
21
+ // callback came first) | {step: "done", provider} | {step: "cancelled"} | {step: "error", text} | {step: "models", models}
22
+ // (model_probe's answer) | {step: "changed"} (a login, endpoint or model changed: agents are synced)
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { randomUUID } from "node:crypto";
26
+ import { ModelRuntime, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+
28
+ const host = (globalThis as any).chalky;
29
+ const MODELS = path.join(host.agent, "models.json");
30
+ const STATE = path.join(host.data, "model.json");
31
+ const APIS = ["openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai"];
32
+
33
+ // one runtime for logins, on the same files as the agents' (each agent's session has its own: synced after a change)
34
+ if (!host.shared.has("model-runtime"))
35
+ host.shared.set("model-runtime", ModelRuntime.create({ authPath: path.join(host.agent, "auth.json"), modelsPath: MODELS }));
36
+ const runtime = (): Promise<ModelRuntime> => host.shared.get("model-runtime");
37
+
38
+ const readModels = () => (fs.existsSync(MODELS) ? JSON.parse(fs.readFileSync(MODELS, "utf8")) : { providers: {} });
39
+ const readState = () => (fs.existsSync(STATE) ? JSON.parse(fs.readFileSync(STATE, "utf8")) : {});
40
+ const writeState = (s: any) => fs.writeFileSync(STATE, JSON.stringify(s, null, 2) + "\n");
41
+ function deviceId() {
42
+ const s = readState();
43
+ if (!s.deviceId) writeState({ ...s, deviceId: (s.deviceId = randomUUID()) });
44
+ return s.deviceId;
45
+ }
46
+
47
+ // the model an agent should use: its own choice, else the all-agents one; skipped when its login is gone
48
+ function modelFor(name: string, session: any) {
49
+ const s = readState(), rt = session.modelRuntime;
50
+ for (const c of [s.agents?.[name], s.default])
51
+ if (c && rt.hasConfiguredAuth(c.provider)) { const m = rt.getModel(c.provider, c.id); if (m) return m; }
52
+ return undefined;
53
+ }
54
+ const fallback = (session: any) => session.modelRuntime.getModel("chalky", process.env.MODEL_NAME || "default"); // index.ts
55
+ async function apply(a: any) { // put an agent on the model it should use now (after a choice, a login or a logout)
56
+ const m = modelFor(a.name, a.session) ?? fallback(a.session);
57
+ const cur = a.session.model;
58
+ if (m && !(cur && cur.provider === m.provider && cur.id === m.id)) await a.session.setModel(m);
59
+ }
60
+ host.api.model = { modelFor };
61
+
62
+ async function status(rt: ModelRuntime, id: string) {
63
+ if (!rt.getProviderAuthStatus(id).configured) return undefined;
64
+ try { return (await rt.getAuth(id)) ? "connected" : undefined; } catch { return "expired"; } // getAuth renews a sign-in that is due
65
+ }
66
+
67
+ async function synced() { // a login or endpoint changed: every runtime reads the files again (offline)
68
+ await (await runtime()).refresh();
69
+ for (const a of host.agents.values()) { await a.session.modelRuntime.refresh(); await apply(a); }
70
+ host.broadcast("model", { step: "changed" });
71
+ }
72
+
73
+ const fail = (e: any) => host.broadcast("model", { step: "error", text: e?.message ?? String(e) });
74
+
75
+ export default function (pi: ExtensionAPI) {
76
+ if (process.env.CHALKY_LOGIN === "off") return;
77
+
78
+ // at start, once: renew the sign-ins that are due, and say which failed (server log)
79
+ pi.on("session_start", async () => {
80
+ if (host.shared.has("model-checked")) return;
81
+ host.shared.set("model-checked", true);
82
+ const rt = await runtime();
83
+ for (const c of await rt.listCredentials())
84
+ if (c.type === "oauth" && (await status(rt, c.providerId)) === "expired") console.error(`login: the ${c.providerId} sign-in has expired (/login)`);
85
+ });
86
+
87
+ host.routes.set("/model/on", (_req: any, res: any) => res.writeHead(204).end()); // browser.js: add the commands only when this answers
88
+ host.routes.set("/model/choices", (_req: any, res: any) => models(res).catch((e) =>
89
+ res.writeHead(500, { "content-type": "text/plain" }).end(String(e?.message ?? e))));
90
+ async function models(res: any) { // read through an agent's runtime: it also has the MODEL_BASE_URL endpoint ("chalky")
91
+ const agents = [...host.agents.values()], s = readState();
92
+ const rt = agents[0]?.session.modelRuntime;
93
+ const groups = new Map<string, any>();
94
+ for (const m of rt ? await rt.getAvailable() : []) {
95
+ if (!groups.has(m.provider)) groups.set(m.provider, { provider: m.provider, name: m.provider === "chalky" ? "Chalky LiteLLM (MODEL_BASE_URL)"
96
+ : (readModels().providers[m.provider] ? `${m.provider} (custom)` : rt.getProvider(m.provider)?.name ?? m.provider), models: [] });
97
+ groups.get(m.provider).models.push({ id: m.id, name: m.name, context: m.contextWindow });
98
+ }
99
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
100
+ groups: [...groups.values()], default: s.default ?? null,
101
+ agents: agents.filter((a) => !a.app).map((a) => ({ name: a.name, model: a.session.model ? { provider: a.session.model.provider, id: a.session.model.id } : null,
102
+ own: !!s.agents?.[a.name] })) }));
103
+ }
104
+
105
+ host.routes.set("/model/logins", (_req: any, res: any) => list(res).catch((e) =>
106
+ res.writeHead(500, { "content-type": "text/plain" }).end(String(e?.message ?? e))));
107
+ async function list(res: any) {
108
+ const rt = await runtime();
109
+ await rt.refresh();
110
+ const custom = readModels().providers;
111
+ const providers = await Promise.all(rt.getProviders().filter((p) => p.auth.oauth && !(p.id in custom)).map(async (p) =>
112
+ ({ id: p.id, name: p.name, subscription: p.auth.oauth!.isSubscription === true, status: await status(rt, p.id) })));
113
+ const list = await Promise.all(Object.entries(custom).map(async ([id, p]: any) =>
114
+ ({ id, baseUrl: p.baseUrl, api: p.api, models: (p.models ?? []).map((m: any) => m.id), status: await status(rt, id) })));
115
+ res.writeHead(200, { "content-type": "application/json" })
116
+ .end(JSON.stringify({ providers: providers.sort((a, b) => a.name.localeCompare(b.name)), custom: list }));
117
+ }
118
+
119
+ host.pageHandlers.set("model", async (msg: any) => {
120
+ const flow = host.shared.get("model-flow"); // the sign-in under way: {abort, answer?}
121
+ if (msg.type === "model_answer") return flow?.answer?.(String(msg.text ?? ""));
122
+ if (msg.type === "model_cancel") return flow?.abort.abort();
123
+
124
+ if (msg.type === "model_login") {
125
+ flow?.abort.abort(); // one at a time
126
+ const abort = new AbortController(), me: any = { abort };
127
+ host.shared.set("model-flow", me);
128
+ try {
129
+ await (await runtime()).login(String(msg.provider), "oauth", {
130
+ signal: abort.signal,
131
+ notify: (event) => host.broadcast("model", { step: "notify", event }),
132
+ prompt: ({ signal, ...prompt }) => new Promise((resolve, reject) => {
133
+ const signals = [signal, abort.signal].filter((s) => s) as AbortSignal[];
134
+ const end = () => { me.answer = undefined; for (const s of signals) s.removeEventListener("abort", stop); };
135
+ me.answer = (text: string) => { end(); resolve(text); };
136
+ const stop = () => { end(); host.broadcast("model", { step: "prompt_done" }); reject(new Error("Login cancelled")); };
137
+ for (const s of signals) s.addEventListener("abort", stop, { once: true });
138
+ host.broadcast("model", { step: "prompt", prompt });
139
+ }),
140
+ }, { getDeviceId: deviceId });
141
+ await synced();
142
+ host.broadcast("model", { step: "done", provider: msg.provider });
143
+ } catch (e) { if (abort.signal.aborted) host.broadcast("model", { step: "cancelled" }); else fail(e); } // ✕: not an error
144
+ finally { if (host.shared.get("model-flow") === me) host.shared.delete("model-flow"); }
145
+ }
146
+
147
+ if (msg.type === "model_custom") {
148
+ const id = String(msg.id ?? "").trim(), baseUrl = String(msg.baseUrl ?? "").trim().replace(/\/+$/, "");
149
+ const models = [...new Set<string>((msg.models ?? []).map((m: any) => String(m).trim()).filter(Boolean))];
150
+ const rt = await runtime(), saved = readModels();
151
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(id)) return fail("The name can have letters, digits, '.', '_' and '-' only");
152
+ if (rt.getProvider(id) && !(id in saved.providers)) return fail(`'${id}' is one of pi's own providers: pick another name`);
153
+ if (!/^https?:\/\//.test(baseUrl)) return fail("The base URL starts with http:// or https://");
154
+ if (!APIS.includes(msg.api)) return fail(`Speaks: one of ${APIS.join(", ")}`);
155
+ if (!models.length) return fail("Add at least one model name");
156
+ const key = String(msg.key ?? "").trim();
157
+ const stored = (await rt.listCredentials()).some((c) => c.providerId === id);
158
+ // no key and none stored: a placeholder, so pi counts the endpoint usable (pi's docs: Ollama ignores it)
159
+ saved.providers[id] = { baseUrl, api: msg.api, ...(key || stored ? {} : { apiKey: "none" }), models: models.map((m) => ({ id: m })) };
160
+ fs.writeFileSync(MODELS, JSON.stringify(saved, null, 2) + "\n");
161
+ try {
162
+ await rt.refresh();
163
+ if (key) await rt.login(id, "api_key", { prompt: async () => key, notify() {} }); // pi saves it in auth.json
164
+ await synced();
165
+ host.broadcast("model", { step: "done", provider: id });
166
+ } catch (e) { fail(e); }
167
+ }
168
+
169
+ if (msg.type === "model_probe") { // the endpoint's own list (GET <baseUrl>/models); the key never goes to the page
170
+ try {
171
+ const rt = await runtime(), baseUrl = String(msg.baseUrl ?? "").trim().replace(/\/+$/, "");
172
+ const key = String(msg.key ?? "").trim() || (msg.id && rt.getProviderAuthStatus(msg.id).configured ? (await rt.getAuth(msg.id))?.auth.apiKey : "");
173
+ const headers: Record<string, string> = msg.api === "anthropic-messages" ? { "x-api-key": key ?? "", "anthropic-version": "2023-06-01" }
174
+ : msg.api === "google-generative-ai" ? { "x-goog-api-key": key ?? "" } : key ? { authorization: `Bearer ${key}` } : {};
175
+ const r = await fetch(`${baseUrl}/models`, { headers, signal: AbortSignal.timeout(10_000) });
176
+ if (!r.ok) return fail(`${baseUrl}/models answered ${r.status} ${r.statusText}`);
177
+ const body = await r.json();
178
+ const models = (body.data ?? body.models ?? []).map((m: any) => m.id ?? m.name?.replace(/^models\//, "")).filter(Boolean);
179
+ host.broadcast("model", { step: "models", models });
180
+ } catch (e) { fail(e); }
181
+ }
182
+
183
+ if (msg.type === "model_set") {
184
+ const s = readState(), who = String(msg.for ?? "all"), rt = host.agents.values().next().value?.session.modelRuntime;
185
+ const choice = msg.provider ? { provider: String(msg.provider), id: String(msg.id) } : undefined;
186
+ if (choice && !(rt?.hasConfiguredAuth(choice.provider) && rt.getModel(choice.provider, choice.id)))
187
+ return fail(`${choice.provider}/${choice.id} isn't usable now: connect it with /login`);
188
+ if (who === "all") {
189
+ if (!choice) return fail("Pick a model");
190
+ s.default = choice;
191
+ } else {
192
+ if (!host.agents.has(who)) return fail(`No agent '${who}'`);
193
+ s.agents = { ...s.agents };
194
+ if (choice) s.agents[who] = choice; else delete s.agents[who]; // no model: back to the all-agents one
195
+ }
196
+ writeState(s);
197
+ try {
198
+ for (const a of host.agents.values()) await apply(a);
199
+ host.broadcast("model", { step: "changed" });
200
+ } catch (e) { fail(e); }
201
+ }
202
+
203
+ if (msg.type === "model_logout") {
204
+ const id = String(msg.provider ?? ""), rt = await runtime(), saved = readModels();
205
+ try {
206
+ if ((await rt.listCredentials()).some((c) => c.providerId === id)) await rt.logout(id);
207
+ if (id in saved.providers) {
208
+ delete saved.providers[id];
209
+ fs.writeFileSync(MODELS, JSON.stringify(saved, null, 2) + "\n");
210
+ }
211
+ await synced();
212
+ } catch (e) { fail(e); }
213
+ }
214
+ });
215
+ }