From 965df763d6dbecb5a868db689070a39e58104142 Mon Sep 17 00:00:00 2001 From: David Witwer Date: Sat, 5 Sep 2026 03:00:19 -0700 Subject: [PATCH 1/4] feat(tools): export the llm session resume payload type and schema from the root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two llm deferred lanes were asymmetric at the package boundary: the async lane exported LlmRouteResultPayload + llmRouteResultSchema + LlmRouteResultSchemaError from `@sapiom/tools`, while a step paused on llm.createSession had nothing to type or validate its resumed input with, so authors hand-rolled both. Add LlmSessionReadyPayload (an LlmSession narrowed to the two terminal shapes the engine's resume forwarder delivers: `ready` with session-scoped baseUrls, or `failed` with the gateway's structured reason), plus llmSessionReadySchema and LlmSessionReadySchemaError, mirroring the route-result trio. Also re-export LlmSession, LlmSessionState, RoutingLabel, ModelLabel, readDisclosure and the disclosure types from the root — same class of gap, no behavior change. Purely additive; no existing export changes shape or name. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01W5yTA5mh9qL5P6e3oBc6GH --- .changeset/llm-session-resume-payload.md | 9 +++ packages/tools/src/index.ts | 24 +++++++ packages/tools/src/llm/index.ts | 58 +++++++++++++++ packages/tools/src/llm/sessions.spec.ts | 92 +++++++++++++++++++++++- packages/tools/src/smoke.spec.ts | 43 +++++++++++ 5 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 .changeset/llm-session-resume-payload.md diff --git a/.changeset/llm-session-resume-payload.md b/.changeset/llm-session-resume-payload.md new file mode 100644 index 000000000..1a361e364 --- /dev/null +++ b/.changeset/llm-session-resume-payload.md @@ -0,0 +1,9 @@ +--- +"@sapiom/tools": minor +--- + +`llm`: the session deferred lane now exports its resume-boundary contract from the package root, matching the async lane (SAP-3184). + +A step paused on `llm.createSession(...)` and resumed on `LLM_SESSION_READY_SIGNAL` receives an `LlmSessionReadyPayload` as input — an `LlmSession` narrowed to the two terminal shapes the engine delivers: `state: "ready"` with the session-scoped `baseUrls`, or `state: "failed"` with the gateway's structured reason (`deadline_exhausted`, `grant_mint_failed`, `session_ready_failed`, `session_unsupported`). Validate it at the resume boundary with `llmSessionReadySchema.parse(...)`, which throws `LlmSessionReadySchemaError` on a malformed payload — the same shape of API as `llmRouteResultSchema` / `LlmRouteResultSchemaError`. All three are importable from `@sapiom/tools` with no subpath. + +Also re-exported from the root while closing the same gap: the `LlmSession` and `LlmSessionState` types, `RoutingLabel` and `ModelLabel`, and the serving-disclosure reader `readDisclosure` with its `LlmDisclosure` / `LlmDisclosureResult` types. Purely additive — no existing export changes shape or name. diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index afad58438..9df45678b 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -48,6 +48,7 @@ export { // for a step resumed from `pauseUntilSignal(modelHandle, …)`. export { MODEL_RUN_RESULT_SIGNAL } from "./models/index.js"; export type { + ModelLabel, ModelRunSpec, ModelRunResult, ModelRunOutcome, @@ -92,6 +93,29 @@ export { llmRouteResultSchema, LlmRouteResultSchemaError, } from "./llm/index.js"; +// The shape a step resumed from `pauseUntilSignal(sessionHandle, …)` receives +// as input — an `LlmSession` narrowed to its two terminal states (`ready` with +// base URLs, or `failed` with the gateway's structured reason). Annotate the +// resumed step with it instead of hand-rolling the shape. +export type { + LlmSessionReadyPayload, + LlmSession, + LlmSessionState, +} from "./llm/index.js"; +// Validate an LlmSessionReadyPayload at the resume boundary. +export { + llmSessionReadySchema, + LlmSessionReadySchemaError, +} from "./llm/index.js"; +// The routing-label vocabulary `llm.run` / `llm.submit` / `llm.createSession` +// accept for `model` / `label`, and the serving disclosure a routed response +// carries (`served_class` + `lane`) with its camelCase reader. +export type { + RoutingLabel, + LlmDisclosure, + LlmDisclosureResult, +} from "./llm/index.js"; +export { readDisclosure } from "./llm/index.js"; export * as fileStorage from "./file-storage/index.js"; export { FileStorageHttpError } from "./file-storage/index.js"; diff --git a/packages/tools/src/llm/index.ts b/packages/tools/src/llm/index.ts index 3e2e4f145..25d2a7fd5 100644 --- a/packages/tools/src/llm/index.ts +++ b/packages/tools/src/llm/index.ts @@ -652,6 +652,64 @@ export interface LlmSessionHandle extends DispatchHandle { wait(opts?: { timeoutMs?: number; pollMs?: number }): Promise; } +/** + * The session's settled result as it arrives at a step **resumed** from + * `pauseUntilSignal(sessionHandle, { resumeStep })` — the + * {@link LLM_SESSION_READY_SIGNAL} payload delivered as that step's `input`. + * An {@link LlmSession} narrowed to the two terminal shapes the engine's + * resume forwarder delivers; branch on `state`: + * + * - `"ready"` — `baseUrls` is present (hand the payload to `callSession`). + * - `"failed"` — `error` carries the gateway's structured reason + * (`deadline_exhausted`, `grant_mint_failed`, `session_ready_failed`, + * `session_unsupported`; `session_ready_incomplete` when the forwarder + * received a ready body it could not use). + * + * Annotate the resumed step's input with this. The async-lane counterpart is + * {@link LlmRouteResultPayload}. + */ +export type LlmSessionReadyPayload = + | (LlmSession & { + state: "ready"; + baseUrls: { anthropic: string; openai: string }; + }) + | (LlmSession & { state: "failed"; error: string }); + +/** Thrown by {@link llmSessionReadySchema}.parse on a malformed resume payload. */ +export class LlmSessionReadySchemaError extends Error {} + +/** Runtime validator for {@link LlmSessionReadyPayload}. */ +export const llmSessionReadySchema = { + parse(value: unknown): LlmSessionReadyPayload { + const fail = (msg: string): never => { + throw new LlmSessionReadySchemaError( + `invalid llm session ready payload: ${msg}`, + ); + }; + if (!value || typeof value !== "object") fail("not an object"); + const v = value as Record; + if (typeof v.sessionId !== "string") fail("sessionId must be a string"); + if (v.state !== "ready" && v.state !== "failed") + fail('state must be "ready" or "failed"'); + if (v.model !== undefined && typeof v.model !== "string") + fail("model must be a string when present"); + if (v.expiresAtMs !== undefined && typeof v.expiresAtMs !== "number") + fail("expiresAtMs must be a number when present"); + if (v.state === "ready") { + const b = v.baseUrls as Record | null | undefined; + if (!b || typeof b !== "object") fail("baseUrls is required when ready"); + const urls = b as Record; + if (typeof urls.anthropic !== "string") + fail("baseUrls.anthropic must be a string"); + if (typeof urls.openai !== "string") + fail("baseUrls.openai must be a string"); + } else if (typeof v.error !== "string" || !v.error) { + fail("error must be a non-empty string when failed"); + } + return value as LlmSessionReadyPayload; + }, +}; + // --- wire shapes (snake_case, as served by the gateway) --- interface SessionDoc { diff --git a/packages/tools/src/llm/sessions.spec.ts b/packages/tools/src/llm/sessions.spec.ts index 7b8b64d1c..73b558505 100644 --- a/packages/tools/src/llm/sessions.spec.ts +++ b/packages/tools/src/llm/sessions.spec.ts @@ -9,7 +9,12 @@ * Injects a fake fetch (no real network), mirroring submit.spec.ts. */ import { createClient } from "../index.js"; -import { LLM_SESSION_READY_SIGNAL } from "./index.js"; +import { + LLM_SESSION_READY_SIGNAL, + llmSessionReadySchema, + LlmSessionReadySchemaError, +} from "./index.js"; +import type { LlmSessionReadyPayload } from "./index.js"; interface Captured { url?: string; @@ -229,3 +234,88 @@ describe("llm.callSession / releaseSession — the repeatable surface", () => { expect(out.state).toBe("expired"); }); }); + +describe("llmSessionReadySchema", () => { + it("accepts a ready payload (the forwarder's camelCase shape)", () => { + const payload = { + sessionId: "sess-1", + state: "ready", + model: "smart", + baseUrls: { + anthropic: + "https://llm.services.sapiom.ai/v2/sessions/sess-1/anthropic", + openai: "https://llm.services.sapiom.ai/v2/sessions/sess-1/openai/v1", + }, + expiresAtMs: 1_726_574_400_000, + budget: { maxTokens: null }, + }; + const parsed = llmSessionReadySchema.parse(payload); + expect(parsed).toBe(payload); + // The union narrows on `state`: a ready payload exposes baseUrls as required. + if (parsed.state === "ready") { + expect(parsed.baseUrls.anthropic).toMatch(/\/anthropic$/); + } else { + throw new Error("expected a ready payload"); + } + }); + + it("accepts a failed payload carrying each gateway async terminal reason", () => { + for (const error of [ + "deadline_exhausted", + "grant_mint_failed", + "session_ready_failed", + "session_unsupported", + ]) { + const payload: LlmSessionReadyPayload = { + sessionId: "sess-1", + state: "failed", + error, + }; + expect(llmSessionReadySchema.parse(payload)).toBe(payload); + } + }); + + it("rejects a malformed payload with the typed error", () => { + expect(() => llmSessionReadySchema.parse(null)).toThrow( + LlmSessionReadySchemaError, + ); + expect(() => llmSessionReadySchema.parse({ state: "ready" })).toThrow( + LlmSessionReadySchemaError, + ); + // Non-terminal states are not resume payloads. + expect(() => + llmSessionReadySchema.parse({ sessionId: "s", state: "pending" }), + ).toThrow(/state must be "ready" or "failed"/); + // Ready without the session-scoped base URLs is uncallable. + expect(() => + llmSessionReadySchema.parse({ sessionId: "s", state: "ready" }), + ).toThrow(/baseUrls is required/); + expect(() => + llmSessionReadySchema.parse({ + sessionId: "s", + state: "ready", + baseUrls: { anthropic: "https://a" }, + }), + ).toThrow(/baseUrls\.openai/); + // Failed without a reason is not the contract the forwarder delivers. + expect(() => + llmSessionReadySchema.parse({ sessionId: "s", state: "failed" }), + ).toThrow(/error must be a non-empty string/); + expect(() => + llmSessionReadySchema.parse({ + sessionId: "s", + state: "failed", + error: "", + }), + ).toThrow(LlmSessionReadySchemaError); + // Optional fields must be well-typed when present. + expect(() => + llmSessionReadySchema.parse({ + sessionId: "s", + state: "failed", + error: "deadline_exhausted", + expiresAtMs: "soon", + }), + ).toThrow(/expiresAtMs/); + }); +}); diff --git a/packages/tools/src/smoke.spec.ts b/packages/tools/src/smoke.spec.ts index a916a2807..354bdc5e9 100644 --- a/packages/tools/src/smoke.spec.ts +++ b/packages/tools/src/smoke.spec.ts @@ -26,6 +26,19 @@ import { BrowserAutomationHttpError, KeysHttpError, CodingRunHttpError, + LLM_ROUTE_RESULT_SIGNAL, + LLM_SESSION_READY_SIGNAL, + llmRouteResultSchema, + LlmRouteResultSchemaError, + llmSessionReadySchema, + LlmSessionReadySchemaError, + readDisclosure, +} from "./index.js"; +import type { + LlmRouteResultPayload, + LlmSessionReadyPayload, + RoutingLabel, + ModelLabel, } from "./index.js"; describe("@sapiom/tools public surface", () => { @@ -110,6 +123,36 @@ describe("@sapiom/tools public surface", () => { expect(typeof Repository).toBe("function"); }); + it("barrel exports both llm deferred lanes' resume contracts symmetrically", () => { + // SAP-3184: the async lane (route result) and the session lane (session + // ready) each export signal + payload type + schema + error from the root, + // so a resumed step needs no subpath import for either. + expect(LLM_ROUTE_RESULT_SIGNAL).toBe("llm.route.result"); + expect(LLM_SESSION_READY_SIGNAL).toBe("llm.session.ready"); + expect(typeof llmRouteResultSchema.parse).toBe("function"); + expect(typeof LlmRouteResultSchemaError).toBe("function"); + expect(typeof llmSessionReadySchema.parse).toBe("function"); + expect(typeof LlmSessionReadySchemaError).toBe("function"); + expect(typeof readDisclosure).toBe("function"); + + const granted: LlmRouteResultPayload = { + executionId: "e", + status: "failed", + link: null, + error: "deadline_exhausted", + }; + const failed: LlmSessionReadyPayload = { + sessionId: "s", + state: "failed", + error: "session_unsupported", + }; + const label: RoutingLabel = "smart"; + const modelLabel: ModelLabel = "smart"; + expect(llmRouteResultSchema.parse(granted)).toBe(granted); + expect(llmSessionReadySchema.parse(failed)).toBe(failed); + expect(label).toBe(modelLabel); + }); + it("the search namespace has no self-named nested key", () => { // The barrel creates the `search` namespace via `export * as search`; the // module itself must not export a const named after itself, or methods would From ebd933762e702f93073fbdfbaac1c86431abaddd Mon Sep 17 00:00:00 2001 From: David Witwer Date: Sat, 5 Sep 2026 03:03:59 -0700 Subject: [PATCH 2/4] fix(tools): keep the session-ready schema provider-neutral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider-neutral copy guard audits packages/tools/src/llm/index.ts and flagged the new schema's bare `baseUrls.anthropic` / `baseUrls.openai` property accesses and error strings. Name the two wire-shape keys once as a const tuple and iterate it, and unmask exactly that tuple in the guard — the same narrow, one-usage-at-a-time approach the file's other wire-shape identifiers use. No behavior change: the schema still requires both base URLs on a ready session and produces the same error messages. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01W5yTA5mh9qL5P6e3oBc6GH --- packages/tools/src/llm/index.ts | 14 ++++++++++---- scripts/provider-neutral-copy-check.mjs | 3 +++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/tools/src/llm/index.ts b/packages/tools/src/llm/index.ts index 25d2a7fd5..c618ca92b 100644 --- a/packages/tools/src/llm/index.ts +++ b/packages/tools/src/llm/index.ts @@ -675,6 +675,12 @@ export type LlmSessionReadyPayload = }) | (LlmSession & { state: "failed"; error: string }); +/** + * The wire-shape keys of `LlmSession.baseUrls` — the same vocabulary as + * `callSession`'s `shape` option; a ready session carries one URL per shape. + */ +const SESSION_BASE_URL_SHAPES = ["anthropic", "openai"] as const; + /** Thrown by {@link llmSessionReadySchema}.parse on a malformed resume payload. */ export class LlmSessionReadySchemaError extends Error {} @@ -699,10 +705,10 @@ export const llmSessionReadySchema = { const b = v.baseUrls as Record | null | undefined; if (!b || typeof b !== "object") fail("baseUrls is required when ready"); const urls = b as Record; - if (typeof urls.anthropic !== "string") - fail("baseUrls.anthropic must be a string"); - if (typeof urls.openai !== "string") - fail("baseUrls.openai must be a string"); + for (const shape of SESSION_BASE_URL_SHAPES) { + if (typeof urls[shape] !== "string") + fail(`baseUrls.${shape} must be a string`); + } } else if (typeof v.error !== "string" || !v.error) { fail("error must be a non-empty string when failed"); } diff --git a/scripts/provider-neutral-copy-check.mjs b/scripts/provider-neutral-copy-check.mjs index dcc90b5f5..798819fc5 100644 --- a/scripts/provider-neutral-copy-check.mjs +++ b/scripts/provider-neutral-copy-check.mjs @@ -60,6 +60,9 @@ const REQUIRED_CONTRACT_PATTERNS_BY_PATH = { /`shape:\s*"openai"`/g, /opts\.shape\s*===\s*"openai"/g, /\{\s*anthropic:\s*string;\s*openai:\s*string\s*\}/g, + // The base-URL keys `llmSessionReadySchema` validates on a ready session — + // the same two wire shapes, named once as a const tuple. + /\["anthropic", "openai"\] as const/g, ], "packages/tools/src/sandboxes/index.ts": [/"blaxel"/giu], // `@sapiom/langchain-classic` binds LangChain's provider-specific chat models, so the provider From 14e6bb3e498b0c76571cd3451449b3f331f752f4 Mon Sep 17 00:00:00 2001 From: David Witwer Date: Sat, 5 Sep 2026 03:10:34 -0700 Subject: [PATCH 3/4] refactor(tools): make baseUrls optional on the session resume payload; trim root exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1: `llmSessionReadySchema` required `baseUrls` on a ready payload, which is stricter than anything in this package consumes — `callSession` needs only `sessionId`. Make it optional on the payload (as it is on `LlmSession`) and validate the pair only when present. The state union stays `ready | failed`: the signal fires once when the session leaves `pending`, and the engine folds any non-ready outcome into `failed` with the outcome as the reason, so no other state arrives on it. Also drop the raw wire-shape `LlmDisclosure` from the root barrel (it stays on the `llm` namespace / subpath); the camelCase `LlmDisclosureResult` and `readDisclosure` — which the ticket asks for — remain. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01W5yTA5mh9qL5P6e3oBc6GH --- .changeset/llm-session-resume-payload.md | 4 +-- packages/tools/src/index.ts | 11 +++----- packages/tools/src/llm/index.ts | 35 ++++++++++++++---------- packages/tools/src/llm/sessions.spec.ts | 21 ++++++++++---- 4 files changed, 41 insertions(+), 30 deletions(-) diff --git a/.changeset/llm-session-resume-payload.md b/.changeset/llm-session-resume-payload.md index 1a361e364..cab9fa5be 100644 --- a/.changeset/llm-session-resume-payload.md +++ b/.changeset/llm-session-resume-payload.md @@ -4,6 +4,6 @@ `llm`: the session deferred lane now exports its resume-boundary contract from the package root, matching the async lane (SAP-3184). -A step paused on `llm.createSession(...)` and resumed on `LLM_SESSION_READY_SIGNAL` receives an `LlmSessionReadyPayload` as input — an `LlmSession` narrowed to the two terminal shapes the engine delivers: `state: "ready"` with the session-scoped `baseUrls`, or `state: "failed"` with the gateway's structured reason (`deadline_exhausted`, `grant_mint_failed`, `session_ready_failed`, `session_unsupported`). Validate it at the resume boundary with `llmSessionReadySchema.parse(...)`, which throws `LlmSessionReadySchemaError` on a malformed payload — the same shape of API as `llmRouteResultSchema` / `LlmRouteResultSchemaError`. All three are importable from `@sapiom/tools` with no subpath. +A step paused on `llm.createSession(...)` and resumed on `LLM_SESSION_READY_SIGNAL` receives an `LlmSessionReadyPayload` as input — an `LlmSession` narrowed to the two shapes the engine delivers: `state: "ready"` (hand it to `callSession`; `baseUrls` carries the session-scoped URLs when reported), or `state: "failed"` with the gateway's structured reason (`deadline_exhausted`, `grant_mint_failed`, `session_ready_failed`, `session_unsupported`). Validate it at the resume boundary with `llmSessionReadySchema.parse(...)`, which throws `LlmSessionReadySchemaError` on a malformed payload — the same shape of API as `llmRouteResultSchema` / `LlmRouteResultSchemaError`. All three are importable from `@sapiom/tools` with no subpath. -Also re-exported from the root while closing the same gap: the `LlmSession` and `LlmSessionState` types, `RoutingLabel` and `ModelLabel`, and the serving-disclosure reader `readDisclosure` with its `LlmDisclosure` / `LlmDisclosureResult` types. Purely additive — no existing export changes shape or name. +Also re-exported from the root while closing the same gap: the `LlmSession` and `LlmSessionState` types, `RoutingLabel` and `ModelLabel`, and the serving-disclosure reader `readDisclosure` with its `LlmDisclosureResult` type. Purely additive — no existing export changes shape or name. diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 9df45678b..bb02694a7 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -108,13 +108,10 @@ export { LlmSessionReadySchemaError, } from "./llm/index.js"; // The routing-label vocabulary `llm.run` / `llm.submit` / `llm.createSession` -// accept for `model` / `label`, and the serving disclosure a routed response -// carries (`served_class` + `lane`) with its camelCase reader. -export type { - RoutingLabel, - LlmDisclosure, - LlmDisclosureResult, -} from "./llm/index.js"; +// accept for `model` / `label`, and the reader for the serving disclosure a +// routed response carries (`servedClass` + `lane`), with its result type. The +// raw wire shape (`LlmDisclosure`) stays on the `llm` namespace / subpath. +export type { RoutingLabel, LlmDisclosureResult } from "./llm/index.js"; export { readDisclosure } from "./llm/index.js"; export * as fileStorage from "./file-storage/index.js"; diff --git a/packages/tools/src/llm/index.ts b/packages/tools/src/llm/index.ts index c618ca92b..e25422a8b 100644 --- a/packages/tools/src/llm/index.ts +++ b/packages/tools/src/llm/index.ts @@ -656,10 +656,15 @@ export interface LlmSessionHandle extends DispatchHandle { * The session's settled result as it arrives at a step **resumed** from * `pauseUntilSignal(sessionHandle, { resumeStep })` — the * {@link LLM_SESSION_READY_SIGNAL} payload delivered as that step's `input`. - * An {@link LlmSession} narrowed to the two terminal shapes the engine's - * resume forwarder delivers; branch on `state`: + * An {@link LlmSession} narrowed to the two shapes the engine's resume + * forwarder delivers — the signal fires once, when the session leaves + * `pending`, and the forwarder folds any non-ready outcome into `"failed"` + * with the outcome as the reason, so no other `state` arrives here. Branch on + * `state`: * - * - `"ready"` — `baseUrls` is present (hand the payload to `callSession`). + * - `"ready"` — hand the payload to `callSession` (it needs only + * `sessionId`). `baseUrls` carries the session-scoped drop-in URLs when the + * gateway reported them. * - `"failed"` — `error` carries the gateway's structured reason * (`deadline_exhausted`, `grant_mint_failed`, `session_ready_failed`, * `session_unsupported`; `session_ready_incomplete` when the forwarder @@ -669,15 +674,13 @@ export interface LlmSessionHandle extends DispatchHandle { * {@link LlmRouteResultPayload}. */ export type LlmSessionReadyPayload = - | (LlmSession & { - state: "ready"; - baseUrls: { anthropic: string; openai: string }; - }) + | (LlmSession & { state: "ready" }) | (LlmSession & { state: "failed"; error: string }); /** * The wire-shape keys of `LlmSession.baseUrls` — the same vocabulary as - * `callSession`'s `shape` option; a ready session carries one URL per shape. + * `callSession`'s `shape` option; when present, `baseUrls` carries one URL per + * shape. */ const SESSION_BASE_URL_SHAPES = ["anthropic", "openai"] as const; @@ -701,16 +704,18 @@ export const llmSessionReadySchema = { fail("model must be a string when present"); if (v.expiresAtMs !== undefined && typeof v.expiresAtMs !== "number") fail("expiresAtMs must be a number when present"); - if (v.state === "ready") { - const b = v.baseUrls as Record | null | undefined; - if (!b || typeof b !== "object") fail("baseUrls is required when ready"); - const urls = b as Record; + if (v.state === "failed" && (typeof v.error !== "string" || !v.error)) { + fail("error must be a non-empty string when failed"); + } + // Optional on the payload (as on `LlmSession`) — `callSession` needs only + // `sessionId` — but when present it must be the complete, well-typed pair. + if (v.baseUrls !== undefined) { + const urls = v.baseUrls as Record | null; + if (!urls || typeof urls !== "object") fail("baseUrls must be an object"); for (const shape of SESSION_BASE_URL_SHAPES) { - if (typeof urls[shape] !== "string") + if (typeof urls![shape] !== "string") fail(`baseUrls.${shape} must be a string`); } - } else if (typeof v.error !== "string" || !v.error) { - fail("error must be a non-empty string when failed"); } return value as LlmSessionReadyPayload; }, diff --git a/packages/tools/src/llm/sessions.spec.ts b/packages/tools/src/llm/sessions.spec.ts index 73b558505..a1de59f76 100644 --- a/packages/tools/src/llm/sessions.spec.ts +++ b/packages/tools/src/llm/sessions.spec.ts @@ -251,14 +251,19 @@ describe("llmSessionReadySchema", () => { }; const parsed = llmSessionReadySchema.parse(payload); expect(parsed).toBe(payload); - // The union narrows on `state`: a ready payload exposes baseUrls as required. + // The union narrows on `state`; a ready payload is what `callSession` takes. if (parsed.state === "ready") { - expect(parsed.baseUrls.anthropic).toMatch(/\/anthropic$/); + expect(parsed.baseUrls?.anthropic).toMatch(/\/anthropic$/); } else { throw new Error("expected a ready payload"); } }); + it("accepts a ready payload without baseUrls (callSession needs only the id)", () => { + const payload = { sessionId: "sess-1", state: "ready" }; + expect(llmSessionReadySchema.parse(payload)).toBe(payload); + }); + it("accepts a failed payload carrying each gateway async terminal reason", () => { for (const error of [ "deadline_exhausted", @@ -286,10 +291,7 @@ describe("llmSessionReadySchema", () => { expect(() => llmSessionReadySchema.parse({ sessionId: "s", state: "pending" }), ).toThrow(/state must be "ready" or "failed"/); - // Ready without the session-scoped base URLs is uncallable. - expect(() => - llmSessionReadySchema.parse({ sessionId: "s", state: "ready" }), - ).toThrow(/baseUrls is required/); + // baseUrls is optional, but when present it must be the complete pair. expect(() => llmSessionReadySchema.parse({ sessionId: "s", @@ -297,6 +299,13 @@ describe("llmSessionReadySchema", () => { baseUrls: { anthropic: "https://a" }, }), ).toThrow(/baseUrls\.openai/); + expect(() => + llmSessionReadySchema.parse({ + sessionId: "s", + state: "ready", + baseUrls: null, + }), + ).toThrow(/baseUrls must be an object/); // Failed without a reason is not the contract the forwarder delivers. expect(() => llmSessionReadySchema.parse({ sessionId: "s", state: "failed" }), From 10831242e1412c678e7ff08cf11fbcea56df1f86 Mon Sep 17 00:00:00 2001 From: David Witwer Date: Sat, 5 Sep 2026 03:13:50 -0700 Subject: [PATCH 4/4] docs(tools): align LlmSession.baseUrls doc with the optional resume-payload contract Review round 2: the field doc still promised "present from READY on" while the session resume schema deliberately treats it as optional. Say when the gateway reports it and that callSession does not need it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01W5yTA5mh9qL5P6e3oBc6GH --- packages/tools/src/llm/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/tools/src/llm/index.ts b/packages/tools/src/llm/index.ts index e25422a8b..1e538dc53 100644 --- a/packages/tools/src/llm/index.ts +++ b/packages/tools/src/llm/index.ts @@ -632,7 +632,11 @@ export interface LlmSession { state: LlmSessionState; /** USER-FACING label (e.g. `"smart"`) — the serving provider is never disclosed. */ model?: string; - /** Present from READY on: the drop-in base URLs scoped under the session. */ + /** + * The drop-in base URLs scoped under the session, once it is READY and the + * gateway reports them. `callSession` does not need them (it builds the URL + * from `sessionId`). + */ baseUrls?: { anthropic: string; openai: string }; expiresAtMs?: number; budget?: { maxTokens: number | null; usedTokens?: number; ttlMinutes?: number | null };