openvibers/openvibe.ai Commits

Let image runs use the route timeout, skip retries and clamp dall-e-3 n

OP OpenVibers committed 14 Oct 2026 · ef4058a486 · 6 files, +34 −10

Requested by finish-f1. Handed to the local harness by the cloud job pr-OpenVibe.AI-66; done by Claude opus.

Changes

ddiff --git a/README.md b/README.md
iindex f0e56f1..e6b39a8 100644
---- a/README.md
++++ b/README.md
@@@ -362,10 +362,10 @@ the service boots without Valkey; with it on, production refuses to boot without
Off unless `AI_IMAGE_MODEL` names the shared provider's image model (`dall-e-3`, `gpt-image-1`; OpenAI-shaped
providers only). Then boot gives `shared` the `image` capability, registers the model as type `image` and seeds
route `default.image` (a capability pool of registered `image` models, objective `correctness`,
--`provider_scope: paid`, timeout 120 s). Without it no route is seeded and `POST /api/v1/image` answers
++`provider_scope: paid`, timeout 120 s, above `AI_PROVIDER_TIMEOUT_MS`; a timed-out image is not retried, so it is never paid for twice). Without it no route is seeded and `POST /api/v1/image` answers
`503 route.unavailable`, like any operation whose route is not configured.
--`POST /api/v1/image` (`ai.run.create`, or `ai.app.run` for an app) takes `{ prompt, size?, n? }` (`n` 1–4;
++`POST /api/v1/image` (`ai.run.create`, or `ai.app.run` for an app) takes `{ prompt, size?, n? }` (`n` 1–4, 1 for `dall-e-3`;
`size` defaults to `AI_IMAGE_SIZE`, `1024x1024`) and answers `output: { images: [{ b64, url }], count }`
(`dall-e-*` is asked for `b64_json`; `gpt-image-1` answers base64 already). A missing prompt is
`422 input.invalid`, a provider that returns no image `502 output.invalid`, no provider `503 provider.unavailable`.
ddiff --git a/STATUS.json b/STATUS.json
iindex 379ff01..279a615 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -52,7 +52,7 @@
"owner: a provider key that serves the OpenAI Images API, then AI_IMAGE_MODEL (and AI_IMAGE_SIZE) in /etc/openvibe/ai.env — image generation stays off until then",
"owner/T5: a per-image rate card (metric output-images:<model>, per provider) — none is written by AI; until it exists an image run costs 0 in AI's usage and quotas (max_requests still bounds it)",
"owner/T5: Billing unitFits() to accept unit image (OpenVibe.Billing server/ops/rating.js) — until then Billing stores image readings unrated (billing.no_card / billing.unit_mismatch)",
-- "Contracts: list POST /api/v1/image under ai.run.create and ai.app.run implementedBy (manifests/capabilities)"
++ "Contracts: list POST /api/v1/image under ai.run.create and ai.app.run implementedBy (manifests/capabilities); add image to provider.v1 and provider-manage-request.v1 capabilities, model.v1 and provider-manage-request.v1 model type, definition-version-request.v1 step kind (media_analysis lags the same way)"
],
"updated": "2026-10-13"
}
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 809f30a..d137b2e 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -415,7 +415,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: why });
continue;
}
-- const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || route.timeout_ms || p.timeout_ms || 30000, p.timeout_ms || 600000));
++ // An image generation outlasts the shared provider's chat timeout (AI_PROVIDER_TIMEOUT_MS): the route's wins.
++ const cap = operation === 'image' ? 600000 : (p.timeout_ms || 600000);
++ const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || route.timeout_ms || p.timeout_ms || 30000, cap));
const retries = config.providerRetries;
let lastErr = null;
for (let attempt = 0; attempt <= retries; attempt++) {
@@@ -456,7 +458,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
statFor(c.provider).failures++;
if (ctx.logRequest) ctx.logRequest({ ...base, status: err && err.code === 'provider.timeout' ? 'timeout' : 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });
-- if (attempt < retries && retryable(err)) { await sleep(config.retryDelayMs + Math.floor(Math.random() * Math.min(700, config.retryDelayMs)), ctx.signal).catch(() => {}); continue; }
++ // A timed-out image may still be generated and billed upstream: never pay for it twice.
++ if (attempt < retries && retryable(err) && !(operation === 'image' && err.code === 'provider.timeout')) { await sleep(config.retryDelayMs + Math.floor(Math.random() * Math.min(700, config.retryDelayMs)), ctx.signal).catch(() => {}); continue; }
break;
}
}
ddiff --git a/server/providers/openai.js b/server/providers/openai.js
iindex 6997d52..d11db39 100644
---- a/server/providers/openai.js
++++ b/server/providers/openai.js
@@@ -110,11 +110,11 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
}
// OpenAI Images API: metered per image (usage.output = images returned), never per token.
-- // dall-e-* answers a URL unless asked for b64_json; gpt-image-1 answers base64 already.
++ // dall-e-* answers a URL unless asked for b64_json; gpt-image-1 answers base64 already; dall-e-3 takes only n=1.
async function images(req) {
const model = req.model;
if (!model) throw new ProviderError('image model is not configured', { code: 'provider.model' });
-- const body = { model, prompt: String(req.prompt || ''), n: Math.max(1, Math.min(Number(req.n) || 1, 4)) };
++ const body = { model, prompt: String(req.prompt || ''), n: Math.max(1, Math.min(Number(req.n) || 1, /^dall-e-3/i.test(model) ? 1 : 4)) };
if (req.size) body.size = String(req.size);
if (!/^gpt-image-1/i.test(model)) body.response_format = 'b64_json';
const j = await postJson(`${baseUrl}/images/generations`, headers(), body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
ddiff --git a/server/workflows/core.js b/server/workflows/core.js
iindex 4fb6a79..9f6d421 100644
---- a/server/workflows/core.js
++++ b/server/workflows/core.js
@@@ -89,7 +89,8 @@ const workflows = [
key: 'ai.image', name: 'Generate an image', namespace: 'ai',
input_schema: OBJ({ prompt: STR(4000), size: { type: 'string', pattern: '^[0-9]{3,4}x[0-9]{3,4}$' }, n: { type: 'integer', minimum: 1, maximum: 4 } }, ['prompt']),
output_schema: OBJ({ images: { type: 'array', minItems: 1, items: OBJ({ b64: { type: ['string', 'null'] }, url: { type: ['string', 'null'] } }) }, count: { type: 'integer', minimum: 1 } }, ['images', 'count']),
-- steps: [{ kind: 'image', route: 'default.image' }], cache_mode: 'private',
++ // No cache: several MB of base64 per run, and a fresh image is what a caller asks for.
++ steps: [{ kind: 'image', route: 'default.image' }], cache_mode: 'none',
},
];
ddiff --git a/test/provider-image.test.js b/test/provider-image.test.js
iindex 2a1b088..9f06988 100644
---- a/test/provider-image.test.js
++++ b/test/provider-image.test.js
@@@ -59,13 +59,15 @@ t.test('(a) the Images API answers: output.images, a request log with operation
assert.deepStrictEqual({ resource: s.resource, unit: s.unit, quantity: s.quantity, operation: s.operation, provider: s.provider },
{ resource: 'output-images:gpt-image-1', unit: 'image', quantity: 1, operation: 'ai.image', provider: 'shared' });
-- // dall-e-* is asked for b64_json; n is clamped to 1..4.
++ // dall-e-* is asked for b64_json; n is clamped to 1..4, and to 1 for dall-e-3 (which refuses more).
await h.registry.upsertModel({ provider_key: 'shared', model_key: 'dall-e-3', type: 'image' });
const a = (await h.pool.adapter('shared')).adapter;
const r = await a.image({ model: 'dall-e-3', prompt: 'x', n: 9, timeoutMs: 3000 });
assert.strictEqual(up.last.response_format, 'b64_json');
-- assert.strictEqual(up.last.n, 4);
++ assert.strictEqual(up.last.n, 1);
assert.strictEqual(r.usage.output, 1);
++ await a.image({ model: 'gpt-image-1', prompt: 'x', n: 9, timeoutMs: 3000 });
++ assert.strictEqual(up.last.n, 4);
// A missing prompt is the caller's: 422 input.invalid.
const bad = await request(h.base, 'POST', '/api/v1/image?wait=10000', { tok, body: {} });
assert.strictEqual(bad.status, 422, bad.text);
@@@ -94,6 +96,24 @@ t.test('(b) the first image provider answers 500: the pool falls back, with expl
} finally { await h.stop(); await dead.close(); await ok.close(); }
});
++t.test('(b2) the route timeout outlasts AI_PROVIDER_TIMEOUT_MS, and a timed-out image is never retried', async () => {
++ let delay = 1500;
++ const up = await imagesServer(async () => { await new Promise(r => setTimeout(r, delay)); return { data: [{ b64_json: PNG }] }; });
++ const h = await boot({ env: { ...ENV, AI_BASE_URL: `${up.url}/v1`, AI_PROVIDER_TIMEOUT_MS: '1000', AI_PROVIDER_RETRIES: '1' } });
++ try {
++ assert.strictEqual((await h.registry.getProvider('shared')).timeout_ms, 1000);
++ const route = await h.registry.resolveRoute('default.image');
++ const ok = await h.pool.execute(route, 'image', { prompt: 'x', n: 1 }, { routeKey: route.key });
++ assert.strictEqual(ok.result.images.length, 1, 'the 120 s route timeout, not the provider\'s 1 s');
++ assert.strictEqual(up.calls, 1);
++ delay = 2500;
++ let err = null;
++ try { await h.pool.execute(route, 'image', { prompt: 'x', n: 1, timeoutMs: 1000 }, { routeKey: route.key }); } catch (e) { err = e; }
++ assert.ok(err, 'timed out');
++ assert.strictEqual(up.calls, 2, 'one attempt: a retry would be a second paid generation');
++ } finally { await h.stop(); await up.close(); }
++});
++
t.test('(c) no image provider answers: 503 provider.unavailable, never synthetic', async () => {
const h = await boot({ env: { ...ENV, AI_BASE_URL: 'http://127.0.0.1:1/v1' } });
try {