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,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential injector — turn an active Profile into the env vars and
|
|
3
|
+
* additional argv that should be applied when spawning a specific agent.
|
|
4
|
+
*
|
|
5
|
+
* This is the single point where pikiloop's Profile abstraction is
|
|
6
|
+
* translated into per-agent quirks. Adding a new agent (e.g. OpenCode)
|
|
7
|
+
* = adding one entry to AGENT_INJECT_TABLE.
|
|
8
|
+
*/
|
|
9
|
+
import { resolveCredential } from '../core/secrets/index.js';
|
|
10
|
+
import { getActiveProfile, getProvider } from './store.js';
|
|
11
|
+
import { peekProviderModelInfo, prefetchProviderModels } from './provider-models.js';
|
|
12
|
+
const EMPTY = { env: {}, argvAppend: [], detail: '' };
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Shared host-based provider identification
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
function providerHost(provider) {
|
|
17
|
+
try {
|
|
18
|
+
return new URL(provider.baseURL).host.toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Stable slug used to identify the provider in TOML-style configs (Codex
|
|
26
|
+
* `model_providers.<slug>`, Hermes ACP `<slug>:<model>`). Host-aware so a
|
|
27
|
+
* "DeepSeek personal" provider always resolves to `deepseek`, regardless of
|
|
28
|
+
* its display name.
|
|
29
|
+
*/
|
|
30
|
+
function providerSlug(provider) {
|
|
31
|
+
if (provider.kind === 'anthropic')
|
|
32
|
+
return 'anthropic';
|
|
33
|
+
if (provider.kind === 'openai')
|
|
34
|
+
return 'openai';
|
|
35
|
+
if (provider.kind === 'google')
|
|
36
|
+
return 'google';
|
|
37
|
+
const host = providerHost(provider);
|
|
38
|
+
if (host.includes('deepseek'))
|
|
39
|
+
return 'deepseek';
|
|
40
|
+
if (host.includes('moonshot') || host.includes('kimi'))
|
|
41
|
+
return 'kimi';
|
|
42
|
+
if (host.includes('minimax'))
|
|
43
|
+
return 'minimax';
|
|
44
|
+
if (host.includes('zhipuai') || host.includes('z.ai') || host.includes('bigmodel'))
|
|
45
|
+
return 'zai';
|
|
46
|
+
if (host.includes('x.ai'))
|
|
47
|
+
return 'xai';
|
|
48
|
+
if (host.includes('stepfun'))
|
|
49
|
+
return 'stepfun';
|
|
50
|
+
if (host.includes('dashscope') || host.includes('qwen'))
|
|
51
|
+
return 'qwen';
|
|
52
|
+
if (host.includes('volces') || host.includes('volcengine') || host.includes('doubao'))
|
|
53
|
+
return 'doubao';
|
|
54
|
+
if (host.includes('openrouter'))
|
|
55
|
+
return 'openrouter';
|
|
56
|
+
return 'openrouter';
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Canonical env-var name(s) carrying the credential for a provider. Returned
|
|
60
|
+
* as a map so e.g. Google fans out to both `GOOGLE_API_KEY` and
|
|
61
|
+
* `GEMINI_API_KEY` for SDKs that expect either.
|
|
62
|
+
*/
|
|
63
|
+
function providerCredentialEnv(provider, apiKey) {
|
|
64
|
+
if (provider.kind === 'anthropic')
|
|
65
|
+
return { ANTHROPIC_API_KEY: apiKey };
|
|
66
|
+
if (provider.kind === 'openai')
|
|
67
|
+
return { OPENAI_API_KEY: apiKey };
|
|
68
|
+
if (provider.kind === 'google')
|
|
69
|
+
return { GOOGLE_API_KEY: apiKey, GEMINI_API_KEY: apiKey };
|
|
70
|
+
const host = providerHost(provider);
|
|
71
|
+
if (host.includes('openrouter'))
|
|
72
|
+
return { OPENROUTER_API_KEY: apiKey };
|
|
73
|
+
if (host.includes('deepseek'))
|
|
74
|
+
return { DEEPSEEK_API_KEY: apiKey };
|
|
75
|
+
if (host.includes('moonshot') || host.includes('kimi'))
|
|
76
|
+
return { KIMI_API_KEY: apiKey, MOONSHOT_API_KEY: apiKey };
|
|
77
|
+
if (host.includes('minimax'))
|
|
78
|
+
return { MINIMAX_API_KEY: apiKey };
|
|
79
|
+
if (host.includes('zhipuai') || host.includes('z.ai') || host.includes('bigmodel'))
|
|
80
|
+
return { ZAI_API_KEY: apiKey, ZHIPU_API_KEY: apiKey };
|
|
81
|
+
if (host.includes('x.ai'))
|
|
82
|
+
return { XAI_API_KEY: apiKey };
|
|
83
|
+
if (host.includes('stepfun'))
|
|
84
|
+
return { STEPFUN_API_KEY: apiKey };
|
|
85
|
+
if (host.includes('dashscope') || host.includes('qwen'))
|
|
86
|
+
return { DASHSCOPE_API_KEY: apiKey, QWEN_API_KEY: apiKey };
|
|
87
|
+
if (host.includes('volces') || host.includes('volcengine') || host.includes('doubao'))
|
|
88
|
+
return { ARK_API_KEY: apiKey, DOUBAO_API_KEY: apiKey };
|
|
89
|
+
return { OPENROUTER_API_KEY: apiKey };
|
|
90
|
+
}
|
|
91
|
+
/** Pick the single env var Codex's `model_providers.<slug>.env_key` should reference. */
|
|
92
|
+
function codexEnvKey(provider) {
|
|
93
|
+
const keys = Object.keys(providerCredentialEnv(provider, ''));
|
|
94
|
+
return keys[0] || 'OPENROUTER_API_KEY';
|
|
95
|
+
}
|
|
96
|
+
/** Escape a string for embedding inside a TOML double-quoted string literal. */
|
|
97
|
+
function tomlEscape(value) {
|
|
98
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Anthropic-protocol baseURL: the SDK appends `/v1/messages` itself, so
|
|
102
|
+
* `ANTHROPIC_BASE_URL` must NOT carry a trailing `/v1` (otherwise requests
|
|
103
|
+
* land on `/v1/v1/messages` and 404). Providers (OpenRouter, DeepSeek, …)
|
|
104
|
+
* publish their endpoints with `/v1` for OpenAI-protocol callers, so we
|
|
105
|
+
* keep that as the canonical stored form and strip it here for Claude.
|
|
106
|
+
*/
|
|
107
|
+
function anthropicBaseURL(rawBaseURL) {
|
|
108
|
+
return rawBaseURL.replace(/\/+$/, '').replace(/\/v1$/, '');
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Claude Code respects `ANTHROPIC_BASE_URL` + `ANTHROPIC_API_KEY` (or
|
|
112
|
+
* `ANTHROPIC_AUTH_TOKEN`) as a BYOK route. The CLI itself is unchanged.
|
|
113
|
+
* The model is overridden via opts.claudeModel (handled in stream.ts).
|
|
114
|
+
*
|
|
115
|
+
* For OpenAI-compatible providers (OpenRouter, DeepSeek native, …), the
|
|
116
|
+
* baseURL must point to an Anthropic-protocol-compatible endpoint
|
|
117
|
+
* (`/v1/messages`-shaped). OpenRouter's `/api/v1` and DeepSeek's
|
|
118
|
+
* `/anthropic/v1` both qualify.
|
|
119
|
+
*/
|
|
120
|
+
const claudeInjector = (provider, profile, apiKey) => {
|
|
121
|
+
if (provider.kind !== 'anthropic' && provider.kind !== 'openai-compatible') {
|
|
122
|
+
return {
|
|
123
|
+
...EMPTY,
|
|
124
|
+
detail: `Claude BYOK requires Anthropic or OpenAI-compatible (Anthropic-API-shaped) provider; got ${provider.kind}.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
env: {
|
|
129
|
+
ANTHROPIC_BASE_URL: anthropicBaseURL(provider.baseURL),
|
|
130
|
+
ANTHROPIC_API_KEY: apiKey,
|
|
131
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
132
|
+
},
|
|
133
|
+
argvAppend: [],
|
|
134
|
+
modelOverride: profile.modelId,
|
|
135
|
+
detail: `Claude BYOK → ${provider.name} / ${profile.modelId}`,
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Codex CLI honours `model_providers.<slug>` definitions in `config.toml`.
|
|
140
|
+
* Setting `OPENAI_BASE_URL` alone is not enough — Codex still routes through
|
|
141
|
+
* the default `openai` provider's auth flow. The robust path is to declare a
|
|
142
|
+
* one-shot `model_providers.<slug>` via `-c` overrides and bind it via
|
|
143
|
+
* `model_provider="<slug>"`. The credential lives in the env var named by
|
|
144
|
+
* `env_key`, picked host-aware (e.g. `OPENROUTER_API_KEY` for openrouter.ai).
|
|
145
|
+
*
|
|
146
|
+
* Note on `wire_api`: codex 0.130 dropped `"chat"` ("no longer supported"); we
|
|
147
|
+
* omit the field entirely so codex picks its current default (`responses`),
|
|
148
|
+
* which OpenRouter and other major OpenAI-compatible providers accept.
|
|
149
|
+
*/
|
|
150
|
+
const codexInjector = (provider, profile, apiKey) => {
|
|
151
|
+
if (provider.kind !== 'openai' && provider.kind !== 'openai-compatible') {
|
|
152
|
+
return {
|
|
153
|
+
...EMPTY,
|
|
154
|
+
detail: `Codex BYOK requires OpenAI-compatible provider; got ${provider.kind}.`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const slug = providerSlug(provider);
|
|
158
|
+
const envKey = codexEnvKey(provider);
|
|
159
|
+
const overrides = [
|
|
160
|
+
`model_providers.${slug}.name="${tomlEscape(provider.name)}"`,
|
|
161
|
+
`model_providers.${slug}.base_url="${tomlEscape(provider.baseURL)}"`,
|
|
162
|
+
`model_providers.${slug}.env_key="${envKey}"`,
|
|
163
|
+
`model_provider="${slug}"`,
|
|
164
|
+
];
|
|
165
|
+
return {
|
|
166
|
+
env: { [envKey]: apiKey },
|
|
167
|
+
argvAppend: [],
|
|
168
|
+
codexConfigOverrides: overrides,
|
|
169
|
+
modelOverride: profile.modelId,
|
|
170
|
+
detail: `Codex BYOK → ${provider.name} / ${profile.modelId} (provider=${slug})`,
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
/** Gemini CLI accepts `GEMINI_API_KEY` but does not allow custom baseURL. */
|
|
174
|
+
const geminiInjector = (provider, profile, apiKey) => {
|
|
175
|
+
if (provider.kind !== 'google') {
|
|
176
|
+
return {
|
|
177
|
+
...EMPTY,
|
|
178
|
+
detail: `Gemini BYOK only supports Google AI Studio keys; got ${provider.kind}.`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
env: { GEMINI_API_KEY: apiKey },
|
|
183
|
+
argvAppend: [],
|
|
184
|
+
modelOverride: profile.modelId,
|
|
185
|
+
detail: `Gemini BYOK → ${provider.name} / ${profile.modelId}`,
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Hermes injector. Two channels:
|
|
190
|
+
* 1. Env vars carry the credential — `hermes acp` honours `OPENROUTER_API_KEY`,
|
|
191
|
+
* `ANTHROPIC_API_KEY`, etc. just like the top-level `hermes` CLI.
|
|
192
|
+
* 2. The model is bound *per-session* by the driver via the ACP
|
|
193
|
+
* `session/set_model` request — `hermes acp` does NOT accept `-m` /
|
|
194
|
+
* `--provider` (only `--accept-hooks`); appending `-m` here used to make
|
|
195
|
+
* every BYOK-bound spawn die with `unrecognized arguments`.
|
|
196
|
+
*
|
|
197
|
+
* The model is handed to the driver via `modelOverride` (an ACP-style
|
|
198
|
+
* `<provider>:<model>` string). The driver passes it to `session/set_model`
|
|
199
|
+
* after `session/new` returns; if the user has no Profile bound, no
|
|
200
|
+
* `set_model` call is made and Hermes uses its `~/.hermes/config.yaml`
|
|
201
|
+
* default.
|
|
202
|
+
*/
|
|
203
|
+
const hermesInjector = (provider, profile, apiKey) => {
|
|
204
|
+
const env = providerCredentialEnv(provider, apiKey);
|
|
205
|
+
const slug = providerSlug(provider);
|
|
206
|
+
// Only strip a leading `<slug>/` or `<slug>:` if the user accidentally
|
|
207
|
+
// stored a redundant provider prefix. Do NOT strip the *first segment* of
|
|
208
|
+
// a slash-separated model id wholesale — for OpenRouter the canonical
|
|
209
|
+
// model id is `vendor/model` (e.g. `deepseek/deepseek-v4-flash`), and
|
|
210
|
+
// dropping the `vendor/` part yields a non-existent model.
|
|
211
|
+
let bareModel = profile.modelId;
|
|
212
|
+
if (bareModel.startsWith(`${slug}/`) || bareModel.startsWith(`${slug}:`)) {
|
|
213
|
+
bareModel = bareModel.slice(slug.length + 1);
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
env,
|
|
217
|
+
argvAppend: [],
|
|
218
|
+
modelOverride: `${slug}:${bareModel}`,
|
|
219
|
+
detail: `Hermes → ${provider.name} / ${profile.modelId}`,
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
const AGENT_INJECT_TABLE = {
|
|
223
|
+
claude: claudeInjector,
|
|
224
|
+
codex: codexInjector,
|
|
225
|
+
gemini: geminiInjector,
|
|
226
|
+
hermes: hermesInjector,
|
|
227
|
+
};
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Public API
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
/**
|
|
232
|
+
* Resolve the active Profile for an agent and return the spawn config to
|
|
233
|
+
* inject. Returns `null` when no Profile is bound (caller should fall back
|
|
234
|
+
* to the agent's native auth / default model).
|
|
235
|
+
*/
|
|
236
|
+
export async function resolveAgentInjection(agentId) {
|
|
237
|
+
const profile = getActiveProfile(agentId);
|
|
238
|
+
if (!profile)
|
|
239
|
+
return null;
|
|
240
|
+
const provider = getProvider(profile.providerId);
|
|
241
|
+
if (!provider)
|
|
242
|
+
return null;
|
|
243
|
+
const injector = AGENT_INJECT_TABLE[agentId];
|
|
244
|
+
if (!injector)
|
|
245
|
+
return null;
|
|
246
|
+
let apiKey;
|
|
247
|
+
try {
|
|
248
|
+
apiKey = await resolveCredential(provider.credential);
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
throw new Error(`Failed to resolve credential for ${provider.name}: ${e?.message || e}`);
|
|
252
|
+
}
|
|
253
|
+
const result = await injector(provider, profile, apiKey);
|
|
254
|
+
// Attach the provider display name so renders can surface "via <provider>"
|
|
255
|
+
// — this is what tells the user the turn is going through a BYOK route.
|
|
256
|
+
result.providerName = provider.name;
|
|
257
|
+
// Attach the real context window from the provider's cached /models listing.
|
|
258
|
+
// Sync peek — no network blocking; on miss we kick off a fetch so the *next*
|
|
259
|
+
// session has it, and this turn falls back to the CLI's advertised value.
|
|
260
|
+
const cached = peekProviderModelInfo(provider.id, profile.modelId);
|
|
261
|
+
if (cached?.contextLength && cached.contextLength > 0) {
|
|
262
|
+
result.contextWindow = cached.contextLength;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
prefetchProviderModels(provider.id);
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
/** Returns `true` if the given agent is bound to a Profile. */
|
|
270
|
+
export function isAgentBoundToProfile(agentId) {
|
|
271
|
+
return getActiveProfile(agentId) !== null;
|
|
272
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider model-list cache.
|
|
3
|
+
*
|
|
4
|
+
* Backs the GET /api/models/providers/:id/models endpoint and the agent-status
|
|
5
|
+
* + IM /models surfaces. Each entry is a list of model ids the provider's
|
|
6
|
+
* /models endpoint reported, plus a fetch timestamp for TTL invalidation.
|
|
7
|
+
*
|
|
8
|
+
* Cache is in-memory only — providers' validation state already persists in
|
|
9
|
+
* setting.json; the model list itself can be re-fetched cheaply on demand.
|
|
10
|
+
*/
|
|
11
|
+
import { getProvider } from './store.js';
|
|
12
|
+
import { validateProvider } from './validation.js';
|
|
13
|
+
const TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
14
|
+
// Cache is pinned to globalThis so module re-instantiation under tsx ESM
|
|
15
|
+
// (e.g. when the same file is imported via different specifier strings) can't
|
|
16
|
+
// fragment it into multiple disjoint maps. Without this, `getProviderModelList`
|
|
17
|
+
// from `dashboard/routes/models.ts` (barrel) and `peekProviderModelList` from
|
|
18
|
+
// `dashboard/routes/agents.ts` (also barrel) were writing/reading two
|
|
19
|
+
// different Map instances, making the peek perpetually miss.
|
|
20
|
+
// Cache pinned to globalThis so module re-instantiation under tsx ESM
|
|
21
|
+
// (where the same file imported via different specifier strings ends up as
|
|
22
|
+
// separate ESM module instances) can't fragment it into multiple disjoint
|
|
23
|
+
// maps. Without this, `getProviderModelList` from `dashboard/routes/models.ts`
|
|
24
|
+
// and `peekProviderModelList` from `dashboard/routes/agents.ts` would be
|
|
25
|
+
// writing/reading two different Map instances and the peek would
|
|
26
|
+
// perpetually miss.
|
|
27
|
+
const GLOBAL_KEY = Symbol.for('pikiloop.providerModelsCache');
|
|
28
|
+
const _existing = globalThis[GLOBAL_KEY];
|
|
29
|
+
const cache = _existing || new Map();
|
|
30
|
+
if (!_existing)
|
|
31
|
+
globalThis[GLOBAL_KEY] = cache;
|
|
32
|
+
function isFresh(entry, provider) {
|
|
33
|
+
if (entry.providerUpdatedAt !== provider.updatedAt)
|
|
34
|
+
return false;
|
|
35
|
+
return (Date.now() - entry.fetchedAt) < TTL_MS;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Get the model list for a provider, fetching from /models on cache miss or
|
|
39
|
+
* when the provider config has been updated since the last fetch.
|
|
40
|
+
*/
|
|
41
|
+
export async function getProviderModelList(providerId, opts = {}) {
|
|
42
|
+
const provider = getProvider(providerId);
|
|
43
|
+
if (!provider)
|
|
44
|
+
return null;
|
|
45
|
+
const cached = cache.get(providerId);
|
|
46
|
+
if (!opts.forceRefresh && cached && isFresh(cached, provider)) {
|
|
47
|
+
return {
|
|
48
|
+
models: cached.models,
|
|
49
|
+
modelInfos: cached.modelInfos,
|
|
50
|
+
fetchedAt: cached.fetchedAt,
|
|
51
|
+
fromCache: true,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const result = await validateProvider(provider);
|
|
55
|
+
const entry = {
|
|
56
|
+
models: result.models,
|
|
57
|
+
modelInfos: result.modelInfos,
|
|
58
|
+
fetchedAt: Date.now(),
|
|
59
|
+
providerUpdatedAt: provider.updatedAt,
|
|
60
|
+
};
|
|
61
|
+
cache.set(providerId, entry);
|
|
62
|
+
return {
|
|
63
|
+
models: entry.models,
|
|
64
|
+
modelInfos: entry.modelInfos,
|
|
65
|
+
fetchedAt: entry.fetchedAt,
|
|
66
|
+
fromCache: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Invalidate cached model list (e.g. after a provider edit/delete).
|
|
71
|
+
*/
|
|
72
|
+
export function invalidateProviderModels(providerId) {
|
|
73
|
+
cache.delete(providerId);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Synchronous peek for a single model's cached metadata (context length,
|
|
77
|
+
* pricing). Returns `null` on cache miss or when the entry is stale relative
|
|
78
|
+
* to the provider's `updatedAt` — callers should treat that as "unknown" and
|
|
79
|
+
* fall back to whatever the agent CLI reports. Pair with
|
|
80
|
+
* `prefetchProviderModels` to populate the cache lazily for the next call.
|
|
81
|
+
*/
|
|
82
|
+
export function peekProviderModelInfo(providerId, modelId) {
|
|
83
|
+
const provider = getProvider(providerId);
|
|
84
|
+
if (!provider)
|
|
85
|
+
return null;
|
|
86
|
+
const entry = cache.get(providerId);
|
|
87
|
+
if (!entry || !isFresh(entry, provider))
|
|
88
|
+
return null;
|
|
89
|
+
return entry.modelInfos.find(info => info.id === modelId) ?? null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Synchronous peek for the full cached model list of a provider. Returns
|
|
93
|
+
* `null` on cache miss / stale entry — callers should fall back and let a
|
|
94
|
+
* background refresh populate it (`prefetchProviderModels`).
|
|
95
|
+
*/
|
|
96
|
+
export function peekProviderModelList(providerId) {
|
|
97
|
+
const provider = getProvider(providerId);
|
|
98
|
+
if (!provider)
|
|
99
|
+
return null;
|
|
100
|
+
const entry = cache.get(providerId);
|
|
101
|
+
if (!entry || !isFresh(entry, provider))
|
|
102
|
+
return null;
|
|
103
|
+
return entry.modelInfos;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Fire-and-forget cache fill. Safe to call repeatedly: no-ops when the cache
|
|
107
|
+
* is already fresh, otherwise triggers a single in-flight fetch and discards
|
|
108
|
+
* the result (the next sync peek will see the populated cache).
|
|
109
|
+
*/
|
|
110
|
+
export function prefetchProviderModels(providerId) {
|
|
111
|
+
void getProviderModelList(providerId).catch(() => { });
|
|
112
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence for Provider / Profile / activeProfileByAgent.
|
|
3
|
+
* Reads and writes the `models` section of ~/.pikiloop/setting.json.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { loadUserConfig, saveUserConfig } from '../core/config/user-config.js';
|
|
7
|
+
import { persistSecret, forgetSecret } from '../core/secrets/index.js';
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Read helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
function getModelLayer() {
|
|
12
|
+
const config = loadUserConfig();
|
|
13
|
+
return config.models || {};
|
|
14
|
+
}
|
|
15
|
+
function writeModelLayer(layer) {
|
|
16
|
+
const config = loadUserConfig();
|
|
17
|
+
saveUserConfig({ ...config, models: layer });
|
|
18
|
+
}
|
|
19
|
+
export function listProviders() {
|
|
20
|
+
const layer = getModelLayer();
|
|
21
|
+
const map = layer.providers || {};
|
|
22
|
+
return Object.values(map).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
23
|
+
}
|
|
24
|
+
export function getProvider(id) {
|
|
25
|
+
const layer = getModelLayer();
|
|
26
|
+
return layer.providers?.[id] || null;
|
|
27
|
+
}
|
|
28
|
+
export async function addProvider(input) {
|
|
29
|
+
if (!input.apiKey && !input.credentialRef) {
|
|
30
|
+
throw new Error('addProvider requires either apiKey or credentialRef');
|
|
31
|
+
}
|
|
32
|
+
const id = randomUUID();
|
|
33
|
+
const credential = input.credentialRef
|
|
34
|
+
? input.credentialRef
|
|
35
|
+
: await persistSecret(`provider/${id}`, input.apiKey);
|
|
36
|
+
const now = new Date().toISOString();
|
|
37
|
+
const provider = {
|
|
38
|
+
id,
|
|
39
|
+
kind: input.kind,
|
|
40
|
+
name: input.name.trim(),
|
|
41
|
+
baseURL: input.baseURL.trim().replace(/\/+$/, ''),
|
|
42
|
+
credential,
|
|
43
|
+
extraHeaders: input.extraHeaders && Object.keys(input.extraHeaders).length ? input.extraHeaders : undefined,
|
|
44
|
+
validation: null,
|
|
45
|
+
createdAt: now,
|
|
46
|
+
updatedAt: now,
|
|
47
|
+
};
|
|
48
|
+
const layer = getModelLayer();
|
|
49
|
+
const providers = { ...(layer.providers || {}) };
|
|
50
|
+
providers[id] = provider;
|
|
51
|
+
writeModelLayer({ ...layer, providers });
|
|
52
|
+
return provider;
|
|
53
|
+
}
|
|
54
|
+
export async function updateProvider(id, patch) {
|
|
55
|
+
const layer = getModelLayer();
|
|
56
|
+
const providers = { ...(layer.providers || {}) };
|
|
57
|
+
const existing = providers[id];
|
|
58
|
+
if (!existing)
|
|
59
|
+
throw new Error(`Provider not found: ${id}`);
|
|
60
|
+
let credential = existing.credential;
|
|
61
|
+
if (patch.credentialRef)
|
|
62
|
+
credential = patch.credentialRef;
|
|
63
|
+
else if (patch.apiKey !== undefined) {
|
|
64
|
+
if (existing.credential.source === 'keychain') {
|
|
65
|
+
// overwrite same keychain slot
|
|
66
|
+
credential = await persistSecret(existing.credential.account, patch.apiKey);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// upgrade from inline/env/command to keychain
|
|
70
|
+
credential = await persistSecret(`provider/${id}`, patch.apiKey);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const next = {
|
|
74
|
+
...existing,
|
|
75
|
+
name: patch.name?.trim() ?? existing.name,
|
|
76
|
+
baseURL: patch.baseURL?.trim().replace(/\/+$/, '') ?? existing.baseURL,
|
|
77
|
+
credential,
|
|
78
|
+
extraHeaders: patch.extraHeaders === null
|
|
79
|
+
? undefined
|
|
80
|
+
: patch.extraHeaders ?? existing.extraHeaders,
|
|
81
|
+
validation: patch.apiKey !== undefined || patch.credentialRef ? null : existing.validation,
|
|
82
|
+
updatedAt: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
providers[id] = next;
|
|
85
|
+
writeModelLayer({ ...layer, providers });
|
|
86
|
+
return next;
|
|
87
|
+
}
|
|
88
|
+
export async function removeProvider(id) {
|
|
89
|
+
const layer = getModelLayer();
|
|
90
|
+
const providers = { ...(layer.providers || {}) };
|
|
91
|
+
const existing = providers[id];
|
|
92
|
+
if (!existing)
|
|
93
|
+
return false;
|
|
94
|
+
delete providers[id];
|
|
95
|
+
// also drop any profiles bound to this provider
|
|
96
|
+
const profiles = { ...(layer.profiles || {}) };
|
|
97
|
+
for (const [pid, prof] of Object.entries(profiles)) {
|
|
98
|
+
if (prof.providerId === id)
|
|
99
|
+
delete profiles[pid];
|
|
100
|
+
}
|
|
101
|
+
// and any active bindings
|
|
102
|
+
const bindings = { ...(layer.activeProfileByAgent || {}) };
|
|
103
|
+
for (const agentId of Object.keys(bindings)) {
|
|
104
|
+
const profileId = bindings[agentId];
|
|
105
|
+
if (profileId && !profiles[profileId])
|
|
106
|
+
bindings[agentId] = null;
|
|
107
|
+
}
|
|
108
|
+
writeModelLayer({ ...layer, providers, profiles, activeProfileByAgent: bindings });
|
|
109
|
+
await forgetSecret(existing.credential).catch(() => { });
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
export function setProviderValidation(id, status) {
|
|
113
|
+
const layer = getModelLayer();
|
|
114
|
+
const providers = { ...(layer.providers || {}) };
|
|
115
|
+
const existing = providers[id];
|
|
116
|
+
if (!existing)
|
|
117
|
+
return;
|
|
118
|
+
providers[id] = { ...existing, validation: status, updatedAt: new Date().toISOString() };
|
|
119
|
+
writeModelLayer({ ...layer, providers });
|
|
120
|
+
}
|
|
121
|
+
export function listProfiles() {
|
|
122
|
+
const layer = getModelLayer();
|
|
123
|
+
return Object.values(layer.profiles || {}).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
124
|
+
}
|
|
125
|
+
export function getProfile(id) {
|
|
126
|
+
const layer = getModelLayer();
|
|
127
|
+
return layer.profiles?.[id] || null;
|
|
128
|
+
}
|
|
129
|
+
function defaultProfileName(_providerName, modelId, _effort) {
|
|
130
|
+
// Keep the auto-generated label short: the brand icon already carries the
|
|
131
|
+
// provider, and effort lives in its own pill on the card. Just the model id
|
|
132
|
+
// gives the cleanest tile + IM list, with the user free to override.
|
|
133
|
+
return modelId;
|
|
134
|
+
}
|
|
135
|
+
export function addProfile(input) {
|
|
136
|
+
const layer = getModelLayer();
|
|
137
|
+
const provider = layer.providers?.[input.providerId];
|
|
138
|
+
if (!provider)
|
|
139
|
+
throw new Error(`Provider not found: ${input.providerId}`);
|
|
140
|
+
const id = randomUUID();
|
|
141
|
+
const now = new Date().toISOString();
|
|
142
|
+
const profile = {
|
|
143
|
+
id,
|
|
144
|
+
name: input.name?.trim() || defaultProfileName(provider.name, input.modelId, input.effort),
|
|
145
|
+
providerId: input.providerId,
|
|
146
|
+
modelId: input.modelId.trim(),
|
|
147
|
+
effort: input.effort ?? null,
|
|
148
|
+
maxOutputTokens: input.maxOutputTokens ?? null,
|
|
149
|
+
extras: input.extras,
|
|
150
|
+
createdAt: now,
|
|
151
|
+
updatedAt: now,
|
|
152
|
+
};
|
|
153
|
+
const profiles = { ...(layer.profiles || {}) };
|
|
154
|
+
profiles[id] = profile;
|
|
155
|
+
writeModelLayer({ ...layer, profiles });
|
|
156
|
+
return profile;
|
|
157
|
+
}
|
|
158
|
+
export function updateProfile(id, patch) {
|
|
159
|
+
const layer = getModelLayer();
|
|
160
|
+
const profiles = { ...(layer.profiles || {}) };
|
|
161
|
+
const existing = profiles[id];
|
|
162
|
+
if (!existing)
|
|
163
|
+
throw new Error(`Profile not found: ${id}`);
|
|
164
|
+
const next = {
|
|
165
|
+
...existing,
|
|
166
|
+
...('name' in patch && patch.name !== undefined ? { name: patch.name.trim() || existing.name } : {}),
|
|
167
|
+
...('modelId' in patch && patch.modelId !== undefined ? { modelId: patch.modelId.trim() } : {}),
|
|
168
|
+
...('effort' in patch ? { effort: patch.effort ?? null } : {}),
|
|
169
|
+
...('maxOutputTokens' in patch ? { maxOutputTokens: patch.maxOutputTokens ?? null } : {}),
|
|
170
|
+
...('extras' in patch ? { extras: patch.extras } : {}),
|
|
171
|
+
updatedAt: new Date().toISOString(),
|
|
172
|
+
};
|
|
173
|
+
profiles[id] = next;
|
|
174
|
+
writeModelLayer({ ...layer, profiles });
|
|
175
|
+
return next;
|
|
176
|
+
}
|
|
177
|
+
export function removeProfile(id) {
|
|
178
|
+
const layer = getModelLayer();
|
|
179
|
+
const profiles = { ...(layer.profiles || {}) };
|
|
180
|
+
if (!profiles[id])
|
|
181
|
+
return false;
|
|
182
|
+
delete profiles[id];
|
|
183
|
+
const bindings = { ...(layer.activeProfileByAgent || {}) };
|
|
184
|
+
for (const agentId of Object.keys(bindings)) {
|
|
185
|
+
if (bindings[agentId] === id)
|
|
186
|
+
bindings[agentId] = null;
|
|
187
|
+
}
|
|
188
|
+
writeModelLayer({ ...layer, profiles, activeProfileByAgent: bindings });
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Active binding
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
export function getActiveProfileId(agentId) {
|
|
195
|
+
const layer = getModelLayer();
|
|
196
|
+
return layer.activeProfileByAgent?.[agentId] || null;
|
|
197
|
+
}
|
|
198
|
+
export function getActiveProfile(agentId) {
|
|
199
|
+
const id = getActiveProfileId(agentId);
|
|
200
|
+
if (!id)
|
|
201
|
+
return null;
|
|
202
|
+
return getProfile(id);
|
|
203
|
+
}
|
|
204
|
+
export function setActiveProfile(agentId, profileId) {
|
|
205
|
+
const layer = getModelLayer();
|
|
206
|
+
if (profileId && !layer.profiles?.[profileId]) {
|
|
207
|
+
throw new Error(`Profile not found: ${profileId}`);
|
|
208
|
+
}
|
|
209
|
+
const bindings = { ...(layer.activeProfileByAgent || {}) };
|
|
210
|
+
bindings[agentId] = profileId;
|
|
211
|
+
writeModelLayer({ ...layer, activeProfileByAgent: bindings });
|
|
212
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pikiloop "Model" layer — Provider/Profile data model.
|
|
3
|
+
*
|
|
4
|
+
* Provider = a configured endpoint + credential reference (e.g. "OpenRouter
|
|
5
|
+
* personal", "Anthropic direct"). Holds baseURL + extra headers.
|
|
6
|
+
* Profile = a Provider + modelId + tuning params (effort, max output).
|
|
7
|
+
* This is the unit a user binds to an agent.
|
|
8
|
+
*
|
|
9
|
+
* Profiles are the smallest selectable unit because Hermes (and most coding
|
|
10
|
+
* agents) only support one model per session. Binding `activeProfileByAgent`
|
|
11
|
+
* tells the driver which Profile to use when spawning that agent.
|
|
12
|
+
*/
|
|
13
|
+
export {};
|