openvibers/openvibe.exchange Commits
C2 first slice: migration guide and API key expiry controls (#141)
Changes
nnew file mode 100644@@@ -0,0 +1,74 @@++[++ {++ "name": "migration",++ "width": 390,++ "scheme": "dark",++ "painted": "dark",++ "docWidth": 390,++ "errors": [],++ "consoleErrors": 4++ },++ {++ "name": "dashboard",++ "width": 390,++ "scheme": "dark",++ "painted": "dark",++ "docWidth": 390,++ "errors": [],++ "consoleErrors": 6++ },++ {++ "name": "migration",++ "width": 390,++ "scheme": "light",++ "painted": "light",++ "docWidth": 390,++ "errors": [],++ "consoleErrors": 4++ },++ {++ "name": "dashboard",++ "width": 390,++ "scheme": "light",++ "painted": "light",++ "docWidth": 390,++ "errors": [],++ "consoleErrors": 6++ },++ {++ "name": "migration",++ "width": 1280,++ "scheme": "dark",++ "painted": "dark",++ "docWidth": 1280,++ "errors": [],++ "consoleErrors": 4++ },++ {++ "name": "dashboard",++ "width": 1280,++ "scheme": "dark",++ "painted": "dark",++ "docWidth": 1280,++ "errors": [],++ "consoleErrors": 6++ },++ {++ "name": "migration",++ "width": 1280,++ "scheme": "light",++ "painted": "light",++ "docWidth": 1280,++ "errors": [],++ "consoleErrors": 4++ },++ {++ "name": "dashboard",++ "width": 1280,++ "scheme": "light",++ "painted": "light",++ "docWidth": 1280,++ "errors": [],++ "consoleErrors": 6++ }++]nnew file mode 100644BBinary files /dev/null and b/docs/c2/dashboard-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/c2/dashboard-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/c2/dashboard-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/c2/dashboard-390-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/c2/migration-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/c2/migration-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/c2/migration-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/c2/migration-390-light.png differnnew file mode 100644@@@ -0,0 +1,18 @@++# Gateway migration research — 2026-11-09++++The plan's OpenRouter routing/fallback/preset/BYOK documentation and Eden AI, Vercel and Respan comparisons were checked alongside the existing exchange-chat research. Vendor comparisons are discovery material, not independent proof of parity or performance. The older collected price/count claims are not repeated here.++++## Three additions worth building++++1. **Reject unsupported configuration rather than silently dropping it.** A [LiteLLM user report](https://github.com/BerriAI/litellm/issues/37125) describes response_format being accepted but ignored. Another [user report](https://github.com/BerriAI/litellm/issues/44815) describes configuration failures being hidden by a more expensive fallback. These are reported experiences, not a claim about every current version. The C2 preview rejects unknown fields and validates types before any provider call. It does not claim execution-time enforcement yet.++2. **Explain a route before sending a prompt.** [OpenRouter's provider routing](https://openrouter.ai/docs/guides/routing/provider-selection) has independent order, privacy and price constraints. A zero-inference preview makes excluded offers and absent evidence inspectable. The new diagnostic refuses to treat an unknown price as zero or missing privacy metadata as a promise. Quality/throughput execution requires real evidence and is still pending.++3. **Forward request idempotency through compatible endpoints.** [LiteLLM's reliability guide](https://docs.litellm.ai/docs/router/learn/reliability) separates retries, fallbacks and cooldowns. Multiple retry layers make accidental repeated execution worth preventing. C2 connects the Idempotency-Key header to the existing durable run deduplication for both protocols, exposes replay status and avoids charging an opaque key's local usage counter for replayed results. This does not add automatic retries or relax budget reservations.++++## Migration evidence and limits++++- [OpenRouter preset examples](https://openrouter.ai/blog/tutorials/presets/) bundle model selection, routing and sampling. The importer here validates a deliberately explicit JSON subset; it is a preview, not a live account importer or a persistent preset service.++- [OpenRouter model fallbacks](https://openrouter.ai/docs/guides/routing/model-fallbacks) and [BYOK](https://openrouter.ai/docs/guides/overview/auth/byok) remain execution requirements for later C2 work; no equivalence is claimed by these preview tools.++- [Vercel's OpenAI-compatible provider](https://ai-sdk.dev/providers/openai-compatible-providers) exposes a base URL, but gateway-specific options do not automatically translate.++- [OpenRouter's routing update](https://openrouter.ai/blog/announcements/how-we-rebuilt-provider-routing/) describes conversation affinity to preserve caches. It is promising future work, but adding affinity requires provider health and privacy-aware invalidation; no untested cache sharing is added.++++Built using public documentation, web search and local fixtures. No new accounts, paid research calls, provider requests or production writes were made.@@@ -51,6 +51,7 @@ function createDashboardRoutes({ config, ai }) {method: 'POST',body: {name: req.body.name,++ ...(req.body.expires_at ? { expires_at: String(req.body.expires_at) + 'Z' } : {}),monthly_cap_usd: Number(req.body.monthly_cap_usd),allowed_models: String(req.body.allowed_models || '').split(',')@@@ -95,6 +95,7 @@ function createPageRoutes(ctx) {r.get('/pricing', (req, res) => page(req, res, { title: 'Pricing', description: 'What a call to OpenVibe.Exchange costs: your own key at 0%, managed models at their listed price, your own endpoint by the hour, search per 1,000.', body: raw(platform.pricingPage()) }));r.get('/docs', (req,res) => res.redirect(301,'/api'));++ r.get('/docs/migrate', (req,res) => page(req,res,{title:'Migrate to Exchange', description:'Move an OpenAI or Anthropic client to Exchange, validate routing and inspect API receipts.', jsonLd:[{'@context':'https://schema.org','@type':'TechArticle',headline:'Move your application to Exchange',url:'https://openvibe.exchange/docs/migrate'}], body:raw(require('../views/migration').migrationPage())}));// ── Home ─────────────────────────────────────────────────r.get('/', async (req, res) => {@@@ -26,6 +26,13 @@ const day = (iso) => {const d = new Date(`${String(iso).slice(0, 10)}T12:00:00Z`);return Number.isNaN(d.getTime()) ? String(iso || '') : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });};++/** A key's expiry, to the minute and with its year: "Jan 1, 2027, 00:00 UTC". */++const expiry = (iso) => {++ const d = new Date(String(iso || ''));++ if (Number.isNaN(d.getTime())) return String(iso || '');++ const date = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });++ return `${date}, ${d.toISOString().slice(11, 16)} UTC`;++};const plural = (n, one, many = `${one}s`) => `${Number(n).toLocaleString('en-US')} ${Number(n) === 1 ? one : many}`;/** The frame every tab shares: the page head, the tab rail, the tab's body. */@@@ -40,7 +47,7 @@ function keyCard(k) {const made = k.created_at ? `made ${day(k.created_at)}` : '';const revoked = !!k.revoked_at;return `<li class="xd-key${revoked ? ' is-revoked' : ''}"><div class="xd-key-main"><strong>${esc(k.name)}</strong><code>${esc(k.prefix)}…</code></div>`-- + `<p class="xd-key-meta">${esc([`${money(k.monthly_cap_usd)} a month`, models, revoked ? `revoked ${day(k.revoked_at)}` : made].filter(Boolean).join(' · '))}</p>`++ + `<p class="xd-key-meta">${esc([`${money(k.monthly_cap_usd)} a month`, models, k.expires_at ? `expires ${expiry(k.expires_at)}` : '', revoked ? `revoked ${day(k.revoked_at)}` : made].filter(Boolean).join(' · '))}</p>`+ (revoked ? '' : `<form method="post" action="/dashboard/keys/${encodeURIComponent(k.id)}/revoke" class="xd-key-act">${kit.button({ label: 'Revoke', type: 'submit', kind: 'danger', small: true })}</form>`)+ '</li>';}@@@ -55,6 +62,7 @@ function keysPage(keys = []) {+ '<form class="xd-form" method="post" action="/dashboard/keys">'+ '<label><span>Name</span><input name="name" required maxlength="80" placeholder="My application" autocomplete="off"></label>'+ '<label><span>Monthly cap</span><span class="xd-money"><span aria-hidden="true">$</span><input name="monthly_cap_usd" type="number" min="1" step="0.01" value="5" required inputmode="decimal" aria-describedby="cap-hint"></span><small id="cap-hint">Each call holds up to $1 (or its max_cost_usd) until it finishes; the unused part comes back.</small></label>'++ + '<label><span>Expiry (UTC; optional)</span><input name="expires_at" type="datetime-local"></label>'+ '<label><span>Models it may call</span><input name="allowed_models" placeholder="Every model" autocomplete="off" aria-describedby="models-hint"><small id="models-hint">Model ids, comma separated, e.g. deepseek/deepseek-chat. Empty allows every model.</small></label>'+ `${kit.button({ label: 'Make the key', type: 'submit' })}</form></section>`);}@@@ -494,6 +494,7 @@ ${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatib${panel({ title: 'Batches, at half price', body: `<p class="ex-prose">Half price. Answered within your deadline when spare low-cost capacity is free; if none comes free in time, the prompt expires and you pay nothing.</p>` })}${panel({ title: 'One endpoint', body: `<p class="ex-prose"><code>${ENDPOINT}</code> speaks OpenAI's Chat Completions; <code>https://api.openvibe.exchange/v1/messages</code> speaks Anthropic's Messages API, so Claude Code works by pointing <code>ANTHROPIC_BASE_URL</code> at it.</p>++<p class="ex-prose">Coming from OpenRouter or a provider's own API? <a href="/docs/migrate">Move your application to Exchange</a>: the first request, the gateway settings, routing checks and limited keys, step by step.</p>${kit.snippet({ code: `curl ${ENDPOINT} \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"openvibe/auto","messages":[{"role":"user","content":"Hello"}]}'`, id: 'api-curl' })}<h3 class="ex-h3">Keys</h3><p class="ex-prose">Keys are OpenVibe.Network developer keys: make one, name its scope, and revoke it any time. They are shown once and never stored here. Manage your own provider API keys and browser session cookies on <a href="/privacy/keys">Your keys and accounts</a>; saved values stay sealed on OpenVibe.AI and are never shown again.</p>nnew file mode 100644@@@ -0,0 +1,15 @@++'use strict';++const kit = require('openvibe-shared/kit');++const section = (title,body)=>kit.section({title,body});++function migrationPage(){++ const curl=`curl https://api.openvibe.exchange/v1/chat/completions \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -H "Idempotency-Key: migration-example-001" \\\n -d '{"model":"openvibe/auto","messages":[{"role":"user","content":"Hello"}]}'`;++ const sdk=`import OpenAI from 'openai';\nconst ai = new OpenAI({\n apiKey: process.env.OPENVIBE_KEY,\n baseURL: 'https://api.openvibe.exchange/v1'\n});\nconst answer = await ai.chat.completions.create({\n model: 'openvibe/auto',\n messages: [{ role: 'user', content: 'Hello' }]\n});\nconsole.log(answer.choices[0].message.content);`;++ return `<div class="ex-wrap xp"><section class="xp-hero"><p class="ex-kicker">Developer guide</p><h1>Move your application to Exchange</h1><p>Keep your OpenAI or Anthropic client. Start with a model call, check the receipt, then migrate your routing rules.</p><p>This guide describes the review implementation. Full OpenRouter routing parity is still in progress; the preview tools below never run a model.</p>${kit.button({href:'/dashboard/keys',label:'Manage API keys'})} ${kit.button({href:'/models',label:'Browse models',kind:'quiet'})}</section>`+++ section('1. Make your first request',`<p>Set OPENVIBE_KEY to your Exchange key locally. Never put it in browser code. Use a new Idempotency-Key for each logical request; reuse it only when retrying the same input.</p>${kit.snippet({id:'migration-curl',code:curl})}${kit.snippet({id:'migration-sdk',code:sdk})}`)+++ section('2. Change your gateway settings',`<div class="xp-models"><table><thead><tr><th>From</th><th>Change</th><th>Check before rollout</th></tr></thead><tbody><tr><td>OpenRouter</td><td>Use https://api.openvibe.exchange/v1 and an Exchange key.</td><td>Use the catalogue's model ids. Provider routing fields need the C2 execution integration; preview results are not an execution policy.</td></tr><tr><td>LiteLLM</td><td>Configure an OpenAI-compatible provider with Exchange's api_base and key.</td><td>Turn off duplicate gateway retry layers; verify timeouts and budget limits in your application.</td></tr><tr><td>Vercel AI SDK</td><td>Configure your OpenAI provider's baseURL and apiKey for Exchange.</td><td>Gateway-specific providerOptions are not automatically translated.</td></tr><tr><td>Portkey</td><td>Point the application's OpenAI client directly to Exchange.</td><td>Portkey configuration ids and virtual-key headers do not carry across; recreate policies explicitly.</td></tr></tbody></table></div>`)+++ section('3. Inspect routing without sending a prompt',`<p>Use your Network developer token for the diagnostic routes. They do not hold credits, reveal provider keys or call an upstream. Unknown provider fields are rejected; unknown prices and privacy policies cannot satisfy a hard requirement.</p>${kit.snippet({id:'migration-preview',code:`curl https://api.openvibe.exchange/v1/routing/preview \\\n -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"openvibe/auto","provider":{"sort":"price","data_collection":"deny","zdr":true,"max_price":{"prompt":1,"completion":2}}}'`})}<p>The response identifies excluded providers and the rule that excluded them. It is catalogue-only: live availability, account gates and budgets still apply. Quality and speed need measured evidence.</p>`)+++ section('4. Validate an exported preset',`<p>Send the preset configuration, never an OpenRouter key. This preview validates names, model lists, sampling and provider rules. It returns a validation summary without repeating submitted values and does not save the preset; persistence and execution are pending.</p>${kit.snippet({id:'migration-import',code:`curl https://api.openvibe.exchange/v1/presets/import/preview \\\n -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n -H "Content-Type: application/json" \\\n -d '{"name":"writer","model":"openvibe/auto","temperature":0.4,"provider":{"sort":"price"}}'`})}`)+++ section('5. Limit the credential',`<p>The key provisioning API accepts a name, monthly_cap_usd, allowed_models and optional expires_at in ISO 8601. Expired keys fail authentication, including reservations made with a previously resolved key. Revocation is immediate. Key metadata never returns the secret again.</p><p>Inspect the completion's x_openvibe receipt for cost and provider. Existing streaming, tool calls and structured outputs use the corresponding provider's capabilities. Test your exact media and caching inputs before moving traffic.</p>`)+++ section('Compatibility work still under review',`<p>Legacy completions, embeddings and image-generation compatibility endpoints; model suffix execution; model fallbacks and full preset persistence; per-request generation lookup; web grounding; Report quality routing; and task/choice-driven agent streams are not implemented by this guide. The existing API reference documents the currently available endpoints.</p>${kit.button({href:'/api',label:'Current API reference',kind:'quiet'})}`)+`</div>`;++}++module.exports={migrationPage};nnew file mode 100644@@@ -0,0 +1,4 @@++'use strict';++const assert=require('node:assert/strict');++const {boot}=require('./helpers/boot');++(async()=>{const t=await boot();try{const r=await t.get('/docs/migrate');assert.equal(r.status,200);for(const text of ['<h1>','TechArticle','OpenRouter','LiteLLM','Vercel','Portkey','routing/preview','Idempotency-Key','still under review'])assert.ok(r.text.includes(text),text);assert.ok(!r.text.includes('sk-'));}finally{await t.close();}})().catch(e=>{console.error(e);process.exitCode=1;});@@@ -23,7 +23,9 @@ const { createFakeAi } = require('./helpers/ai');},],}-- : { keys: [] };++ : options?.method === 'POST'++ ? { secret: 'fixture-only', key: { name: options.body.name } }++ : { keys: [{ id: 'fixture', name: 'Expiring key', prefix: 'fixture', monthly_cap_usd: 5, allowed_models: [], expires_at: '2027-01-01T00:00:00.000Z' }] };},};const t = await boot({ ai });@@@ -37,8 +39,20 @@ const { createFakeAi } = require('./helpers/ai');assert.equal(r.status, 200);assert.ok(r.headers.get('cache-control').includes('no-store'));assert.ok(r.text.includes('Monthly cap'));++ assert.ok(r.text.includes('name="expires_at"'));++ assert.ok(r.text.includes('expires Jan 1, 2027, 00:00 UTC'), 'the expiry reads as a date with its year and time');++ assert.ok(r.text.includes('xd-key-main'), 'main dashboard cards are preserved');},);++ await check('key creation forwards optional UTC expiry', async () => {++ const response = await t.get('/dashboard/keys', {++ as: user,++ form: { name: 'Expires', monthly_cap_usd: '5', allowed_models: '', expires_at: '2027-01-01T00:00' },++ });++ assert.equal(response.status, 200);++ assert.equal(calls.at(-1).options.body.expires_at, '2027-01-01T00:00Z');++ assert.ok(response.text.includes('Copy your key now'));++ });await check('foreign site cannot create keys', async () => {const n = calls.length;assert.equal(
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+[
+ {
+ "name": "migration",
+ "width": 390,
+ "scheme": "dark",
+ "painted": "dark",
+ "docWidth": 390,
+ "errors": [],
+ "consoleErrors": 4
+ },
+ {
+ "name": "dashboard",
+ "width": 390,
+ "scheme": "dark",
+ "painted": "dark",
+ "docWidth": 390,
+ "errors": [],
+ "consoleErrors": 6
+ },
+ {
+ "name": "migration",
+ "width": 390,
+ "scheme": "light",
+ "painted": "light",
+ "docWidth": 390,
+ "errors": [],
+ "consoleErrors": 4
+ },
+ {
+ "name": "dashboard",
+ "width": 390,
+ "scheme": "light",
+ "painted": "light",
+ "docWidth": 390,
+ "errors": [],
+ "consoleErrors": 6
+ },
+ {
+ "name": "migration",
+ "width": 1280,
+ "scheme": "dark",
+ "painted": "dark",
+ "docWidth": 1280,
+ "errors": [],
+ "consoleErrors": 4
+ },
+ {
+ "name": "dashboard",
+ "width": 1280,
+ "scheme": "dark",
+ "painted": "dark",
+ "docWidth": 1280,
+ "errors": [],
+ "consoleErrors": 6
+ },
+ {
+ "name": "migration",
+ "width": 1280,
+ "scheme": "light",
+ "painted": "light",
+ "docWidth": 1280,
+ "errors": [],
+ "consoleErrors": 4
+ },
+ {
+ "name": "dashboard",
+ "width": 1280,
+ "scheme": "light",
+ "painted": "light",
+ "docWidth": 1280,
+ "errors": [],
+ "consoleErrors": 6
+ }
+]
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/c2/dashboard-1280-dark.png differ
Binary files /dev/null and b/docs/c2/dashboard-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/c2/dashboard-1280-light.png differ
Binary files /dev/null and b/docs/c2/dashboard-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/c2/dashboard-390-dark.png differ
Binary files /dev/null and b/docs/c2/dashboard-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/c2/dashboard-390-light.png differ
Binary files /dev/null and b/docs/c2/dashboard-390-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/c2/migration-1280-dark.png differ
Binary files /dev/null and b/docs/c2/migration-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/c2/migration-1280-light.png differ
Binary files /dev/null and b/docs/c2/migration-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/c2/migration-390-dark.png differ
Binary files /dev/null and b/docs/c2/migration-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/c2/migration-390-light.png differ
Binary files /dev/null and b/docs/c2/migration-390-light.png differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+# Gateway migration research — 2026-11-09
+
+The plan's OpenRouter routing/fallback/preset/BYOK documentation and Eden AI, Vercel and Respan comparisons were checked alongside the existing exchange-chat research. Vendor comparisons are discovery material, not independent proof of parity or performance. The older collected price/count claims are not repeated here.
+
+## Three additions worth building
+
+1. **Reject unsupported configuration rather than silently dropping it.** A [LiteLLM user report](https://github.com/BerriAI/litellm/issues/37125) describes response_format being accepted but ignored. Another [user report](https://github.com/BerriAI/litellm/issues/44815) describes configuration failures being hidden by a more expensive fallback. These are reported experiences, not a claim about every current version. The C2 preview rejects unknown fields and validates types before any provider call. It does not claim execution-time enforcement yet.
+2. **Explain a route before sending a prompt.** [OpenRouter's provider routing](https://openrouter.ai/docs/guides/routing/provider-selection) has independent order, privacy and price constraints. A zero-inference preview makes excluded offers and absent evidence inspectable. The new diagnostic refuses to treat an unknown price as zero or missing privacy metadata as a promise. Quality/throughput execution requires real evidence and is still pending.
+3. **Forward request idempotency through compatible endpoints.** [LiteLLM's reliability guide](https://docs.litellm.ai/docs/router/learn/reliability) separates retries, fallbacks and cooldowns. Multiple retry layers make accidental repeated execution worth preventing. C2 connects the Idempotency-Key header to the existing durable run deduplication for both protocols, exposes replay status and avoids charging an opaque key's local usage counter for replayed results. This does not add automatic retries or relax budget reservations.
+
+## Migration evidence and limits
+
+- [OpenRouter preset examples](https://openrouter.ai/blog/tutorials/presets/) bundle model selection, routing and sampling. The importer here validates a deliberately explicit JSON subset; it is a preview, not a live account importer or a persistent preset service.
+- [OpenRouter model fallbacks](https://openrouter.ai/docs/guides/routing/model-fallbacks) and [BYOK](https://openrouter.ai/docs/guides/overview/auth/byok) remain execution requirements for later C2 work; no equivalence is claimed by these preview tools.
+- [Vercel's OpenAI-compatible provider](https://ai-sdk.dev/providers/openai-compatible-providers) exposes a base URL, but gateway-specific options do not automatically translate.
+- [OpenRouter's routing update](https://openrouter.ai/blog/announcements/how-we-rebuilt-provider-routing/) describes conversation affinity to preserve caches. It is promising future work, but adding affinity requires provider health and privacy-aware invalidation; no untested cache sharing is added.
+
+Built using public documentation, web search and local fixtures. No new accounts, paid research calls, provider requests or production writes were made.
@@ -51,6 +51,7 @@ function createDashboardRoutes({ config, ai }) {
@@ -51,6 +51,7 @@ function createDashboardRoutes({ config, ai }) {
method: 'POST',
method: 'POST',
body: {
body: {
name: req.body.name,
name: req.body.name,
+ ...(req.body.expires_at ? { expires_at: String(req.body.expires_at) + 'Z' } : {}),
monthly_cap_usd: Number(req.body.monthly_cap_usd),
monthly_cap_usd: Number(req.body.monthly_cap_usd),
allowed_models: String(req.body.allowed_models || '')
allowed_models: String(req.body.allowed_models || '')
.split(',')
.split(',')
@@ -95,6 +95,7 @@ function createPageRoutes(ctx) {
@@ -95,6 +95,7 @@ function createPageRoutes(ctx) {
r.get('/pricing', (req, res) => page(req, res, { title: 'Pricing', description: 'What a call to OpenVibe.Exchange costs: your own key at 0%, managed models at their listed price, your own endpoint by the hour, search per 1,000.', body: raw(platform.pricingPage()) }));
r.get('/pricing', (req, res) => page(req, res, { title: 'Pricing', description: 'What a call to OpenVibe.Exchange costs: your own key at 0%, managed models at their listed price, your own endpoint by the hour, search per 1,000.', body: raw(platform.pricingPage()) }));
r.get('/docs', (req,res) => res.redirect(301,'/api'));
r.get('/docs', (req,res) => res.redirect(301,'/api'));
+ r.get('/docs/migrate', (req,res) => page(req,res,{title:'Migrate to Exchange', description:'Move an OpenAI or Anthropic client to Exchange, validate routing and inspect API receipts.', jsonLd:[{'@context':'https://schema.org','@type':'TechArticle',headline:'Move your application to Exchange',url:'https://openvibe.exchange/docs/migrate'}], body:raw(require('../views/migration').migrationPage())}));
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
@@ -26,6 +26,13 @@ const day = (iso) => {
@@ -26,6 +26,13 @@ const day = (iso) => {
const d = new Date(`${String(iso).slice(0, 10)}T12:00:00Z`);
const d = new Date(`${String(iso).slice(0, 10)}T12:00:00Z`);
return Number.isNaN(d.getTime()) ? String(iso || '') : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
return Number.isNaN(d.getTime()) ? String(iso || '') : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
};
};
+/** A key's expiry, to the minute and with its year: "Jan 1, 2027, 00:00 UTC". */
+const expiry = (iso) => {
+ const d = new Date(String(iso || ''));
+ if (Number.isNaN(d.getTime())) return String(iso || '');
+ const date = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
+ return `${date}, ${d.toISOString().slice(11, 16)} UTC`;
+};
const plural = (n, one, many = `${one}s`) => `${Number(n).toLocaleString('en-US')} ${Number(n) === 1 ? one : many}`;
const plural = (n, one, many = `${one}s`) => `${Number(n).toLocaleString('en-US')} ${Number(n) === 1 ? one : many}`;
/** The frame every tab shares: the page head, the tab rail, the tab's body. */
/** The frame every tab shares: the page head, the tab rail, the tab's body. */
@@ -40,7 +47,7 @@ function keyCard(k) {
@@ -40,7 +47,7 @@ function keyCard(k) {
const made = k.created_at ? `made ${day(k.created_at)}` : '';
const made = k.created_at ? `made ${day(k.created_at)}` : '';
const revoked = !!k.revoked_at;
const revoked = !!k.revoked_at;
return `<li class="xd-key${revoked ? ' is-revoked' : ''}"><div class="xd-key-main"><strong>${esc(k.name)}</strong><code>${esc(k.prefix)}…</code></div>`
return `<li class="xd-key${revoked ? ' is-revoked' : ''}"><div class="xd-key-main"><strong>${esc(k.name)}</strong><code>${esc(k.prefix)}…</code></div>`
- + `<p class="xd-key-meta">${esc([`${money(k.monthly_cap_usd)} a month`, models, revoked ? `revoked ${day(k.revoked_at)}` : made].filter(Boolean).join(' · '))}</p>`
+ + `<p class="xd-key-meta">${esc([`${money(k.monthly_cap_usd)} a month`, models, k.expires_at ? `expires ${expiry(k.expires_at)}` : '', revoked ? `revoked ${day(k.revoked_at)}` : made].filter(Boolean).join(' · '))}</p>`
+ (revoked ? '' : `<form method="post" action="/dashboard/keys/${encodeURIComponent(k.id)}/revoke" class="xd-key-act">${kit.button({ label: 'Revoke', type: 'submit', kind: 'danger', small: true })}</form>`)
+ (revoked ? '' : `<form method="post" action="/dashboard/keys/${encodeURIComponent(k.id)}/revoke" class="xd-key-act">${kit.button({ label: 'Revoke', type: 'submit', kind: 'danger', small: true })}</form>`)
+ '</li>';
+ '</li>';
}
}
@@ -55,6 +62,7 @@ function keysPage(keys = []) {
@@ -55,6 +62,7 @@ function keysPage(keys = []) {
+ '<form class="xd-form" method="post" action="/dashboard/keys">'
+ '<form class="xd-form" method="post" action="/dashboard/keys">'
+ '<label><span>Name</span><input name="name" required maxlength="80" placeholder="My application" autocomplete="off"></label>'
+ '<label><span>Name</span><input name="name" required maxlength="80" placeholder="My application" autocomplete="off"></label>'
+ '<label><span>Monthly cap</span><span class="xd-money"><span aria-hidden="true">$</span><input name="monthly_cap_usd" type="number" min="1" step="0.01" value="5" required inputmode="decimal" aria-describedby="cap-hint"></span><small id="cap-hint">Each call holds up to $1 (or its max_cost_usd) until it finishes; the unused part comes back.</small></label>'
+ '<label><span>Monthly cap</span><span class="xd-money"><span aria-hidden="true">$</span><input name="monthly_cap_usd" type="number" min="1" step="0.01" value="5" required inputmode="decimal" aria-describedby="cap-hint"></span><small id="cap-hint">Each call holds up to $1 (or its max_cost_usd) until it finishes; the unused part comes back.</small></label>'
+ + '<label><span>Expiry (UTC; optional)</span><input name="expires_at" type="datetime-local"></label>'
+ '<label><span>Models it may call</span><input name="allowed_models" placeholder="Every model" autocomplete="off" aria-describedby="models-hint"><small id="models-hint">Model ids, comma separated, e.g. deepseek/deepseek-chat. Empty allows every model.</small></label>'
+ '<label><span>Models it may call</span><input name="allowed_models" placeholder="Every model" autocomplete="off" aria-describedby="models-hint"><small id="models-hint">Model ids, comma separated, e.g. deepseek/deepseek-chat. Empty allows every model.</small></label>'
+ `${kit.button({ label: 'Make the key', type: 'submit' })}</form></section>`);
+ `${kit.button({ label: 'Make the key', type: 'submit' })}</form></section>`);
}
}
@@ -494,6 +494,7 @@ ${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatib
@@ -494,6 +494,7 @@ ${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatib
${panel({ title: 'Batches, at half price', body: `<p class="ex-prose">Half price. Answered within your deadline when spare low-cost capacity is free; if none comes free in time, the prompt expires and you pay nothing.</p>` })}
${panel({ title: 'Batches, at half price', body: `<p class="ex-prose">Half price. Answered within your deadline when spare low-cost capacity is free; if none comes free in time, the prompt expires and you pay nothing.</p>` })}
${panel({ title: 'One endpoint', body: `
${panel({ title: 'One endpoint', body: `
<p class="ex-prose"><code>${ENDPOINT}</code> speaks OpenAI's Chat Completions; <code>https://api.openvibe.exchange/v1/messages</code> speaks Anthropic's Messages API, so Claude Code works by pointing <code>ANTHROPIC_BASE_URL</code> at it.</p>
<p class="ex-prose"><code>${ENDPOINT}</code> speaks OpenAI's Chat Completions; <code>https://api.openvibe.exchange/v1/messages</code> speaks Anthropic's Messages API, so Claude Code works by pointing <code>ANTHROPIC_BASE_URL</code> at it.</p>
+<p class="ex-prose">Coming from OpenRouter or a provider's own API? <a href="/docs/migrate">Move your application to Exchange</a>: the first request, the gateway settings, routing checks and limited keys, step by step.</p>
${kit.snippet({ code: `curl ${ENDPOINT} \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"openvibe/auto","messages":[{"role":"user","content":"Hello"}]}'`, id: 'api-curl' })}
${kit.snippet({ code: `curl ${ENDPOINT} \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"openvibe/auto","messages":[{"role":"user","content":"Hello"}]}'`, id: 'api-curl' })}
<h3 class="ex-h3">Keys</h3>
<h3 class="ex-h3">Keys</h3>
<p class="ex-prose">Keys are OpenVibe.Network developer keys: make one, name its scope, and revoke it any time. They are shown once and never stored here. Manage your own provider API keys and browser session cookies on <a href="/privacy/keys">Your keys and accounts</a>; saved values stay sealed on OpenVibe.AI and are never shown again.</p>
<p class="ex-prose">Keys are OpenVibe.Network developer keys: make one, name its scope, and revoke it any time. They are shown once and never stored here. Manage your own provider API keys and browser session cookies on <a href="/privacy/keys">Your keys and accounts</a>; saved values stay sealed on OpenVibe.AI and are never shown again.</p>
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+'use strict';
+const kit = require('openvibe-shared/kit');
+const section = (title,body)=>kit.section({title,body});
+function migrationPage(){
+ const curl=`curl https://api.openvibe.exchange/v1/chat/completions \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -H "Idempotency-Key: migration-example-001" \\\n -d '{"model":"openvibe/auto","messages":[{"role":"user","content":"Hello"}]}'`;
+ const sdk=`import OpenAI from 'openai';\nconst ai = new OpenAI({\n apiKey: process.env.OPENVIBE_KEY,\n baseURL: 'https://api.openvibe.exchange/v1'\n});\nconst answer = await ai.chat.completions.create({\n model: 'openvibe/auto',\n messages: [{ role: 'user', content: 'Hello' }]\n});\nconsole.log(answer.choices[0].message.content);`;
+ return `<div class="ex-wrap xp"><section class="xp-hero"><p class="ex-kicker">Developer guide</p><h1>Move your application to Exchange</h1><p>Keep your OpenAI or Anthropic client. Start with a model call, check the receipt, then migrate your routing rules.</p><p>This guide describes the review implementation. Full OpenRouter routing parity is still in progress; the preview tools below never run a model.</p>${kit.button({href:'/dashboard/keys',label:'Manage API keys'})} ${kit.button({href:'/models',label:'Browse models',kind:'quiet'})}</section>`+
+ section('1. Make your first request',`<p>Set OPENVIBE_KEY to your Exchange key locally. Never put it in browser code. Use a new Idempotency-Key for each logical request; reuse it only when retrying the same input.</p>${kit.snippet({id:'migration-curl',code:curl})}${kit.snippet({id:'migration-sdk',code:sdk})}`)+
+ section('2. Change your gateway settings',`<div class="xp-models"><table><thead><tr><th>From</th><th>Change</th><th>Check before rollout</th></tr></thead><tbody><tr><td>OpenRouter</td><td>Use https://api.openvibe.exchange/v1 and an Exchange key.</td><td>Use the catalogue's model ids. Provider routing fields need the C2 execution integration; preview results are not an execution policy.</td></tr><tr><td>LiteLLM</td><td>Configure an OpenAI-compatible provider with Exchange's api_base and key.</td><td>Turn off duplicate gateway retry layers; verify timeouts and budget limits in your application.</td></tr><tr><td>Vercel AI SDK</td><td>Configure your OpenAI provider's baseURL and apiKey for Exchange.</td><td>Gateway-specific providerOptions are not automatically translated.</td></tr><tr><td>Portkey</td><td>Point the application's OpenAI client directly to Exchange.</td><td>Portkey configuration ids and virtual-key headers do not carry across; recreate policies explicitly.</td></tr></tbody></table></div>`)+
+ section('3. Inspect routing without sending a prompt',`<p>Use your Network developer token for the diagnostic routes. They do not hold credits, reveal provider keys or call an upstream. Unknown provider fields are rejected; unknown prices and privacy policies cannot satisfy a hard requirement.</p>${kit.snippet({id:'migration-preview',code:`curl https://api.openvibe.exchange/v1/routing/preview \\\n -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"openvibe/auto","provider":{"sort":"price","data_collection":"deny","zdr":true,"max_price":{"prompt":1,"completion":2}}}'`})}<p>The response identifies excluded providers and the rule that excluded them. It is catalogue-only: live availability, account gates and budgets still apply. Quality and speed need measured evidence.</p>`)+
+ section('4. Validate an exported preset',`<p>Send the preset configuration, never an OpenRouter key. This preview validates names, model lists, sampling and provider rules. It returns a validation summary without repeating submitted values and does not save the preset; persistence and execution are pending.</p>${kit.snippet({id:'migration-import',code:`curl https://api.openvibe.exchange/v1/presets/import/preview \\\n -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n -H "Content-Type: application/json" \\\n -d '{"name":"writer","model":"openvibe/auto","temperature":0.4,"provider":{"sort":"price"}}'`})}`)+
+ section('5. Limit the credential',`<p>The key provisioning API accepts a name, monthly_cap_usd, allowed_models and optional expires_at in ISO 8601. Expired keys fail authentication, including reservations made with a previously resolved key. Revocation is immediate. Key metadata never returns the secret again.</p><p>Inspect the completion's x_openvibe receipt for cost and provider. Existing streaming, tool calls and structured outputs use the corresponding provider's capabilities. Test your exact media and caching inputs before moving traffic.</p>`)+
+ section('Compatibility work still under review',`<p>Legacy completions, embeddings and image-generation compatibility endpoints; model suffix execution; model fallbacks and full preset persistence; per-request generation lookup; web grounding; Report quality routing; and task/choice-driven agent streams are not implemented by this guide. The existing API reference documents the currently available endpoints.</p>${kit.button({href:'/api',label:'Current API reference',kind:'quiet'})}`)+`</div>`;
+}
+module.exports={migrationPage};
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+'use strict';
+const assert=require('node:assert/strict');
+const {boot}=require('./helpers/boot');
+(async()=>{const t=await boot();try{const r=await t.get('/docs/migrate');assert.equal(r.status,200);for(const text of ['<h1>','TechArticle','OpenRouter','LiteLLM','Vercel','Portkey','routing/preview','Idempotency-Key','still under review'])assert.ok(r.text.includes(text),text);assert.ok(!r.text.includes('sk-'));}finally{await t.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
@@ -23,7 +23,9 @@ const { createFakeAi } = require('./helpers/ai');
@@ -23,7 +23,9 @@ const { createFakeAi } = require('./helpers/ai');
},
},
],
],
}
}
- : { keys: [] };
+ : options?.method === 'POST'
+ ? { secret: 'fixture-only', key: { name: options.body.name } }
+ : { keys: [{ id: 'fixture', name: 'Expiring key', prefix: 'fixture', monthly_cap_usd: 5, allowed_models: [], expires_at: '2027-01-01T00:00:00.000Z' }] };
},
},
};
};
const t = await boot({ ai });
const t = await boot({ ai });
@@ -37,8 +39,20 @@ const { createFakeAi } = require('./helpers/ai');
@@ -37,8 +39,20 @@ const { createFakeAi } = require('./helpers/ai');
assert.equal(r.status, 200);
assert.equal(r.status, 200);
assert.ok(r.headers.get('cache-control').includes('no-store'));
assert.ok(r.headers.get('cache-control').includes('no-store'));
assert.ok(r.text.includes('Monthly cap'));
assert.ok(r.text.includes('Monthly cap'));
+ assert.ok(r.text.includes('name="expires_at"'));
+ assert.ok(r.text.includes('expires Jan 1, 2027, 00:00 UTC'), 'the expiry reads as a date with its year and time');
+ assert.ok(r.text.includes('xd-key-main'), 'main dashboard cards are preserved');
},
},
);
);
+ await check('key creation forwards optional UTC expiry', async () => {
+ const response = await t.get('/dashboard/keys', {
+ as: user,
+ form: { name: 'Expires', monthly_cap_usd: '5', allowed_models: '', expires_at: '2027-01-01T00:00' },
+ });
+ assert.equal(response.status, 200);
+ assert.equal(calls.at(-1).options.body.expires_at, '2027-01-01T00:00Z');
+ assert.ok(response.text.includes('Copy your key now'));
+ });
await check('foreign site cannot create keys', async () => {
await check('foreign site cannot create keys', async () => {
const n = calls.length;
const n = calls.length;
assert.equal(
assert.equal(