openvibers/openvibe.codes Commits

Agents: your AI keys from the one place, Your AI (#212)

OP OpenVibers committed 13 days ago · 8558ea2947 · 4 files, +58 −20

The Agents page lists the person's own keys from OpenVibe.AI's Your AI
(GET /api/v1/me/credentials with their own token: provider, the last four
characters, working or not) and links to ai.openvibe.services/you to add or
manage them: Codes' tasks run on the person's behalf, so AI uses those keys
first. Codes no longer has its own add-a-key form; keys saved in Codes before
stay listed (Test, Remove) with a note to move them.

Changes

ddiff --git a/server/domain/connections.js b/server/domain/connections.js
iindex 9b6adc8..a90ea26 100644
---- a/server/domain/connections.js
++++ b/server/domain/connections.js
@@@ -18,6 +18,12 @@
* put(subject, provider, body) → the connection as AI stored it (the hint, never the key)
* remove(subject, provider) → true when there was one
* test(subject, provider) → { ok, detail }: one tiny request through AI, or why not
++ * mine(token) → the person's own keys in OpenVibe.AI (their "Your AI" page, L36), read with
++ * their own token: { credentials: [{ provider, hint, status, … }] }
++ *
++ * Since 2026-10-31 a person keeps their keys in one place, ai.openvibe.services/you, for every site and agent; Codes'
++ * tasks run on the person's behalf, so OpenVibe.AI uses those keys first. The keys stored here before stay usable
++ * until the person removes them.
*/
const { createServiceTokenClient } = require('openvibe-sdk/auth');
@@@ -165,7 +171,21 @@ function createConnections({ config, fetchImpl = globalThis.fetch, log = console
return keys.map((k) => k.provider);
}
-- return { list, put, remove, test, providers, present, PROVIDERS };
++ /** The person's own keys in OpenVibe.AI (GET /api/v1/me/credentials with their own token): hints and states only. */
++ async function mine(token) {
++ if (!token) throw Object.assign(new Error('the person is not signed in'), { status: 401, code: 'token.missing' });
++ let res;
++ try {
++ res = await fetchImpl(`${base}/api/v1/me/credentials`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } });
++ } catch (e) {
++ throw Object.assign(new Error('OpenVibe.AI did not answer'), { status: 502, code: 'ai.unavailable' });
++ }
++ const body = await res.json().catch(() => ({}));
++ if (!res.ok) throw Object.assign(new Error((body && (body.detail || body.title)) || `OpenVibe.AI answered ${res.status}`), { status: res.status, code: (body && body.code) || 'ai.error' });
++ return { credentials: Array.isArray(body.credentials) ? body.credentials : [] };
++ }
++
++ return { list, put, remove, test, mine, providers, present, PROVIDERS };
}
module.exports = { createConnections, PROVIDERS, ANTHROPIC_FORMAT, providerOf, present };
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex 865426a..3e95bc6 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -64,7 +64,9 @@ const asObject = (v) => {
if (typeof v === 'string') { try { const p = JSON.parse(v); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; } }
return null;
};
--const PROVIDER_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', deepseek: 'DeepSeek', minimax: 'MiniMax', openrouter: 'OpenRouter', custom: 'Custom (OpenAI-compatible)' };
++const PROVIDER_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', gemini: 'Google Gemini', deepseek: 'DeepSeek', mistral: 'Mistral', xai: 'xAI', groq: 'Groq', cerebras: 'Cerebras', together: 'Together AI', fireworks: 'Fireworks', minimax: 'MiniMax', openrouter: 'OpenRouter', custom: 'Custom (OpenAI-compatible)' };
++// A person's keys live in one place for every site and agent (plan L36, L39): Codes shows them and links there.
++const YOUR_AI = 'https://ai.openvibe.services/you';
function createC4Routes(ctx) {
const r = asyncRouter();
@@@ -556,33 +558,29 @@ ${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline
};
// ── Agents: the person's connections (keys live in OpenVibe.AI) ────────────────────────────────────────────
-- const connectionsCard = async (req) => {
++ const connectionsCard = async (req, res = null) => {
let list = { keys: [], plans: [] };
++ let mine = null;
let problem = null;
try { list = await connections.list(req.viewer.subject); } catch (e) {
problem = { status: e && e.status, detail: (e && e.message) || 'the connections could not be read' };
}
-- const now = Date.now();
-- const rows = (list.keys || []).map((k) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span><span class="c4-row-label">${esc(PROVIDER_NAMES[k.provider] || k.provider)}</span><span class="c4-row-note">${esc(k.hint || 'key stored')}${k.budget_usd_per_day != null ? ` · cap $${Number(k.budget_usd_per_day).toFixed(2)} a day` : ''}${k.last_used_at ? ` · last used ${esc(age(k.last_used_at))}` : ''}</span><span class="c4-inline-actions"><form method="post" action="/agents/connections/${esc(k.provider)}/test"><button class="c4-opill" type="submit">Test</button></form><form method="post" action="/agents/connections/${esc(k.provider)}/remove"><button class="c4-opill c4-opill-quiet" type="submit">Remove</button></form></span></li>`).join('');
++ try { mine = await sso.asViewer(req, res, (token) => connections.mine(token)); } catch (e) { mine = null; }
++ const state = (k) => (k.status === 'ok' ? pill('Working', 'ok') : k.status === 'failing' ? pill('Not working', 'bad') : pill('Not tested', 'skip'));
++ const own = ((mine && mine.credentials) || []).map((k) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot${k.status === 'ok' ? ' c4-dot-ok' : k.status === 'failing' ? ' c4-dot-bad' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(PROVIDER_NAMES[k.provider] || k.provider)}</span><span class="c4-row-note">\u2026${esc(k.hint || '????')}${k.checked_at ? ` · checked ${esc(age(k.checked_at))}` : ''}</span>${state(k)}</li>`).join('');
++ const before = (list.keys || []).map((k) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-skip" aria-hidden="true"></span><span class="c4-row-label">${esc(PROVIDER_NAMES[k.provider] || k.provider)}</span><span class="c4-row-note">${esc(k.hint || 'key stored')}${k.last_used_at ? ` · last used ${esc(age(k.last_used_at))}` : ''}</span><span class="c4-inline-actions"><form method="post" action="/agents/connections/${esc(k.provider)}/test"><button class="c4-opill" type="submit">Test</button></form><form method="post" action="/agents/connections/${esc(k.provider)}/remove"><button class="c4-opill c4-opill-quiet" type="submit">Remove</button></form></span></li>`).join('');
const plans = (list.plans || []).map((p) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-skip" aria-hidden="true"></span><span class="c4-row-label">${esc(p.label || p.provider || 'a plan')}</span><span class="c4-row-note">on ${esc(p.machine || 'your machine')}</span>${pill('On your machine', 'skip')}</li>`).join('');
-- return `<section class="c4-card" id="connections"><div class="c4-card-head"><h2>Connections</h2></div>
--<p class="c4-sub">Keys live in OpenVibe.AI, never here</p>
--${problem ? note(`Your connections could not be read: ${esc(problem.detail)}`, 'bad') : ''}
--${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-emptyline">No provider keys yet. Add one below and the router can use it, and so can a task.</div>`}
--<h3 class="c4-subhead">Add or replace a key</h3>
--<form class="c4-form" method="post" action="/agents/connections">
-- <label>Provider <select name="provider">${connections.PROVIDERS.map((p) => `<option value="${esc(p)}">${esc(PROVIDER_NAMES[p] || p)}</option>`).join('')}</select></label>
-- <label>Key <input type="password" name="key" required minlength="8" maxlength="400" autocomplete="off" spellcheck="false" placeholder="sk-…"></label>
-- <label>Base URL <input type="url" name="base_url" placeholder="Only for a custom provider" autocomplete="off"></label>
-- <label>Daily cap <input type="number" name="budget_usd_per_day" min="0" step="0.01" placeholder="Optional, in dollars"></label>
-- <button class="c4-button" type="submit">Save the key</button>
--</form>
--<p class="c4-hint">The key is forwarded to OpenVibe.AI and sealed there; Codes keeps only the four-character hint. Test sends one tiny request with your key.</p>
++ return `<section class="c4-card" id="connections"><div class="c4-card-head"><h2>Your AI keys</h2><a class="c4-button" href="${YOUR_AI}">Add or manage keys \u2197</a></div>
++<p class="c4-sub">One place for every OpenVibe site and agent: your tasks here use these first, with no fee.</p>
++${own ? `<ul class="c4-rows">${own}</ul>` : `<div class="c4-emptyline">${mine ? 'No keys yet.' : 'Your keys could not be read just now.'} <a href="${YOUR_AI}">Add one on Your AI</a>: Anthropic, OpenAI, Google, DeepSeek and more.</div>`}
++${plans ? `<h3 class="c4-subhead">Plans on your machines</h3><ul class="c4-rows">${plans}</ul>` : ''}
++${problem ? note(`The keys saved in Codes could not be read: ${esc(problem.detail)}`, 'bad') : ''}
++${before ? `<h3 class="c4-subhead">Saved in Codes before</h3><ul class="c4-rows">${before}</ul><p class="c4-hint">These still work for your tasks. Add them on <a href="${YOUR_AI}">Your AI</a> to use them on every site, then remove them here.</p>` : ''}
</section>`;
};
r.get('/agents', async (req, res) => {
-- const content = `${header('Agents', 'The agents your tasks can run, what they have left, and the keys behind them.')}${viewer(req) ? `${flash(req)}${await fleetCard(req, res)}${await connectionsCard(req)}` : signIn(req)}`;
++ const content = `${header('Agents', 'The agents your tasks can run, what they have left, and the keys behind them.')}${viewer(req) ? `${flash(req)}${await fleetCard(req, res)}${await connectionsCard(req, res)}` : signIn(req)}`;
page(req, res, 'Agents', wrap(content));
});
r.post('/agents/connections', form, async (req, res) => {
ddiff --git a/test/c4b.test.js b/test/c4b.test.js
iindex 1fa2ad4..388886c 100644
---- a/test/c4b.test.js
++++ b/test/c4b.test.js
@@@ -123,7 +123,20 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
const remove = await t.get('/agents/connections/deepseek/remove', { as: ada, method: 'POST', form: { x: '1' } });
assert.strictEqual(remove.status, 303);
const after = await t.get('/agents', { as: ada });
-- assert.ok(after.text.includes('No provider keys yet'), 'the key is forgotten');
++ assert.ok(!after.text.includes('Saved in Codes before'), 'the key is forgotten');
++ });
++
++ await check('Agents: your keys from Your AI, read with your own token, and the one place to manage them', async () => {
++ t.network.state.selfKeys = [{ provider: 'anthropic', hint: 'WXYZ', status: 'ok', checked_at: new Date().toISOString() }, { provider: 'gemini', hint: 'AB12', status: 'failing' }];
++ const page = await t.get('/agents', { as: ada });
++ assert.ok(page.text.includes('<h2>Your AI keys</h2>') && page.text.includes('href="https://ai.openvibe.services/you"'), 'the one place');
++ assert.ok(page.text.includes('Anthropic') && page.text.includes('\u2026WXYZ') && page.text.includes('Working'));
++ assert.ok(page.text.includes('Google Gemini') && page.text.includes('Not working'));
++ assert.ok(t.network.state.meRequests.some((h) => h.startsWith('Bearer ')), 'read as the person');
++ assert.ok(!page.text.includes('action="/agents/connections"'), 'no second place to add a key');
++ t.network.state.selfKeys = [];
++ const empty = await t.get('/agents', { as: ada });
++ assert.ok(empty.text.includes('No keys yet.') && empty.text.includes('Add one on Your AI'));
});
await check('Machines: the person&rsquo;s own machines, their state, and Node&rsquo;s pairing code flow', async () => {
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex a2e3eb4..165d00a 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -307,6 +307,13 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });
}
// ── OpenVibe.AI's stand-in (Codes C3): the vault of a person's own keys and the two model endpoints ──
++ // The person's own keys on Your AI (L36): read with their own token; tests put rows in st.selfKeys.
++ if (url.pathname === '/api/v1/me/credentials' && req.method === 'GET') {
++ st.meRequests = st.meRequests || [];
++ st.meRequests.push(String(req.headers.authorization || ''));
++ if (!String(req.headers.authorization || '').startsWith('Bearer ')) return problem(json, 401, 'token.missing', 'a person token is required');
++ return json(200, { credentials: st.selfKeys || [] });
++ }
if (url.pathname.startsWith('/api/v1/credentials/')) {
const parts = url.pathname.split('/').filter(Boolean); // api, v1, credentials, subject, provider
const subject = parts[3]; const provider = parts[4];