openvibers/openvibe.ai Commits
Let owners order managed providers per capability (#441)
Changes
nnew file mode 100644@@@ -0,0 +1,13 @@++# Owner provider order++++The console's Provider order section stores each capability's array in the existing versioned route table (`provider-order.<capability>`). It does not create a second provider/model list. An owner enables an order explicitly; no order is enabled by installation. Turning the order off restores normal routing. An enabled empty array allows no managed offer.++++The owner API reads `GET /api/v1/admin/console/orders` and writes `POST /api/v1/admin/console/orders/:capability` with `{ "enabled": true, "choices": [{ "provider": "openai", "model": "registered-model-id" }] }`. Choices must name existing providers and models, cannot repeat, and are limited to 64. Existing provider-management authorization, audit and route versions apply. Exchange additionally requires its owner role and same-origin writes.++++Chat, vision, tools, image, video, speech, transcription, embeddings and rerank have separate orders. Chat-compatible operations use chat; image input uses vision and a tools request uses tools. Eligibility still applies: content tier and adult floor, provider access, scope, payer, price ceiling and model support are checked before an attempt. Own-key routing remains the person's preference. Exact-artifact requests cannot substitute another model.++++Managed candidates that pass those checks run in the owner's order. A failed provider proceeds to the next choice without retrying that provider. Provider and model off switches are rechecked at each attempt. Disabled, gone and failed attempts remain in the existing receipt. An explicit order may reach more than the ordinary three-provider fallback limit. Run-specific exact pins retain their existing single-offer behavior.++++This slice covers model routing. Search order arrays and provider spend caps follow separately; no search behavior changes here.++++For Fable: the console Provider order section uses plain kit forms and a JSON array editor. It needs the designed order/reorder controls and model selection treatment.@@@ -13,6 +13,7 @@ const { tokenCosts } = require('../gpu/token-cost');/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, abuse = null, priceBook = null, modelSync = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {const r = express.Router();++ const providerOrder = require('../providers/order').createProviderOrder({ registry });const supply = require('../platform/supply').createSupply({ db, registry, clock });const ownerConfig = require('../owner-config').createOwnerConfig({ db, registry, clock });// The public marketplace readings and the per-token cost they imply, added to both console sections. Data only:@@@ -71,6 +72,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,return { models: (await registry.listModels({ provider })).map(m => modelView(m, providers.find(p => p.key === m.provider_key))),provider: provider || null, sync_log: modelSync ? await modelSync.read(provider) : [] };}++ if (section === 'orders') return { orders: await providerOrder.list() };if (section === 'sync') return { sync_log: modelSync ? await modelSync.read() : [] };if (section === 'runs') {const { filters, form, bad } = q.parseRunFilters(req.query);@@@ -132,6 +134,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,}));r.post('/api/v1/admin/console/supply/quotes', json, write, wrap(async req => ({ recorded: await require('../pricebook/gpus').recordGpuQuotes(db, JSON.stringify(req.body.quotes)), dry_run: true })));r.post('/api/v1/admin/console/supply', json, write, wrap(req => supply.save(req.body || {}, actor(req))));++ r.post('/api/v1/admin/console/orders/:capability', json, write, wrap(req => providerOrder.save(req.params.capability, req.body, { actor: actor(req), trace: req.traceId })));r.post('/api/v1/admin/console/owner-inference/spend-caps', json, write, wrap(async (req) => ({ cap: await ownerConfig.setCap(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));r.post('/api/v1/admin/console/owner-inference/routing', json, write, wrap(async (req) => ({ routing: await ownerConfig.setRouting(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));r.post('/api/v1/admin/console/owner-inference/economy', json, write, wrap(async (req) => ({ economy: await ownerConfig.setEconomy(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));@@@ -1537,6 +1537,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// CHECKPOINT D3: an exact id runs only its own artifact. The candidates are the catalog's deployments of// that id, filtered first by commercial access and what the request needs; no other model is ever added.const exact = ctx.exact || null;++ const ownerOrder = await require('./order').createProviderOrder({ registry }).read(features.includes('vision') ? 'vision' : features.includes('tools') ? 'tools' : operation);++ const ordered = ownerOrder?.enabled === true;++ // Feed configured models through the existing eligibility and policy checks.++ if (ordered && !exact) route = { ...route, capability: route.capability || operation, pinned: ownerOrder.choices };// A routing preset's objective is the more specific request: it decides what the router optimises// (cheapest, fastest, balanced, private). An exact id stays exact: a pin is never re-ranked.const presetObjective = ctx.presetPolicy && ctx.presetPolicy.objective ? ctx.presetPolicy.objective : null;@@@ -1747,6 +1751,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (uncensored) explain.label = tier.LABEL;const attempts = [...selfAttempts];explain.paid_by = 'openvibe';++ if (ordered) {++ list = require('./order').orderedSubset(list, ownerOrder.choices);++ explain.reasons = [`owner ${ownerOrder.capability} order v${ownerOrder.version}`, ...(explain.reasons || [])];++ }explain.attempts = attempts;explain.reasons = [...(explain.reasons || []), ...(selfAttempts.length ? ['your own keys did not answer; the network is the fallback'] : [])];// A first-party service's generate run may name one offer from GET /api/v1/routes; the advisor's own@@@ -1763,14 +1771,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noexplain.reasons = [`pinned to ${chosen.model ? `${chosen.provider}/${chosen.model}` : chosen.provider} for this run`, ...(explain.reasons || [])];}// L36 Y7: the fallback chain stops after three providers.-- const maxAttempts = (config.routing && config.routing.maxAttempts) || DEFAULT_MAX_ATTEMPTS;++ const maxAttempts = ordered ? ownerOrder.choices.length : (config.routing && config.routing.maxAttempts) || DEFAULT_MAX_ATTEMPTS;let attemptsMade = 0;// BA1: a run that may wait (the 'background' class) looks for capacity that is cheap because it may// take minutes: the host's own idle model first, when the route's quality allows it. The provider// order stays the router's own (the authority keeps critical work); what changes is the tier each// candidate runs on, and that is decided per candidate below.const latencyClass = latency.isClass(ctx.latencyClass) ? ctx.latencyClass : latency.DEFAULT;-- if (latencyClass !== latency.DEFAULT && !exact) {++ if (latencyClass !== latency.DEFAULT && !exact && !ordered) {list = await orderForWaiting(list, { route, operation, req });explain.latency_class = latencyClass;}@@@ -1873,7 +1881,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst room = waiting ? Math.max(latency.timeoutMs(latencyClass), Number(route.timeout_ms) || 0) : 0;const cap = (outlast || waiting) ? Math.max(own, Number(route.timeout_ms) || 0, room, operation === 'image' ? 600000 : 0) : (own || 600000);const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || route.timeout_ms || own || DEFAULT_PROVIDER_TIMEOUT_MS, cap));-- const retries = config.providerRetries;++ const retries = ordered ? 0 : config.providerRetries;// BA1: the tier this candidate runs on. A background run asks for the cheaper tier the// provider sells. A refusal of that tier (a Flex tier with no capacity, or a model that does// not serve it) sends the same run once more on the standard tier: the refusal is counted andnnew file mode 100644@@@ -0,0 +1,40 @@++'use strict';++const { AiError } = require('../util');++// Orders are ordinary versioned routes; no parallel provider or model catalogue.++const CAPABILITIES = ['chat', 'vision', 'tools', 'image', 'video', 'speech', 'transcribe', 'embed', 'rerank'];++const capabilityFor = operation => ['complete', 'messages', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json'].includes(operation) ? 'chat' : operation;++const keyFor = capability => 'provider-order.' + capability;++function createProviderOrder({ registry }) {++ async function read(capability) {++ capability = capabilityFor(capability);++ if (!CAPABILITIES.includes(capability)) return null;++ const route = await registry.getRoute(keyFor(capability));++ return route?.options?.provider_order === true ? { capability, enabled: route.status === 'active', version: route.version, choices: route.pinned || [] } : null;++ }++ async function list() {++ return Promise.all(CAPABILITIES.map(async capability => await read(capability) || { capability, enabled: false, version: null, choices: [] }));++ }++ async function save(capability, input, actor) {++ if (!CAPABILITIES.includes(capability) || typeof input?.enabled !== 'boolean' || !Array.isArray(input.choices) || input.choices.length > 64)++ throw new AiError(422, 'ai.invalid', 'A supported capability, enabled flag and at most 64 choices are required');++ const choices = [], seen = new Set();++ for (const choice of input.choices) {++ if (!choice || typeof choice.provider !== 'string' || choice.model != null && typeof choice.model !== 'string' || Object.keys(choice).some(k => !['provider', 'model'].includes(k)))++ throw new AiError(422, 'ai.invalid', 'Each choice needs a provider and optional model');++ const provider = await registry.getProvider(choice.provider);++ if (!provider) throw new AiError(422, 'ai.invalid', 'Unknown provider');++ const model = choice.model || provider.default_model || null;++ if (!model || !await registry.getModel(provider.key, model)) throw new AiError(422, 'ai.invalid', 'Model choices must name a registered model');++ const id = provider.key + ':' + model;++ if (seen.has(id)) throw new AiError(422, 'ai.invalid', 'Duplicate choice');++ seen.add(id); choices.push({ provider: provider.key, model });++ }++ await registry.createRouteVersion(keyFor(capability), { capability, pinned: choices, status: input.enabled ? 'active' : 'disabled', options: { provider_order: true } }, actor);++ return read(capability);++ }++ return { read, list, save };++}++function orderedSubset(candidates, choices) {++ return choices.flatMap(choice => candidates.filter(c => c.provider === choice.provider && (choice.model == null || c.model === choice.model)));++}++module.exports = { createProviderOrder, CAPABILITIES, capabilityFor, orderedSubset };@@@ -130,6 +130,7 @@ t.test('the seed writes the privacy label, the quantization and the refusal rateconst saved = new Map();const models = new Map();const registry = {++ getRoute: async () => null,getProvider: async (key) => saved.get(key) || null,upsertProvider: async (row) => { saved.set(row.key, { ...row, metadata: row.metadata || {} }); },getModel: async (p, m) => models.get(`${p}/${m}`) || null,@@@ -160,6 +161,7 @@ t.test('seed refreshes unused endpoints while preserving configured and owner-mabase_url: 'https://legacy.example/v1', secret_ref: 'env:REGISTRY_TEST_KEY', metadata: {},}]]);const registry = {++ getRoute: async () => null,getProvider: async key => records.get(key) || null,upsertProvider: async (row, options) => records.set(row.key, { ...records.get(row.key), ...row, origin: options.origin }),getModel: async () => null, upsertModel: async () => {},@@@ -202,6 +204,7 @@ t.test('a catalogue offer with no key is off: the pool skips it no_credentials',// credential gate is what this test is about, so the row carries the access mode the owner would grant.const row = { ...seed.recordOf(pool.byKey.get('qwen')), status: 'active', provider_access: 'OWN_DEPLOYMENT', origin: 'catalog' };const registry = {++ getRoute: async () => null,getProvider: async (k) => (k === 'qwen' ? row : null),listProviders: async () => [row],listModels: async () => [],@@@ -28,6 +28,7 @@ function provider(key, extra = {}) {/** A pool over a fixed provider map; models is keyed '<provider>/<model>' -> a models row. */function poolFor(providers, models = {}) {const registry = {++ getRoute: async () => null,getProvider: async (k) => providers[k] || null,listProviders: async () => Object.values(providers),listModels: async () => [],nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++const assert = require('node:assert/strict');++const { boot, request, token, suite } = require('./helpers');++const { createProviderOrder } = require('../server/providers/order');++const t = suite('provider-order');++const owner = token('exchange', ['ai.provider.manage', 'ai.usage.read']);++const denied = token('chat', ['ai.run.read']);++let h, orders;++const choices = ['order-second', 'order-first'].map(provider => ({ provider, model: 'm' }));++const route = { key: 'order-test', primary: { provider: 'order-first', model: 'm' }, fallbacks: [] };++const run = () => h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], maxTokens: 20 });++t.test('orders start off and use the existing versioned route store', async () => {++ h = await boot(); orders = createProviderOrder({ registry: h.registry });++ assert.ok((await orders.list()).every(r => !r.enabled));++ for (const choice of choices) {++ await h.registry.upsertProvider({ key: choice.provider, kind: 'stub', status: 'active', auth_mode: 'none', capabilities: ['chat'], default_model: 'm' });++ await h.registry.upsertModel({ provider_key: choice.provider, model_key: 'm', status: 'active' });++ }++ assert.equal((await run()).provider, 'order-first');++ const saved = await orders.save('chat', { enabled: true, choices });++ assert.equal(saved.version, 1);++ assert.deepEqual((await h.registry.getRoute('provider-order.chat')).pinned, choices);++ assert.equal((await run()).provider, 'order-second');++});++t.test('provider off takes effect on the next run and retains the fallback reason', async () => {++ await h.registry.upsertProvider({ key: 'order-second', status: 'disabled' });++ const result = await run();++ assert.equal(result.provider, 'order-first');++ assert.ok(result.explain.attempts.some(a => a.provider === 'order-second' && a.status === 'skipped' && a.error === 'disabled'));++ await h.registry.upsertProvider({ key: 'order-second', status: 'active' });++ assert.equal((await run()).provider, 'order-second');++});++t.test('a provider failure reaches the next configured choice', async () => {++ const entry = await h.pool.adapter('order-second');++ const original = entry.adapter.chat;++ entry.adapter.chat = async () => { throw Object.assign(new Error('test timeout'), { code: 'provider.timeout' }); };++ try {++ const result = await run();++ assert.equal(result.provider, 'order-first');++ assert.ok(result.explain.attempts.some(a => a.provider === 'order-second' && a.status === 'timeout'));++ } finally { entry.adapter.chat = original; await h.pool.resetHealth('order-second'); }++});++t.test('empty enabled arrays fail closed, while disabling the override restores ordinary routing', async () => {++ await orders.save('chat', { enabled: true, choices: [] });++ await assert.rejects(run);++ await orders.save('chat', { enabled: false, choices });++ assert.equal((await run()).provider, 'order-first');++});++t.test('owner capability gate and invalid references cannot change an order', async () => {++ const path = '/api/v1/admin/console/orders/chat';++ assert.equal((await request(h.base, 'POST', path, { tok: denied, body: { enabled: true, choices } })).status, 403);++ for (const invalid of [{ enabled: true, choices: [{ provider: 'missing', model: 'm' }] }, { enabled: true, choices: [choices[0], choices[0]] }, { enabled: 'true', choices }]) {++ assert.equal((await request(h.base, 'POST', path, { tok: owner, body: invalid })).status, 422);++ }++ const saved = await request(h.base, 'POST', path, { tok: owner, body: { enabled: true, choices } });++ assert.equal(saved.status, 200);++ assert.equal((await orders.read('chat')).enabled, true);++});++t.run(() => h && h.stop());
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+# Owner provider order
+
+The console's Provider order section stores each capability's array in the existing versioned route table (`provider-order.<capability>`). It does not create a second provider/model list. An owner enables an order explicitly; no order is enabled by installation. Turning the order off restores normal routing. An enabled empty array allows no managed offer.
+
+The owner API reads `GET /api/v1/admin/console/orders` and writes `POST /api/v1/admin/console/orders/:capability` with `{ "enabled": true, "choices": [{ "provider": "openai", "model": "registered-model-id" }] }`. Choices must name existing providers and models, cannot repeat, and are limited to 64. Existing provider-management authorization, audit and route versions apply. Exchange additionally requires its owner role and same-origin writes.
+
+Chat, vision, tools, image, video, speech, transcription, embeddings and rerank have separate orders. Chat-compatible operations use chat; image input uses vision and a tools request uses tools. Eligibility still applies: content tier and adult floor, provider access, scope, payer, price ceiling and model support are checked before an attempt. Own-key routing remains the person's preference. Exact-artifact requests cannot substitute another model.
+
+Managed candidates that pass those checks run in the owner's order. A failed provider proceeds to the next choice without retrying that provider. Provider and model off switches are rechecked at each attempt. Disabled, gone and failed attempts remain in the existing receipt. An explicit order may reach more than the ordinary three-provider fallback limit. Run-specific exact pins retain their existing single-offer behavior.
+
+This slice covers model routing. Search order arrays and provider spend caps follow separately; no search behavior changes here.
+
+For Fable: the console Provider order section uses plain kit forms and a JSON array editor. It needs the designed order/reorder controls and model selection treatment.
@@ -13,6 +13,7 @@ const { tokenCosts } = require('../gpu/token-cost');
@@ -13,6 +13,7 @@ const { tokenCosts } = require('../gpu/token-cost');
/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */
/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */
function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, abuse = null, priceBook = null, modelSync = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, abuse = null, priceBook = null, modelSync = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
const r = express.Router();
const r = express.Router();
+ const providerOrder = require('../providers/order').createProviderOrder({ registry });
const supply = require('../platform/supply').createSupply({ db, registry, clock });
const supply = require('../platform/supply').createSupply({ db, registry, clock });
const ownerConfig = require('../owner-config').createOwnerConfig({ db, registry, clock });
const ownerConfig = require('../owner-config').createOwnerConfig({ db, registry, clock });
// The public marketplace readings and the per-token cost they imply, added to both console sections. Data only:
// The public marketplace readings and the per-token cost they imply, added to both console sections. Data only:
@@ -71,6 +72,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -71,6 +72,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
return { models: (await registry.listModels({ provider })).map(m => modelView(m, providers.find(p => p.key === m.provider_key))),
return { models: (await registry.listModels({ provider })).map(m => modelView(m, providers.find(p => p.key === m.provider_key))),
provider: provider || null, sync_log: modelSync ? await modelSync.read(provider) : [] };
provider: provider || null, sync_log: modelSync ? await modelSync.read(provider) : [] };
}
}
+ if (section === 'orders') return { orders: await providerOrder.list() };
if (section === 'sync') return { sync_log: modelSync ? await modelSync.read() : [] };
if (section === 'sync') return { sync_log: modelSync ? await modelSync.read() : [] };
if (section === 'runs') {
if (section === 'runs') {
const { filters, form, bad } = q.parseRunFilters(req.query);
const { filters, form, bad } = q.parseRunFilters(req.query);
@@ -132,6 +134,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -132,6 +134,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
}));
}));
r.post('/api/v1/admin/console/supply/quotes', json, write, wrap(async req => ({ recorded: await require('../pricebook/gpus').recordGpuQuotes(db, JSON.stringify(req.body.quotes)), dry_run: true })));
r.post('/api/v1/admin/console/supply/quotes', json, write, wrap(async req => ({ recorded: await require('../pricebook/gpus').recordGpuQuotes(db, JSON.stringify(req.body.quotes)), dry_run: true })));
r.post('/api/v1/admin/console/supply', json, write, wrap(req => supply.save(req.body || {}, actor(req))));
r.post('/api/v1/admin/console/supply', json, write, wrap(req => supply.save(req.body || {}, actor(req))));
+ r.post('/api/v1/admin/console/orders/:capability', json, write, wrap(req => providerOrder.save(req.params.capability, req.body, { actor: actor(req), trace: req.traceId })));
r.post('/api/v1/admin/console/owner-inference/spend-caps', json, write, wrap(async (req) => ({ cap: await ownerConfig.setCap(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
r.post('/api/v1/admin/console/owner-inference/spend-caps', json, write, wrap(async (req) => ({ cap: await ownerConfig.setCap(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
r.post('/api/v1/admin/console/owner-inference/routing', json, write, wrap(async (req) => ({ routing: await ownerConfig.setRouting(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
r.post('/api/v1/admin/console/owner-inference/routing', json, write, wrap(async (req) => ({ routing: await ownerConfig.setRouting(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
r.post('/api/v1/admin/console/owner-inference/economy', json, write, wrap(async (req) => ({ economy: await ownerConfig.setEconomy(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
r.post('/api/v1/admin/console/owner-inference/economy', json, write, wrap(async (req) => ({ economy: await ownerConfig.setEconomy(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
@@ -1537,6 +1537,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1537,6 +1537,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// CHECKPOINT D3: an exact id runs only its own artifact. The candidates are the catalog's deployments of
// CHECKPOINT D3: an exact id runs only its own artifact. The candidates are the catalog's deployments of
// that id, filtered first by commercial access and what the request needs; no other model is ever added.
// that id, filtered first by commercial access and what the request needs; no other model is ever added.
const exact = ctx.exact || null;
const exact = ctx.exact || null;
+ const ownerOrder = await require('./order').createProviderOrder({ registry }).read(features.includes('vision') ? 'vision' : features.includes('tools') ? 'tools' : operation);
+ const ordered = ownerOrder?.enabled === true;
+ // Feed configured models through the existing eligibility and policy checks.
+ if (ordered && !exact) route = { ...route, capability: route.capability || operation, pinned: ownerOrder.choices };
// A routing preset's objective is the more specific request: it decides what the router optimises
// A routing preset's objective is the more specific request: it decides what the router optimises
// (cheapest, fastest, balanced, private). An exact id stays exact: a pin is never re-ranked.
// (cheapest, fastest, balanced, private). An exact id stays exact: a pin is never re-ranked.
const presetObjective = ctx.presetPolicy && ctx.presetPolicy.objective ? ctx.presetPolicy.objective : null;
const presetObjective = ctx.presetPolicy && ctx.presetPolicy.objective ? ctx.presetPolicy.objective : null;
@@ -1747,6 +1751,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1747,6 +1751,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (uncensored) explain.label = tier.LABEL;
if (uncensored) explain.label = tier.LABEL;
const attempts = [...selfAttempts];
const attempts = [...selfAttempts];
explain.paid_by = 'openvibe';
explain.paid_by = 'openvibe';
+ if (ordered) {
+ list = require('./order').orderedSubset(list, ownerOrder.choices);
+ explain.reasons = [`owner ${ownerOrder.capability} order v${ownerOrder.version}`, ...(explain.reasons || [])];
+ }
explain.attempts = attempts;
explain.attempts = attempts;
explain.reasons = [...(explain.reasons || []), ...(selfAttempts.length ? ['your own keys did not answer; the network is the fallback'] : [])];
explain.reasons = [...(explain.reasons || []), ...(selfAttempts.length ? ['your own keys did not answer; the network is the fallback'] : [])];
// A first-party service's generate run may name one offer from GET /api/v1/routes; the advisor's own
// A first-party service's generate run may name one offer from GET /api/v1/routes; the advisor's own
@@ -1763,14 +1771,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1763,14 +1771,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
explain.reasons = [`pinned to ${chosen.model ? `${chosen.provider}/${chosen.model}` : chosen.provider} for this run`, ...(explain.reasons || [])];
explain.reasons = [`pinned to ${chosen.model ? `${chosen.provider}/${chosen.model}` : chosen.provider} for this run`, ...(explain.reasons || [])];
}
}
// L36 Y7: the fallback chain stops after three providers.
// L36 Y7: the fallback chain stops after three providers.
- const maxAttempts = (config.routing && config.routing.maxAttempts) || DEFAULT_MAX_ATTEMPTS;
+ const maxAttempts = ordered ? ownerOrder.choices.length : (config.routing && config.routing.maxAttempts) || DEFAULT_MAX_ATTEMPTS;
let attemptsMade = 0;
let attemptsMade = 0;
// BA1: a run that may wait (the 'background' class) looks for capacity that is cheap because it may
// BA1: a run that may wait (the 'background' class) looks for capacity that is cheap because it may
// take minutes: the host's own idle model first, when the route's quality allows it. The provider
// take minutes: the host's own idle model first, when the route's quality allows it. The provider
// order stays the router's own (the authority keeps critical work); what changes is the tier each
// order stays the router's own (the authority keeps critical work); what changes is the tier each
// candidate runs on, and that is decided per candidate below.
// candidate runs on, and that is decided per candidate below.
const latencyClass = latency.isClass(ctx.latencyClass) ? ctx.latencyClass : latency.DEFAULT;
const latencyClass = latency.isClass(ctx.latencyClass) ? ctx.latencyClass : latency.DEFAULT;
- if (latencyClass !== latency.DEFAULT && !exact) {
+ if (latencyClass !== latency.DEFAULT && !exact && !ordered) {
list = await orderForWaiting(list, { route, operation, req });
list = await orderForWaiting(list, { route, operation, req });
explain.latency_class = latencyClass;
explain.latency_class = latencyClass;
}
}
@@ -1873,7 +1881,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1873,7 +1881,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const room = waiting ? Math.max(latency.timeoutMs(latencyClass), Number(route.timeout_ms) || 0) : 0;
const room = waiting ? Math.max(latency.timeoutMs(latencyClass), Number(route.timeout_ms) || 0) : 0;
const cap = (outlast || waiting) ? Math.max(own, Number(route.timeout_ms) || 0, room, operation === 'image' ? 600000 : 0) : (own || 600000);
const cap = (outlast || waiting) ? Math.max(own, Number(route.timeout_ms) || 0, room, operation === 'image' ? 600000 : 0) : (own || 600000);
const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || route.timeout_ms || own || DEFAULT_PROVIDER_TIMEOUT_MS, cap));
const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || route.timeout_ms || own || DEFAULT_PROVIDER_TIMEOUT_MS, cap));
- const retries = config.providerRetries;
+ const retries = ordered ? 0 : config.providerRetries;
// BA1: the tier this candidate runs on. A background run asks for the cheaper tier the
// BA1: the tier this candidate runs on. A background run asks for the cheaper tier the
// provider sells. A refusal of that tier (a Flex tier with no capacity, or a model that does
// provider sells. A refusal of that tier (a Flex tier with no capacity, or a model that does
// not serve it) sends the same run once more on the standard tier: the refusal is counted and
// not serve it) sends the same run once more on the standard tier: the refusal is counted and
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+'use strict';
+const { AiError } = require('../util');
+// Orders are ordinary versioned routes; no parallel provider or model catalogue.
+const CAPABILITIES = ['chat', 'vision', 'tools', 'image', 'video', 'speech', 'transcribe', 'embed', 'rerank'];
+const capabilityFor = operation => ['complete', 'messages', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json'].includes(operation) ? 'chat' : operation;
+const keyFor = capability => 'provider-order.' + capability;
+function createProviderOrder({ registry }) {
+ async function read(capability) {
+ capability = capabilityFor(capability);
+ if (!CAPABILITIES.includes(capability)) return null;
+ const route = await registry.getRoute(keyFor(capability));
+ return route?.options?.provider_order === true ? { capability, enabled: route.status === 'active', version: route.version, choices: route.pinned || [] } : null;
+ }
+ async function list() {
+ return Promise.all(CAPABILITIES.map(async capability => await read(capability) || { capability, enabled: false, version: null, choices: [] }));
+ }
+ async function save(capability, input, actor) {
+ if (!CAPABILITIES.includes(capability) || typeof input?.enabled !== 'boolean' || !Array.isArray(input.choices) || input.choices.length > 64)
+ throw new AiError(422, 'ai.invalid', 'A supported capability, enabled flag and at most 64 choices are required');
+ const choices = [], seen = new Set();
+ for (const choice of input.choices) {
+ if (!choice || typeof choice.provider !== 'string' || choice.model != null && typeof choice.model !== 'string' || Object.keys(choice).some(k => !['provider', 'model'].includes(k)))
+ throw new AiError(422, 'ai.invalid', 'Each choice needs a provider and optional model');
+ const provider = await registry.getProvider(choice.provider);
+ if (!provider) throw new AiError(422, 'ai.invalid', 'Unknown provider');
+ const model = choice.model || provider.default_model || null;
+ if (!model || !await registry.getModel(provider.key, model)) throw new AiError(422, 'ai.invalid', 'Model choices must name a registered model');
+ const id = provider.key + ':' + model;
+ if (seen.has(id)) throw new AiError(422, 'ai.invalid', 'Duplicate choice');
+ seen.add(id); choices.push({ provider: provider.key, model });
+ }
+ await registry.createRouteVersion(keyFor(capability), { capability, pinned: choices, status: input.enabled ? 'active' : 'disabled', options: { provider_order: true } }, actor);
+ return read(capability);
+ }
+ return { read, list, save };
+}
+function orderedSubset(candidates, choices) {
+ return choices.flatMap(choice => candidates.filter(c => c.provider === choice.provider && (choice.model == null || c.model === choice.model)));
+}
+module.exports = { createProviderOrder, CAPABILITIES, capabilityFor, orderedSubset };
@@ -130,6 +130,7 @@ t.test('the seed writes the privacy label, the quantization and the refusal rate
@@ -130,6 +130,7 @@ t.test('the seed writes the privacy label, the quantization and the refusal rate
const saved = new Map();
const saved = new Map();
const models = new Map();
const models = new Map();
const registry = {
const registry = {
+ getRoute: async () => null,
getProvider: async (key) => saved.get(key) || null,
getProvider: async (key) => saved.get(key) || null,
upsertProvider: async (row) => { saved.set(row.key, { ...row, metadata: row.metadata || {} }); },
upsertProvider: async (row) => { saved.set(row.key, { ...row, metadata: row.metadata || {} }); },
getModel: async (p, m) => models.get(`${p}/${m}`) || null,
getModel: async (p, m) => models.get(`${p}/${m}`) || null,
@@ -160,6 +161,7 @@ t.test('seed refreshes unused endpoints while preserving configured and owner-ma
@@ -160,6 +161,7 @@ t.test('seed refreshes unused endpoints while preserving configured and owner-ma
base_url: 'https://legacy.example/v1', secret_ref: 'env:REGISTRY_TEST_KEY', metadata: {},
base_url: 'https://legacy.example/v1', secret_ref: 'env:REGISTRY_TEST_KEY', metadata: {},
}]]);
}]]);
const registry = {
const registry = {
+ getRoute: async () => null,
getProvider: async key => records.get(key) || null,
getProvider: async key => records.get(key) || null,
upsertProvider: async (row, options) => records.set(row.key, { ...records.get(row.key), ...row, origin: options.origin }),
upsertProvider: async (row, options) => records.set(row.key, { ...records.get(row.key), ...row, origin: options.origin }),
getModel: async () => null, upsertModel: async () => {},
getModel: async () => null, upsertModel: async () => {},
@@ -202,6 +204,7 @@ t.test('a catalogue offer with no key is off: the pool skips it no_credentials',
@@ -202,6 +204,7 @@ t.test('a catalogue offer with no key is off: the pool skips it no_credentials',
// credential gate is what this test is about, so the row carries the access mode the owner would grant.
// credential gate is what this test is about, so the row carries the access mode the owner would grant.
const row = { ...seed.recordOf(pool.byKey.get('qwen')), status: 'active', provider_access: 'OWN_DEPLOYMENT', origin: 'catalog' };
const row = { ...seed.recordOf(pool.byKey.get('qwen')), status: 'active', provider_access: 'OWN_DEPLOYMENT', origin: 'catalog' };
const registry = {
const registry = {
+ getRoute: async () => null,
getProvider: async (k) => (k === 'qwen' ? row : null),
getProvider: async (k) => (k === 'qwen' ? row : null),
listProviders: async () => [row],
listProviders: async () => [row],
listModels: async () => [],
listModels: async () => [],
@@ -28,6 +28,7 @@ function provider(key, extra = {}) {
@@ -28,6 +28,7 @@ function provider(key, extra = {}) {
/** A pool over a fixed provider map; models is keyed '<provider>/<model>' -> a models row. */
/** A pool over a fixed provider map; models is keyed '<provider>/<model>' -> a models row. */
function poolFor(providers, models = {}) {
function poolFor(providers, models = {}) {
const registry = {
const registry = {
+ getRoute: async () => null,
getProvider: async (k) => providers[k] || null,
getProvider: async (k) => providers[k] || null,
listProviders: async () => Object.values(providers),
listProviders: async () => Object.values(providers),
listModels: async () => [],
listModels: async () => [],
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { boot, request, token, suite } = require('./helpers');
+const { createProviderOrder } = require('../server/providers/order');
+const t = suite('provider-order');
+const owner = token('exchange', ['ai.provider.manage', 'ai.usage.read']);
+const denied = token('chat', ['ai.run.read']);
+let h, orders;
+const choices = ['order-second', 'order-first'].map(provider => ({ provider, model: 'm' }));
+const route = { key: 'order-test', primary: { provider: 'order-first', model: 'm' }, fallbacks: [] };
+const run = () => h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], maxTokens: 20 });
+t.test('orders start off and use the existing versioned route store', async () => {
+ h = await boot(); orders = createProviderOrder({ registry: h.registry });
+ assert.ok((await orders.list()).every(r => !r.enabled));
+ for (const choice of choices) {
+ await h.registry.upsertProvider({ key: choice.provider, kind: 'stub', status: 'active', auth_mode: 'none', capabilities: ['chat'], default_model: 'm' });
+ await h.registry.upsertModel({ provider_key: choice.provider, model_key: 'm', status: 'active' });
+ }
+ assert.equal((await run()).provider, 'order-first');
+ const saved = await orders.save('chat', { enabled: true, choices });
+ assert.equal(saved.version, 1);
+ assert.deepEqual((await h.registry.getRoute('provider-order.chat')).pinned, choices);
+ assert.equal((await run()).provider, 'order-second');
+});
+t.test('provider off takes effect on the next run and retains the fallback reason', async () => {
+ await h.registry.upsertProvider({ key: 'order-second', status: 'disabled' });
+ const result = await run();
+ assert.equal(result.provider, 'order-first');
+ assert.ok(result.explain.attempts.some(a => a.provider === 'order-second' && a.status === 'skipped' && a.error === 'disabled'));
+ await h.registry.upsertProvider({ key: 'order-second', status: 'active' });
+ assert.equal((await run()).provider, 'order-second');
+});
+t.test('a provider failure reaches the next configured choice', async () => {
+ const entry = await h.pool.adapter('order-second');
+ const original = entry.adapter.chat;
+ entry.adapter.chat = async () => { throw Object.assign(new Error('test timeout'), { code: 'provider.timeout' }); };
+ try {
+ const result = await run();
+ assert.equal(result.provider, 'order-first');
+ assert.ok(result.explain.attempts.some(a => a.provider === 'order-second' && a.status === 'timeout'));
+ } finally { entry.adapter.chat = original; await h.pool.resetHealth('order-second'); }
+});
+t.test('empty enabled arrays fail closed, while disabling the override restores ordinary routing', async () => {
+ await orders.save('chat', { enabled: true, choices: [] });
+ await assert.rejects(run);
+ await orders.save('chat', { enabled: false, choices });
+ assert.equal((await run()).provider, 'order-first');
+});
+t.test('owner capability gate and invalid references cannot change an order', async () => {
+ const path = '/api/v1/admin/console/orders/chat';
+ assert.equal((await request(h.base, 'POST', path, { tok: denied, body: { enabled: true, choices } })).status, 403);
+ for (const invalid of [{ enabled: true, choices: [{ provider: 'missing', model: 'm' }] }, { enabled: true, choices: [choices[0], choices[0]] }, { enabled: 'true', choices }]) {
+ assert.equal((await request(h.base, 'POST', path, { tok: owner, body: invalid })).status, 422);
+ }
+ const saved = await request(h.base, 'POST', path, { tok: owner, body: { enabled: true, choices } });
+ assert.equal(saved.status, 200);
+ assert.equal((await orders.read('chat')).enabled, true);
+});
+t.run(() => h && h.stop());