pikiloop 0.4.0

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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. package/package.json +82 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Dashboard API: Local Model backends (Ollama / mlx-lm).
3
+ *
4
+ * Surfaces a probe endpoint plus a connect action that links a detected
5
+ * backend into the Provider/Profile model layer so the agent cards above
6
+ * pick it up without any further configuration.
7
+ *
8
+ * GET /api/local-models/probe → which backends are running, what models
9
+ * they expose, install/run hints, and
10
+ * whether a Provider already points at each.
11
+ * POST /api/local-models/connect → idempotently create the Provider for the
12
+ * named backend.
13
+ *
14
+ * Endpoints we expect:
15
+ * - Ollama baseURL → http://127.0.0.1:11434
16
+ * version → GET /api/version
17
+ * models → GET /api/tags
18
+ * OpenAI → /v1/chat/completions, /v1/models
19
+ *
20
+ * - mlx-lm baseURL → http://127.0.0.1:8080 (mlx_lm.server default)
21
+ * probe → GET /v1/models (200 OK iff server up; no version)
22
+ *
23
+ * Model downloads stay manual (user runs `ollama pull <tag>` or restarts
24
+ * `mlx_lm.server --model <repo>` in their own terminal). The install spec
25
+ * is shipped alongside detection so the UI can mirror the CLI tools page.
26
+ */
27
+ import { Hono } from 'hono';
28
+ import { LOCAL_MODELS } from '../../catalog/local-models.js';
29
+ import { listProviders, addProvider, listProfiles, addProfile, } from '../../model/index.js';
30
+ const router = new Hono();
31
+ const BACKENDS = [
32
+ {
33
+ id: 'ollama',
34
+ label: 'Ollama',
35
+ baseURL: 'http://127.0.0.1:11434',
36
+ openAIBaseURL: 'http://127.0.0.1:11434/v1',
37
+ probePath: '/api/version',
38
+ homepage: 'https://ollama.com/',
39
+ install: {
40
+ docs: 'https://github.com/ollama/ollama#ollama',
41
+ darwin: [
42
+ { label: 'Homebrew', cmd: 'brew install ollama' },
43
+ { label: 'Install script', cmd: 'curl -fsSL https://ollama.com/install.sh | sh' },
44
+ ],
45
+ linux: [
46
+ { label: 'Install script', cmd: 'curl -fsSL https://ollama.com/install.sh | sh' },
47
+ ],
48
+ win: [
49
+ { label: 'winget', cmd: 'winget install Ollama.Ollama' },
50
+ ],
51
+ },
52
+ runHint: { label: 'Start the daemon', cmd: 'ollama serve' },
53
+ pullCommandTemplate: 'ollama pull ${model}',
54
+ modelField: 'ollamaTag',
55
+ platforms: ['darwin', 'linux', 'win'],
56
+ },
57
+ {
58
+ id: 'mlx',
59
+ label: 'mlx-lm',
60
+ baseURL: 'http://127.0.0.1:8080',
61
+ openAIBaseURL: 'http://127.0.0.1:8080/v1',
62
+ // mlx_lm.server has no /api/version; /v1/models doubles as liveness probe.
63
+ probePath: '/v1/models',
64
+ homepage: 'https://github.com/ml-explore/mlx-lm',
65
+ install: {
66
+ docs: 'https://github.com/ml-explore/mlx-lm#installation',
67
+ darwin: [
68
+ { label: 'pipx (recommended)', cmd: 'pipx install mlx-lm' },
69
+ { label: 'pip', cmd: 'pip install mlx-lm' },
70
+ ],
71
+ },
72
+ runHint: {
73
+ label: 'Start the server (replace model)',
74
+ cmd: 'mlx_lm.server --model mlx-community/Qwen2.5-Coder-7B-Instruct-4bit --port 8080',
75
+ },
76
+ // mlx-lm loads a single model per server instance — "pull" here means
77
+ // re-launching the server with that model id.
78
+ pullCommandTemplate: 'mlx_lm.server --model ${model} --port 8080',
79
+ modelField: 'mlxModel',
80
+ platforms: ['darwin'],
81
+ },
82
+ ];
83
+ const PROBE_TIMEOUT_MS = 1500;
84
+ // ---------------------------------------------------------------------------
85
+ // HTTP helpers
86
+ // ---------------------------------------------------------------------------
87
+ async function fetchJson(url, timeoutMs = PROBE_TIMEOUT_MS) {
88
+ const controller = new AbortController();
89
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
90
+ try {
91
+ const res = await fetch(url, { signal: controller.signal });
92
+ if (!res.ok)
93
+ return null;
94
+ return (await res.json());
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ finally {
100
+ clearTimeout(timer);
101
+ }
102
+ }
103
+ function currentOs() {
104
+ if (process.platform === 'darwin')
105
+ return 'darwin';
106
+ if (process.platform === 'win32')
107
+ return 'win';
108
+ return 'linux';
109
+ }
110
+ async function probeOllama(spec) {
111
+ const ver = await fetchJson(`${spec.baseURL}${spec.probePath}`);
112
+ if (!ver)
113
+ return { detected: false, models: [] };
114
+ const tags = await fetchJson(`${spec.baseURL}/api/tags`, 3000);
115
+ const models = (tags?.models || []).map(m => ({
116
+ id: m.name,
117
+ sizeBytes: typeof m.size === 'number' ? m.size : undefined,
118
+ }));
119
+ return { detected: true, version: ver.version, models };
120
+ }
121
+ async function probeMlx(spec) {
122
+ const res = await fetchJson(`${spec.baseURL}${spec.probePath}`, 3000);
123
+ if (!res)
124
+ return { detected: false, models: [] };
125
+ return {
126
+ detected: true,
127
+ models: (res.data || []).map(m => ({ id: m.id })),
128
+ };
129
+ }
130
+ /**
131
+ * Normalize a provider baseURL for comparison: drop trailing slashes and
132
+ * collapse the localhost ↔ 127.0.0.1 distinction.
133
+ */
134
+ function normalizeBaseURL(raw) {
135
+ return raw
136
+ .replace(/\/+$/, '')
137
+ .replace(/^http:\/\/localhost(?=[:/]|$)/i, 'http://127.0.0.1')
138
+ .replace(/^https:\/\/localhost(?=[:/]|$)/i, 'https://127.0.0.1');
139
+ }
140
+ function findProviderForBackend(providers, spec) {
141
+ const target = normalizeBaseURL(spec.openAIBaseURL);
142
+ return providers.find(p => normalizeBaseURL(p.baseURL) === target) || null;
143
+ }
144
+ async function probeBackend(spec, providers) {
145
+ const os = currentOs();
146
+ const supported = spec.platforms.includes(os);
147
+ const result = !supported
148
+ ? { detected: false, models: [] }
149
+ : spec.id === 'ollama' ? await probeOllama(spec) : await probeMlx(spec);
150
+ const existing = findProviderForBackend(providers, spec);
151
+ return {
152
+ id: spec.id,
153
+ label: spec.label,
154
+ detected: result.detected,
155
+ version: result.version,
156
+ baseURL: spec.baseURL,
157
+ openAIBaseURL: spec.openAIBaseURL,
158
+ models: result.models,
159
+ existingProviderId: existing?.id || null,
160
+ homepage: spec.homepage,
161
+ install: spec.install,
162
+ runHint: spec.runHint,
163
+ pullCommandTemplate: spec.pullCommandTemplate,
164
+ supportedOnThisOs: supported,
165
+ };
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // Catalog join — recommended models × backend availability
169
+ // ---------------------------------------------------------------------------
170
+ function isEntryInstalled(entry, spec, installed) {
171
+ const target = entry[spec.modelField];
172
+ if (!target)
173
+ return null;
174
+ const base = target.split(':')[0].toLowerCase();
175
+ for (const m of installed) {
176
+ if (m.id.toLowerCase().startsWith(base))
177
+ return m.id;
178
+ }
179
+ return null;
180
+ }
181
+ function joinCatalog(backends) {
182
+ return LOCAL_MODELS.map(entry => {
183
+ for (const b of backends) {
184
+ if (!b.detected)
185
+ continue;
186
+ const spec = BACKENDS.find(s => s.id === b.id);
187
+ if (!spec)
188
+ continue;
189
+ const hit = isEntryInstalled(entry, spec, b.models);
190
+ if (hit)
191
+ return { ...entry, installed: { backend: b.id, id: hit } };
192
+ }
193
+ return { ...entry, installed: null };
194
+ });
195
+ }
196
+ /**
197
+ * For a connected local backend, mirror every detected model as a Profile under
198
+ * its Provider so the unified picker shows them without an extra user gesture.
199
+ * Idempotent. Never deletes Profiles — a model that disappears from probe
200
+ * output might be a transient blip.
201
+ */
202
+ function syncLocalProfilesForBackend(providerId, detected) {
203
+ if (!providerId || !detected.length)
204
+ return { added: 0 };
205
+ const existing = new Set(listProfiles().filter(p => p.providerId === providerId).map(p => p.modelId));
206
+ let added = 0;
207
+ for (const m of detected) {
208
+ if (!m.id || existing.has(m.id))
209
+ continue;
210
+ try {
211
+ addProfile({ providerId, modelId: m.id });
212
+ added += 1;
213
+ existing.add(m.id);
214
+ }
215
+ catch {
216
+ // Provider may have been removed between calls — skip; next probe retries.
217
+ }
218
+ }
219
+ return { added };
220
+ }
221
+ /**
222
+ * Idempotently create a Provider pointing at this backend. Returns the
223
+ * provider id. The placeholder API key is a sentinel ("local-no-auth") rather
224
+ * than something that looks like a real key, so future code can recognize and
225
+ * special-case local providers.
226
+ */
227
+ async function ensureProviderForBackend(spec) {
228
+ const providers = listProviders();
229
+ const existing = findProviderForBackend(providers, spec);
230
+ if (existing)
231
+ return existing.id;
232
+ try {
233
+ const provider = await addProvider({
234
+ kind: 'openai-compatible',
235
+ name: spec.label,
236
+ baseURL: spec.openAIBaseURL,
237
+ apiKey: 'local-no-auth',
238
+ });
239
+ return provider.id;
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ }
245
+ // ---------------------------------------------------------------------------
246
+ // Routes
247
+ // ---------------------------------------------------------------------------
248
+ /**
249
+ * Single probe-and-attach endpoint. The Local Models page no longer asks the
250
+ * user to "connect" — every detected backend becomes a Provider automatically
251
+ * so its models show up in the unified picker without an extra click.
252
+ *
253
+ * - Backend detected, no existing Provider → create one, then sync Profiles.
254
+ * - Backend detected, Provider already exists → just sync Profiles.
255
+ * - Backend not detected → leave existing Provider in place (a transient
256
+ * blip during a restart shouldn't tear down config).
257
+ *
258
+ * Response includes `addedProviderIds` so the dashboard can refetch the upper
259
+ * Model Providers / agent layer exactly when something new appears.
260
+ */
261
+ router.get('/api/local-models/probe', async (c) => {
262
+ try {
263
+ const initialProviders = listProviders();
264
+ const backends = await Promise.all(BACKENDS.map(spec => probeBackend(spec, initialProviders)));
265
+ const addedProviderIds = [];
266
+ for (const b of backends) {
267
+ if (!b.detected)
268
+ continue;
269
+ const spec = BACKENDS.find(s => s.id === b.id);
270
+ if (!spec)
271
+ continue;
272
+ let providerId = b.existingProviderId;
273
+ if (!providerId) {
274
+ providerId = await ensureProviderForBackend(spec);
275
+ if (providerId) {
276
+ b.existingProviderId = providerId;
277
+ addedProviderIds.push(providerId);
278
+ }
279
+ }
280
+ if (providerId)
281
+ syncLocalProfilesForBackend(providerId, b.models);
282
+ }
283
+ const catalog = joinCatalog(backends);
284
+ return c.json({ ok: true, backends, catalog, currentOs: currentOs(), addedProviderIds });
285
+ }
286
+ catch (e) {
287
+ return c.json({ ok: false, error: e?.message || String(e) }, 500);
288
+ }
289
+ });
290
+ export default router;
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Dashboard API: Model layer (Providers, Profiles, BYOK bindings).
3
+ *
4
+ * Endpoints:
5
+ * GET /api/models/catalog → models.dev metadata
6
+ * GET /api/models/providers → list configured providers
7
+ * POST /api/models/providers → add new provider (paste key / use env)
8
+ * PATCH /api/models/providers/:id → update provider
9
+ * DELETE /api/models/providers/:id → remove provider + bound profiles
10
+ * POST /api/models/providers/:id/validate → Feishu-style validation
11
+ *
12
+ * GET /api/models/profiles → list profiles
13
+ * POST /api/models/profiles → add profile
14
+ * PATCH /api/models/profiles/:id → update profile
15
+ * DELETE /api/models/profiles/:id → remove profile
16
+ *
17
+ * GET /api/models/agents → list active bindings
18
+ * POST /api/models/agents/:agent/active → bind/unbind a Profile
19
+ */
20
+ import { Hono } from 'hono';
21
+ import { getModelsDevCatalog, searchCatalogProviders, listProviders, getProvider, addProvider, updateProvider, removeProvider, setProviderValidation, listProfiles, getProfile, addProfile, updateProfile, removeProfile, getActiveProfileId, setActiveProfile, validateProvider, getProviderModelList, invalidateProviderModels, } from '../../model/index.js';
22
+ import { isCredentialRef, describeCredentialRef } from '../../core/secrets/index.js';
23
+ import { allDriverIds } from '../../agent/index.js';
24
+ const router = new Hono();
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+ function publicProvider(p) {
29
+ return {
30
+ id: p.id,
31
+ kind: p.kind,
32
+ name: p.name,
33
+ baseURL: p.baseURL,
34
+ extraHeaders: p.extraHeaders,
35
+ credential: { source: p.credential.source, summary: describeCredentialRef(p.credential) },
36
+ validation: p.validation || null,
37
+ createdAt: p.createdAt,
38
+ updatedAt: p.updatedAt,
39
+ };
40
+ }
41
+ const VALID_KINDS = ['anthropic', 'openai', 'openai-compatible', 'google'];
42
+ // ---------------------------------------------------------------------------
43
+ // Catalog (read-only models.dev mirror)
44
+ // ---------------------------------------------------------------------------
45
+ router.get('/api/models/catalog', async (c) => {
46
+ const q = c.req.query('q') || '';
47
+ const refresh = c.req.query('refresh') === '1';
48
+ try {
49
+ if (refresh)
50
+ await getModelsDevCatalog({ forceRefresh: true });
51
+ const providers = await searchCatalogProviders(q);
52
+ return c.json({
53
+ ok: true,
54
+ providers: providers.map(p => ({
55
+ id: p.id,
56
+ name: p.name,
57
+ api: p.api,
58
+ doc: p.doc,
59
+ env: p.env || [],
60
+ modelCount: Object.keys(p.models || {}).length,
61
+ })),
62
+ });
63
+ }
64
+ catch (e) {
65
+ return c.json({ ok: false, error: e?.message || String(e) }, 500);
66
+ }
67
+ });
68
+ router.get('/api/models/catalog/:providerId', async (c) => {
69
+ const id = c.req.param('providerId');
70
+ try {
71
+ const cat = await getModelsDevCatalog();
72
+ const provider = cat[id];
73
+ if (!provider)
74
+ return c.json({ ok: false, error: 'Catalog provider not found' }, 404);
75
+ const models = Object.values(provider.models || {}).map(m => ({
76
+ id: m.id,
77
+ name: m.name || m.id,
78
+ reasoning: !!m.reasoning,
79
+ tool_call: !!m.tool_call,
80
+ context: m.limit?.context || null,
81
+ output: m.limit?.output || null,
82
+ cost: m.cost || null,
83
+ release_date: m.release_date || null,
84
+ }));
85
+ return c.json({
86
+ ok: true,
87
+ provider: {
88
+ id: provider.id,
89
+ name: provider.name,
90
+ api: provider.api,
91
+ doc: provider.doc,
92
+ env: provider.env || [],
93
+ },
94
+ models,
95
+ });
96
+ }
97
+ catch (e) {
98
+ return c.json({ ok: false, error: e?.message || String(e) }, 500);
99
+ }
100
+ });
101
+ // ---------------------------------------------------------------------------
102
+ // Providers
103
+ // ---------------------------------------------------------------------------
104
+ router.get('/api/models/providers', c => {
105
+ return c.json({ ok: true, providers: listProviders().map(publicProvider) });
106
+ });
107
+ router.post('/api/models/providers', async (c) => {
108
+ let body;
109
+ try {
110
+ body = await c.req.json();
111
+ }
112
+ catch {
113
+ body = {};
114
+ }
115
+ const kind = body.kind;
116
+ const name = String(body.name || '').trim();
117
+ const baseURL = String(body.baseURL || '').trim();
118
+ const apiKey = typeof body.apiKey === 'string' ? body.apiKey : '';
119
+ const credentialRef = isCredentialRef(body.credentialRef) ? body.credentialRef : undefined;
120
+ const extraHeaders = body.extraHeaders && typeof body.extraHeaders === 'object' ? body.extraHeaders : undefined;
121
+ if (!VALID_KINDS.includes(kind))
122
+ return c.json({ ok: false, error: `Invalid kind. Use one of: ${VALID_KINDS.join(', ')}` }, 400);
123
+ if (!name)
124
+ return c.json({ ok: false, error: 'name is required' }, 400);
125
+ if (!baseURL)
126
+ return c.json({ ok: false, error: 'baseURL is required' }, 400);
127
+ if (!apiKey && !credentialRef)
128
+ return c.json({ ok: false, error: 'apiKey or credentialRef is required' }, 400);
129
+ try {
130
+ const provider = await addProvider({ kind, name, baseURL, apiKey: apiKey || undefined, credentialRef, extraHeaders });
131
+ return c.json({ ok: true, provider: publicProvider(provider) });
132
+ }
133
+ catch (e) {
134
+ return c.json({ ok: false, error: e?.message || String(e) }, 500);
135
+ }
136
+ });
137
+ router.patch('/api/models/providers/:id', async (c) => {
138
+ const id = c.req.param('id');
139
+ if (!getProvider(id))
140
+ return c.json({ ok: false, error: 'Provider not found' }, 404);
141
+ let body;
142
+ try {
143
+ body = await c.req.json();
144
+ }
145
+ catch {
146
+ body = {};
147
+ }
148
+ try {
149
+ const provider = await updateProvider(id, {
150
+ name: typeof body.name === 'string' ? body.name : undefined,
151
+ baseURL: typeof body.baseURL === 'string' ? body.baseURL : undefined,
152
+ apiKey: typeof body.apiKey === 'string' && body.apiKey.length > 0 ? body.apiKey : undefined,
153
+ credentialRef: isCredentialRef(body.credentialRef) ? body.credentialRef : undefined,
154
+ extraHeaders: body.extraHeaders === null ? null : (body.extraHeaders && typeof body.extraHeaders === 'object' ? body.extraHeaders : undefined),
155
+ });
156
+ invalidateProviderModels(id);
157
+ return c.json({ ok: true, provider: publicProvider(provider) });
158
+ }
159
+ catch (e) {
160
+ return c.json({ ok: false, error: e?.message || String(e) }, 500);
161
+ }
162
+ });
163
+ router.delete('/api/models/providers/:id', async (c) => {
164
+ const id = c.req.param('id');
165
+ const removed = await removeProvider(id);
166
+ if (!removed)
167
+ return c.json({ ok: false, error: 'Provider not found' }, 404);
168
+ invalidateProviderModels(id);
169
+ return c.json({ ok: true });
170
+ });
171
+ /**
172
+ * GET /api/models/providers/:id/models — model id list for the configured
173
+ * provider, served from a 30-minute in-memory TTL cache. The first hit (or any
174
+ * hit after a config edit) re-fetches from the provider's /models endpoint.
175
+ *
176
+ * Used by:
177
+ * - Dashboard: per-agent unified config modal model picker
178
+ * - IM /models command: when an agent is bound to this provider
179
+ * - Agent status response: when an agent is bound to this provider
180
+ */
181
+ router.get('/api/models/providers/:id/models', async (c) => {
182
+ const id = c.req.param('id');
183
+ const refresh = c.req.query('refresh') === '1';
184
+ try {
185
+ const result = await getProviderModelList(id, { forceRefresh: refresh });
186
+ if (!result)
187
+ return c.json({ ok: false, error: 'Provider not found' }, 404);
188
+ return c.json({
189
+ ok: true,
190
+ models: result.models,
191
+ modelInfos: result.modelInfos,
192
+ fetchedAt: new Date(result.fetchedAt).toISOString(),
193
+ fromCache: result.fromCache,
194
+ });
195
+ }
196
+ catch (e) {
197
+ return c.json({ ok: false, error: e?.message || String(e) }, 500);
198
+ }
199
+ });
200
+ router.post('/api/models/providers/:id/validate', async (c) => {
201
+ const id = c.req.param('id');
202
+ const provider = getProvider(id);
203
+ if (!provider)
204
+ return c.json({ ok: false, error: 'Provider not found' }, 404);
205
+ try {
206
+ const result = await validateProvider(provider);
207
+ setProviderValidation(id, result.status);
208
+ return c.json({ ok: true, validation: result.status, models: result.models });
209
+ }
210
+ catch (e) {
211
+ const status = {
212
+ state: 'error',
213
+ detail: e?.message || String(e),
214
+ checkedAt: new Date().toISOString(),
215
+ };
216
+ setProviderValidation(id, status);
217
+ return c.json({ ok: true, validation: status, models: [] });
218
+ }
219
+ });
220
+ // ---------------------------------------------------------------------------
221
+ // Profiles
222
+ // ---------------------------------------------------------------------------
223
+ router.get('/api/models/profiles', c => {
224
+ return c.json({ ok: true, profiles: listProfiles() });
225
+ });
226
+ router.post('/api/models/profiles', async (c) => {
227
+ let body;
228
+ try {
229
+ body = await c.req.json();
230
+ }
231
+ catch {
232
+ body = {};
233
+ }
234
+ const providerId = String(body.providerId || '').trim();
235
+ const modelId = String(body.modelId || '').trim();
236
+ if (!providerId)
237
+ return c.json({ ok: false, error: 'providerId is required' }, 400);
238
+ if (!modelId)
239
+ return c.json({ ok: false, error: 'modelId is required' }, 400);
240
+ if (!getProvider(providerId))
241
+ return c.json({ ok: false, error: `Provider not found: ${providerId}` }, 404);
242
+ try {
243
+ const profile = addProfile({
244
+ name: typeof body.name === 'string' ? body.name : undefined,
245
+ providerId,
246
+ modelId,
247
+ effort: body.effort || null,
248
+ maxOutputTokens: typeof body.maxOutputTokens === 'number' ? body.maxOutputTokens : null,
249
+ extras: body.extras && typeof body.extras === 'object' ? body.extras : undefined,
250
+ });
251
+ return c.json({ ok: true, profile });
252
+ }
253
+ catch (e) {
254
+ return c.json({ ok: false, error: e?.message || String(e) }, 500);
255
+ }
256
+ });
257
+ router.patch('/api/models/profiles/:id', async (c) => {
258
+ const id = c.req.param('id');
259
+ if (!getProfile(id))
260
+ return c.json({ ok: false, error: 'Profile not found' }, 404);
261
+ let body;
262
+ try {
263
+ body = await c.req.json();
264
+ }
265
+ catch {
266
+ body = {};
267
+ }
268
+ try {
269
+ const profile = updateProfile(id, {
270
+ name: typeof body.name === 'string' ? body.name : undefined,
271
+ modelId: typeof body.modelId === 'string' ? body.modelId : undefined,
272
+ effort: 'effort' in body ? body.effort : undefined,
273
+ maxOutputTokens: 'maxOutputTokens' in body ? body.maxOutputTokens : undefined,
274
+ extras: 'extras' in body ? body.extras : undefined,
275
+ });
276
+ return c.json({ ok: true, profile });
277
+ }
278
+ catch (e) {
279
+ return c.json({ ok: false, error: e?.message || String(e) }, 500);
280
+ }
281
+ });
282
+ router.delete('/api/models/profiles/:id', c => {
283
+ const id = c.req.param('id');
284
+ const removed = removeProfile(id);
285
+ if (!removed)
286
+ return c.json({ ok: false, error: 'Profile not found' }, 404);
287
+ return c.json({ ok: true });
288
+ });
289
+ // ---------------------------------------------------------------------------
290
+ // Agent bindings
291
+ // ---------------------------------------------------------------------------
292
+ router.get('/api/models/agents', c => {
293
+ const agents = allDriverIds();
294
+ return c.json({
295
+ ok: true,
296
+ bindings: agents.map(agent => ({
297
+ agent,
298
+ activeProfileId: getActiveProfileId(agent),
299
+ })),
300
+ });
301
+ });
302
+ router.post('/api/models/agents/:agent/active', async (c) => {
303
+ const agent = c.req.param('agent');
304
+ if (!allDriverIds().includes(agent))
305
+ return c.json({ ok: false, error: `Unknown agent: ${agent}` }, 400);
306
+ let body;
307
+ try {
308
+ body = await c.req.json();
309
+ }
310
+ catch {
311
+ body = {};
312
+ }
313
+ const profileId = body.profileId === null ? null : (typeof body.profileId === 'string' ? body.profileId : undefined);
314
+ if (profileId === undefined)
315
+ return c.json({ ok: false, error: 'profileId (string|null) is required' }, 400);
316
+ try {
317
+ setActiveProfile(agent, profileId);
318
+ return c.json({ ok: true, agent, activeProfileId: profileId });
319
+ }
320
+ catch (e) {
321
+ return c.json({ ok: false, error: e?.message || String(e) }, 400);
322
+ }
323
+ });
324
+ export default router;