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.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- 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;
|