Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/llm-session-resume-payload.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions packages/tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down
75 changes: 74 additions & 1 deletion packages/tools/src/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -652,6 +656,75 @@ export interface LlmSessionHandle extends DispatchHandle {
wait(opts?: { timeoutMs?: number; pollMs?: number }): Promise<LlmSession>;
}

/**
* 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<string, unknown>;
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<string, unknown> | 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 {
Expand Down
101 changes: 100 additions & 1 deletion packages/tools/src/llm/sessions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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/);
});
});
43 changes: 43 additions & 0 deletions packages/tools/src/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions scripts/provider-neutral-copy-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading