diff --git a/.changeset/llm-session-resume-payload.md b/.changeset/llm-session-resume-payload.md new file mode 100644 index 000000000..cab9fa5be --- /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 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 `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 afad58438..bb02694a7 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,26 @@ 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 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"; 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..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 }; @@ -652,6 +656,75 @@ 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 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"` — 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 + * 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" }) + | (LlmSession & { state: "failed"; error: string }); + +/** + * The wire-shape keys of `LlmSession.baseUrls` — the same vocabulary as + * `callSession`'s `shape` option; when present, `baseUrls` 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 {} + +/** 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 === "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") + fail(`baseUrls.${shape} must be a string`); + } + } + 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..a1de59f76 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,97 @@ 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 is what `callSession` takes. + if (parsed.state === "ready") { + 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", + "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"/); + // baseUrls is optional, but when present it must be the complete pair. + expect(() => + llmSessionReadySchema.parse({ + sessionId: "s", + state: "ready", + 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" }), + ).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 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