openvibers/openvibe.ai Commits
Catalog: offer status from the breaker, free_now and free_available (#329)
Changes
@@@ -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,@@@ -93,6 +93,32 @@ t.test('chat, embeddings, speech-to-text and image are separate kinds, and ?kindassert.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);
@@ -123,6 +123,18 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
@@ -123,6 +123,18 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
const now = clock.now();
const now = clock.now();
const providers = await registry.listProviders();
const providers = await registry.listProviders();
const byKey = new Map(providers.map((p) => [p.key, p]));
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();
const upstreamByKey = new Map();
for (const p of providers) upstreamByKey.set(p.key, upstream.upstreamOf(p));
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: (
@@ -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
// 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.
// 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),
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.
// 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,
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.
// 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: (
@@ -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.cached_today = m.offers.reduce((n, o) => n + o.cached_today, 0);
m.community = m.offers.some((o) => o.trust === 'community');
m.community = m.offers.some((o) => o.trust === 'community');
m.priced = m.offers.some((o) => o.price);
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;
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));
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: (
@@ -240,6 +257,8 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
window_days: days,
window_days: days,
...(kind ? { kind } : {}),
...(kind ? { kind } : {}),
count: models.length,
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)),
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)),
kinds: [...kinds.values()].sort((a, b) => KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind)),
models,
models,
@@ -93,6 +93,32 @@ t.test('chat, embeddings, speech-to-text and image are separate kinds, and ?kind
@@ -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');
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 () => {
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: {} });
const unknown = upstream.upstreamOf({ key: 'shared', display_name: 'Shared provider', base_url: 'https://proxy.internal/v1', metadata: {} });
assert.strictEqual(unknown, null);
assert.strictEqual(unknown, null);