Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/mycel-client/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion deploy/mycel/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions packages/mycel/src/buyer/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,23 @@ 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");
expect(entries[0].status).toBe("available");
});

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);
Expand Down Expand Up @@ -73,6 +81,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" })]);
Expand Down
33 changes: 28 additions & 5 deletions packages/mycel/src/buyer/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -34,10 +36,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<string> } = {},
): ModelEntry[] {
const byModel = new Map<string, Offer[]>();
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]);
}

Expand All @@ -49,20 +62,25 @@ 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.
const guarantees = group
.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,
object: "model",
owned_by: "exchange",
status: "available",
pricing: {
prompt: toDollarsPerMillion(cheapest.retailPromptPerMillion),
completion: toDollarsPerMillion(cheapest.retailCompletionPerMillion),
Expand All @@ -76,7 +94,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<string>;
}) {
return async function handleModels(
req: IncomingMessage,
res: ServerResponse,
Expand All @@ -91,7 +112,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/");
});

Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
},
};
4 changes: 3 additions & 1 deletion packages/mycel/src/client-surface/components/models.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,16 @@ export default {
note.textContent = models.length ? "" : "no models on offer";
table.innerHTML = models.length
? `<tr style="color:var(--muted);">
<th style="${td}">model</th><th style="${td}">prompt</th>
<th style="${td}">model</th><th style="${td}">status</th>
<th style="${td}">prompt</th>
<th style="${td}">completion</th><th style="${td}">context</th>
<th style="${td}">capabilities</th><th style="${td}">guarantees</th>
</tr>` +
models
.map(
(m) => `<tr style="border-top:1px solid var(--line);">
<td style="${td}">${esc(m.id)}</td>
<td style="${td}color:#77c593;">● ${m.status === "available" ? "Available now" : "Unknown"}</td>
<td style="${td}">${dollars(m.pricing?.prompt)}</td>
<td style="${td}">${dollars(m.pricing?.completion)}</td>
<td style="${td}">${m.context_length ? esc(m.context_length) : "—"}</td>
Expand Down
1 change: 1 addition & 0 deletions packages/mycel/src/client-surface/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/mycel/src/client-surface/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
14 changes: 13 additions & 1 deletion packages/mycel/src/customer/account-console.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 }[]>(
() => {
Expand All @@ -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
Expand Down
48 changes: 48 additions & 0 deletions packages/mycel/src/customer/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading