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)

OP OpenVibers committed 29 days ago · 6a0aa32ab6 · 3 files, +57 −6

**`/v1/chat/completions` reaches production's providers. The pool offers `complete` from any OpenAI-format provider that declares chat, and an unavailable upstream answers 503 rather than 502 (follow-ups to #70 and #71).**

**Why:** the production proof of #70 got `provider.unavailable` with no candidates at all. The router builds the pool's offers from each provider's declared capabilities, and no provider declares `complete`. The adapter's rule that "complete follows chat" never reached the pool. The tests passed only because the stub closes every test route, and production runs with the stub fallback off.

Cloudflare also replaces a 502 from the origin with its own HTML page, so an OpenAI client received a page instead of the JSON error.

**Changes:**
- **`server/providers/index.js`:** `offeredCapabilities(p)` returns a provider's declared capabilities plus `complete` when it declares `chat` and its adapter passes a request through (the OpenAI format). The pool's offers and its pinned candidates both use it. Anthropic and the HTTP seam still do not offer `complete`.
- **`server/api/openai.js`:** a failed run with an upstream code answers 503, OpenAI's "unavailable, retry", with the JSON body kept. A thrown 502 or 504 becomes 503 too. Input errors stay 400 and quota errors 429.

**Test (`openai-compat.test.js`):** a new check boots with the stub fallback off and one OpenAI-format provider (a local fake upstream) that declares only `chat`.
- A plain request is answered by that provider, which receives the routed model.
- A request with tools gets a 503 `provider.unavailable` JSON error, and that provider is never asked.
- Without the pool change this check fails with production's exact error, 503 "no eligible candidate".

The 15 test files this change reaches pass.

Changes

ddiff --git a/server/api/openai.js b/server/api/openai.js
iindex 48bcd16..de4899d 100644
---- a/server/api/openai.js
++++ b/server/api/openai.js
@@@ -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)) {
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 71961ae..63437f2 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -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.no
if (!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.no
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 = {}) {
const { order, explain, placement: result = null, ...rest } = route.capability && operation
? await poolCandidates(route, features, operation, ctx)
ddiff --git a/test/openai-compat.test.js b/test/openai-compat.test.js
iindex 04e5490..b99f90b 100644
---- a/test/openai-compat.test.js
++++ b/test/openai-compat.test.js
@@@ -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', async
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.run();