From 18adc21143fc941217f73f915bbac72584a9a885 Mon Sep 17 00:00:00 2001 From: Will Schenk Date: Thu, 3 Sep 2026 15:45:13 +0000 Subject: [PATCH 1/2] fix(supplier): recover silent Mycel connections Amp-Thread-ID: https://ampcode.com/threads/T-01a04da1-e7b2-73fa-aa50-7cca88bf7f9a --- packages/mycel/src/buyer/models.test.ts | 26 ++++- packages/mycel/src/buyer/models.ts | 30 ++++- packages/mycel/src/server.ts | 7 +- .../mycel/src/supply/connection-e2e.test.ts | 72 ++++++++++-- packages/supplier/src/dial.test.ts | 106 ++++++++++++++++-- packages/supplier/src/dial.ts | 50 ++++++++- 6 files changed, 257 insertions(+), 34 deletions(-) diff --git a/packages/mycel/src/buyer/models.test.ts b/packages/mycel/src/buyer/models.test.ts index 6897213e..bc7cc293 100644 --- a/packages/mycel/src/buyer/models.test.ts +++ b/packages/mycel/src/buyer/models.test.ts @@ -26,7 +26,10 @@ describe("summarizeOffers", () => { // Two Suppliers serving the same Model is one thing a buyer can ask for. // Exposing them separately leaks the supply side into a surface whose // whole point is to hide it. - const entries = summarizeOffers([offer({ supplierId: "a" }), offer({ supplierId: "b" })]); + const entries = summarizeOffers([ + offer({ supplierId: "a" }), + offer({ supplierId: "b" }), + ]); expect(entries).toHaveLength(1); expect(entries[0].id).toBe("gemma-4-26b"); }); @@ -34,7 +37,11 @@ describe("summarizeOffers", () => { it("quotes the cheapest Offer's retail price, in dollars per million", () => { const entries = summarizeOffers([ offer({ supplierId: "dear", retailCompletionPerMillion: 900_000 }), - offer({ supplierId: "cheap", retailPromptPerMillion: 50_000, retailCompletionPerMillion: 150_000 }), + offer({ + supplierId: "cheap", + retailPromptPerMillion: 50_000, + retailCompletionPerMillion: 150_000, + }), ]); expect(entries[0].pricing.completion).toBe(0.15); expect(entries[0].pricing.prompt).toBe(0.05); @@ -73,6 +80,21 @@ describe("summarizeOffers", () => { expect(entries).toEqual([]); }); + it("omits a machine whose supplier Connection is down", () => { + const entries = summarizeOffers([ + offer({ supplierId: "thor", supplierKind: "agent" }), + ]); + expect(entries).toEqual([]); + }); + + it("includes a machine while its supplier Connection is live", () => { + const entries = summarizeOffers( + [offer({ supplierId: "thor", supplierKind: "agent" })], + { connectedSupplierIds: new Set(["thor"]) }, + ); + expect(entries.map((entry) => entry.id)).toEqual(["gemma-4-26b"]); + }); + it("does not name a Supplier anywhere", () => { // A buyer names a Model; which machine serves it is Dispatch's business. const entries = summarizeOffers([offer({ supplierId: "very-secret-box" })]); diff --git a/packages/mycel/src/buyer/models.ts b/packages/mycel/src/buyer/models.ts index e179f257..03c37387 100644 --- a/packages/mycel/src/buyer/models.ts +++ b/packages/mycel/src/buyer/models.ts @@ -34,10 +34,21 @@ function toDollarsPerMillion(microDollars: number): number { return microDollars / 1_000_000; } -export function summarizeOffers(offers: Offer[]): ModelEntry[] { +export function summarizeOffers( + offers: Offer[], + opts: { connectedSupplierIds?: Set } = {}, +): ModelEntry[] { const byModel = new Map(); for (const offer of offers) { if (!offer.enabled) continue; + // A machine's Connection is its availability (ADR 0023). Its stored Offer + // survives a disconnect so pricing and capabilities do not need to be + // recreated, but it must not make an unavailable Model look live to buyers. + if ( + offer.supplierKind === "agent" && + !opts.connectedSupplierIds?.has(offer.supplierId) + ) + continue; byModel.set(offer.model, [...(byModel.get(offer.model) ?? []), offer]); } @@ -49,7 +60,9 @@ export function summarizeOffers(offers: Offer[]): ModelEntry[] { // Capabilities are a union: a buyer asking for tool calling can be served // if *some* Offer has it, and Dispatch will pick that one. - const capabilities = [...new Set(group.flatMap((o) => o.capabilities))].sort(); + const capabilities = [ + ...new Set(group.flatMap((o) => o.capabilities)), + ].sort(); // Guarantees are an intersection: advertising one that only some Offers // carry would promise something a request might not get. @@ -57,7 +70,9 @@ export function summarizeOffers(offers: Offer[]): ModelEntry[] { .map((o) => o.guarantees) .reduce((acc, next) => acc.filter((g) => next.includes(g))); - const contexts = group.map((o) => o.contextTokens).filter((c): c is number => c !== undefined); + const contexts = group + .map((o) => o.contextTokens) + .filter((c): c is number => c !== undefined); return { id: model, @@ -76,7 +91,10 @@ export function summarizeOffers(offers: Offer[]): ModelEntry[] { .sort((a, b) => a.id.localeCompare(b.id)); } -export function createModelsHandler(opts: { store: ExchangeStore }) { +export function createModelsHandler(opts: { + store: ExchangeStore; + connectedSupplierIds?: () => Set; +}) { return async function handleModels( req: IncomingMessage, res: ServerResponse, @@ -91,7 +109,9 @@ export function createModelsHandler(opts: { store: ExchangeStore }) { // Deliberately unauthenticated: the catalogue is not secret, and requiring // a token to list models would mean a client cannot discover what it may // ask for before it asks. Nothing here reveals a Supplier. - const data = summarizeOffers(await opts.store.listOffers()); + const data = summarizeOffers(await opts.store.listOffers(), { + connectedSupplierIds: opts.connectedSupplierIds?.(), + }); res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ object: "list", data })); return true; diff --git a/packages/mycel/src/server.ts b/packages/mycel/src/server.ts index 00d1d6d5..672f8b5a 100644 --- a/packages/mycel/src/server.ts +++ b/packages/mycel/src/server.ts @@ -163,7 +163,12 @@ export function createExchangeApp( // The operational surface remains a provider-free, read-only assembly. createClientSurfaceHandler({ componentsDir: opts.componentsDir }), createSupplyHandler({ store }), - createModelsHandler({ store }), + createModelsHandler({ + store, + connectedSupplierIds: connections + ? () => connections.connectedSupplierIds() + : undefined, + }), buyerHandler, ]; diff --git a/packages/mycel/src/supply/connection-e2e.test.ts b/packages/mycel/src/supply/connection-e2e.test.ts index 277fb2f8..494a4040 100644 --- a/packages/mycel/src/supply/connection-e2e.test.ts +++ b/packages/mycel/src/supply/connection-e2e.test.ts @@ -20,8 +20,15 @@ import { supplierFixture } from "../store/conformance.js"; import { hashCredential } from "../auth/credentials.js"; import { createExchangeServer, type RunningExchange } from "../server.js"; import { createIdentityVerifier } from "../auth/identity.js"; -import { makeTestApplication, type TestApplicationKeys } from "../testing/application-keys.js"; -import { startMockUpstream, type MockUpstream, type UpstreamMode } from "../testing/mock-upstream.js"; +import { + makeTestApplication, + type TestApplicationKeys, +} from "../testing/application-keys.js"; +import { + startMockUpstream, + type MockUpstream, + type UpstreamMode, +} from "../testing/mock-upstream.js"; import { Balances, endUserOwner } from "../metering/balances.js"; import { WIRE_VERSION, CONNECT_PATH } from "./wire.js"; @@ -64,13 +71,17 @@ function machine( body?: Record; }; if (frame.type === "welcome") return resolve(); - if (frame.type === "cancel" && frame.id) return inFlight.get(frame.id)?.abort(); + if (frame.type === "cancel" && frame.id) + return inFlight.get(frame.id)?.abort(); if (frame.type !== "request" || !frame.id || !frame.body) return; void serve(frame.id, frame.body); }); }); - async function serve(id: string, body: Record): Promise { + async function serve( + id: string, + body: Record, + ): Promise { const abort = new AbortController(); inFlight.set(id, abort); try { @@ -86,7 +97,11 @@ function machine( for (;;) { const { done, value } = await reader.read(); if (done || abort.signal.aborted) break; - send({ type: "chunk", id, data: decoder.decode(value, { stream: true }) }); + send({ + type: "chunk", + id, + data: decoder.decode(value, { stream: true }), + }); } if (!abort.signal.aborted) send({ type: "end", id }); } catch (error) { @@ -128,7 +143,11 @@ describe("a machine serves a buyer over its Connection", () => { }), ); await store.replaceOffers("thor", [ - { model: MODEL, capabilities: ["chat", "streaming"], servingMode: "managed" }, + { + model: MODEL, + capabilities: ["chat", "streaming"], + servingMode: "managed", + }, ]); app = await makeTestApplication(); @@ -139,7 +158,10 @@ describe("a machine serves a buyer over its Connection", () => { port: 0, host: "127.0.0.1", handshakeTimeoutMs: 200, - verifyCaller: createIdentityVerifier({ store, makeKeySet: () => app.keySet }), + verifyCaller: createIdentityVerifier({ + store, + makeKeySet: () => app.keySet, + }), }); await new Balances(store).grant( endUserOwner({ application: app.application, subject: "user-1" }), @@ -157,7 +179,10 @@ describe("a machine serves a buyer over its Connection", () => { const token = await app.sign("user-1"); return fetch(`${exchange.url}/v1/chat/completions`, { method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + }, // Streaming, because that is the case worth testing over a Connection — // tokens crossing a socket as they are produced — and because the // truncation and cancellation modes only exist on the streaming path. @@ -195,7 +220,10 @@ describe("a machine serves a buyer over its Connection", () => { await boot(); await dial(); const balances = new Balances(store); - const owner = endUserOwner({ application: app.application, subject: "user-1" }); + const owner = endUserOwner({ + application: app.application, + subject: "user-1", + }); const before = (await balances.get(owner)).microDollars; await (await chat()).text(); @@ -300,7 +328,10 @@ describe("a machine serves a buyer over its Connection", () => { port: 0, host: "127.0.0.1", handshakeTimeoutMs: 200, - verifyCaller: createIdentityVerifier({ store, makeKeySet: () => app.keySet }), + verifyCaller: createIdentityVerifier({ + store, + makeKeySet: () => app.keySet, + }), }); await new Balances(store).grant( endUserOwner({ application: app.application, subject: "user-1" }), @@ -311,12 +342,20 @@ describe("a machine serves a buyer over its Connection", () => { expect(await store.listOffers()).toEqual([]); thor = machine(exchange.url, runtime.baseUrl, { - offers: [{ model: MODEL, capabilities: ["chat", "streaming"], servingMode: "managed" }], + offers: [ + { + model: MODEL, + capabilities: ["chat", "streaming"], + servingMode: "managed", + }, + ], guarantees: ["on-premise"], }); await thor.welcomed; - const catalogue = (await (await fetch(`${exchange.url}/v1/models`)).json()) as { + const catalogue = (await ( + await fetch(`${exchange.url}/v1/models`) + ).json()) as { data: { id: string }[]; }; expect(catalogue.data.map((m) => m.id)).toEqual([MODEL]); @@ -328,6 +367,15 @@ describe("a machine serves a buyer over its Connection", () => { const [record] = await store.listRequests(); expect(record.supplierId).toBe("thor"); expect(record.outcome).toBe("completed"); + + await thor.hangUp(); + thor = undefined; + const withdrawn = (await ( + await fetch(`${exchange.url}/v1/models`) + ).json()) as { + data: { id: string }[]; + }; + expect(withdrawn.data).toEqual([]); }); it("counts what the machine is working on while it works", async () => { diff --git a/packages/supplier/src/dial.test.ts b/packages/supplier/src/dial.test.ts index e7a9d911..dbc61124 100644 --- a/packages/supplier/src/dial.test.ts +++ b/packages/supplier/src/dial.test.ts @@ -7,7 +7,7 @@ * and laptops close. */ -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; import { dialIn, WIRE_VERSION, @@ -21,16 +21,23 @@ function scriptedSocket() { const listeners: { open: (() => void)[]; message: ((data: string) => void)[]; + ping: (() => void)[]; close: ((code?: number, reason?: string) => void)[]; error: ((error: Error) => void)[]; - } = { open: [], message: [], close: [], error: [] }; + } = { open: [], message: [], ping: [], close: [], error: [] }; const sent: string[] = []; + let terminations = 0; const socket: DialSocket = { send: (data) => sent.push(data), close: () => listeners.close.forEach((l) => l(1000, "closed")), + terminate: () => { + terminations += 1; + listeners.close.forEach((l) => l(1006, "heartbeat timeout")); + }, onOpen: (l) => listeners.open.push(l), onMessage: (l) => listeners.message.push(l), + onPing: (l) => listeners.ping.push(l), onClose: (l) => listeners.close.push(l), onError: (l) => listeners.error.push(l), }; @@ -38,13 +45,25 @@ function scriptedSocket() { return { socket, sent, + get terminations() { + return terminations; + }, open: () => listeners.open.forEach((l) => l()), + ping: () => listeners.ping.forEach((l) => l()), welcome: () => listeners.message.forEach((l) => - l(JSON.stringify({ type: "welcome", wireVersion: WIRE_VERSION, supplierId: "thor" })), + l( + JSON.stringify({ + type: "welcome", + wireVersion: WIRE_VERSION, + supplierId: "thor", + }), + ), ), goodbye: (reason: string) => - listeners.message.forEach((l) => l(JSON.stringify({ type: "goodbye", reason }))), + listeners.message.forEach((l) => + l(JSON.stringify({ type: "goodbye", reason })), + ), drop: (code = 1006) => listeners.close.forEach((l) => l(code, "gone")), }; } @@ -52,7 +71,11 @@ function scriptedSocket() { /** Run the dial loop for a scripted sequence of sockets, then stop it. */ async function runDial( script: ((socket: ReturnType) => void)[], - opts: { minBackoffMs?: number; offers?: DialOptions["offers"]; guarantees?: string[] } = {}, + opts: { + minBackoffMs?: number; + offers?: DialOptions["offers"]; + guarantees?: string[]; + } = {}, ) { const events: DialEvent[] = []; const controller = new AbortController(); @@ -98,6 +121,8 @@ function draft(): NonNullable[number] { } describe("dialling in", () => { + afterEach(() => vi.useRealTimers()); + it("says hello with the wire version as soon as the socket opens", async () => { const { sockets } = await runDial([ (s) => { @@ -154,7 +179,9 @@ describe("dialling in", () => { expect(sockets).toHaveLength(2); for (const socket of sockets) { - expect((JSON.parse(socket.sent[0]) as { offers: unknown }).offers).toEqual(offers); + expect( + (JSON.parse(socket.sent[0]) as { offers: unknown }).offers, + ).toEqual(offers); } }); @@ -204,13 +231,69 @@ describe("dialling in", () => { expect(events.filter((e) => e.type === "connected")).toHaveLength(2); }); + it("terminates a silent half-open socket so the dial loop can reconnect", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const first = scriptedSocket(); + const events: DialEvent[] = []; + + const done = dialIn({ + exchangeUrl: "https://mycel.example", + credential: "sk-mycel-thor", + heartbeatTimeoutMs: 75, + signal: controller.signal, + onEvent: (event) => events.push(event), + connect: () => first.socket, + sleep: async () => controller.abort(), + }); + + first.open(); + first.welcome(); + await vi.advanceTimersByTimeAsync(76); + await done; + + expect(first.terminations).toBe(1); + expect(events).toContainEqual({ + type: "disconnected", + code: 1006, + reason: "heartbeat timeout", + }); + expect(events.some((event) => event.type === "retrying")).toBe(true); + }); + + it("keeps a connection alive while Exchange pings arrive", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const first = scriptedSocket(); + + const done = dialIn({ + exchangeUrl: "https://mycel.example", + credential: "sk-mycel-thor", + heartbeatTimeoutMs: 75, + signal: controller.signal, + connect: () => first.socket, + }); + + first.open(); + first.welcome(); + await vi.advanceTimersByTimeAsync(50); + first.ping(); + await vi.advanceTimersByTimeAsync(50); + expect(first.terminations).toBe(0); + + controller.abort(); + await done; + }); + it("backs off further each time it fails to connect at all", async () => { const { events } = await runDial( [(s) => s.drop(), (s) => s.drop(), (s) => s.drop(), (s) => s.drop()], { minBackoffMs: 2 }, ); - const waits = events.filter((e) => e.type === "retrying").map((e) => e.inMs); + const waits = events + .filter((e) => e.type === "retrying") + .map((e) => e.inMs); // Doubling, so an Exchange that is down is not hammered by every machine // on every tick. expect(waits.slice(0, 3)).toEqual([4, 8, 16]); @@ -231,7 +314,9 @@ describe("dialling in", () => { { minBackoffMs: 2 }, ); - const waits = events.filter((e) => e.type === "retrying").map((e) => e.inMs); + const waits = events + .filter((e) => e.type === "retrying") + .map((e) => e.inMs); // A machine up for hours that drops once should come straight back, not // wait out an outage from last week. expect(waits[2]).toBe(2); @@ -246,7 +331,10 @@ describe("dialling in", () => { }, ]); - expect(events).toContainEqual({ type: "refused", reason: "wire version 1 required" }); + expect(events).toContainEqual({ + type: "refused", + reason: "wire version 1 required", + }); }); it("stops when told to, and does not dial again", async () => { diff --git a/packages/supplier/src/dial.ts b/packages/supplier/src/dial.ts index 4b9e4df7..a6473e10 100644 --- a/packages/supplier/src/dial.ts +++ b/packages/supplier/src/dial.ts @@ -30,6 +30,12 @@ export interface DialOptions { /** Backoff floor and ceiling. A dropped link should retry, not hammer. */ minBackoffMs?: number; maxBackoffMs?: number; + /** + * Close and redial when the Exchange's ping stream goes silent. The server + * pings every 30 seconds; waiting for two missed pings avoids reconnecting + * merely because one timer ran late. + */ + heartbeatTimeoutMs?: number; signal?: AbortSignal; onEvent?: (event: DialEvent) => void; /** Injectable for tests; defaults to a real socket. */ @@ -69,14 +75,17 @@ export type DialEvent = export interface DialSocket { send(data: string): void; close(): void; + terminate(): void; onOpen(listener: () => void): void; onMessage(listener: (data: string) => void): void; + onPing(listener: () => void): void; onClose(listener: (code?: number, reason?: string) => void): void; onError(listener: (error: Error) => void): void; } const DEFAULT_MIN_BACKOFF_MS = 1_000; const DEFAULT_MAX_BACKOFF_MS = 30_000; +const DEFAULT_HEARTBEAT_TIMEOUT_MS = 75_000; function websocketUrl(exchangeUrl: string): string { const url = new URL(CONNECT_PATH, exchangeUrl); @@ -85,13 +94,18 @@ function websocketUrl(exchangeUrl: string): string { } function realSocket(url: string, credential: string): DialSocket { - const ws = new WebSocket(url, { headers: { authorization: `Bearer ${credential}` } }); + const ws = new WebSocket(url, { + headers: { authorization: `Bearer ${credential}` }, + }); return { send: (data) => ws.send(data), close: () => ws.close(), + terminate: () => ws.terminate(), onOpen: (l) => ws.on("open", l), onMessage: (l) => ws.on("message", (raw) => l(String(raw))), - onClose: (l) => ws.on("close", (code, reason) => l(code, String(reason ?? ""))), + onPing: (l) => ws.on("ping", l), + onClose: (l) => + ws.on("close", (code, reason) => l(code, String(reason ?? ""))), onError: (l) => ws.on("error", l), }; } @@ -137,6 +151,8 @@ export async function dialIn(opts: DialOptions): Promise { onServeEvent: opts.onServeEvent, offers: opts.offers, guarantees: opts.guarantees, + heartbeatTimeoutMs: + opts.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS, }); if (signal?.aborted) return; @@ -144,7 +160,8 @@ export async function dialIn(opts: DialOptions): Promise { // A Connection that lived resets the backoff: a machine that has been up // for hours and drops once should come straight back, not wait thirty // seconds because of an outage last week. - backoff = outcome === "connected" ? minBackoff : Math.min(backoff * 2, maxBackoff); + backoff = + outcome === "connected" ? minBackoff : Math.min(backoff * 2, maxBackoff); onEvent({ type: "retrying", inMs: backoff }); await sleep(backoff); @@ -166,15 +183,18 @@ function holdOne( onServeEvent?: (event: ServeEvent) => void; offers?: OfferDraft[]; guarantees?: string[]; + heartbeatTimeoutMs: number; }, ): Promise<"connected" | "failed"> { return new Promise((resolve) => { let settled = false; let everConnected = false; + let heartbeatTimer: ReturnType | undefined; const finish = (outcome: "connected" | "failed") => { if (settled) return; settled = true; + if (heartbeatTimer) clearTimeout(heartbeatTimer); resolve(outcome); }; @@ -193,6 +213,15 @@ function holdOne( const hangUp = () => socket.close(); ctx.signal?.addEventListener("abort", hangUp, { once: true }); + const expectHeartbeat = () => { + if (heartbeatTimer) clearTimeout(heartbeatTimer); + heartbeatTimer = setTimeout( + () => socket.terminate(), + ctx.heartbeatTimeoutMs, + ); + heartbeatTimer.unref?.(); + }; + // Scoped to this Connection. A machine that reconnects gets a fresh one, // because nothing in flight on the old socket can be delivered. const server = ctx.runtimeUrl @@ -206,6 +235,7 @@ function holdOne( : undefined; socket.onOpen(() => { + expectHeartbeat(); // The catalogue rides the handshake. The Exchange registers this machine // only once it has accepted both, so it never believes a machine is // available without knowing what it serves. @@ -221,6 +251,7 @@ function holdOne( }); socket.onMessage((data) => { + expectHeartbeat(); const frame = safeParse(data); if (frame?.type === "welcome") { everConnected = true; @@ -230,7 +261,10 @@ function holdOne( if (frame?.type === "goodbye") { // The Exchange refusing us for a stated reason — a wire mismatch, say. // Worth surfacing rather than showing up as a bare close code. - ctx.onEvent({ type: "refused", reason: String(frame.reason ?? "refused") }); + ctx.onEvent({ + type: "refused", + reason: String(frame.reason ?? "refused"), + }); return; } // Everything else is work. Without a runtime configured this machine @@ -239,6 +273,10 @@ function holdOne( server?.handleFrame(data); }); + // `ws` answers protocol pings automatically. Observing them separately is + // what tells us the path is alive when no inference work is flowing. + socket.onPing(expectHeartbeat); + socket.onError((error) => { ctx.onEvent({ type: "refused", reason: error.message }); }); @@ -254,7 +292,9 @@ function holdOne( }); } -function safeParse(data: string): { type?: string; reason?: unknown } | undefined { +function safeParse( + data: string, +): { type?: string; reason?: unknown } | undefined { try { const parsed = JSON.parse(data) as { type?: string; reason?: unknown }; return parsed && typeof parsed === "object" ? parsed : undefined; From db66665e731b89f7a097123b6d5569c52c570bc5 Mon Sep 17 00:00:00 2001 From: Will Schenk Date: Thu, 3 Sep 2026 16:30:28 +0000 Subject: [PATCH 2/2] feat(mycel): show live supplier availability Amp-Thread-ID: https://ampcode.com/threads/T-01a04da1-e7b2-73fa-aa50-7cca88bf7f9a --- apps/mycel-client/public/llms-full.txt | 3 +- deploy/mycel/deploy.sh | 2 +- packages/mycel/src/buyer/models.test.ts | 1 + packages/mycel/src/buyer/models.ts | 3 + .../client-surface.integration.test.ts | 5 +- .../account-supplier-connections.js | 59 +++++++++++++++++++ .../src/client-surface/components/models.js | 4 +- .../mycel/src/client-surface/serve.test.ts | 1 + packages/mycel/src/client-surface/serve.ts | 4 ++ .../account-console.integration.test.ts | 14 ++++- packages/mycel/src/customer/handler.test.ts | 48 +++++++++++++++ packages/mycel/src/customer/handler.ts | 38 ++++++++++++ packages/mycel/src/server.ts | 3 + 13 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 packages/mycel/src/client-surface/components/account-supplier-connections.js diff --git a/apps/mycel-client/public/llms-full.txt b/apps/mycel-client/public/llms-full.txt index 4c21d8df..f5976abc 100644 --- a/apps/mycel-client/public/llms-full.txt +++ b/apps/mycel-client/public/llms-full.txt @@ -21,11 +21,12 @@ The credential belongs in a server-side secret manager or environment variable. `GET /v1/models` is public and is the source of truth. Its response is an OpenAI-shaped list. Each entry adds: - `pricing.prompt` and `pricing.completion`: US dollars per million tokens at the cheapest eligible Offer. +- `status`: `available` means at least one Offer is dispatchable now. - `capabilities`: union of capabilities available from at least one Offer, such as `chat`, `streaming`, or tool support. - `guarantees`: guarantees carried by every Offer represented by the catalogue entry. - `context_length`: the minimum context length across represented Offers, when known. -Availability changes as Suppliers connect, disconnect, publish, or age out. Do not assume a model listed yesterday is available now. Do not interpret ordering as ranking: entries are sorted lexicographically by model id. In particular, examples using `deepseek/deepseek-v4-pro` do not claim it is best; it was simply a live model used to make the request concrete. +Availability changes as Suppliers connect, disconnect, publish, or age out. A disconnected machine Supplier's stored Offers are retained internally but omitted from this endpoint. Do not assume a model listed yesterday is available now. Do not interpret ordering as ranking: entries are sorted lexicographically by model id. In particular, examples using `deepseek/deepseek-v4-pro` do not claim it is best; it was simply a live model used to make the request concrete. A reasonable generic policy is: diff --git a/deploy/mycel/deploy.sh b/deploy/mycel/deploy.sh index 148cd3ea..2db0ff3e 100755 --- a/deploy/mycel/deploy.sh +++ b/deploy/mycel/deploy.sh @@ -139,7 +139,7 @@ verify_client_surface() { echo "error: account assembly manifest unavailable" >&2 return 1 } - for component in account-authentication account-customer account-overview account-applications account-playground account-funding account-admin-grant account-ledger account-usage account-team; do + for component in account-authentication account-customer account-overview account-applications account-playground account-funding account-admin-grant account-supplier-connections account-ledger account-usage account-team; do if ! grep -Eq '"id"[[:space:]]*:[[:space:]]*"'"$component"'"' <<<"$account_manifest"; then echo "error: account manifest missing component: $component" >&2 return 1 diff --git a/packages/mycel/src/buyer/models.test.ts b/packages/mycel/src/buyer/models.test.ts index bc7cc293..0f984d41 100644 --- a/packages/mycel/src/buyer/models.test.ts +++ b/packages/mycel/src/buyer/models.test.ts @@ -32,6 +32,7 @@ describe("summarizeOffers", () => { ]); expect(entries).toHaveLength(1); expect(entries[0].id).toBe("gemma-4-26b"); + expect(entries[0].status).toBe("available"); }); it("quotes the cheapest Offer's retail price, in dollars per million", () => { diff --git a/packages/mycel/src/buyer/models.ts b/packages/mycel/src/buyer/models.ts index 03c37387..3706f24f 100644 --- a/packages/mycel/src/buyer/models.ts +++ b/packages/mycel/src/buyer/models.ts @@ -20,6 +20,8 @@ export interface ModelEntry { id: string; object: "model"; owned_by: string; + /** Every returned Model has at least one Offer dispatchable right now. */ + status: "available"; /** Dollars per million tokens, at the cheapest eligible Offer. */ pricing: { prompt: number; completion: number }; /** The union of what any Offer for this Model can do. */ @@ -78,6 +80,7 @@ export function summarizeOffers( id: model, object: "model", owned_by: "exchange", + status: "available", pricing: { prompt: toDollarsPerMillion(cheapest.retailPromptPerMillion), completion: toDollarsPerMillion(cheapest.retailCompletionPerMillion), diff --git a/packages/mycel/src/client-surface/client-surface.integration.test.ts b/packages/mycel/src/client-surface/client-surface.integration.test.ts index fc9bda3d..8930d9d3 100644 --- a/packages/mycel/src/client-surface/client-surface.integration.test.ts +++ b/packages/mycel/src/client-surface/client-surface.integration.test.ts @@ -49,8 +49,8 @@ afterAll(async () => { }); describe("the Exchange's client surface, assembled", () => { - it("the hostname root lands a browser on the shell", async () => { - await page.goto(`http://127.0.0.1:${exchange.port}/`); + it("the shell route loads the assembled operational surface", async () => { + await page.goto(`http://127.0.0.1:${exchange.port}/shell/`); expect(new URL(page.url()).pathname).toBe("/shell/"); }); @@ -70,6 +70,7 @@ describe("the Exchange's client surface, assembled", () => { const text = await models.textContent(); expect(text).toContain("/M"); // a price per million tokens is quoted expect(text).toContain("32768"); + expect(text).toContain("Available now"); }); it("solo projection works here too — the contract came over whole", async () => { diff --git a/packages/mycel/src/client-surface/components/account-supplier-connections.js b/packages/mycel/src/client-surface/components/account-supplier-connections.js new file mode 100644 index 00000000..8d2d5e32 --- /dev/null +++ b/packages/mycel/src/client-surface/components/account-supplier-connections.js @@ -0,0 +1,59 @@ +import { customerKey, regionKey } from "./account-services.js"; +import { card, empty, trackSubscription } from "./account-ui.js"; + +const when = (value) => (value ? new Date(value).toLocaleString() : "Never"); + +export default { + name: "account-supplier-connections", + inject: [regionKey, customerKey], + apply(ctx, view) { + const region = view.get(regionKey); + const customer = view.get(customerKey); + const element = card( + "account-supplier-connections", + "Admin", + "Supplier connections", + ); + const body = element.querySelector(".account-card-body"); + region.append(element); + + const render = (state) => { + const dashboard = state.dashboard; + element.hidden = state.phase !== "ready" || !dashboard?.canAdminGrant; + if (element.hidden) return; + body.replaceChildren(); + const list = document.createElement("ul"); + list.className = "account-list"; + for (const supplier of dashboard.supplierConnections ?? []) { + const item = document.createElement("li"); + const details = document.createElement("div"); + const name = document.createElement("strong"); + name.textContent = supplier.displayName || supplier.id; + const status = document.createElement("small"); + status.textContent = supplier.connected + ? `● Connected since ${when(supplier.connectedAt)} · ${supplier.inFlight} in flight` + : `○ Disconnected · last: ${when(supplier.lastDisconnectAt)}${ + supplier.lastDisconnectReason + ? ` (${supplier.lastDisconnectReason})` + : "" + }`; + status.style.color = supplier.connected ? "#77c593" : "var(--muted)"; + const id = document.createElement("code"); + id.textContent = `${supplier.id}${supplier.enabled ? "" : " · disabled"}`; + details.append(name, status, id); + item.append(details); + list.append(item); + } + if (!list.children.length) + list.append(empty("No machine Suppliers are registered.")); + body.append(list); + }; + trackSubscription(ctx, customer, render); + + const timer = setInterval(() => { + if (!element.hidden) void customer.refresh(); + }, 15_000); + ctx.effect(() => () => clearInterval(timer)); + return () => element.remove(); + }, +}; diff --git a/packages/mycel/src/client-surface/components/models.js b/packages/mycel/src/client-surface/components/models.js index 8333c64c..4de9e56e 100644 --- a/packages/mycel/src/client-surface/components/models.js +++ b/packages/mycel/src/client-surface/components/models.js @@ -48,7 +48,8 @@ export default { note.textContent = models.length ? "" : "no models on offer"; table.innerHTML = models.length ? ` - modelprompt + modelstatus + prompt completioncontext capabilitiesguarantees ` + @@ -56,6 +57,7 @@ export default { .map( (m) => ` ${esc(m.id)} + ● ${m.status === "available" ? "Available now" : "Unknown"} ${dollars(m.pricing?.prompt)} ${dollars(m.pricing?.completion)} ${m.context_length ? esc(m.context_length) : "—"} diff --git a/packages/mycel/src/client-surface/serve.test.ts b/packages/mycel/src/client-surface/serve.test.ts index c33140fb..ef875fed 100644 --- a/packages/mycel/src/client-surface/serve.test.ts +++ b/packages/mycel/src/client-surface/serve.test.ts @@ -114,6 +114,7 @@ describe("the Exchange's client surface", () => { "account-playground", "account-funding", "account-admin-grant", + "account-supplier-connections", "account-ledger", "account-usage", "account-team", diff --git a/packages/mycel/src/client-surface/serve.ts b/packages/mycel/src/client-surface/serve.ts index eefe5a03..8012f3f1 100644 --- a/packages/mycel/src/client-surface/serve.ts +++ b/packages/mycel/src/client-surface/serve.ts @@ -55,6 +55,10 @@ const ACCOUNT_ENTRIES: ShellManifestEntry[] = [ id: "account-admin-grant", url: "./components/account-admin-grant.js", }, + { + id: "account-supplier-connections", + url: "./components/account-supplier-connections.js", + }, { id: "account-ledger", url: "./components/account-ledger.js" }, { id: "account-usage", url: "./components/account-usage.js" }, { id: "account-team", url: "./components/account-team.js" }, diff --git a/packages/mycel/src/customer/account-console.integration.test.ts b/packages/mycel/src/customer/account-console.integration.test.ts index 141bb45a..201c50ad 100644 --- a/packages/mycel/src/customer/account-console.integration.test.ts +++ b/packages/mycel/src/customer/account-console.integration.test.ts @@ -39,6 +39,9 @@ beforeAll(async () => { await store.createSupplier( supplierFixture({ id: "browser-supplier", displayName: "Browser Supplier" }), ); + await store.createSupplier( + supplierFixture({ id: "thor", kind: "agent", displayName: "Thor" }), + ); await store.replaceOffers("browser-supplier", [ { model: "browser-model", @@ -82,6 +85,10 @@ beforeAll(async () => { const handler = createCustomerHandler({ store, completeChat: buyer.handleAs, + supplierConnection: (supplierId) => + supplierId === "thor" + ? { connectedAt: new Date("2026-09-03T15:28:00Z"), inFlight: 0 } + : undefined, verifyOperator: async (authorization) => { if (authorization !== "Bearer user_browser") throw new Error("unauthorized"); @@ -159,6 +166,11 @@ describe("Mycel's signed-in account console", () => { expect( await page.locator('[data-component="account-funding"]').textContent(), ).toContain("Payment funding is not active"); + expect( + await page + .locator('[data-component="account-supplier-connections"]') + .textContent(), + ).toContain("Thor● Connected"); const assembly = await page.evaluate<{ id: string; active: boolean }[]>( () => { @@ -177,7 +189,7 @@ describe("Mycel's signed-in account console", () => { })); }, ); - expect(assembly).toHaveLength(11); + expect(assembly).toHaveLength(12); expect(assembly.every((entry) => entry.active)).toBe(true); await expect diff --git a/packages/mycel/src/customer/handler.test.ts b/packages/mycel/src/customer/handler.test.ts index c9d0f7cc..6580b0bb 100644 --- a/packages/mycel/src/customer/handler.test.ts +++ b/packages/mycel/src/customer/handler.test.ts @@ -4,6 +4,7 @@ import type { AddressInfo } from "node:net"; import { exportJWK, generateKeyPair, SignJWT } from "jose"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { hashCredential } from "../auth/credentials.js"; +import { supplierFixture } from "../store/conformance.js"; import { MemoryStore } from "../store/memory-store.js"; import { createClerkOperatorVerifier, @@ -69,11 +70,17 @@ describe("Mycel's self-service customer control plane", () => { let store: MemoryStore; let server: http.Server; let origin: string; + let liveSuppliers: Map< + string, + { connectedAt: Date; inFlight: number } + >; beforeEach(async () => { store = new MemoryStore(); + liveSuppliers = new Map(); const handler = createCustomerHandler({ store, + supplierConnection: (supplierId) => liveSuppliers.get(supplierId), defaultCreditLimitMicroDollars: 5_000_000, verifyOperator: async (authorization) => { const subject = authorization?.match(/^Bearer (.+)$/)?.[1]; @@ -132,6 +139,47 @@ describe("Mycel's self-service customer control plane", () => { expect((await request("/api/customer")).status).toBe(401); }); + it("shows live machine connection state only to Clerk administrators", async () => { + await store.createSupplier( + supplierFixture({ id: "thor", kind: "agent", displayName: "Thor" }), + ); + await store.createSupplier( + supplierFixture({ id: "vendor", kind: "vendor" }), + ); + await store.appendConnectionEvent({ + id: "disconnect-1", + supplierId: "thor", + event: "disconnected", + reason: "transport-error", + at: new Date("2026-09-03T12:00:00Z"), + }); + liveSuppliers.set("thor", { + connectedAt: new Date("2026-09-03T15:28:00Z"), + inFlight: 2, + }); + + const member = await request("/api/customer", { subject: "user_member" }); + expect(JSON.stringify(member.body)).not.toContain("thor"); + + const admin = await request("/api/customer", { subject: "user_admin" }); + expect(admin.body).toMatchObject({ + canAdminGrant: true, + supplierConnections: [ + { + id: "thor", + displayName: "Thor", + enabled: true, + connected: true, + connectedAt: "2026-09-03T15:28:00.000Z", + inFlight: 2, + lastDisconnectAt: "2026-09-03T12:00:00.000Z", + lastDisconnectReason: "transport-error", + }, + ], + }); + expect(JSON.stringify(admin.body)).not.toContain("vendor"); + }); + it("provisions a Client and first Application without storing the credential", async () => { expect( (await request("/api/customer", { subject: "user_alice" })).body, diff --git a/packages/mycel/src/customer/handler.ts b/packages/mycel/src/customer/handler.ts index 2b987337..28caf608 100644 --- a/packages/mycel/src/customer/handler.ts +++ b/packages/mycel/src/customer/handler.ts @@ -34,6 +34,10 @@ export interface CustomerHandlerOptions { authorizedParties?: string[]; /** Process-local entry into the normal buyer pipeline for the playground. */ completeChat?: BuyerHandler["handleAs"]; + /** Process-local machine liveness; persisted events cannot answer "now". */ + supplierConnection?: ( + supplierId: string, + ) => { connectedAt: Date; inFlight: number } | undefined; defaultCreditLimitMicroDollars?: number; stripeSecretKey?: string; stripeWebhookSecret?: string; @@ -244,14 +248,47 @@ export function createCustomerHandler(opts: CustomerHandlerOptions) { return store.getClientOperator(subject); } + async function supplierConnections() { + const [suppliers, events] = await Promise.all([ + store.listSuppliers(), + store.listConnectionEvents(), + ]); + return suppliers + .filter((supplier) => supplier.kind === "agent") + .map((supplier) => { + const live = opts.supplierConnection?.(supplier.id); + const lastDisconnect = events + .filter( + (event) => + event.supplierId === supplier.id && event.event === "disconnected", + ) + .sort((left, right) => right.at.getTime() - left.at.getTime())[0]; + return { + id: supplier.id, + displayName: supplier.displayName, + enabled: supplier.enabled, + connected: Boolean(live), + connectedAt: live?.connectedAt, + inFlight: live?.inFlight ?? 0, + lastDisconnectAt: lastDisconnect?.at, + lastDisconnectReason: lastDisconnect?.reason, + }; + }) + .sort((left, right) => left.id.localeCompare(right.id)); + } + async function dashboard(subject: string, canAdminGrant = false) { const link = await callerLink(subject); const client = link ? await store.getClient(link.clientId) : null; + const adminSupplierConnections = canAdminGrant + ? await supplierConnections() + : undefined; if (!client || !link) return { onboarded: false, fundingConfigured: stripeConfigured, canAdminGrant, + supplierConnections: adminSupplierConnections, }; const applications = (await store.listApplications()).filter( (application) => application.clientId === client.id, @@ -307,6 +344,7 @@ export function createCustomerHandler(opts: CustomerHandlerOptions) { .map(({ id, createdAt, expiresAt }) => ({ id, createdAt, expiresAt })), fundingConfigured: stripeConfigured, canAdminGrant, + supplierConnections: adminSupplierConnections, balance, ledger, applications: applications.map((application, index) => ({ diff --git a/packages/mycel/src/server.ts b/packages/mycel/src/server.ts index 672f8b5a..4f673285 100644 --- a/packages/mycel/src/server.ts +++ b/packages/mycel/src/server.ts @@ -151,6 +151,9 @@ export function createExchangeApp( party.trim(), ), completeChat: buyerHandler.handleAs, + supplierConnection: connections + ? (supplierId) => connections.get(supplierId) + : undefined, defaultCreditLimitMicroDollars: opts.selfServiceCreditLimitMicroDollars ?? Number(process.env.MYCEL_SELF_SERVICE_CREDIT_LIMIT_MICRO_DOLLARS ?? 0),