openvibers/openvibe.ai Commits
Let image runs use the route timeout, skip retries and clamp dall-e-3 n
Changes
@@@ -362,10 +362,10 @@ the service boots without Valkey; with it on, production refuses to boot withoutOff unless `AI_IMAGE_MODEL` names the shared provider's image model (`dall-e-3`, `gpt-image-1`; OpenAI-shapedproviders only). Then boot gives `shared` the `image` capability, registers the model as type `image` and seedsroute `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`.@@@ -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"}@@@ -415,7 +415,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (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;}}@@@ -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 });@@@ -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',},];@@@ -59,13 +59,15 @@ t.test('(a) the Images API answers: output.images, a request log with operationassert.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 {
@@ -362,10 +362,10 @@ the service boots without Valkey; with it on, production refuses to boot without
@@ -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
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
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`,
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.
`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 }`
`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
(`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`.
`422 input.invalid`, a provider that returns no image `502 output.invalid`, no provider `503 provider.unavailable`.
@@ -52,7 +52,7 @@
@@ -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: 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: 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)",
"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"
"updated": "2026-10-13"
}
}
@@ -415,7 +415,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -415,7 +415,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: why });
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: why });
continue;
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;
const retries = config.providerRetries;
let lastErr = null;
let lastErr = null;
for (let attempt = 0; attempt <= retries; attempt++) {
for (let attempt = 0; attempt <= retries; attempt++) {
@@ -456,7 +458,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -456,7 +458,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
statFor(c.provider).failures++;
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 (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;
break;
}
}
}
}
@@ -110,11 +110,11 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
@@ -110,11 +110,11 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
}
}
// OpenAI Images API: metered per image (usage.output = images returned), never per token.
// 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) {
async function images(req) {
const model = req.model;
const model = req.model;
if (!model) throw new ProviderError('image model is not configured', { code: 'provider.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 (req.size) body.size = String(req.size);
if (!/^gpt-image-1/i.test(model)) body.response_format = 'b64_json';
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 });
const j = await postJson(`${baseUrl}/images/generations`, headers(), body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
@@ -89,7 +89,8 @@ const workflows = [
@@ -89,7 +89,8 @@ const workflows = [
key: 'ai.image', name: 'Generate an image', namespace: 'ai',
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']),
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']),
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',
},
},
];
];
@@ -59,13 +59,15 @@ t.test('(a) the Images API answers: output.images, a request log with operation
@@ -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 },
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' });
{ 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' });
await h.registry.upsertModel({ provider_key: 'shared', model_key: 'dall-e-3', type: 'image' });
const a = (await h.pool.adapter('shared')).adapter;
const a = (await h.pool.adapter('shared')).adapter;
const r = await a.image({ model: 'dall-e-3', prompt: 'x', n: 9, timeoutMs: 3000 });
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.response_format, 'b64_json');
- assert.strictEqual(up.last.n, 4);
+ assert.strictEqual(up.last.n, 1);
assert.strictEqual(r.usage.output, 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.
// A missing prompt is the caller's: 422 input.invalid.
const bad = await request(h.base, 'POST', '/api/v1/image?wait=10000', { tok, body: {} });
const bad = await request(h.base, 'POST', '/api/v1/image?wait=10000', { tok, body: {} });
assert.strictEqual(bad.status, 422, bad.text);
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
@@ -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(); }
} 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 () => {
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' } });
const h = await boot({ env: { ...ENV, AI_BASE_URL: 'http://127.0.0.1:1/v1' } });
try {
try {