openvibers/openvibe.ai Commits
Schema cache: the compiled-validator cache shared by every caller (ai.extract/ai.enrich schemas are caller-supplied) is an LRU capped by entry count and total schema bytes (AI_SCHEMA_CACHE_MAX=500, AI_SCHEMA_CACHE_MAX_BYTES=8 MiB); evicted and failed schemas are removed from Ajv's own cache too, and a schema larger than the byte budget is compiled for that call only
Changes
@@@ -84,6 +84,9 @@ AI_MAX_COST_USD_PER_DAY=0#AI_CACHE_TTL_SEC=604800#AI_MAX_INPUT_BYTES=6291456#AI_RUN_RETENTION_DAYS=30++# Compiled-schema cache (LRU shared by every caller; ai.extract/ai.enrich schemas are caller-supplied)++#AI_SCHEMA_CACHE_MAX=500++#AI_SCHEMA_CACHE_MAX_BYTES=8388608# Raw prompt/response logging: opt-in debugging only, and only for runs whose caller sets options.debug#AI_DEBUG_RAW_LOG=false#AI_BREAKER_FAILURES=3@@@ -142,6 +142,7 @@ stub joins every route as a last resort outside production only (`AI_STUB_FALLBA| The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` || Idempotency, cancel (running and queued), retry, async polling, audit rows, restart recovery, no raw prompts or inline images kept | `test/runs.test.js` || Media fetched only from allow-listed https OpenVibe hosts; DNS answers and every redirect hop re-checked (a public hop never reaches internal Media); size caps | `test/ssrf.test.js` |++| The shared compiled-schema cache (caller-supplied schemas) is an LRU bounded by count and bytes, in Ajv too | `test/schemas.test.js` || Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` || Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` || Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |@@@ -140,6 +140,12 @@ function load(env = process.env) {maxInputBytes: int(env.AI_MAX_INPUT_BYTES, 6 * 1024 * 1024),retentionDays: int(env.AI_RUN_RETENTION_DAYS, 30),},++ // Compiled JSON Schema validators (server/schemas.js): an LRU shared by every caller, some of++ // whose schemas are caller-supplied, so it is bounded by count and by total schema size.++ schemaCache: {++ maxEntries: Math.max(1, int(env.AI_SCHEMA_CACHE_MAX, 500)),++ maxBytes: Math.max(1024, int(env.AI_SCHEMA_CACHE_MAX_BYTES, 8 * 1024 * 1024)),++ },// Raw prompt/response logging is opt-in debugging only, and only for callers that ask.debugRawLog: bool(env.AI_DEBUG_RAW_LOG, false),@@@ -18,10 +18,12 @@ const { createQuotas } = require('./quota');const { createRuns } = require('./runs');const { seed } = require('./workflows/seed');const { createKeyStore, createAuth } = require('./auth');++const schemas = require('./schemas');const { createApp } = require('./app');async function start({ config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true } = {}) {config = config || load(env);++ schemas.configure(config.schemaCache);const db = openDb(config.dbPath);const registry = createRegistry(db, { clock, env });const quotas = createQuotas(db, { clock, registry });@@@ -2,20 +2,66 @@/*** JSON Schema (2020-12) validation for workflow/template input and output schemas. Compiled* validators are cached by schema hash, so a new version of a workflow compiles once.++ *++ * The cache is shared by every caller and some schemas are caller-supplied (ai.extract / ai.enrich++ * send their own), so it is an LRU bounded by entry count and by the total size of the cached++ * schemas (AI_SCHEMA_CACHE_MAX, AI_SCHEMA_CACHE_MAX_BYTES). Ajv keeps its own reference to every++ * schema it compiles; an evicted entry is removed from Ajv too, so neither side grows without bound.++ * A schema larger than the whole byte budget is compiled for that call and not kept.*/const Ajv2020 = require('ajv/dist/2020');--const { sha256 } = require('./util');++const { sha256, stableStringify } = require('./util');const ajv = new Ajv2020({ allErrors: true, strict: false, allowUnionTypes: true, validateFormats: false });--const compiled = new Map();++const compiled = new Map(); // hash -> { fn, schema, bytes }; Map order is recency (oldest first)++const limits = { maxEntries: 500, maxBytes: 8 * 1024 * 1024 };++let bytesHeld = 0;++++function forget(key) {++ const e = compiled.get(key);++ if (!e) return;++ compiled.delete(key);++ bytesHeld -= e.bytes;++ try { ajv.removeSchema(e.schema); } catch { /* not held by Ajv */ }++}++++function trim() {++ for (const key of compiled.keys()) {++ if (compiled.size <= limits.maxEntries && bytesHeld <= limits.maxBytes) break;++ forget(key);++ }++}++++/** Set the bounds (server/index.js passes config.schemaCache); shrinking evicts at once. */++function configure({ maxEntries, maxBytes } = {}) {++ if (Number.isFinite(maxEntries) && maxEntries >= 1) limits.maxEntries = Math.floor(maxEntries);++ if (Number.isFinite(maxBytes) && maxBytes >= 1) limits.maxBytes = Math.floor(maxBytes);++ trim();++}function compile(schema) {-- const key = sha256(schema || {});-- let fn = compiled.get(key);-- if (!fn) {-- fn = ajv.compile(schema || {});-- compiled.set(key, fn);++ const s = schema || {};++ const text = stableStringify(s);++ const key = sha256(text);++ const hit = compiled.get(key);++ if (hit) {++ compiled.delete(key); // most recently used goes to the end++ compiled.set(key, hit);++ return hit.fn;++ }++ let fn;++ try { fn = ajv.compile(s); } catch (e) {++ try { ajv.removeSchema(s); } catch { /* */ }++ throw e;++ }++ const bytes = Buffer.byteLength(text);++ if (bytes > limits.maxBytes) {++ try { ajv.removeSchema(s); } catch { /* */ }++ return fn;}++ compiled.set(key, { fn, schema: s, bytes });++ bytesHeld += bytes;++ trim();return fn;}@@@ -32,4 +78,9 @@ function validate(schema, value) {return { valid: Boolean(valid), errors: valid ? [] : (fn.errors || []).slice(0, 20).map(e => ({ path: e.instancePath || '/', message: e.message })) };}--module.exports = { compile, assertSchema, validate };++/** Cache occupancy (tests, status): our entries and bytes, and the schemas Ajv itself still holds. */++function stats() {++ return { entries: compiled.size, bytes: bytesHeld, max_entries: limits.maxEntries, max_bytes: limits.maxBytes, ajv_held: ajv._cache ? ajv._cache.size : null };++}++++module.exports = { compile, assertSchema, validate, configure, stats };nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++// The compiled-schema cache is shared by every caller and holds caller-supplied schemas++// (ai.extract / ai.enrich), so it must stay bounded: an LRU capped by entry count and by total schema++// size, with evicted schemas released by Ajv too. Validation stays correct across evictions.++++const assert = require('assert');++const { suite, boot, request, token, ALL } = require('./helpers');++const schemas = require('../server/schemas');++++const t = suite('schemas');++const distinct = (i) => ({ type: 'object', required: [`k${i}`], properties: { [`k${i}`]: { type: 'string' } } });++++t.test('the cache never grows past its entry cap, and Ajv lets go of evicted schemas', () => {++ schemas.configure({ maxEntries: 50, maxBytes: 1024 * 1024 });++ const base = schemas.stats().ajv_held - schemas.stats().entries; // Ajv's own meta-schemas++ for (let i = 0; i < 2000; i++) schemas.compile(distinct(i));++ const s = schemas.stats();++ assert.ok(s.entries <= 50, `entries ${s.entries}`);++ assert.ok(s.ajv_held <= base + 50, `Ajv still holds ${s.ajv_held - base} compiled schemas`);++});++++t.test('least recently used goes first; a hit refreshes an entry', () => {++ schemas.configure({ maxEntries: 3, maxBytes: 1024 * 1024 });++ const a = schemas.compile(distinct('a'));++ schemas.compile(distinct('b'));++ schemas.compile(distinct('c'));++ assert.strictEqual(schemas.compile(distinct('a')), a, 'a is cached (and now most recent)');++ schemas.compile(distinct('d')); // evicts b, not a++ assert.strictEqual(schemas.compile(distinct('a')), a, 'a survived');++ assert.strictEqual(schemas.stats().entries, 3);++});++++t.test('the byte cap bounds total schema size; an oversized schema is compiled but not kept', () => {++ schemas.configure({ maxEntries: 1000, maxBytes: 4096 });++ const big = (i) => ({ type: 'object', description: `${i}`.padEnd(1500, 'x') });++ for (let i = 0; i < 40; i++) schemas.compile(big(i));++ const s = schemas.stats();++ assert.ok(s.bytes <= 4096, `bytes ${s.bytes}`);++ assert.ok(s.entries <= 3, `entries ${s.entries}`);++ const huge = { type: 'object', required: ['x'], description: 'y'.repeat(10000) };++ const before = schemas.stats();++ assert.strictEqual(schemas.validate(huge, {}).valid, false);++ assert.strictEqual(schemas.validate(huge, { x: 1 }).valid, true);++ assert.strictEqual(schemas.stats().entries, before.entries, 'not cached');++ assert.ok(schemas.stats().ajv_held <= before.ajv_held, 'not held by Ajv either');++});++++t.test('invalid schemas leave nothing behind; evicted schemas still validate correctly', () => {++ schemas.configure({ maxEntries: 5, maxBytes: 1024 * 1024 });++ const held = schemas.stats().ajv_held;++ for (let i = 0; i < 200; i++) assert.throws(() => schemas.assertSchema({ type: 'object', properties: { [`p${i}`]: { type: 'no-such-type' } } }), /does not compile/);++ assert.ok(schemas.stats().ajv_held <= held, `failed compiles leaked ${schemas.stats().ajv_held - held} schemas into Ajv`);++ const s0 = distinct(0);++ assert.strictEqual(schemas.validate(s0, {}).valid, false);++ for (let i = 1; i < 20; i++) schemas.compile(distinct(i)); // s0 evicted++ assert.strictEqual(schemas.validate(s0, {}).valid, false);++ assert.strictEqual(schemas.validate(s0, { k0: 'v' }).valid, true);++});++++t.test('config: AI_SCHEMA_CACHE_MAX / AI_SCHEMA_CACHE_MAX_BYTES reach the cache at boot', async () => {++ const h = await boot({ env: { AI_SCHEMA_CACHE_MAX: '7', AI_SCHEMA_CACHE_MAX_BYTES: '65536' } });++ try {++ const tok = token('live', ALL);++ for (let i = 0; i < 20; i++) {++ const r = await request(h.base, 'POST', '/api/v1/extract?wait=3000', { tok, body: { text: `item ${i}`, schema: distinct(`x${i}`), options: { cache: false } } });++ assert.ok(r.status < 500, `extract ${i}: ${r.status}`);++ }++ const s = schemas.stats();++ assert.strictEqual(s.max_entries, 7);++ assert.strictEqual(s.max_bytes, 65536);++ assert.ok(s.entries <= 7, `entries ${s.entries}`);++ } finally { await h.stop(); }++});++++t.run();
@@ -84,6 +84,9 @@ AI_MAX_COST_USD_PER_DAY=0
@@ -84,6 +84,9 @@ AI_MAX_COST_USD_PER_DAY=0
#AI_CACHE_TTL_SEC=604800
#AI_CACHE_TTL_SEC=604800
#AI_MAX_INPUT_BYTES=6291456
#AI_MAX_INPUT_BYTES=6291456
#AI_RUN_RETENTION_DAYS=30
#AI_RUN_RETENTION_DAYS=30
+# Compiled-schema cache (LRU shared by every caller; ai.extract/ai.enrich schemas are caller-supplied)
+#AI_SCHEMA_CACHE_MAX=500
+#AI_SCHEMA_CACHE_MAX_BYTES=8388608
# Raw prompt/response logging: opt-in debugging only, and only for runs whose caller sets options.debug
# Raw prompt/response logging: opt-in debugging only, and only for runs whose caller sets options.debug
#AI_DEBUG_RAW_LOG=false
#AI_DEBUG_RAW_LOG=false
#AI_BREAKER_FAILURES=3
#AI_BREAKER_FAILURES=3
@@ -142,6 +142,7 @@ stub joins every route as a last resort outside production only (`AI_STUB_FALLBA
@@ -142,6 +142,7 @@ stub joins every route as a last resort outside production only (`AI_STUB_FALLBA
| The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` |
| The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` |
| Idempotency, cancel (running and queued), retry, async polling, audit rows, restart recovery, no raw prompts or inline images kept | `test/runs.test.js` |
| Idempotency, cancel (running and queued), retry, async polling, audit rows, restart recovery, no raw prompts or inline images kept | `test/runs.test.js` |
| Media fetched only from allow-listed https OpenVibe hosts; DNS answers and every redirect hop re-checked (a public hop never reaches internal Media); size caps | `test/ssrf.test.js` |
| Media fetched only from allow-listed https OpenVibe hosts; DNS answers and every redirect hop re-checked (a public hop never reaches internal Media); size caps | `test/ssrf.test.js` |
+| The shared compiled-schema cache (caller-supplied schemas) is an LRU bounded by count and bytes, in Ajv too | `test/schemas.test.js` |
| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |
| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |
| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |
| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |
| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
@@ -140,6 +140,12 @@ function load(env = process.env) {
@@ -140,6 +140,12 @@ function load(env = process.env) {
maxInputBytes: int(env.AI_MAX_INPUT_BYTES, 6 * 1024 * 1024),
maxInputBytes: int(env.AI_MAX_INPUT_BYTES, 6 * 1024 * 1024),
retentionDays: int(env.AI_RUN_RETENTION_DAYS, 30),
retentionDays: int(env.AI_RUN_RETENTION_DAYS, 30),
},
},
+ // Compiled JSON Schema validators (server/schemas.js): an LRU shared by every caller, some of
+ // whose schemas are caller-supplied, so it is bounded by count and by total schema size.
+ schemaCache: {
+ maxEntries: Math.max(1, int(env.AI_SCHEMA_CACHE_MAX, 500)),
+ maxBytes: Math.max(1024, int(env.AI_SCHEMA_CACHE_MAX_BYTES, 8 * 1024 * 1024)),
+ },
// Raw prompt/response logging is opt-in debugging only, and only for callers that ask.
// Raw prompt/response logging is opt-in debugging only, and only for callers that ask.
debugRawLog: bool(env.AI_DEBUG_RAW_LOG, false),
debugRawLog: bool(env.AI_DEBUG_RAW_LOG, false),
@@ -18,10 +18,12 @@ const { createQuotas } = require('./quota');
@@ -18,10 +18,12 @@ const { createQuotas } = require('./quota');
const { createRuns } = require('./runs');
const { createRuns } = require('./runs');
const { seed } = require('./workflows/seed');
const { seed } = require('./workflows/seed');
const { createKeyStore, createAuth } = require('./auth');
const { createKeyStore, createAuth } = require('./auth');
+const schemas = require('./schemas');
const { createApp } = require('./app');
const { createApp } = require('./app');
async function start({ config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true } = {}) {
async function start({ config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true } = {}) {
config = config || load(env);
config = config || load(env);
+ schemas.configure(config.schemaCache);
const db = openDb(config.dbPath);
const db = openDb(config.dbPath);
const registry = createRegistry(db, { clock, env });
const registry = createRegistry(db, { clock, env });
const quotas = createQuotas(db, { clock, registry });
const quotas = createQuotas(db, { clock, registry });
@@ -2,20 +2,66 @@
@@ -2,20 +2,66 @@
/**
/**
* JSON Schema (2020-12) validation for workflow/template input and output schemas. Compiled
* JSON Schema (2020-12) validation for workflow/template input and output schemas. Compiled
* validators are cached by schema hash, so a new version of a workflow compiles once.
* validators are cached by schema hash, so a new version of a workflow compiles once.
+ *
+ * The cache is shared by every caller and some schemas are caller-supplied (ai.extract / ai.enrich
+ * send their own), so it is an LRU bounded by entry count and by the total size of the cached
+ * schemas (AI_SCHEMA_CACHE_MAX, AI_SCHEMA_CACHE_MAX_BYTES). Ajv keeps its own reference to every
+ * schema it compiles; an evicted entry is removed from Ajv too, so neither side grows without bound.
+ * A schema larger than the whole byte budget is compiled for that call and not kept.
*/
*/
const Ajv2020 = require('ajv/dist/2020');
const Ajv2020 = require('ajv/dist/2020');
-const { sha256 } = require('./util');
+const { sha256, stableStringify } = require('./util');
const ajv = new Ajv2020({ allErrors: true, strict: false, allowUnionTypes: true, validateFormats: false });
const ajv = new Ajv2020({ allErrors: true, strict: false, allowUnionTypes: true, validateFormats: false });
-const compiled = new Map();
+const compiled = new Map(); // hash -> { fn, schema, bytes }; Map order is recency (oldest first)
+const limits = { maxEntries: 500, maxBytes: 8 * 1024 * 1024 };
+let bytesHeld = 0;
+
+function forget(key) {
+ const e = compiled.get(key);
+ if (!e) return;
+ compiled.delete(key);
+ bytesHeld -= e.bytes;
+ try { ajv.removeSchema(e.schema); } catch { /* not held by Ajv */ }
+}
+
+function trim() {
+ for (const key of compiled.keys()) {
+ if (compiled.size <= limits.maxEntries && bytesHeld <= limits.maxBytes) break;
+ forget(key);
+ }
+}
+
+/** Set the bounds (server/index.js passes config.schemaCache); shrinking evicts at once. */
+function configure({ maxEntries, maxBytes } = {}) {
+ if (Number.isFinite(maxEntries) && maxEntries >= 1) limits.maxEntries = Math.floor(maxEntries);
+ if (Number.isFinite(maxBytes) && maxBytes >= 1) limits.maxBytes = Math.floor(maxBytes);
+ trim();
+}
function compile(schema) {
function compile(schema) {
- const key = sha256(schema || {});
- let fn = compiled.get(key);
- if (!fn) {
- fn = ajv.compile(schema || {});
- compiled.set(key, fn);
+ const s = schema || {};
+ const text = stableStringify(s);
+ const key = sha256(text);
+ const hit = compiled.get(key);
+ if (hit) {
+ compiled.delete(key); // most recently used goes to the end
+ compiled.set(key, hit);
+ return hit.fn;
+ }
+ let fn;
+ try { fn = ajv.compile(s); } catch (e) {
+ try { ajv.removeSchema(s); } catch { /* */ }
+ throw e;
+ }
+ const bytes = Buffer.byteLength(text);
+ if (bytes > limits.maxBytes) {
+ try { ajv.removeSchema(s); } catch { /* */ }
+ return fn;
}
}
+ compiled.set(key, { fn, schema: s, bytes });
+ bytesHeld += bytes;
+ trim();
return fn;
return fn;
}
}
@@ -32,4 +78,9 @@ function validate(schema, value) {
@@ -32,4 +78,9 @@ function validate(schema, value) {
return { valid: Boolean(valid), errors: valid ? [] : (fn.errors || []).slice(0, 20).map(e => ({ path: e.instancePath || '/', message: e.message })) };
return { valid: Boolean(valid), errors: valid ? [] : (fn.errors || []).slice(0, 20).map(e => ({ path: e.instancePath || '/', message: e.message })) };
}
}
-module.exports = { compile, assertSchema, validate };
+/** Cache occupancy (tests, status): our entries and bytes, and the schemas Ajv itself still holds. */
+function stats() {
+ return { entries: compiled.size, bytes: bytesHeld, max_entries: limits.maxEntries, max_bytes: limits.maxBytes, ajv_held: ajv._cache ? ajv._cache.size : null };
+}
+
+module.exports = { compile, assertSchema, validate, configure, stats };
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+// The compiled-schema cache is shared by every caller and holds caller-supplied schemas
+// (ai.extract / ai.enrich), so it must stay bounded: an LRU capped by entry count and by total schema
+// size, with evicted schemas released by Ajv too. Validation stays correct across evictions.
+
+const assert = require('assert');
+const { suite, boot, request, token, ALL } = require('./helpers');
+const schemas = require('../server/schemas');
+
+const t = suite('schemas');
+const distinct = (i) => ({ type: 'object', required: [`k${i}`], properties: { [`k${i}`]: { type: 'string' } } });
+
+t.test('the cache never grows past its entry cap, and Ajv lets go of evicted schemas', () => {
+ schemas.configure({ maxEntries: 50, maxBytes: 1024 * 1024 });
+ const base = schemas.stats().ajv_held - schemas.stats().entries; // Ajv's own meta-schemas
+ for (let i = 0; i < 2000; i++) schemas.compile(distinct(i));
+ const s = schemas.stats();
+ assert.ok(s.entries <= 50, `entries ${s.entries}`);
+ assert.ok(s.ajv_held <= base + 50, `Ajv still holds ${s.ajv_held - base} compiled schemas`);
+});
+
+t.test('least recently used goes first; a hit refreshes an entry', () => {
+ schemas.configure({ maxEntries: 3, maxBytes: 1024 * 1024 });
+ const a = schemas.compile(distinct('a'));
+ schemas.compile(distinct('b'));
+ schemas.compile(distinct('c'));
+ assert.strictEqual(schemas.compile(distinct('a')), a, 'a is cached (and now most recent)');
+ schemas.compile(distinct('d')); // evicts b, not a
+ assert.strictEqual(schemas.compile(distinct('a')), a, 'a survived');
+ assert.strictEqual(schemas.stats().entries, 3);
+});
+
+t.test('the byte cap bounds total schema size; an oversized schema is compiled but not kept', () => {
+ schemas.configure({ maxEntries: 1000, maxBytes: 4096 });
+ const big = (i) => ({ type: 'object', description: `${i}`.padEnd(1500, 'x') });
+ for (let i = 0; i < 40; i++) schemas.compile(big(i));
+ const s = schemas.stats();
+ assert.ok(s.bytes <= 4096, `bytes ${s.bytes}`);
+ assert.ok(s.entries <= 3, `entries ${s.entries}`);
+ const huge = { type: 'object', required: ['x'], description: 'y'.repeat(10000) };
+ const before = schemas.stats();
+ assert.strictEqual(schemas.validate(huge, {}).valid, false);
+ assert.strictEqual(schemas.validate(huge, { x: 1 }).valid, true);
+ assert.strictEqual(schemas.stats().entries, before.entries, 'not cached');
+ assert.ok(schemas.stats().ajv_held <= before.ajv_held, 'not held by Ajv either');
+});
+
+t.test('invalid schemas leave nothing behind; evicted schemas still validate correctly', () => {
+ schemas.configure({ maxEntries: 5, maxBytes: 1024 * 1024 });
+ const held = schemas.stats().ajv_held;
+ for (let i = 0; i < 200; i++) assert.throws(() => schemas.assertSchema({ type: 'object', properties: { [`p${i}`]: { type: 'no-such-type' } } }), /does not compile/);
+ assert.ok(schemas.stats().ajv_held <= held, `failed compiles leaked ${schemas.stats().ajv_held - held} schemas into Ajv`);
+ const s0 = distinct(0);
+ assert.strictEqual(schemas.validate(s0, {}).valid, false);
+ for (let i = 1; i < 20; i++) schemas.compile(distinct(i)); // s0 evicted
+ assert.strictEqual(schemas.validate(s0, {}).valid, false);
+ assert.strictEqual(schemas.validate(s0, { k0: 'v' }).valid, true);
+});
+
+t.test('config: AI_SCHEMA_CACHE_MAX / AI_SCHEMA_CACHE_MAX_BYTES reach the cache at boot', async () => {
+ const h = await boot({ env: { AI_SCHEMA_CACHE_MAX: '7', AI_SCHEMA_CACHE_MAX_BYTES: '65536' } });
+ try {
+ const tok = token('live', ALL);
+ for (let i = 0; i < 20; i++) {
+ const r = await request(h.base, 'POST', '/api/v1/extract?wait=3000', { tok, body: { text: `item ${i}`, schema: distinct(`x${i}`), options: { cache: false } } });
+ assert.ok(r.status < 500, `extract ${i}: ${r.status}`);
+ }
+ const s = schemas.stats();
+ assert.strictEqual(s.max_entries, 7);
+ assert.strictEqual(s.max_bytes, 65536);
+ assert.ok(s.entries <= 7, `entries ${s.entries}`);
+ } finally { await h.stop(); }
+});
+
+t.run();