loopengine 0.1.73 → 0.1.75
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/dist/core/discover-agents.d.ts +0 -20
- package/dist/core/discover-agents.js +37 -12
- package/dist/core/secret-store.js +9 -3
- package/dist/core/trusted-proxy.d.ts +6 -0
- package/dist/core/version.d.ts +1 -0
- package/dist/core/version.js +24 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/web/console-shell.js +24 -1
- package/dist/web/dev-ui-styles.js +5 -0
- package/dist/web/global-config-page.js +107 -4
- package/dist/web/global-config.d.ts +14 -0
- package/dist/web/global-config.js +32 -0
- package/package.json +3 -1
|
@@ -5,26 +5,6 @@ export interface AgentModule {
|
|
|
5
5
|
/** Factory, not a shared instance — a fresh ModelCall per request/session. */
|
|
6
6
|
createModelCall: () => ModelCall;
|
|
7
7
|
}
|
|
8
|
-
/** Builds a lazy, memoized createModelCall from AgentConfig.model, for a
|
|
9
|
-
* module that doesn't export its own — see that field's own doc comment.
|
|
10
|
-
* The provider's SDK module is imported here, eagerly, but that's cheap
|
|
11
|
-
* and safe with no API key at all: importing model-calls/*.ts only
|
|
12
|
-
* defines functions, it never constructs a client at module top level
|
|
13
|
-
* (see e.g. createAnthropicModelCall's own body) — so this can't fail
|
|
14
|
-
* discovery the way eagerly building the actual client would. The real
|
|
15
|
-
* client is only built the first time the returned function is actually
|
|
16
|
-
* called, and reused after that, the same "don't crash the whole server
|
|
17
|
-
* over one agent's missing API key, don't rebuild per call" reasoning
|
|
18
|
-
* agents/customer-service/index.ts's own hand-written createModelCall
|
|
19
|
-
* used before this existed.
|
|
20
|
-
*
|
|
21
|
-
* Exported so a caller updating an *already-registered* agent's model
|
|
22
|
-
* (see agent-file-admin.ts's editAgentFile, applied live via
|
|
23
|
-
* core/agent-registry.ts's updateAgent) can rebuild just this one closure
|
|
24
|
-
* without re-importing the whole module — Node's ESM loader caches an
|
|
25
|
-
* already-imported module forever, so there's no supported way to
|
|
26
|
-
* re-import a changed agent file's own createModelCall directly; this is
|
|
27
|
-
* the one piece of it that's meaningfully regenerable on its own. */
|
|
28
8
|
export declare function synthesizeCreateModelCall(model: AgentModelConfig): Promise<() => ModelCall>;
|
|
29
9
|
/** Imports one agent module at `path` and resolves it to an AgentModule —
|
|
30
10
|
* the single-file version of what discoverAgents does over a whole
|
|
@@ -37,30 +37,55 @@ function isRawAgentModule(mod) {
|
|
|
37
37
|
* already-imported module forever, so there's no supported way to
|
|
38
38
|
* re-import a changed agent file's own createModelCall directly; this is
|
|
39
39
|
* the one piece of it that's meaningfully regenerable on its own. */
|
|
40
|
+
// The env var each provider's key defaults to (same names as
|
|
41
|
+
// web/global-config.ts's Models page).
|
|
42
|
+
const PROVIDER_KEY_ENV = {
|
|
43
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
44
|
+
openai: 'OPENAI_API_KEY',
|
|
45
|
+
deepseek: 'DEEPSEEK_API_KEY',
|
|
46
|
+
kimi: 'MOONSHOT_API_KEY',
|
|
47
|
+
glm: 'GLM_API_KEY',
|
|
48
|
+
gemini: 'GEMINI_API_KEY',
|
|
49
|
+
};
|
|
40
50
|
export async function synthesizeCreateModelCall(model) {
|
|
41
|
-
let
|
|
51
|
+
let build;
|
|
42
52
|
if (model.provider === 'anthropic') {
|
|
43
53
|
const { createAnthropicModelCall } = await import('./model-calls/anthropic-model-call.js');
|
|
44
|
-
|
|
54
|
+
build = () => createAnthropicModelCall(model);
|
|
45
55
|
}
|
|
46
|
-
if (model.provider === 'openai') {
|
|
56
|
+
else if (model.provider === 'openai') {
|
|
47
57
|
const { createOpenAIModelCall } = await import('./model-calls/openai-model-call.js');
|
|
48
|
-
|
|
58
|
+
build = () => createOpenAIModelCall(model);
|
|
49
59
|
}
|
|
50
|
-
if (model.provider === 'deepseek') {
|
|
60
|
+
else if (model.provider === 'deepseek') {
|
|
51
61
|
const { createDeepSeekModelCall } = await import('./model-calls/deepseek-model-call.js');
|
|
52
|
-
|
|
62
|
+
build = () => createDeepSeekModelCall(model);
|
|
53
63
|
}
|
|
54
|
-
if (model.provider === 'kimi') {
|
|
64
|
+
else if (model.provider === 'kimi') {
|
|
55
65
|
const { createKimiModelCall } = await import('./model-calls/kimi-model-call.js');
|
|
56
|
-
|
|
66
|
+
build = () => createKimiModelCall(model);
|
|
57
67
|
}
|
|
58
|
-
if (model.provider === 'glm') {
|
|
68
|
+
else if (model.provider === 'glm') {
|
|
59
69
|
const { createGlmModelCall } = await import('./model-calls/glm-model-call.js');
|
|
60
|
-
|
|
70
|
+
build = () => createGlmModelCall(model);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
const { createGeminiModelCall } = await import('./model-calls/gemini-model-call.js');
|
|
74
|
+
build = () => createGeminiModelCall(model);
|
|
61
75
|
}
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
// Rebuilt when the key it was built with changes, so a key saved or
|
|
77
|
+
// replaced on the console's Models page applies to the next call
|
|
78
|
+
// without a restart. An explicit `apiKey` in the config never changes.
|
|
79
|
+
let cached;
|
|
80
|
+
let cachedKey;
|
|
81
|
+
return () => {
|
|
82
|
+
const key = model.apiKey ?? process.env[PROVIDER_KEY_ENV[model.provider]];
|
|
83
|
+
if (!cached || key !== cachedKey) {
|
|
84
|
+
cached = build();
|
|
85
|
+
cachedKey = key;
|
|
86
|
+
}
|
|
87
|
+
return cached;
|
|
88
|
+
};
|
|
64
89
|
}
|
|
65
90
|
/** Resolves one directory entry to an agent module's path, or undefined if
|
|
66
91
|
* this entry isn't one — a `.ts`/`.js` file directly (the common case), or
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// self-hosted install has always used. A host that keeps secrets
|
|
6
6
|
// somewhere else (a hosted control plane's vault, Vault, a cloud secret
|
|
7
7
|
// manager) swaps it in once at startup with setSecretStore().
|
|
8
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { parseEnv } from 'node:util';
|
|
11
11
|
export function agentEnvFilePath(dir) {
|
|
@@ -72,7 +72,13 @@ function upsertEnvFileLine(path, name, value) {
|
|
|
72
72
|
else {
|
|
73
73
|
lines[existingIndex] = newLine;
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
writeEnvFile(path, lines.join('\n') + '\n');
|
|
76
|
+
}
|
|
77
|
+
/** .env files hold secrets: owner-only (0600), including one that
|
|
78
|
+
* already existed with wider permissions. */
|
|
79
|
+
function writeEnvFile(path, content) {
|
|
80
|
+
writeFileSync(path, content, { mode: 0o600 });
|
|
81
|
+
chmodSync(path, 0o600);
|
|
76
82
|
}
|
|
77
83
|
/** Removes NAME= from the .env file at `path`, if present. Only
|
|
78
84
|
* rewrites the file when something actually changed — a no-op removal
|
|
@@ -84,7 +90,7 @@ function removeEnvFileLine(path, name) {
|
|
|
84
90
|
const lines = readFileSync(path, 'utf8').split('\n');
|
|
85
91
|
const filtered = lines.filter((line) => !line.startsWith(`${name}=`));
|
|
86
92
|
if (filtered.length !== lines.length)
|
|
87
|
-
|
|
93
|
+
writeEnvFile(path, filtered.join('\n').replace(/\n*$/, '\n'));
|
|
88
94
|
}
|
|
89
95
|
/** The project's .env for shared values, agents/<name>/.env for each
|
|
90
96
|
* agent's own — the default, and what every self-hosted install uses. */
|
|
@@ -4,6 +4,12 @@ export interface ProxyIdentity {
|
|
|
4
4
|
sub: string;
|
|
5
5
|
workspace?: string;
|
|
6
6
|
role: string;
|
|
7
|
+
/** The person's email, when the proxy knows it (shown in the console's
|
|
8
|
+
* sidebar). */
|
|
9
|
+
email?: string;
|
|
10
|
+
/** Where the person manages this workspace on the host, shown as the
|
|
11
|
+
* console's "Dashboard" link. */
|
|
12
|
+
dashboardUrl?: string;
|
|
7
13
|
/** Expiry, Unix seconds. */
|
|
8
14
|
exp: number;
|
|
9
15
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function loopengineVersion(): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The installed loopengine version, for the console's Settings section.
|
|
2
|
+
// package.json sits one level above core/ in this repo, and two above
|
|
3
|
+
// dist/core/ in the published package (dist/package.json is only the
|
|
4
|
+
// module-resolution stub the build writes, with no version).
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
let cached;
|
|
7
|
+
export function loopengineVersion() {
|
|
8
|
+
if (cached)
|
|
9
|
+
return cached;
|
|
10
|
+
for (const rel of ['../package.json', '../../package.json']) {
|
|
11
|
+
const url = new URL(rel, import.meta.url);
|
|
12
|
+
if (!existsSync(url))
|
|
13
|
+
continue;
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(url, 'utf8'));
|
|
16
|
+
if (pkg.name === 'loopengine' && pkg.version)
|
|
17
|
+
return (cached = pkg.version);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Not loopengine's package.json; try the next.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return (cached = 'unknown');
|
|
24
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -77,7 +77,8 @@ export { installAbility, upgradeAbility, removeAbility, listInstalledAbilities,
|
|
|
77
77
|
export type { InstalledAbilityRecord, AbilityEnvDecl, UpgradeFileResult } from './bin/ability-manager.js';
|
|
78
78
|
export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
|
|
79
79
|
export type { PublicAbilitySearchResult } from './web/abilities-admin.js';
|
|
80
|
-
export { describeModelProviders, describeGateways } from './web/global-config.js';
|
|
80
|
+
export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError } from './web/global-config.js';
|
|
81
|
+
export { loopengineVersion } from './core/version.js';
|
|
81
82
|
export type { ModelProviderInfo, AgentModelUsage, ModelsView, GatewayProviderInfo, GatewaysView } from './web/global-config.js';
|
|
82
83
|
export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
|
|
83
84
|
export { scaffoldAgent, scaffoldSubagent, AgentNameError, AgentExistsError, AgentNotFoundError, AgentModelError } from './bin/cli.js';
|
package/dist/index.js
CHANGED
|
@@ -68,6 +68,7 @@ export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, setAgentEnvVar, unsetAgent
|
|
|
68
68
|
export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
|
|
69
69
|
export { installAbility, upgradeAbility, removeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
|
|
70
70
|
export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
|
|
71
|
-
export { describeModelProviders, describeGateways } from './web/global-config.js';
|
|
71
|
+
export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError } from './web/global-config.js';
|
|
72
|
+
export { loopengineVersion } from './core/version.js';
|
|
72
73
|
export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
|
|
73
74
|
export { scaffoldAgent, scaffoldSubagent, AgentNameError, AgentExistsError, AgentNotFoundError, AgentModelError } from './bin/cli.js';
|
|
@@ -25,6 +25,8 @@ export const consoleSidebarHtml = `<aside class="le-sidebar" aria-label="Console
|
|
|
25
25
|
<nav class="le-foot" aria-label="Settings">
|
|
26
26
|
<a href="/config?section=models" data-le-nav="models">Models</a>
|
|
27
27
|
<a href="/config?section=gateways" data-le-nav="integrations">Integrations</a>
|
|
28
|
+
<a href="/config?section=settings" data-le-nav="settings">Settings</a>
|
|
29
|
+
<div class="le-who" id="leWho" hidden></div>
|
|
28
30
|
</nav>
|
|
29
31
|
</aside>`;
|
|
30
32
|
export const consoleShellScript = `<script>
|
|
@@ -156,12 +158,33 @@ export const consoleShellScript = `<script>
|
|
|
156
158
|
renderList();
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
var
|
|
161
|
+
var section = params.get('section');
|
|
162
|
+
var foot = page === 'config' ? (section === 'gateways' ? 'integrations' : section === 'settings' ? 'settings' : 'models') : '';
|
|
160
163
|
var footLinks = document.querySelectorAll('[data-le-nav]');
|
|
161
164
|
for (var i = 0; i < footLinks.length; i++) {
|
|
162
165
|
if (footLinks[i].getAttribute('data-le-nav') === foot) footLinks[i].setAttribute('aria-current', 'page');
|
|
163
166
|
}
|
|
164
167
|
|
|
168
|
+
// Who is signed in: the email and a link back to the dashboard when a
|
|
169
|
+
// hosting router signs requests, otherwise how this server is protected.
|
|
170
|
+
var who = document.getElementById('leWho');
|
|
171
|
+
if (who) {
|
|
172
|
+
fetch('/config/settings')
|
|
173
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
174
|
+
.then(function (s) {
|
|
175
|
+
if (!s || !s.signIn) return;
|
|
176
|
+
var si = s.signIn;
|
|
177
|
+
if (si.mode === 'hosted') {
|
|
178
|
+
who.innerHTML = '<span class="le-who-name" title="' + esc(si.email || '') + '">' + esc(si.email || 'Signed in') + '</span>' +
|
|
179
|
+
(si.dashboardUrl ? '<a href="' + esc(si.dashboardUrl) + '">Dashboard</a>' : '');
|
|
180
|
+
} else {
|
|
181
|
+
who.innerHTML = '<span class="le-who-name">' + (si.mode === 'basic' ? 'Signed in with Basic Auth' : 'No sign-in configured') + '</span>';
|
|
182
|
+
}
|
|
183
|
+
who.hidden = false;
|
|
184
|
+
})
|
|
185
|
+
.catch(function () {});
|
|
186
|
+
}
|
|
187
|
+
|
|
165
188
|
document.addEventListener('click', function (ev) {
|
|
166
189
|
if (!app) return;
|
|
167
190
|
if (ev.target.closest('[data-le-menu]')) { app.classList.toggle('nav-open'); return; }
|
|
@@ -130,6 +130,11 @@ export const devUiCss = `
|
|
|
130
130
|
.le-foot a { padding: 7px 8px; border-radius: 6px; color: var(--ink-muted); text-decoration: none; }
|
|
131
131
|
.le-foot a:hover { background: var(--surface-2); color: var(--ink); }
|
|
132
132
|
.le-foot a[aria-current="page"] { background: var(--accent-soft); color: var(--ink); }
|
|
133
|
+
.le-who { padding: 10px 8px 4px; font-size: 12px; color: var(--ink-faint); display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; }
|
|
134
|
+
.le-who[hidden] { display: none; }
|
|
135
|
+
.le-who-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
136
|
+
.le-foot .le-who a { padding: 0; flex: none; color: var(--accent-ink); }
|
|
137
|
+
.le-foot .le-who a:hover { background: none; text-decoration: underline; }
|
|
133
138
|
.le-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
|
|
134
139
|
.le-scroll { flex: 1; min-height: 0; overflow-y: auto; }
|
|
135
140
|
.le-page { padding: 24px; max-width: 1100px; display: grid; gap: 24px; }
|
|
@@ -62,6 +62,11 @@ export const globalConfigPageHtml = `<!doctype html>
|
|
|
62
62
|
border-color: var(--bad);
|
|
63
63
|
}
|
|
64
64
|
.delete-btn:hover { background: var(--bad-soft); }
|
|
65
|
+
form.key-form { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 0; }
|
|
66
|
+
form.key-form input[type=password] { width: 240px; max-width: 100%; font-family: ui-monospace, monospace; font-size: 12px; }
|
|
67
|
+
form.key-form button { font-size: 12px; }
|
|
68
|
+
.key-msg { font-size: 11px; margin-top: 4px; }
|
|
69
|
+
.key-msg.bad { color: var(--bad); }
|
|
65
70
|
</style>
|
|
66
71
|
</head>
|
|
67
72
|
<body data-le-page="config">
|
|
@@ -70,7 +75,7 @@ ${consoleSidebarHtml}
|
|
|
70
75
|
<main class="le-main">
|
|
71
76
|
<div class="section-panel" data-section-panel="models" style="display:contents">
|
|
72
77
|
<div class="le-head plain"><div class="le-title"><button type="button" class="le-menu-btn" data-le-menu aria-label="Open navigation">☰</button>
|
|
73
|
-
<div><h1>Models</h1><p>
|
|
78
|
+
<div><h1>Models</h1><p>API keys for each model provider, and which agents use each one. A saved key is stored on this server and never shown again.</p></div></div></div>
|
|
74
79
|
<div class="le-scroll"><div class="le-page" style="display:block"><div id="modelsContent"><p class="hint">Loading…</p></div></div></div>
|
|
75
80
|
</div>
|
|
76
81
|
<div class="section-panel" data-section-panel="gateways" style="display:none">
|
|
@@ -78,6 +83,11 @@ ${consoleSidebarHtml}
|
|
|
78
83
|
<div><h1>Integrations</h1><p>Outside tool sources (such as Composio) that agents can add tools from.</p></div></div></div>
|
|
79
84
|
<div class="le-scroll"><div class="le-page" style="display:block"><div id="gatewaysContent"><p class="hint">Loading…</p></div></div></div>
|
|
80
85
|
</div>
|
|
86
|
+
<div class="section-panel" data-section-panel="settings" style="display:none">
|
|
87
|
+
<div class="le-head plain"><div class="le-title"><button type="button" class="le-menu-btn" data-le-menu aria-label="Open navigation">☰</button>
|
|
88
|
+
<div><h1>Settings</h1><p>How this server is set up. These come from its deployment, not from this page.</p></div></div></div>
|
|
89
|
+
<div class="le-scroll"><div class="le-page" style="display:block"><div id="settingsContent"><p class="hint">Loading…</p></div></div></div>
|
|
90
|
+
</div>
|
|
81
91
|
</main>
|
|
82
92
|
</div>
|
|
83
93
|
${consoleShellScript}
|
|
@@ -102,9 +112,19 @@ ${consoleShellScript}
|
|
|
102
112
|
|
|
103
113
|
function renderModels(data) {
|
|
104
114
|
var providerRows = data.providers.map(function (p) {
|
|
115
|
+
// Write-only: the field is always empty; the server never sends a
|
|
116
|
+
// key back, only whether one is set.
|
|
117
|
+
var keyCell = data.canSave
|
|
118
|
+
? '<form class="key-form" data-provider="' + escapeHtml(p.provider) + '">' +
|
|
119
|
+
'<input type="password" name="key" autocomplete="off" spellcheck="false" placeholder="' + (p.configured ? 'Paste a new key to replace it' : 'Paste API key') + '" aria-label="' + escapeHtml(p.provider) + ' API key">' +
|
|
120
|
+
'<button type="submit">Save</button>' +
|
|
121
|
+
(p.configured ? '<button type="button" class="delete-btn" data-remove-key="' + escapeHtml(p.provider) + '">Remove</button>' : '') +
|
|
122
|
+
'</form><div class="key-msg" data-key-msg="' + escapeHtml(p.provider) + '"></div>'
|
|
123
|
+
: '<span class="hint">Set LOOPENGINE_ADMIN_AUTH to manage keys here, or put ' + escapeHtml(p.envVar) + ' in .env.</span>';
|
|
105
124
|
return '<tr><td><code>' + escapeHtml(p.provider) + '</code></td>' +
|
|
106
125
|
'<td><code>' + escapeHtml(p.envVar) + '</code></td>' +
|
|
107
|
-
'<td>' + badge(p.configured, 'configured', 'not set') + '</td
|
|
126
|
+
'<td>' + badge(p.configured, 'configured', 'not set') + '</td>' +
|
|
127
|
+
'<td>' + keyCell + '</td></tr>';
|
|
108
128
|
}).join('');
|
|
109
129
|
|
|
110
130
|
var agentRows = data.agents.length
|
|
@@ -116,13 +136,59 @@ ${consoleShellScript}
|
|
|
116
136
|
: '<tr><td colspan="3"><p class="hint">No agents with a resolved model config (a custom createModelCall has nothing to report here).</p></td></tr>';
|
|
117
137
|
|
|
118
138
|
return '<section><h3>Providers</h3>' +
|
|
119
|
-
'<table><thead><tr><th>Provider</th><th>Env var</th><th>Status</th></tr></thead><tbody>' + providerRows + '</tbody></table>' +
|
|
139
|
+
'<table><thead><tr><th>Provider</th><th>Env var</th><th>Status</th><th>Key</th></tr></thead><tbody>' + providerRows + '</tbody></table>' +
|
|
120
140
|
'</section>' +
|
|
121
141
|
'<section><h3>Agents</h3>' +
|
|
122
142
|
'<table><thead><tr><th>Agent</th><th>Provider</th><th>Model</th></tr></thead><tbody>' + agentRows + '</tbody></table>' +
|
|
123
143
|
'</section>';
|
|
124
144
|
}
|
|
125
145
|
|
|
146
|
+
function keyMessage(provider, text, bad) {
|
|
147
|
+
var el = document.querySelector('[data-key-msg="' + provider + '"]');
|
|
148
|
+
if (!el) return;
|
|
149
|
+
el.textContent = text;
|
|
150
|
+
el.className = 'key-msg' + (bad ? ' bad' : '');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function sendKey(provider, method, body) {
|
|
154
|
+
keyMessage(provider, method === 'PUT' ? 'Saving' + String.fromCharCode(8230) : 'Removing' + String.fromCharCode(8230), false);
|
|
155
|
+
return fetch('/config/models/' + encodeURIComponent(provider) + '/key', {
|
|
156
|
+
method: method,
|
|
157
|
+
headers: body ? { 'content-type': 'application/json' } : {},
|
|
158
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
159
|
+
})
|
|
160
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
161
|
+
.then(function (r) {
|
|
162
|
+
if (!r.ok) throw new Error(r.body.error || 'request failed');
|
|
163
|
+
loadModels();
|
|
164
|
+
})
|
|
165
|
+
.catch(function (err) { keyMessage(provider, err.message, true); });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
document.getElementById('modelsContent').addEventListener('submit', function (ev) {
|
|
169
|
+
var form = ev.target.closest('form.key-form');
|
|
170
|
+
if (!form) return;
|
|
171
|
+
ev.preventDefault();
|
|
172
|
+
var input = form.querySelector('input[name=key]');
|
|
173
|
+
var key = input.value.trim();
|
|
174
|
+
if (!key) { keyMessage(form.getAttribute('data-provider'), 'Paste a key first.', true); return; }
|
|
175
|
+
// Cleared at once, so the key doesn't sit in the page while saving.
|
|
176
|
+
input.value = '';
|
|
177
|
+
sendKey(form.getAttribute('data-provider'), 'PUT', { key: key });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
document.getElementById('modelsContent').addEventListener('click', function (ev) {
|
|
181
|
+
var btn = ev.target.closest('[data-remove-key]');
|
|
182
|
+
if (!btn) return;
|
|
183
|
+
var provider = btn.getAttribute('data-remove-key');
|
|
184
|
+
if (btn.getAttribute('data-armed') !== '1') {
|
|
185
|
+
btn.setAttribute('data-armed', '1');
|
|
186
|
+
btn.textContent = 'Remove ' + provider + ' key?';
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
sendKey(provider, 'DELETE');
|
|
190
|
+
});
|
|
191
|
+
|
|
126
192
|
function loadModels() {
|
|
127
193
|
var content = document.getElementById('modelsContent');
|
|
128
194
|
fetch('/config/models')
|
|
@@ -216,6 +282,42 @@ ${consoleShellScript}
|
|
|
216
282
|
});
|
|
217
283
|
}
|
|
218
284
|
|
|
285
|
+
// ---- Settings section ----
|
|
286
|
+
|
|
287
|
+
function renderSettings(d) {
|
|
288
|
+
var si = d.signIn || {};
|
|
289
|
+
var signIn = si.mode === 'hosted'
|
|
290
|
+
? 'Managed by your LoopEngine Cloud account' + (si.email ? ' (' + escapeHtml(si.email) + ')' : '') +
|
|
291
|
+
(si.dashboardUrl ? ' · <a href="' + escapeHtml(si.dashboardUrl) + '">Dashboard</a>' : '')
|
|
292
|
+
: si.mode === 'basic'
|
|
293
|
+
? 'Basic Auth (<code>LOOPENGINE_ADMIN_AUTH</code>)'
|
|
294
|
+
: '<span class="error">None: anyone who can reach this server can use it. Set <code>LOOPENGINE_ADMIN_AUTH</code>.</span>';
|
|
295
|
+
var rows = [
|
|
296
|
+
['Admin sign-in', signIn],
|
|
297
|
+
['LoopEngine version', '<code>' + escapeHtml(d.version) + '</code>'],
|
|
298
|
+
['Environment name', '<code>' + escapeHtml(d.environment) + '</code> <span class="hint">(LOOPENGINE_ENV)</span>'],
|
|
299
|
+
['Public address', d.publicUrl ? '<code>' + escapeHtml(d.publicUrl) + '</code>' : '<span class="hint">Not set (LOOPENGINE_PUBLIC_URL); links in notifications use the address saved with each notifier</span>'],
|
|
300
|
+
['Scheduled tasks', d.scheduler === 'off' ? 'Off on this server (LOOPENGINE_SCHEDULER=off)' : 'Run by this server'],
|
|
301
|
+
['Theme', 'Follows your system'],
|
|
302
|
+
];
|
|
303
|
+
return '<section><table><thead><tr><th>Setting</th><th>Value</th></tr></thead><tbody>' +
|
|
304
|
+
rows.map(function (r) { return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>'; }).join('') +
|
|
305
|
+
'</tbody></table></section>';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function loadSettings() {
|
|
309
|
+
var content = document.getElementById('settingsContent');
|
|
310
|
+
fetch('/config/settings')
|
|
311
|
+
.then(function (r) { return r.json(); })
|
|
312
|
+
.then(function (d) {
|
|
313
|
+
content.innerHTML = renderSettings(d);
|
|
314
|
+
loadedSections.settings = true;
|
|
315
|
+
})
|
|
316
|
+
.catch(function (err) {
|
|
317
|
+
content.innerHTML = '<p class="error">Could not load settings: ' + escapeHtml(err.message) + '</p>';
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
219
321
|
// ---- Section switching ----
|
|
220
322
|
|
|
221
323
|
function switchSection(section) {
|
|
@@ -229,6 +331,7 @@ ${consoleShellScript}
|
|
|
229
331
|
}
|
|
230
332
|
if (section === 'models' && !loadedSections.models) loadModels();
|
|
231
333
|
if (section === 'gateways' && !loadedSections.gateways) loadGateways();
|
|
334
|
+
if (section === 'settings' && !loadedSections.settings) loadSettings();
|
|
232
335
|
}
|
|
233
336
|
|
|
234
337
|
for (var i = 0; i < sectionButtons.length; i++) {
|
|
@@ -240,7 +343,7 @@ ${consoleShellScript}
|
|
|
240
343
|
// real section, same "trust the query param only if it's valid"
|
|
241
344
|
// caution playground.ts's own ?agent= handling already uses.
|
|
242
345
|
var requestedSection = new URLSearchParams(location.search).get('section');
|
|
243
|
-
var initialSection = requestedSection === 'gateways' || requestedSection === 'models' ? requestedSection : currentSection;
|
|
346
|
+
var initialSection = requestedSection === 'gateways' || requestedSection === 'models' || requestedSection === 'settings' ? requestedSection : currentSection;
|
|
244
347
|
switchSection(initialSection);
|
|
245
348
|
})();
|
|
246
349
|
</script>
|
|
@@ -13,6 +13,8 @@ export interface ModelsView {
|
|
|
13
13
|
providers: ModelProviderInfo[];
|
|
14
14
|
agents: AgentModelUsage[];
|
|
15
15
|
}
|
|
16
|
+
export declare class ModelKeyError extends Error {
|
|
17
|
+
}
|
|
16
18
|
/** Every registered agent's resolved model, alongside whether each
|
|
17
19
|
* supported provider actually has credentials available in this
|
|
18
20
|
* process's environment — an operator's first question when a request
|
|
@@ -55,3 +57,15 @@ export interface GatewaysView {
|
|
|
55
57
|
* shown so the Gateways panel isn't silently missing them, not because
|
|
56
58
|
* there's anything to check for them yet. */
|
|
57
59
|
export declare function describeGateways(cliCommand?: string): Promise<GatewaysView>;
|
|
60
|
+
/** The env var a provider's key lives in, or undefined for an unknown
|
|
61
|
+
* provider. */
|
|
62
|
+
export declare function modelKeyEnvVar(provider: string): string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Saves a provider's API key from the console's Models page, through the
|
|
65
|
+
* same secret store as the Environment tab (the project's .env by
|
|
66
|
+
* default), and applies it to this process, so the next model call uses
|
|
67
|
+
* it without a restart. Write-only: nothing here or in describeModelProviders
|
|
68
|
+
* ever returns a key, only whether one is set.
|
|
69
|
+
*/
|
|
70
|
+
export declare function setModelProviderKey(provider: string, key: unknown): Promise<void>;
|
|
71
|
+
export declare function clearModelProviderKey(provider: string): Promise<void>;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { getComposioAuthStatus } from '../core/gateway-tools.js';
|
|
2
|
+
import { setEnvVar, unsetEnvVar } from './env-admin.js';
|
|
3
|
+
export class ModelKeyError extends Error {
|
|
4
|
+
}
|
|
2
5
|
// One entry per provider agent-config.ts's own AgentModelConfig union
|
|
3
6
|
// actually supports (see its own doc comment) — kept here, not derived
|
|
4
7
|
// from that type, since there's no runtime list to introspect a TS
|
|
@@ -70,3 +73,32 @@ export async function describeGateways(cliCommand = 'composio') {
|
|
|
70
73
|
],
|
|
71
74
|
};
|
|
72
75
|
}
|
|
76
|
+
/** The env var a provider's key lives in, or undefined for an unknown
|
|
77
|
+
* provider. */
|
|
78
|
+
export function modelKeyEnvVar(provider) {
|
|
79
|
+
return KNOWN_PROVIDERS.find((p) => p.provider === provider)?.envVar;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Saves a provider's API key from the console's Models page, through the
|
|
83
|
+
* same secret store as the Environment tab (the project's .env by
|
|
84
|
+
* default), and applies it to this process, so the next model call uses
|
|
85
|
+
* it without a restart. Write-only: nothing here or in describeModelProviders
|
|
86
|
+
* ever returns a key, only whether one is set.
|
|
87
|
+
*/
|
|
88
|
+
export async function setModelProviderKey(provider, key) {
|
|
89
|
+
const envVar = modelKeyEnvVar(provider);
|
|
90
|
+
if (!envVar)
|
|
91
|
+
throw new ModelKeyError(`unknown provider '${provider}'`);
|
|
92
|
+
const value = typeof key === 'string' ? key.trim() : '';
|
|
93
|
+
// Keys are single tokens; whitespace inside one is a paste mistake.
|
|
94
|
+
if (value.length < 8 || value.length > 500 || /\s/.test(value)) {
|
|
95
|
+
throw new ModelKeyError('That doesn\'t look like an API key: paste the whole key, with no spaces or line breaks.');
|
|
96
|
+
}
|
|
97
|
+
await setEnvVar(envVar, value);
|
|
98
|
+
}
|
|
99
|
+
export async function clearModelProviderKey(provider) {
|
|
100
|
+
const envVar = modelKeyEnvVar(provider);
|
|
101
|
+
if (!envVar)
|
|
102
|
+
throw new ModelKeyError(`unknown provider '${provider}'`);
|
|
103
|
+
await unsetEnvVar(envVar);
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.75",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
"dist/bin/ability-manager.js",
|
|
27
27
|
"dist/bin/ability-manager.d.ts",
|
|
28
28
|
"dist/core/run-agent.js",
|
|
29
|
+
"dist/core/version.js",
|
|
30
|
+
"dist/core/version.d.ts",
|
|
29
31
|
"dist/core/run-agent.d.ts",
|
|
30
32
|
"dist/core/known-urls.js",
|
|
31
33
|
"dist/core/known-urls.d.ts",
|