openvibers/openvibe.ai Commits
AI: the pool offers complete from OpenAI-format providers that declare chat, and /v1 answers 503 (not 502) when no upstream can (#72)
Changes
@@@ -24,6 +24,10 @@ const { appAttribution } = require('../apps');const FIELDS = ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty', 'temperature'];++// Cloudflare answers a 502 or 504 from the origin with its own HTML page, so an OpenAI client would get no JSON error:++// an upstream that failed or could not be reached is 503 (OpenAI's own "unavailable, retry"), the body kept.++const gatewaySafe = (status) => (status === 502 || status === 504 ? 503 : status);++function openaiError(res, status, message, code, type) {res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: type || (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });}@@@ -69,14 +73,14 @@ function openaiRouter({ runs, auth, config, log = console }) {const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId });run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));} catch (err) {-- const status = (err && err.status) || 500;++ const status = gatewaySafe((err && err.status) || 500);if (status === 429 && err.retryAfter) res.setHeader('Retry-After', String(err.retryAfter));if (status >= 500) log.warn(`[ai] /v1/chat/completions: ${err && err.message}`);return openaiError(res, status, err && (err.detail || err.message), err && err.code);}if (run.status === 'failed' || run.status === 'cancelled') {const code = (run.error && run.error.code) || 'run.failed';-- const status = /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429 : 502;++ const status = /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429 : 503;return openaiError(res, status, (run.error && run.error.detail) || code, code);}if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.choices)) {@@@ -230,7 +230,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no};const scope = route.constraints && route.constraints.provider_scope;for (const p of await registry.listProviders()) {-- if (p.key === 'stub' || !p.capabilities.some(f => features.includes(f))) continue;++ const caps = await offeredCapabilities(p);++ if (p.key === 'stub' || !caps.some(f => features.includes(f))) continue;if (!appOffered(ctx, p)) continue;if (scope === 'local' && !isLocalProvider(p)) continue;if (scope === 'paid' && isFreeProvider(p)) continue;@@@ -238,10 +239,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// A chat model advertised by an OpenAI-shaped provider is not an embedding, STT or image model.const type = MODEL_TYPE[operation];for (const m of await registry.listModels({ provider: p.key })) {-- if (m.type === type && m.status === 'active') await addOffer(p, m.model_key, p.capabilities);++ if (m.type === type && m.status === 'active') await addOffer(p, m.model_key, caps);}} else {-- await addOffer(p, (route.constraints && route.constraints.model) || p.default_model || null, p.capabilities);++ await addOffer(p, (route.constraints && route.constraints.model) || p.default_model || null, caps);}}// A pinned candidate stays in the pool even when the provider's capability list is incomplete (migration):@@@ -253,7 +254,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (!appOffered(ctx, p)) continue;if (scope === 'local' && !isLocalProvider(p)) continue;if (scope === 'paid' && isFreeProvider(p)) continue;-- const caps = route.capability && !p.capabilities.includes(route.capability) ? [...p.capabilities, route.capability] : p.capabilities;++ const declared = await offeredCapabilities(p);++ const caps = route.capability && !declared.includes(route.capability) ? [...declared, route.capability] : declared;await addOffer(p, pin.model || p.default_model || null, caps);}const c = route.constraints || {};@@@ -323,6 +325,17 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noreturn { order, explain };}++ /**++ * What a provider offers the pool: its declared capabilities, plus `complete` (a Chat Completions request passed++ * through, ai.complete) when it declares chat and its adapter passes one through (the OpenAI format). No provider++ * declares `complete`, so without this the pool had no offer for /v1/chat/completions (2026-10-15).++ */++ async function offeredCapabilities(p) {++ if (!p.capabilities.includes('chat') || p.capabilities.includes('complete')) return p.capabilities;++ const entry = await adapter(p.key);++ return entry && entry.adapter && entry.adapter.supports('complete') ? [...p.capabilities, 'complete'] : p.capabilities;++ }++async function candidates(route, features, operation, ctx = {}) {const { order, explain, placement: result = null, ...rest } = route.capability && operation? await poolCandidates(route, features, operation, ctx)@@@ -5,6 +5,7 @@// adapter's complete() passes tools, tool_choice and the tool messages through as written, mapping max_tokens to// max_completion_tokens (and dropping sampling knobs) for a reasoning model.const assert = require('assert');++const http = require('http');const { boot, request, token, suite, ALL } = require('./helpers');const { createOpenAiProvider } = require('../server/providers/openai');@@@ -116,5 +117,38 @@ t.test('the OpenAI-format adapter passes the request through as written', asyncassert.strictEqual(plain.supports('tools'), false, 'tools are declared, never assumed');});++t.test('production\'s shape: an OpenAI-format provider declaring chat (not complete) answers it, no stub behind it', async () => {++ const calls = [];++ const upstream = http.createServer((req, res) => {++ let b = '';++ req.on('data', (d) => { b += d; });++ req.on('end', () => {++ calls.push({ path: req.url, body: JSON.parse(b) });++ res.writeHead(200, { 'content-type': 'application/json' });++ res.end(JSON.stringify({ id: 'chatcmpl-up', object: 'chat.completion', created: 1, model: 'gpt-up', choices: [{ index: 0, message: { role: 'assistant', content: 'from upstream' }, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 } }));++ });++ });++ await new Promise((r) => upstream.listen(0, '127.0.0.1', r));++ const h2 = await boot({ env: { AI_STUB_FALLBACK: 'false' } });++ try {++ const made = await request(h2.base, 'POST', '/api/v1/providers', { tok: service, body: { key: 'up', kind: 'openai', base_url: `http://127.0.0.1:${upstream.address().port}/v1`, auth_mode: 'none', default_model: 'gpt-up', capabilities: ['chat'] } });++ assert.ok(made.status < 300, made.text);++ const r = await request(h2.base, 'POST', '/v1/chat/completions', { tok: service, body: { model: 'anything', messages: [{ role: 'user', content: 'hi' }] } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.choices[0].message.content, 'from upstream');++ assert.strictEqual(r.body.x_openvibe.provider, 'up');++ assert.deepStrictEqual([calls.length, calls[0].path, calls[0].body.model], [1, '/v1/chat/completions', 'gpt-up']);++ // Tools need a provider that declares them: none does here, so the answer is OpenAI's JSON error with 503 (never a++ // 502, which Cloudflare would replace with its own page).++ const tools = await request(h2.base, 'POST', '/v1/chat/completions', { tok: service, body: { messages: [{ role: 'user', content: 'Weather?' }], tools: [WEATHER] } });++ assert.strictEqual(tools.status, 503, tools.text);++ assert.strictEqual(tools.body.error.code, 'provider.unavailable');++ assert.strictEqual(calls.length, 1, 'the provider without tools was not asked');++ } finally {++ await h2.stop();++ await new Promise((r) => upstream.close(r));++ }++});++t.test('close', async () => { await h.stop(); });t.run();
@@ -24,6 +24,10 @@ const { appAttribution } = require('../apps');
@@ -24,6 +24,10 @@ const { appAttribution } = require('../apps');
const FIELDS = ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty', 'temperature'];
const FIELDS = ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty', 'temperature'];
+// Cloudflare answers a 502 or 504 from the origin with its own HTML page, so an OpenAI client would get no JSON error:
+// an upstream that failed or could not be reached is 503 (OpenAI's own "unavailable, retry"), the body kept.
+const gatewaySafe = (status) => (status === 502 || status === 504 ? 503 : status);
+
function openaiError(res, status, message, code, type) {
function openaiError(res, status, message, code, type) {
res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: type || (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });
res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: type || (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });
}
}
@@ -69,14 +73,14 @@ function openaiRouter({ runs, auth, config, log = console }) {
@@ -69,14 +73,14 @@ function openaiRouter({ runs, auth, config, log = console }) {
const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId });
const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId });
run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));
run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));
} catch (err) {
} catch (err) {
- const status = (err && err.status) || 500;
+ const status = gatewaySafe((err && err.status) || 500);
if (status === 429 && err.retryAfter) res.setHeader('Retry-After', String(err.retryAfter));
if (status === 429 && err.retryAfter) res.setHeader('Retry-After', String(err.retryAfter));
if (status >= 500) log.warn(`[ai] /v1/chat/completions: ${err && err.message}`);
if (status >= 500) log.warn(`[ai] /v1/chat/completions: ${err && err.message}`);
return openaiError(res, status, err && (err.detail || err.message), err && err.code);
return openaiError(res, status, err && (err.detail || err.message), err && err.code);
}
}
if (run.status === 'failed' || run.status === 'cancelled') {
if (run.status === 'failed' || run.status === 'cancelled') {
const code = (run.error && run.error.code) || 'run.failed';
const code = (run.error && run.error.code) || 'run.failed';
- const status = /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429 : 502;
+ const status = /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429 : 503;
return openaiError(res, status, (run.error && run.error.detail) || code, code);
return openaiError(res, status, (run.error && run.error.detail) || code, code);
}
}
if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.choices)) {
if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.choices)) {
@@ -230,7 +230,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -230,7 +230,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
};
};
const scope = route.constraints && route.constraints.provider_scope;
const scope = route.constraints && route.constraints.provider_scope;
for (const p of await registry.listProviders()) {
for (const p of await registry.listProviders()) {
- if (p.key === 'stub' || !p.capabilities.some(f => features.includes(f))) continue;
+ const caps = await offeredCapabilities(p);
+ if (p.key === 'stub' || !caps.some(f => features.includes(f))) continue;
if (!appOffered(ctx, p)) continue;
if (!appOffered(ctx, p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
@@ -238,10 +239,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -238,10 +239,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// A chat model advertised by an OpenAI-shaped provider is not an embedding, STT or image model.
// A chat model advertised by an OpenAI-shaped provider is not an embedding, STT or image model.
const type = MODEL_TYPE[operation];
const type = MODEL_TYPE[operation];
for (const m of await registry.listModels({ provider: p.key })) {
for (const m of await registry.listModels({ provider: p.key })) {
- if (m.type === type && m.status === 'active') await addOffer(p, m.model_key, p.capabilities);
+ if (m.type === type && m.status === 'active') await addOffer(p, m.model_key, caps);
}
}
} else {
} else {
- await addOffer(p, (route.constraints && route.constraints.model) || p.default_model || null, p.capabilities);
+ await addOffer(p, (route.constraints && route.constraints.model) || p.default_model || null, caps);
}
}
}
}
// A pinned candidate stays in the pool even when the provider's capability list is incomplete (migration):
// A pinned candidate stays in the pool even when the provider's capability list is incomplete (migration):
@@ -253,7 +254,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -253,7 +254,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (!appOffered(ctx, p)) continue;
if (!appOffered(ctx, p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
- const caps = route.capability && !p.capabilities.includes(route.capability) ? [...p.capabilities, route.capability] : p.capabilities;
+ const declared = await offeredCapabilities(p);
+ const caps = route.capability && !declared.includes(route.capability) ? [...declared, route.capability] : declared;
await addOffer(p, pin.model || p.default_model || null, caps);
await addOffer(p, pin.model || p.default_model || null, caps);
}
}
const c = route.constraints || {};
const c = route.constraints || {};
@@ -323,6 +325,17 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -323,6 +325,17 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
return { order, explain };
return { order, explain };
}
}
+ /**
+ * What a provider offers the pool: its declared capabilities, plus `complete` (a Chat Completions request passed
+ * through, ai.complete) when it declares chat and its adapter passes one through (the OpenAI format). No provider
+ * declares `complete`, so without this the pool had no offer for /v1/chat/completions (2026-10-15).
+ */
+ async function offeredCapabilities(p) {
+ if (!p.capabilities.includes('chat') || p.capabilities.includes('complete')) return p.capabilities;
+ const entry = await adapter(p.key);
+ return entry && entry.adapter && entry.adapter.supports('complete') ? [...p.capabilities, 'complete'] : p.capabilities;
+ }
+
async function candidates(route, features, operation, ctx = {}) {
async function candidates(route, features, operation, ctx = {}) {
const { order, explain, placement: result = null, ...rest } = route.capability && operation
const { order, explain, placement: result = null, ...rest } = route.capability && operation
? await poolCandidates(route, features, operation, ctx)
? await poolCandidates(route, features, operation, ctx)
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
// adapter's complete() passes tools, tool_choice and the tool messages through as written, mapping max_tokens to
// adapter's complete() passes tools, tool_choice and the tool messages through as written, mapping max_tokens to
// max_completion_tokens (and dropping sampling knobs) for a reasoning model.
// max_completion_tokens (and dropping sampling knobs) for a reasoning model.
const assert = require('assert');
const assert = require('assert');
+const http = require('http');
const { boot, request, token, suite, ALL } = require('./helpers');
const { boot, request, token, suite, ALL } = require('./helpers');
const { createOpenAiProvider } = require('../server/providers/openai');
const { createOpenAiProvider } = require('../server/providers/openai');
@@ -116,5 +117,38 @@ t.test('the OpenAI-format adapter passes the request through as written', async
@@ -116,5 +117,38 @@ t.test('the OpenAI-format adapter passes the request through as written', async
assert.strictEqual(plain.supports('tools'), false, 'tools are declared, never assumed');
assert.strictEqual(plain.supports('tools'), false, 'tools are declared, never assumed');
});
});
+t.test('production\'s shape: an OpenAI-format provider declaring chat (not complete) answers it, no stub behind it', async () => {
+ const calls = [];
+ const upstream = http.createServer((req, res) => {
+ let b = '';
+ req.on('data', (d) => { b += d; });
+ req.on('end', () => {
+ calls.push({ path: req.url, body: JSON.parse(b) });
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ id: 'chatcmpl-up', object: 'chat.completion', created: 1, model: 'gpt-up', choices: [{ index: 0, message: { role: 'assistant', content: 'from upstream' }, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 } }));
+ });
+ });
+ await new Promise((r) => upstream.listen(0, '127.0.0.1', r));
+ const h2 = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
+ try {
+ const made = await request(h2.base, 'POST', '/api/v1/providers', { tok: service, body: { key: 'up', kind: 'openai', base_url: `http://127.0.0.1:${upstream.address().port}/v1`, auth_mode: 'none', default_model: 'gpt-up', capabilities: ['chat'] } });
+ assert.ok(made.status < 300, made.text);
+ const r = await request(h2.base, 'POST', '/v1/chat/completions', { tok: service, body: { model: 'anything', messages: [{ role: 'user', content: 'hi' }] } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.choices[0].message.content, 'from upstream');
+ assert.strictEqual(r.body.x_openvibe.provider, 'up');
+ assert.deepStrictEqual([calls.length, calls[0].path, calls[0].body.model], [1, '/v1/chat/completions', 'gpt-up']);
+ // Tools need a provider that declares them: none does here, so the answer is OpenAI's JSON error with 503 (never a
+ // 502, which Cloudflare would replace with its own page).
+ const tools = await request(h2.base, 'POST', '/v1/chat/completions', { tok: service, body: { messages: [{ role: 'user', content: 'Weather?' }], tools: [WEATHER] } });
+ assert.strictEqual(tools.status, 503, tools.text);
+ assert.strictEqual(tools.body.error.code, 'provider.unavailable');
+ assert.strictEqual(calls.length, 1, 'the provider without tools was not asked');
+ } finally {
+ await h2.stop();
+ await new Promise((r) => upstream.close(r));
+ }
+});
+
t.test('close', async () => { await h.stop(); });
t.test('close', async () => { await h.stop(); });
t.run();
t.run();