diff --git a/src/browserNodeProvider.ts b/src/browserNodeProvider.ts index 01081f4..8ef5b54 100644 --- a/src/browserNodeProvider.ts +++ b/src/browserNodeProvider.ts @@ -20,6 +20,7 @@ import { maskTunnelUrl } from "./iicpConsumer.js"; import type { ChatMessage } from "./iicpConsumer.js"; import { createCxKeyPair, decryptPayload } from "./cxConfidentiality.js"; +import type { EffectiveCapability } from "./effectiveCapability.js"; import { BROWSER_NODE_SDK_COMPATIBILITY_VERSION, BROWSER_NODE_SDK_VERSION, @@ -42,6 +43,9 @@ export interface BrowserProviderConfig { directoryUrl?: string; /** Model name advertised to the directory (the loaded WebLLM model id). */ model: string; + /** Complete service-path variants. When present, these replace the legacy + * text-only browser advertisement without merging fields across variants. */ + effectiveCapabilities?: EffectiveCapability[]; region?: string; onLog?: (line: string) => void; /** Called after each served task with the running total. */ @@ -74,6 +78,23 @@ export interface BrowserProviderDiagnostic { const CHAT_INTENT = "urn:iicp:intent:llm:chat:v1"; export { BROWSER_NODE_SDK_VERSION } from "./version.js"; +/** Resolve the exact variants sent to the directory. Explicit effective + * capabilities take precedence; the historical browser default is preserved + * for callers that do not opt in. */ +export function advertisedBrowserCapabilities( + explicit: readonly EffectiveCapability[], + model: string, +): EffectiveCapability[] { + return explicit.length > 0 + ? explicit.map((capability) => ({ ...capability })) + : [{ + intent: CHAT_INTENT, + models: [model], + max_tokens: 1024, + input_modalities: ["text"], + }]; +} + /** * Coarse region autodetect from the browser's timezone (no network, no * geolocation permission). Matches the mesh's region convention @@ -355,14 +376,10 @@ export class BrowserNodeProvider { node_id: this.nodeId, endpoint, region: this.cfg.region ?? detectRegion(), - capabilities: [ - { - intent: CHAT_INTENT, - models: [this.cfg.model], - max_tokens: 1024, - input_modalities: ["text"], - }, - ], + capabilities: advertisedBrowserCapabilities( + this.cfg.effectiveCapabilities ?? [], + this.cfg.model, + ), limits: { max_concurrent: 1, tokens_per_min: 6000 }, transport_method: "turn_relay", exposure_mode: "relay_required", diff --git a/tests/provider.test.ts b/tests/provider.test.ts index b036ac3..c240660 100644 --- a/tests/provider.test.ts +++ b/tests/provider.test.ts @@ -9,6 +9,7 @@ import { BROWSER_NODE_SDK_VERSION, BROWSER_NODE_VERSION, discoverRelay, + advertisedBrowserCapabilities, encryptPayload, type BrowserProviderRuntime, } from "../src/index.ts"; @@ -34,6 +35,20 @@ function runtime(reply = "ok"): BrowserProviderRuntime { }; } +test("explicit effective variants replace the legacy browser advertisement", () => { + const explicit = [{ + intent: "urn:iicp:intent:llm:chat:v1", + variant_id: "browser-vision", + models: ["custom-browser-model"], + input_modalities: ["text", "image"], + claim_provenance: { source: "runtime_introspection" as const }, + }]; + assert.deepEqual( + advertisedBrowserCapabilities(explicit, "legacy-browser-model"), + explicit, + ); +}); + test("discoverRelay requests a short-lived ticket and uses its relay route", async () => { let url = ""; let requestBody: Record | null = null; @@ -119,6 +134,48 @@ test("start registers browser provider with CX key, relay exposure and current b assert.deepEqual(order.slice(0, 3), ["register", "ticket", "bind"]); }); +test("start sends configured effective capability variants to the directory", async () => { + let registerBody: Record | null = null; + const effectiveCapabilities = [{ + intent: "urn:iicp:intent:llm:chat:v1", + variant_id: "browser-vision", + models: ["custom-browser-model"], + input_modalities: ["text", "image"], + output_modalities: ["text"], + claim_provenance: { source: "runtime_introspection" as const }, + }]; + await withFetch(async (input, init) => { + const url = String(input); + if (url.endsWith("/v1/register") && init?.method === "POST") { + registerBody = JSON.parse(String(init.body)); + return new Response(JSON.stringify({ node_token: "node-token" })); + } + if (url.endsWith("/v1/relay/ticket")) { + return new Response(JSON.stringify({ ticket: "signed-bind-ticket" }), { status: 201 }); + } + if (url.endsWith("/v1/relay/bind")) { + return new Response(JSON.stringify({ session_token: "relay-session" })); + } + if (url.endsWith("/v1/heartbeat")) return new Response(JSON.stringify({ ok: true })); + if (url.endsWith("/v1/relay/pull")) return new Promise(() => undefined); + if (url.endsWith("/v1/relay/unbind") || (url.endsWith("/v1/register") && init?.method === "DELETE")) { + return new Response(JSON.stringify({ ok: true })); + } + throw new Error(`unexpected fetch ${url}`); + }, async () => { + const provider = new BrowserNodeProvider(runtime(), { + relayUrl: "https://relay.example", + relayNodeId: "relay-1", + directoryUrl: "https://directory.test/api", + model: "legacy-browser-model", + effectiveCapabilities, + }); + await provider.start(); + await provider.stop(); + }); + assert.deepEqual(registerBody?.capabilities, effectiveCapabilities); +}); + test("provider exposes deterministic recovery state transitions", async () => { const diagnostics: string[] = []; await withFetch(async (input, init) => {