diff --git a/trios/agent-server/apps/server/src/api/routes/queen-public-research.ts b/trios/agent-server/apps/server/src/api/routes/queen-public-research.ts index d9c8b1fb3f..576d75fd94 100644 --- a/trios/agent-server/apps/server/src/api/routes/queen-public-research.ts +++ b/trios/agent-server/apps/server/src/api/routes/queen-public-research.ts @@ -5,13 +5,17 @@ * route adds directionally-correct prerequisites/unlocks and a secret-free * view of paid worker-slot utilisation. It deliberately keeps graph state and * worker activity separate: "partial" means the repository has incomplete - * evidence, not that a model is currently spending tokens on it. + * evidence, not that a model is currently spending tokens on it. The `billing` + * block explains, in the same closed vocabulary /queen/status publishes, + * which quota gate those paid workers answer to - without which an idle + * subscription swarm reads as broken capacity. */ import { Hono } from 'hono' import { Pool } from 'pg' import { logger } from '../../lib/logger' import { configuredWorkerCapacity } from '../services/queen-dispatch' +import { configuredBillingMode } from './queen-public-status' import { isTreeLoadFailure, loadTree as loadCanonicalTree, @@ -19,6 +23,24 @@ import { type TreeLoadFailure, } from './queen-tree' +/** + * The closed billing vocabulary, derived from the one resolver both public + * pages share. Deriving it instead of re-declaring it means this route can + * never drift from /queen/status even if the vocabulary grows. + */ +type BillingMode = ReturnType + +/** + * Every value `quotaAuthority` can carry. + * + * provider_quota a Coding Plan spends a provider-side subscription + * quota; its resets and refusals belong to the + * provider and are observed, never computed locally + * estimated_usd_gate metered API work is gated by the Queen's own + * estimated USD cap, which can refuse a new Bee + */ +type QuotaAuthority = 'provider_quota' | 'estimated_usd_gate' + interface QueryResult { rowCount: number | null rows: Array> @@ -34,6 +56,7 @@ interface QueenPublicResearchDeps { databaseUrl?: () => string | undefined createPool?: (url: string) => ResearchPool workerCapacity?: () => number + billingMode?: () => BillingMode publicOrigin?: (requestUrl: string) => string } @@ -141,6 +164,28 @@ function workerProjection(capacity: number, busyIndices: number[]) { } } +/** + * The closed billing explanation behind the worker panel. + * + * This is an explanation, not a decision: queend owns whether a Bee starts, + * and no value here can make one run. The mode comes from the exact + * resolver /queen/status publishes, so the two pages can never disagree + * about the same swarm. `quotaAuthority` names which gate actually refuses + * work under that mode. Only these two closed words leave this file - never + * credentials, never provider response bodies, never balances or quota + * telemetry, all of which this route never reads in the first place. + */ +function billingProjection(mode: BillingMode): { + billingMode: BillingMode + quotaAuthority: QuotaAuthority +} { + return { + billingMode: mode, + quotaAuthority: + mode === 'coding_plan' ? 'provider_quota' : 'estimated_usd_gate', + } +} + export function createQueenPublicResearchRoute( deps: QueenPublicResearchDeps = {}, ) { @@ -150,6 +195,7 @@ export function createQueenPublicResearchRoute( deps.createPool ?? ((url: string) => new Pool({ connectionString: url }) as ResearchPool) const workerCapacity = deps.workerCapacity ?? configuredWorkerCapacity + const billingMode = deps.billingMode ?? configuredBillingMode const publicOrigin = deps.publicOrigin ?? configuredPublicOrigin return new Hono().get('/', async (c) => { @@ -197,6 +243,7 @@ export function createQueenPublicResearchRoute( return c.json({ ...graph, runtime, + billing: billingProjection(billingMode()), workers: workerProjection(workerCapacity(), busyIndices), agentBootstrap: { version: 'trinity-research-a2a/v1', diff --git a/trios/agent-server/apps/server/tests/api/queen-public-research.test.ts b/trios/agent-server/apps/server/tests/api/queen-public-research.test.ts index 8a2829bcc2..4277f27603 100644 --- a/trios/agent-server/apps/server/tests/api/queen-public-research.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-public-research.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test' import { createQueenPublicResearchRoute } from '../../src/api/routes/queen-public-research' +import { configuredBillingMode } from '../../src/api/routes/queen-public-status' const tree = { nodes: [ @@ -138,3 +139,159 @@ describe('GET /queen/public-research', () => { expect(response.status).toBe(503) }) }) + +describe('GET /queen/public-research billing projection', () => { + type ResearchDeps = Parameters[0] + + const read = async (overrides: Partial = {}) => { + const response = await createQueenPublicResearchRoute({ + loadTree: async () => tree, + databaseUrl: () => undefined, + workerCapacity: () => 4, + ...overrides, + }).request('/') + expect(response.status).toBe(200) + return (await response.json()) as { + billing: Record + workers: Record + } + } + + it('reports Coding Plan with provider quota as the authority, and nothing else', async () => { + const body = await read({ billingMode: () => 'coding_plan' }) + // Closed contract: exactly two fields, both closed words. No key, no + // balance, no provider response body has anywhere to hide in this shape. + expect(Object.keys(body.billing).sort()).toEqual([ + 'billingMode', + 'quotaAuthority', + ]) + expect(body.billing).toEqual({ + billingMode: 'coding_plan', + quotaAuthority: 'provider_quota', + }) + }) + + it('resolves an explicit Coding Plan environment value by itself', async () => { + const previous = process.env.TRIOS_SWARM_BILLING_MODE + try { + process.env.TRIOS_SWARM_BILLING_MODE = 'coding_plan' + const body = await read() + expect(body.billing).toEqual({ + billingMode: 'coding_plan', + quotaAuthority: 'provider_quota', + }) + } finally { + if (previous === undefined) delete process.env.TRIOS_SWARM_BILLING_MODE + else process.env.TRIOS_SWARM_BILLING_MODE = previous + } + }) + + it('stays conservatively metered when the configuration is missing, empty, or unknown', async () => { + const previous = process.env.TRIOS_SWARM_BILLING_MODE + try { + for (const raw of [undefined, '', 'subscription', 'coding-plan']) { + if (raw === undefined) delete process.env.TRIOS_SWARM_BILLING_MODE + else process.env.TRIOS_SWARM_BILLING_MODE = raw + + const body = await read() + expect(body.billing).toEqual({ + billingMode: 'api_metered', + quotaAuthority: 'estimated_usd_gate', + }) + } + } finally { + if (previous === undefined) delete process.env.TRIOS_SWARM_BILLING_MODE + else process.env.TRIOS_SWARM_BILLING_MODE = previous + } + }) + + it('agrees with the public status contract for every raw configuration', async () => { + const previous = process.env.TRIOS_SWARM_BILLING_MODE + try { + for (const raw of [ + 'coding_plan', + ' CODING_PLAN ', + 'api_metered', + undefined, + '', + 'subscription', + ]) { + if (raw === undefined) delete process.env.TRIOS_SWARM_BILLING_MODE + else process.env.TRIOS_SWARM_BILLING_MODE = raw + + // /queen/status publishes this exact resolver's verdict. The worker + // panel must never tell a different story about the same swarm. + const statusMode = configuredBillingMode() + const body = await read() + expect(body.billing.billingMode).toBe(statusMode) + // The named authority and the status gate boolean are the same fact: + // the estimated USD gate refuses work exactly when it is the + // authority, and only then. + expect(body.billing.quotaAuthority).toBe( + statusMode === 'coding_plan' + ? 'provider_quota' + : 'estimated_usd_gate', + ) + } + } finally { + if (previous === undefined) delete process.env.TRIOS_SWARM_BILLING_MODE + else process.env.TRIOS_SWARM_BILLING_MODE = previous + } + }) + + it('cannot fabricate an active worker when capacity is zero or idle', async () => { + const readTelemetry = async (capacity: number, busyIndices: number[]) => { + const response = await createQueenPublicResearchRoute({ + loadTree: async () => tree, + databaseUrl: () => 'postgres://configured', + createPool: () => ({ + query: async () => ({ + rowCount: busyIndices.length, + rows: busyIndices.map((key_index) => ({ key_index })), + }), + end: async () => {}, + }), + workerCapacity: () => capacity, + // The most generous billing story: a Coding Plan that may run + // subscription workers. It still starts none. + billingMode: () => 'coding_plan', + }).request('/') + return (await response.json()) as { + billing: Record + workers: { + capacity: number + active: number + idle: number + utilization: number + slots: Array<{ slot: number; state: string }> + } + } + } + + const zero = await readTelemetry(0, []) + expect(zero.workers).toEqual({ + capacity: 0, + active: 0, + idle: 0, + utilization: 0, + slots: [], + }) + expect(zero.billing).toEqual({ + billingMode: 'coding_plan', + quotaAuthority: 'provider_quota', + }) + + const idle = await readTelemetry(2, []) + expect(idle.workers.active).toBe(0) + expect(idle.workers.utilization).toBe(0) + expect(idle.workers.slots.every((slot) => slot.state === 'idle')).toBe(true) + + // The billing words are labels about a gate, never an activity claim: + // neither closed field can be read as "a worker is running". + for (const body of [zero, idle]) { + expect(JSON.stringify(body.billing)).not.toMatch( + /busy|active|running|worker/i, + ) + } + }) +})