openvibers/openvibe.ai Commits

Catalog: offer status from the breaker, free_now and free_available (#329)

OP OpenVibers committed 7 days ago · e1139cfbb2 · 2 files, +45 −0

* AI: three suites that never ran now do, and a guard keeps it that way

exchange-catalog-kinds, free-ceiling and free-providers built a suite() and never
called t.run(), so the runner counted each as a pass while checking nothing.

Running them showed the two free-tier files were written against the http seam
provider, which has never offered `complete` (server/providers/http.js OPS); the
runs they exercise go through the ai.complete workflow, which only an OpenAI-format
provider serves (server/providers/openai.js: supports complete when it declares
chat, since #72). They now register kind openai against an OpenAI-shaped stub, as
every other ai.complete test does; the free-tier assertions are unchanged.

test/suites-run.test.js fails when any test file builds a suite and never runs it.

* exchange-read runs too, and the guard looks for the suite's own run call

The guard accepted any ".run(", so exchange-read (whose database statements call .run) stayed vacuous. With t.run()
its tests found: catalog prices printed as 0.020000000000000004 (now rounded to the micro-dollar), the DeepSeek
offer needing the pool key the catalog reads, DeepInfra's seeded policy, and usage counting the earlier test's runs.

* Catalog: each offer's status from the router's breaker (up, degraded, down, exhausted), free_now, and free_available

* Merge remote-tracking branch 'origin/ai-vacuous-suite-guard' into feat/free-now-status

# Conflicts:
#	server/exchange/catalog.js

* Test: an out-of-balance provider is exhausted and its free offer is not free now

* Merge remote-tracking branch 'origin/main' into feat/free-now-status

# Conflicts:
#	server/exchange/catalog.js

Changes

ddiff --git a/server/exchange/catalog.js b/server/exchange/catalog.js
iindex a36b633..ddddc56 100644
---- a/server/exchange/catalog.js
++++ b/server/exchange/catalog.js
@@@ -123,6 +123,18 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
const now = clock.now();
const providers = await registry.listProviders();
const byKey = new Map(providers.map((p) => [p.key, p]));
++ // The router's breaker state per provider (server/providers/index.js): an offer whose provider answered
++ // "insufficient balance" is exhausted until a call succeeds again, and the catalog says so rather than
++ // promising free capacity that cannot answer.
++ const healthRows = await db.prepare('SELECT provider_key, state, exhausted_at FROM provider_health').all().catch(() => []);
++ const healthByKey = new Map((healthRows || []).map((h) => [h.provider_key, h]));
++ const statusOf = (key) => {
++ const h = healthByKey.get(key);
++ if (!h || h.state === 'closed') return 'up';
++ if (h.state === 'open' && h.exhausted_at) return 'exhausted';
++ if (h.state === 'open') return 'down';
++ return 'degraded';
++ };
const upstreamByKey = new Map();
for (const p of providers) upstreamByKey.set(p.key, upstream.upstreamOf(p));
@@@ -163,6 +175,8 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
// capacity with no per-token card): both its provider is a named AI_FREE_PROVIDERS source and its
// output price is at or below AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK.
free_tier: allowsOffer({ providerKey: provider.key, providerRecord: provider, outputPrice: price ? price.out : null }, freeUse),
++ // up | degraded | down | exhausted (out of balance), from the router's breaker.
++ status: statusOf(provider.key),
// Rounded to the micro-dollar: a derived cached price was printed as 0.020000000000000004.
price: price ? { in: usd6(price.in), out: usd6(price.out), cached: usd6(price.cached), per: '1M tokens', currency: 'USD' } : null,
// Where the number came from and when someone checked it; null when nobody recorded it.
@@@ -219,6 +233,9 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
m.cached_today = m.offers.reduce((n, o) => n + o.cached_today, 0);
m.community = m.offers.some((o) => o.trust === 'community');
m.priced = m.offers.some((o) => o.price);
++ // Free right now: a free-tier offer whose provider can answer (not exhausted, not down).
++ for (const o of m.offers) o.free_now = Boolean(o.free_tier) && (o.status === 'up' || o.status === 'degraded');
++ m.free_now = m.offers.some((o) => o.free_now);
delete m.offers_listed;
}
models.sort((a, b) => b.requests_7d - a.requests_7d || b.requests_today - a.requests_today || a.name.localeCompare(b.name));
@@@ -240,6 +257,8 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
window_days: days,
...(kind ? { kind } : {}),
count: models.length,
++ // Whether any chat model can serve the free allowance right now (Exchange says "free paused" when not).
++ free_available: models.some((m) => m.kind === 'chat' && m.free_now),
families: [...families.values()].sort((a, b) => b.models - a.models || a.name.localeCompare(b.name)),
kinds: [...kinds.values()].sort((a, b) => KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind)),
models,
ddiff --git a/test/exchange-catalog-kinds.test.js b/test/exchange-catalog-kinds.test.js
iindex 143f342..f72f890 100644
---- a/test/exchange-catalog-kinds.test.js
++++ b/test/exchange-catalog-kinds.test.js
@@@ -93,6 +93,32 @@ t.test('chat, embeddings, speech-to-text and image are separate kinds, and ?kind
assert.strictEqual(bad.body.code, 'exchange.kind_invalid');
});
++t.test('an out-of-balance provider is exhausted, and its free offer is not free now', async () => {
++ // A catalog of its own (the route's answer is cached for 30 s), over the same database and registry.
++ const { createCatalog } = require('../server/exchange/catalog');
++ const catalog = createCatalog({ db: h.db, registry: h.registry, pool: h.pool, env: h.env, config: h.config });
++ const flashOf = (out) => out.models.find((m) => m.slug === 'deepseek/deepseek-flash');
++
++ const before = await catalog.models({ days: 7 });
++ const flash0 = flashOf(before).offers.find((o) => o.provider === 'deepseek');
++ assert.strictEqual(flash0.status, 'up');
++ assert.strictEqual(flash0.free_now, true, 'a free-tier offer of a healthy provider is free now');
++ assert.strictEqual(before.free_available, true);
++
++ const now = Date.now();
++ await h.db.prepare(`INSERT INTO provider_health (provider_key, state, consecutive_failures, opened_at, exhausted_at, last_error, last_success_at, last_failure_at)
++ VALUES ('deepseek', 'open', 1, ?, ?, 'insufficient balance', NULL, ?)`).run(now, now, now);
++ const after = await catalog.models({ days: 7 });
++ const flash = flashOf(after);
++ const offer = flash.offers.find((o) => o.provider === 'deepseek');
++ assert.strictEqual(offer.status, 'exhausted');
++ assert.strictEqual(offer.free_tier, true, 'still a free-tier source by the rules');
++ assert.strictEqual(offer.free_now, false, 'but not free right now');
++ assert.strictEqual(flash.free_now, false);
++ assert.strictEqual(after.free_available, false, 'no chat model can serve the free allowance');
++ await h.db.prepare("DELETE FROM provider_health WHERE provider_key = 'deepseek'").run();
++});
++
t.test('an unknown shared upstream is no offer at all', async () => {
const unknown = upstream.upstreamOf({ key: 'shared', display_name: 'Shared provider', base_url: 'https://proxy.internal/v1', metadata: {} });
assert.strictEqual(unknown, null);