diff --git a/README.md b/README.md index 0803b259..8691eef2 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ monorepo, the strict toolchain + GitHub Actions CI, `@relavium/shared` (the full contract set), the no-vendor-type seam fence, and `@relavium/db` (Drizzle schema + migrations + SQLite client) are all in place and green. Work is now on [Phase 1 — engine and LLM](docs/roadmap/phases/phase-1-engine-and-llm.md): the -`@relavium/llm` provider seam and the `@relavium/core` engine — which now also adds -the **AgentSession** runtime, persistence, and export-to-workflow workstream -alongside the workflow runner. See +`@relavium/llm` provider seam (1.A/1.B/1.E in review under **PR #7**, 2026-06-05) and the +`@relavium/core` engine — which now also adds the **AgentSession** runtime, persistence, and +export-to-workflow workstream alongside the workflow runner. See [docs/roadmap/current.md](docs/roadmap/current.md) for live status. diff --git a/docs/reference/shared-core/llm-provider-seam.md b/docs/reference/shared-core/llm-provider-seam.md index 75d6f82e..642d6306 100644 --- a/docs/reference/shared-core/llm-provider-seam.md +++ b/docs/reference/shared-core/llm-provider-seam.md @@ -254,6 +254,14 @@ All native stop reasons map onto the five-value `StopReason` enum | Gemini | `usageMetadata { promptTokenCount, candidatesTokenCount }` | `inputTokens` / `outputTokens` | | DeepSeek | mirrors OpenAI + `prompt_cache_hit_tokens` | `inputTokens` / `outputTokens` / `cacheReadTokens` | +> **`inputTokens` is net of cache (each token billed once).** `inputTokens`, `cacheReadTokens`, +> and `cacheWriteTokens` are **disjoint** counts — the `CostTracker` bills each at its own rate, so +> `inputTokens` must exclude cache reads/writes. Anthropic's native `input_tokens` is already net, +> so it maps straight across. **OpenAI / DeepSeek `prompt_tokens` is *gross*** (it includes +> `cached_tokens` / `prompt_cache_hit_tokens`), so that adapter sets `inputTokens = prompt_tokens − +> cached_tokens` (the cached subset moves to `cacheReadTokens`). Gemini exposes no cache split, so +> `inputTokens` is simply the prompt count. + Two streaming subtleties the adapters must handle: - **OpenAI** requires `stream_options: { include_usage: true }` to emit a final diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 3b2eca83..0658689e 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -71,8 +71,10 @@ parked in [deferred-tasks.md](deferred-tasks.md). The foundation is settled and > agent-first sub-spine (1.V–1.AA), the QuickJS-wasm sandbox (1.AB — on the critical path inside 1.P), > and the pre-egress budget governor (1.AC). The **agent-first sub-spine (1.V–1.AA) adds no work to the M1/M2 critical path** (it runs in parallel and is proven by its own harness); the hardening sandbox **1.AB *is* new M2-critical-path work** — it folds into 1.P and raises the 1.m4 cost. Phases 2–4 add > non-critical chat workstreams; phases 5–6 are largely unaffected (sessions inherit managed/cloud via -> the existing seams). All decisions/specs are landed at the docs/ADR layer — no engine code yet -> (Phase 1 has not started). +> the existing seams). All decisions/specs are landed at the docs/ADR layer. **Phase 1 has begun:** +> 1.L.0 — the `@relavium/shared` reconciliation to these agent-first + hardening contracts (the new +> events, `ErrorCode`/`StopReason`, the authored budget/validation/output_schema fields, the `[chat]` +> config) — merged in **PR #6** (2026-06-05). No engine code yet; the seam + parser come next. **Phase 0 is done (M0 reached); the active phase is now [Phase 1 — engine and LLM](phases/phase-1-engine-and-llm.md)** (Product Phase 1, the @@ -91,21 +93,26 @@ The next checkpoint is global milestone **M1 — LLM seam proven** (see the ## Immediate next steps -Phase 0's workstreams (0.A–0.I) are all complete and merged — kept for the record in -[phase-0-foundations.md](phases/phase-0-foundations.md#work-breakdown). Work now moves to -[Phase 1 — engine and LLM](phases/phase-1-engine-and-llm.md); see that phase doc for the -ordered workstreams and acceptance criteria. In brief: - -1. **[1.x] `@relavium/llm`** — the `LLMProvider` seam (Relavium/Zod types only — **no vendor - type crosses it**) + thin hand-rolled adapters over the official Anthropic/OpenAI/Gemini - SDKs ([ADR-0011](../decisions/0011-internal-llm-abstraction.md)). The seam fence (0.F) - polices the first adapter import from line one. -2. **[1.x] `@relavium/core`** — the pure-TypeScript engine: parse authored YAML → DAG, the - runner, checkpoint/resume (persisted via `@relavium/db`), and retry/fallback. **Zero - platform-specific imports**, so it runs identically on every surface. - -Carry-over hardening that did **not** block M0 is tracked as discrete tasks in -[deferred-tasks.md](deferred-tasks.md) — pick them up as Phase 1 first touches each file. +Phase 1 has started. **Wave 0 — 1.L.0** (`@relavium/shared` reconciliation) is **✅ done and +merged (PR #6)**: +44 schema tests (167 total), three independent reviews + adversarial +verification, all green. Per the +[sequencing plan](phases/phase-1-engine-and-llm.md#sequencing--parallelization), **Wave 1** opens +two parallel lanes (both unblocked by 1.L.0): + +1. **1.A — freeze the `LLMProvider` seam types** *(critical path)* — scaffold `packages/llm` and + declare the immovable seam in `packages/llm/src/types.ts` (Relavium/Zod types only — **no vendor + type crosses it**: `LlmRequest`/`LlmMessage`/`ContentPart`/`ToolDef`/`LlmResult`/`StopReason`/ + `Usage`/`StreamChunk`/`CapabilityFlags`/`LlmError`), re-exporting the run-event types from + `@relavium/shared` ([ADR-0011](../decisions/0011-internal-llm-abstraction.md), + [llm-provider-seam.md](../reference/shared-core/llm-provider-seam.md)). The seam fence (0.F) + polices it from line one. +2. **1.L — `WorkflowYAMLParser`** *(critical path, engine lane)* — scaffold `packages/core` and + parse+validate a `.relavium.yaml` against the (now-reconciled) `WorkflowSchema`, with typed, + field-named errors. **Zero platform-specific imports.** + +The engine lane then runs 1.L → 1.L2 → 1.M → 1.N → 1.R concurrently with the seam lane and waits +at the **1.O join** for the fallback runner (1.K). Carry-over hardening is tracked in +[deferred-tasks.md](deferred-tasks.md) — pick items up as Phase 1 first touches each file. ## Not started yet diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 94ad0e4a..906bafb7 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -92,6 +92,16 @@ Severity is the review's verified rating. Check an item off in the PR that resol doesn't define which keys are legal per `InputType`. Specify that matrix, then add a `WorkflowInputSchema.superRefine((type, validation) => …)`. *(minor · workflow.ts, workflow-yaml-spec.md)* +- [ ] **Verify the non-Anthropic prices in `pricing.ts` (at 1.G/1.H)** — the OpenAI / Gemini / + DeepSeek rows are best-known **placeholders** (Anthropic is confirmed via claude-api). Verify + each against the provider's pricing page when its adapter lands, and replace Gemini's flat + ≤128K-tier figures if context-tiered pricing matters. *(low → 1.G/1.H · packages/llm/src/pricing.ts)* +- [ ] **`model_catalog` cache-write column (at the seeder)** — `ModelPricing` carries + `cacheWritePerMtokMicrocents` (Anthropic charges one), but `model_catalog` + ([database-schema.md](../reference/desktop/database-schema.md)) has only + `input/output/cached_input_*_per_mtok_microcents`. When the catalog seeder lands, either add a + `cache_write_cost_per_mtok_microcents` column or knowingly drop the cache-write price from the DB + projection (`pricing.ts` stays the source of truth either way). *(nit · database-schema.md)* ## Test depth diff --git a/docs/roadmap/phases/phase-1-engine-and-llm.md b/docs/roadmap/phases/phase-1-engine-and-llm.md index 38d6c697..02e1e005 100644 --- a/docs/roadmap/phases/phase-1-engine-and-llm.md +++ b/docs/roadmap/phases/phase-1-engine-and-llm.md @@ -1,6 +1,9 @@ # Phase 1 — Engine and LLM -> Status: Not started — the critical path (Product Phase 1). Blocked on Phase 0 exit. +> Status: In progress — the critical path (Product Phase 1). Wave 0 (**1.L.0**, the +> `@relavium/shared` reconciliation) landed in **PR #6** (2026-06-05); Wave 1 is **in progress** — +> the seam workstreams **1.A** (types), **1.B** (CostTracker), and **1.E** (ToolNormalizer) are in +> review under **PR #7** (2026-06-05), alongside the **1.L** parser. - **Related**: [../README.md](../README.md), [phase-0-foundations.md](phase-0-foundations.md), [phase-2-cli.md](phase-2-cli.md), [../../architecture/shared-core-engine.md](../../architecture/shared-core-engine.md), [../../architecture/execution-model.md](../../architecture/execution-model.md), [../../architecture/multi-llm-providers.md](../../architecture/multi-llm-providers.md), [../../reference/shared-core/llm-provider-seam.md](../../reference/shared-core/llm-provider-seam.md), [../../reference/shared-core/node-types.md](../../reference/shared-core/node-types.md), [../../reference/shared-core/built-in-tools.md](../../reference/shared-core/built-in-tools.md), [../../reference/contracts/sse-event-schema.md](../../reference/contracts/sse-event-schema.md), [../../standards/testing.md](../../standards/testing.md), [../../standards/error-handling.md](../../standards/error-handling.md), [../../decisions/0011-internal-llm-abstraction.md](../../decisions/0011-internal-llm-abstraction.md) @@ -382,7 +385,7 @@ budgets. Adapters stay dumb; this owns the policy. to the next provider and the run succeeds; a fatal error stops the chain; and per-attempt cost is summed across a failover. -### 1.L.0 — Reconcile `@relavium/shared` to the 2026-06-05 contract — *critical path, do first* +### 1.L.0 — Reconcile `@relavium/shared` to the 2026-06-05 contract — ✅ **Done (PR #6)** · *critical path, do first* `@relavium/shared` was frozen in Phase 0 (2026-06-04); the agent-first + hardening ADRs ([0026](../../decisions/0026-session-export-to-workflow.md)/[0027](../../decisions/0027-expression-sandbox.md)/[0028](../../decisions/0028-workflow-resource-governance.md)/[0029](../../decisions/0029-tool-policy-hardening.md)) diff --git a/packages/llm/README.md b/packages/llm/README.md index f0d70265..a92f4c23 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -1,11 +1,21 @@ -# `@relavium/llm` — placeholder +# @relavium/llm -Relavium's own multi-LLM abstraction: the `LLMProvider` seam + thin hand-rolled -adapters over each provider's official SDK. **No vendor SDK type crosses the seam** -([ADR-0011](../../docs/decisions/0011-internal-llm-abstraction.md)). +Relavium's **own** multi-LLM abstraction: the provider-agnostic **`LLMProvider` seam** plus +(later) thin hand-rolled adapters over each provider's official TypeScript SDK. No Vercel AI SDK, +no LangChain — see [ADR-0011](../../docs/decisions/0011-internal-llm-abstraction.md). -> **Not built yet.** This package is a directory placeholder. It is built out in -> [Phase 1 — engine and LLM](../../docs/roadmap/phases/phase-1-engine-and-llm.md). It has -> no `package.json` yet, so pnpm/Turborepo do not treat it as a workspace. +## The immovable contract -Canonical contract: [llm-provider-seam.md](../../docs/reference/shared-core/llm-provider-seam.md). +The seam (`src/types.ts`) is expressed **only** in Relavium/Zod types. **No vendor SDK type ever +crosses it** — provider SDKs are imported _only_ inside `src/adapters/*` (enforced by the +import-zone lint fence). The canonical contract is +[docs/reference/shared-core/llm-provider-seam.md](../../docs/reference/shared-core/llm-provider-seam.md); +this package implements it. The seam's _shape_ is frozen; the adapters behind it are reversible, +and the _set_ of provider ids is meant to grow (an additive amendment, not a contract change). + +## Status + +**1.A — seam types frozen.** The adapters (Anthropic, the OpenAI-compatible adapter serving +OpenAI + DeepSeek, Gemini), the `FallbackChain` runner, the `CostTracker`, and the conformance +suite land in workstreams 1.B–1.K (see +[docs/roadmap/phases/phase-1-engine-and-llm.md](../../docs/roadmap/phases/phase-1-engine-and-llm.md)). diff --git a/packages/llm/package.json b/packages/llm/package.json new file mode 100644 index 00000000..7b7bdcb7 --- /dev/null +++ b/packages/llm/package.json @@ -0,0 +1,35 @@ +{ + "name": "@relavium/llm", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Relavium's provider-agnostic LLMProvider seam + (later) thin hand-rolled adapters over the official provider SDKs (ADR-0011). No vendor SDK type crosses the seam.", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "eslint src", + "test": "vitest run" + }, + "dependencies": { + "@relavium/shared": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "@types/json-schema": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/llm/src/cost-tracker.test.ts b/packages/llm/src/cost-tracker.test.ts new file mode 100644 index 00000000..6cb0fb95 --- /dev/null +++ b/packages/llm/src/cost-tracker.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { CostTracker, cost, priceModel } from './cost-tracker.js'; +import { UnknownModelError } from './errors.js'; +import { KNOWN_MODEL_IDS, MODEL_PRICING, type ModelPricing } from './pricing.js'; + +describe('priceModel', () => { + it('returns pricing for a known canonical model id', () => { + const p = priceModel('claude-opus-4-8'); + expect(p.provider).toBe('anthropic'); + expect(p.inputPerMtokMicrocents).toBe(500_000_000); // $5/MTok → 5e8 micro-cents + }); + + it('throws a typed UnknownModelError (never a silent zero)', () => { + expect(() => priceModel('gpt-9-ultra')).toThrowError(UnknownModelError); + try { + priceModel('gpt-9-ultra'); + } catch (err) { + expect(err).toBeInstanceOf(UnknownModelError); + if (err instanceof UnknownModelError) { + expect(err.code).toBe('unknown_model'); + expect(err.modelId).toBe('gpt-9-ultra'); + expect(err.knownModels).toEqual(KNOWN_MODEL_IDS); + } + } + }); +}); + +describe('cost', () => { + it('prices input + output in integer micro-cents (Opus 4.8)', () => { + // 1000 in @ $5/MTok = 500_000µ¢; 500 out @ $25/MTok = 1_250_000µ¢ + expect(cost('claude-opus-4-8', { inputTokens: 1000, outputTokens: 500 })).toBe(1_750_000); + }); + + it('prices each token class once, including cache read + write (Opus 4.8)', () => { + expect( + cost('claude-opus-4-8', { + inputTokens: 1000, // 500_000 + outputTokens: 500, // 1_250_000 + cacheReadTokens: 2000, // 2000 @ $0.50/MTok = 100_000 + cacheWriteTokens: 4000, // 4000 @ $6.25/MTok = 2_500_000 + }), + ).toBe(4_350_000); + }); + + it('prices Sonnet 4.6 and DeepSeek from their own rows', () => { + expect(cost('claude-sonnet-4-6', { inputTokens: 1000, outputTokens: 500 })).toBe(1_050_000); + // deepseek-chat: 1000 in @ $0.27/MTok = 27_000µ¢; 500 out @ $1.10/MTok = 55_000µ¢ + expect(cost('deepseek-chat', { inputTokens: 1000, outputTokens: 500 })).toBe(82_000); + }); + + it('ignores cache-write tokens for a provider with no cache-write price (gpt-4o)', () => { + // gpt-4o has no cacheWritePerMtokMicrocents → the 1000 cache-write tokens cost 0. + expect(cost('gpt-4o', { inputTokens: 1000, outputTokens: 0, cacheWriteTokens: 1000 })).toBe( + 250_000, + ); // 1000 in @ $2.50/MTok = 250_000 + }); + + it('rounds the per-class micro-cent figure (half-up)', () => { + // gpt-4o-mini cached input $0.075/MTok = 7_500_000µ¢/MTok → 1 token = 7.5 → rounds to 8. + expect(cost('gpt-4o-mini', { inputTokens: 0, outputTokens: 0, cacheReadTokens: 1 })).toBe(8); + }); +}); + +describe('CostTracker', () => { + it('accumulates per-attempt cost across a failover', () => { + const tracker = new CostTracker(); + const a = tracker.record('claude-opus-4-8', { inputTokens: 1000, outputTokens: 500 }); + expect(a).toEqual({ + inputTokens: 1000, + outputTokens: 500, + costMicrocents: 1_750_000, + cumulativeCostMicrocents: 1_750_000, + }); + const b = tracker.record('gpt-4o', { inputTokens: 1000, outputTokens: 0 }); + expect(b.costMicrocents).toBe(250_000); + expect(b.cumulativeCostMicrocents).toBe(2_000_000); // 1_750_000 + 250_000 + expect(tracker.cumulativeCostMicrocents).toBe(2_000_000); + }); +}); + +describe('MODEL_PRICING table invariants (the values seeded into model_catalog)', () => { + it('keys match KNOWN_MODEL_IDS and every catalog-projection field is complete + integer', () => { + const byLocale = (a: string, b: string): number => a.localeCompare(b); + expect(Object.keys(MODEL_PRICING).sort(byLocale)).toEqual([...KNOWN_MODEL_IDS].sort(byLocale)); + const rows: Array<[string, ModelPricing]> = Object.entries(MODEL_PRICING); + for (const [id, row] of rows) { + expect(row.nativeId.length, id).toBeGreaterThan(0); + expect(row.displayName.length, id).toBeGreaterThan(0); + expect(row.contextWindowTokens, id).toBeGreaterThan(0); + expect(row.maxOutputTokens, id).toBeGreaterThan(0); + // Prices are integer micro-cents per MTok — never a float, and never negative. + for (const price of [ + row.inputPerMtokMicrocents, + row.outputPerMtokMicrocents, + row.cachedInputPerMtokMicrocents, + row.cacheWritePerMtokMicrocents ?? 0, + ]) { + expect(Number.isInteger(price), id).toBe(true); + expect(price, id).toBeGreaterThanOrEqual(0); + } + } + }); +}); diff --git a/packages/llm/src/cost-tracker.ts b/packages/llm/src/cost-tracker.ts new file mode 100644 index 00000000..930ce7c1 --- /dev/null +++ b/packages/llm/src/cost-tracker.ts @@ -0,0 +1,81 @@ +import { UnknownModelError } from './errors.js'; +import { + isCanonicalModelId, + KNOWN_MODEL_IDS, + MODEL_PRICING, + type ModelPricing, +} from './pricing.js'; +import type { Usage } from './types.js'; + +/** + * Cost tracking Relavium owns, keyed on the canonical model id — never read from a provider + * field (1.B). All figures are integer **micro-cents** (1 micro-cent = 1e-8 USD), consistent with + * the seam's `Usage.costMicrocents` and the `cost:updated` event. + */ + +/** Look up pricing for a canonical model id; throws `UnknownModelError` (never a silent zero). */ +export function priceModel(modelId: string): ModelPricing { + if (!isCanonicalModelId(modelId)) { + throw new UnknownModelError(modelId, KNOWN_MODEL_IDS); + } + return MODEL_PRICING[modelId]; +} + +const TOKENS_PER_MTOK = 1_000_000; + +/** + * The integer micro-cent cost of one usage record. Each token class is `tokens × + * pricePerMtokMicrocents / 1e6`, rounded once (the only float in the path; everything downstream + * sums integers). + * + * **Convention:** `inputTokens` is the count billed at the full input rate — cache-read and + * cache-write tokens are **separate** counts, not included in `inputTokens`. Adapters normalize to + * this (Anthropic's `input_tokens` is already net; the OpenAI-compatible adapter subtracts the + * cached subset). So each token class is billed exactly once. + */ +export function cost(modelId: string, usage: Usage): number { + const p = priceModel(modelId); + const cacheReadTokens = usage.cacheReadTokens ?? 0; + const cacheWriteTokens = usage.cacheWriteTokens ?? 0; + const perClass = (tokens: number, ratePerMtok: number): number => + Math.round((tokens * ratePerMtok) / TOKENS_PER_MTOK); + return ( + perClass(usage.inputTokens, p.inputPerMtokMicrocents) + + perClass(usage.outputTokens, p.outputPerMtokMicrocents) + + perClass(cacheReadTokens, p.cachedInputPerMtokMicrocents) + + perClass(cacheWriteTokens, p.cacheWritePerMtokMicrocents ?? 0) + ); +} + +/** The cost figures for one `cost:updated` event (the engine adds `nodeId` / `model` / `attemptNumber`). */ +export interface CostUpdate { + readonly inputTokens: number; + readonly outputTokens: number; + readonly costMicrocents: number; + readonly cumulativeCostMicrocents: number; +} + +/** + * Accumulates per-attempt cost across a node/session. Call `record` once per LLM attempt (each + * fallback attempt has its own `Usage`), so the running total stays accurate across a failover. + */ +export class CostTracker { + #cumulativeMicrocents = 0; + + /** Price one attempt's usage and fold it into the running total. */ + record(modelId: string, usage: Usage): CostUpdate { + const costMicrocents = cost(modelId, usage); + this.#cumulativeMicrocents += costMicrocents; + return { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + costMicrocents, + cumulativeCostMicrocents: this.#cumulativeMicrocents, + }; + } + + /** The running total in integer micro-cents. */ + get cumulativeCostMicrocents(): number { + return this.#cumulativeMicrocents; + } +} diff --git a/packages/llm/src/errors.ts b/packages/llm/src/errors.ts new file mode 100644 index 00000000..a5052414 --- /dev/null +++ b/packages/llm/src/errors.ts @@ -0,0 +1,53 @@ +import type { ProviderId } from './types.js'; + +/** + * Typed, discriminated config/validation errors thrown on the Relavium side of the seam (cost + * pricing, tool normalization). These are distinct from `LlmError` — the normalized shape a + * provider *call* fails with; these are authored/config mistakes caught before egress. Each + * carries a stable `code` discriminant and structured context, never an interpolated message + * (docs/standards/error-handling.md). + */ + +export type LlmConfigErrorCode = 'unknown_model' | 'unsupported_tool_schema'; + +/** Base for the seam's thrown config errors — narrow on `code`, never on `message`. */ +export abstract class LlmConfigError extends Error { + abstract readonly code: LlmConfigErrorCode; +} + +/** + * A model id is not in the pricing table — surfaced rather than silently priced at zero (1.B). + * User-facing: names the offending id and the known set. + */ +export class UnknownModelError extends LlmConfigError { + readonly code = 'unknown_model'; + readonly modelId: string; + readonly knownModels: readonly string[]; + + constructor(modelId: string, knownModels: readonly string[]) { + super(`unknown model id '${modelId}' — not in the pricing table`); + this.name = 'UnknownModelError'; + this.modelId = modelId; + this.knownModels = knownModels; + } +} + +/** + * A tool's JSON-Schema cannot be expressed for a given provider's wire format (e.g. the Gemini + * OpenAPI-subset reshape hit an unsupported keyword) — 1.E. User-facing: names the tool, the + * provider, and the reason. + */ +export class ToolSchemaError extends LlmConfigError { + readonly code = 'unsupported_tool_schema'; + readonly provider: ProviderId; + readonly toolName: string; + readonly reason: string; + + constructor(provider: ProviderId, toolName: string, reason: string) { + super(`tool '${toolName}' cannot be expressed for provider '${provider}': ${reason}`); + this.name = 'ToolSchemaError'; + this.provider = provider; + this.toolName = toolName; + this.reason = reason; + } +} diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts new file mode 100644 index 00000000..974597fa --- /dev/null +++ b/packages/llm/src/index.ts @@ -0,0 +1,65 @@ +/** + * `@relavium/llm` public surface — the frozen `LLMProvider` seam (ADR-0011). Curated, not + * `export *`: only the seam contract is public. Provider adapters (1.C/1.G/1.H), the + * `FallbackChain` runner (1.K), and the `CostTracker` (1.B) extend this surface as they land. + */ + +export { + ProviderIdSchema, + ToolDefSchema, + LlmRoleSchema, + LlmMessageSchema, + ToolChoiceSchema, + UsageSchema, + CapabilityFlagsSchema, + LlmErrorKindSchema, + LlmErrorSchema, + LlmRequestSchema, + LlmResultSchema, + StreamChunkSchema, +} from './types.js'; + +export type { + ProviderId, + ToolDef, + LlmRole, + LlmMessage, + ToolChoice, + Usage, + CapabilityFlags, + LlmErrorKind, + LlmError, + LlmRequest, + LlmResult, + StreamChunk, + LlmProvider, +} from './types.js'; + +// The shared-owned seam substrate, re-exported so callers import it from the seam (single +// canonical home — `@relavium/shared` owns it; the dependency direction is `shared → llm`). +export { ContentPartSchema, StopReasonSchema } from '@relavium/shared'; +export type { ContentPart, StopReason, AbortSignalLike } from '@relavium/shared'; + +// Typed config/validation errors (1.B/1.E). +export { LlmConfigError, UnknownModelError, ToolSchemaError } from './errors.js'; +export type { LlmConfigErrorCode } from './errors.js'; + +// CostTracker + the canonical model-pricing table (1.B). +export { MODEL_PRICING, KNOWN_MODEL_IDS } from './pricing.js'; +export type { ModelPricing, CanonicalModelId } from './pricing.js'; +export { priceModel, cost, CostTracker } from './cost-tracker.js'; +export type { CostUpdate } from './cost-tracker.js'; + +// ToolNormalizer (1.E). +export { + toWire, + reshapeForGemini, + GeminiToolCallIds, + normalizeToolCall, +} from './tool-normalizer.js'; +export type { + ToolWire, + OpenAiToolWire, + AnthropicToolWire, + GeminiToolWire, +} from './tool-normalizer.js'; diff --git a/packages/llm/src/pricing.ts b/packages/llm/src/pricing.ts new file mode 100644 index 00000000..64adda9b --- /dev/null +++ b/packages/llm/src/pricing.ts @@ -0,0 +1,159 @@ +import type { ProviderId } from './types.js'; + +/** + * The canonical model-pricing table — the in-code **source of truth** the adapters (1.C/1.G/1.H) + * and the `CostTracker` (1.B) share, keyed on canonical model id. `model_catalog` + * ([database-schema.md](../../../docs/reference/desktop/database-schema.md)) ships empty and is a + * display projection *seeded from here*. Prices are **integer micro-cents per million tokens** + * (1 micro-cent = 1e-8 USD) — no float, ever. The `input` / `output` / `cachedInput` prices map to + * the DB's `*_per_mtok_microcents` columns; `cacheWrite` (Anthropic-only) is **in-code only** — + * there is no `model_catalog` cache-write column, so it is never seeded or persisted. + * + * USD/MTok → micro-cents/MTok is `usd × 1e8` (e.g. $5.00 → 500_000_000). + * + * **Verification.** Anthropic figures are confirmed against the model catalog (claude-api, + * 2026-05-26). The OpenAI / Gemini / DeepSeek figures are best-known **placeholders — VERIFY + * against each provider's pricing page before relying on a cost figure**; updating one is a + * one-line edit. (Gemini's real pricing is context-tiered; the ≤128K tier is used here.) + */ + +export interface ModelPricing { + /** The seam provider this model belongs to. */ + readonly provider: ProviderId; + /** The provider-native model id the adapter sends (often equal to the canonical key). */ + readonly nativeId: string; + readonly displayName: string; + readonly contextWindowTokens: number; + readonly maxOutputTokens: number; + readonly inputPerMtokMicrocents: number; + readonly outputPerMtokMicrocents: number; + /** Cache-read (cached-input) price; 0 when the provider does not discount cache reads. */ + readonly cachedInputPerMtokMicrocents: number; + /** Cache-write price, where the provider charges one (Anthropic does); undefined otherwise. */ + readonly cacheWritePerMtokMicrocents?: number; +} + +const USD_PER_MTOK_TO_MICROCENTS = 100_000_000; // 1 USD = 1e8 micro-cents +/** USD-per-million-tokens → integer micro-cents-per-million-tokens. */ +const usd = (perMtok: number): number => Math.round(perMtok * USD_PER_MTOK_TO_MICROCENTS); + +/** Canonical model id → pricing. The canonical id is what an authored agent/workflow names. */ +export const MODEL_PRICING = { + // --- Anthropic (confirmed: claude-api model catalog, 2026-05-26) ---------------------------- + 'claude-opus-4-8': { + provider: 'anthropic', + nativeId: 'claude-opus-4-8', + displayName: 'Claude Opus 4.8', + contextWindowTokens: 1_000_000, + maxOutputTokens: 128_000, + inputPerMtokMicrocents: usd(5), + outputPerMtokMicrocents: usd(25), + cachedInputPerMtokMicrocents: usd(0.5), // cache read = 0.1× input + cacheWritePerMtokMicrocents: usd(6.25), // cache write (5-min TTL) = 1.25× input + }, + 'claude-sonnet-4-6': { + provider: 'anthropic', + nativeId: 'claude-sonnet-4-6', + displayName: 'Claude Sonnet 4.6', + contextWindowTokens: 1_000_000, + maxOutputTokens: 64_000, + inputPerMtokMicrocents: usd(3), + outputPerMtokMicrocents: usd(15), + cachedInputPerMtokMicrocents: usd(0.3), + cacheWritePerMtokMicrocents: usd(3.75), + }, + 'claude-haiku-4-5': { + provider: 'anthropic', + nativeId: 'claude-haiku-4-5', + displayName: 'Claude Haiku 4.5', + contextWindowTokens: 200_000, + maxOutputTokens: 64_000, + inputPerMtokMicrocents: usd(1), + outputPerMtokMicrocents: usd(5), + cachedInputPerMtokMicrocents: usd(0.1), + cacheWritePerMtokMicrocents: usd(1.25), + }, + + // --- OpenAI (VERIFY against platform.openai.com/pricing) ------------------------------------ + 'gpt-4o': { + provider: 'openai', + nativeId: 'gpt-4o', + displayName: 'GPT-4o', + contextWindowTokens: 128_000, + maxOutputTokens: 16_384, + inputPerMtokMicrocents: usd(2.5), + outputPerMtokMicrocents: usd(10), + cachedInputPerMtokMicrocents: usd(1.25), // OpenAI auto-caches; no separate write charge + }, + 'gpt-4o-mini': { + provider: 'openai', + nativeId: 'gpt-4o-mini', + displayName: 'GPT-4o mini', + contextWindowTokens: 128_000, + maxOutputTokens: 16_384, + inputPerMtokMicrocents: usd(0.15), + outputPerMtokMicrocents: usd(0.6), + cachedInputPerMtokMicrocents: usd(0.075), + }, + + // --- Gemini (VERIFY against ai.google.dev/pricing; real pricing is context-tiered) ---------- + 'gemini-2.0-flash': { + provider: 'gemini', + nativeId: 'gemini-2.0-flash', + displayName: 'Gemini 2.0 Flash', + contextWindowTokens: 1_048_576, + maxOutputTokens: 8_192, + inputPerMtokMicrocents: usd(0.1), + outputPerMtokMicrocents: usd(0.4), + cachedInputPerMtokMicrocents: usd(0.025), + }, + 'gemini-1.5-pro': { + provider: 'gemini', + nativeId: 'gemini-1.5-pro', + displayName: 'Gemini 1.5 Pro', + contextWindowTokens: 2_097_152, + maxOutputTokens: 8_192, + inputPerMtokMicrocents: usd(1.25), + outputPerMtokMicrocents: usd(5), + cachedInputPerMtokMicrocents: usd(0.3125), + }, + + // --- DeepSeek (VERIFY against api-docs.deepseek.com; served via the OpenAI-compatible adapter) - + 'deepseek-chat': { + provider: 'deepseek', + nativeId: 'deepseek-chat', + displayName: 'DeepSeek-V3 (chat)', + contextWindowTokens: 64_000, + maxOutputTokens: 8_192, + inputPerMtokMicrocents: usd(0.27), + outputPerMtokMicrocents: usd(1.1), + cachedInputPerMtokMicrocents: usd(0.07), // cache-hit input + }, + 'deepseek-reasoner': { + provider: 'deepseek', + nativeId: 'deepseek-reasoner', + displayName: 'DeepSeek-R1 (reasoner)', + contextWindowTokens: 64_000, + maxOutputTokens: 8_192, + inputPerMtokMicrocents: usd(0.55), + outputPerMtokMicrocents: usd(2.19), + cachedInputPerMtokMicrocents: usd(0.14), + }, +} as const satisfies Readonly>; + +/** The canonical model ids the pricing table covers. */ +export type CanonicalModelId = keyof typeof MODEL_PRICING; + +const CANONICAL_MODEL_IDS = new Set(Object.keys(MODEL_PRICING)); + +/** + * Type guard: is `value` a canonical model id the pricing table covers? Backed by a `Set` of own + * keys, so it is immune to prototype-chain keys (`'toString'`, `'constructor'`, …) — unlike `in`. + */ +export function isCanonicalModelId(value: string): value is CanonicalModelId { + return CANONICAL_MODEL_IDS.has(value); +} + +/** Every canonical model id, for diagnostics (e.g. the unknown-model error). */ +export const KNOWN_MODEL_IDS: readonly CanonicalModelId[] = + Object.keys(MODEL_PRICING).filter(isCanonicalModelId); diff --git a/packages/llm/src/tool-normalizer.test.ts b/packages/llm/src/tool-normalizer.test.ts new file mode 100644 index 00000000..91b1814a --- /dev/null +++ b/packages/llm/src/tool-normalizer.test.ts @@ -0,0 +1,282 @@ +import type { JSONSchema7 } from 'json-schema'; +import { describe, expect, it } from 'vitest'; + +import { ToolSchemaError } from './errors.js'; +import { + GeminiToolCallIds, + normalizeToolCall, + reshapeForGemini, + toWire, +} from './tool-normalizer.js'; +import type { ToolDef } from './types.js'; + +const parameters: JSONSchema7 = { + type: 'object', + properties: { + path: { type: 'string', description: 'the file path' }, + encoding: { type: 'string', enum: ['utf8', 'base64'] }, + }, + required: ['path'], + additionalProperties: false, +}; + +const toolDef: ToolDef = { name: 'read_file', description: 'Read a file', parameters }; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** Narrow a reshaped property to a record at runtime — fails the test (no unsafe `as`) if it isn't. */ +function recordAt(record: Record, key: string): Record { + const value = record[key]; + if (!isRecord(value)) { + throw new Error(`expected '${key}' to be a record, got ${JSON.stringify(value)}`); + } + return value; +} + +/** A non-array object is the structural floor for a JSON-Schema root — enough to type a test input. */ +const isObjectSchema = (value: unknown): value is JSONSchema7 => isRecord(value); + +/** + * Parse a schema the way a tool's YAML/JSON would actually arrive. Needed where an object literal + * can't express the input: an own `__proto__` key (a literal `__proto__:` sets the prototype) or a + * non-JSON-Schema-7 keyword like OpenAPI `nullable`. Parses to `unknown` and narrows with a runtime + * guard, so no `as` assertion is needed. + */ +const parseSchema = (json: string): JSONSchema7 => { + const parsed: unknown = JSON.parse(json); + if (!isObjectSchema(parsed)) { + throw new Error(`parseSchema expected an object schema, got: ${json}`); + } + return parsed; +}; + +describe('toWire — one canonical ToolDef, three native shapes', () => { + it('shapes the Anthropic input_schema form (parameters passed through)', () => { + const wire = toWire(toolDef, 'anthropic'); + expect(wire).toEqual({ + name: 'read_file', + description: 'Read a file', + input_schema: parameters, + }); + }); + + it('shapes the OpenAI / DeepSeek function form (one adapter, both ids)', () => { + const openai = toWire(toolDef, 'openai'); + expect(openai).toEqual({ + type: 'function', + function: { name: 'read_file', description: 'Read a file', parameters }, + }); + expect(toWire(toolDef, 'deepseek')).toEqual(openai); + }); + + it('shapes the Gemini functionDeclarations form with the reshaped schema', () => { + const wire = toWire(toolDef, 'gemini'); + if (!('functionDeclarations' in wire)) { + throw new Error('expected a Gemini functionDeclarations wire shape'); + } + const decl = wire.functionDeclarations[0]; + expect(decl?.name).toBe('read_file'); + // additionalProperties is stripped by the Gemini reshape; the kept keys survive. + expect(decl?.parameters).not.toHaveProperty('additionalProperties'); + expect(decl?.parameters).toMatchObject({ type: 'object', required: ['path'] }); + }); + + it('omits description when the ToolDef has none', () => { + const wire = toWire({ name: 'now', parameters: { type: 'object' } }, 'anthropic'); + expect(wire).not.toHaveProperty('description'); + }); +}); + +describe('reshapeForGemini — OpenAPI-subset reshape', () => { + it('strips unsupported keywords and unsupported format values', () => { + const reshaped = reshapeForGemini( + { + type: 'object', + properties: { + when: { type: 'string', format: 'date-time' }, // supported format — kept + email: { type: 'string', format: 'email' }, // unsupported format — dropped + }, + additionalProperties: false, // unsupported keyword — stripped + patternProperties: {}, // unsupported keyword — stripped + } satisfies JSONSchema7, + 'demo', + ); + expect(reshaped).not.toHaveProperty('additionalProperties'); + expect(reshaped).not.toHaveProperty('patternProperties'); + const props = recordAt(reshaped, 'properties'); + expect(props['when']).toEqual({ type: 'string', format: 'date-time' }); + expect(props['email']).toEqual({ type: 'string' }); // format dropped + }); + + it('preserves an anyOf union (and reshapes its branches) — Gemini supports it', () => { + const reshaped = reshapeForGemini( + { + type: 'object', + properties: { + value: { + anyOf: [ + { type: 'string', minLength: 1 }, + { type: 'number', minimum: 0 }, + ], + }, + }, + } satisfies JSONSchema7, + 'demo', + ); + const value = recordAt(reshaped, 'properties')['value']; + // The union survives with both branches and their (supported) bound keywords intact. + expect(value).toEqual({ + anyOf: [ + { type: 'string', minLength: 1 }, + { type: 'number', minimum: 0 }, + ], + }); + }); + + it('keeps the numeric / string bound keywords Gemini honors', () => { + const reshaped = reshapeForGemini( + { + type: 'object', + properties: { + age: { type: 'integer', minimum: 0, maximum: 120 }, + name: { type: 'string', minLength: 1, maxLength: 50, pattern: '^[a-z]+$' }, + }, + } satisfies JSONSchema7, + 'demo', + ); + const props = recordAt(reshaped, 'properties'); + expect(props['age']).toEqual({ type: 'integer', minimum: 0, maximum: 120 }); + expect(props['name']).toEqual({ + type: 'string', + minLength: 1, + maxLength: 50, + pattern: '^[a-z]+$', + }); + }); + + it('throws a typed ToolSchemaError on a $ref it cannot express', () => { + const schema = { + type: 'object', + properties: { ref: { $ref: '#/$defs/Thing' } }, + } satisfies JSONSchema7; + expect(() => reshapeForGemini(schema, 'demo')).toThrowError(ToolSchemaError); + try { + reshapeForGemini(schema, 'demo'); + } catch (err) { + expect(err).toBeInstanceOf(ToolSchemaError); + if (err instanceof ToolSchemaError) { + expect(err.code).toBe('unsupported_tool_schema'); + expect(err.provider).toBe('gemini'); + expect(err.toolName).toBe('demo'); + } + } + }); + + it('rejects a primitive-root schema — Gemini parameters must be an object root', () => { + expect(() => reshapeForGemini({ type: 'string' }, 'demo')).toThrowError(ToolSchemaError); + }); + + it('collapses a nullable union type:["string","null"] to a scalar type + nullable:true', () => { + const reshaped = reshapeForGemini( + { + type: 'object', + properties: { opt: { type: ['string', 'null'] } }, + }, + 'demo', + ); + const props = recordAt(reshaped, 'properties'); + expect(props['opt']).toEqual({ type: 'string', nullable: true }); + }); + + it('throws on an inexpressible non-null type union like ["string","number"]', () => { + expect(() => + reshapeForGemini( + { type: 'object', properties: { bad: { type: ['string', 'number'] } } }, + 'demo', + ), + ).toThrowError(ToolSchemaError); + }); + + it('defaults a no-argument tool (typeless {} root) to an object schema', () => { + // Valid for the other providers; Gemini requires the explicit object type, so default it. + expect(reshapeForGemini({}, 'demo')).toEqual({ type: 'object' }); + }); + + it('keeps a __proto__-named property as an own key without mutating the prototype', () => { + const reshaped = reshapeForGemini( + parseSchema( + '{"type":"object","properties":{"__proto__":{"type":"string"},"keep":{"type":"number"}}}', + ), + 'demo', + ); + const props = recordAt(reshaped, 'properties'); + expect(Object.getPrototypeOf(props)).toBe(Object.prototype); // own key, not a prototype mutation + expect(Object.hasOwn(props, '__proto__')).toBe(true); + expect(recordAt(props, '__proto__')).toEqual({ type: 'string' }); + expect(recordAt(props, 'keep')).toEqual({ type: 'number' }); + }); + + it('lets a type-union null win over a contradictory verbatim nullable:false', () => { + const reshaped = reshapeForGemini( + parseSchema( + '{"type":"object","properties":{"opt":{"type":["string","null"],"nullable":false}}}', + ), + 'demo', + ); + expect(recordAt(reshaped, 'properties')['opt']).toEqual({ type: 'string', nullable: true }); + }); + + it('keeps a boolean (primitive) items schema verbatim', () => { + const reshaped = reshapeForGemini( + { type: 'object', properties: { arr: { type: 'array', items: true } } }, + 'demo', + ); + expect(recordAt(reshaped, 'properties')['arr']).toEqual({ type: 'array', items: true }); + }); + + it('throws a typed ToolSchemaError when nesting exceeds the depth cap (DoS guard)', () => { + let schema: JSONSchema7 = { type: 'object' }; + for (let i = 0; i < 200; i += 1) { + schema = { type: 'object', properties: { n: schema } }; + } + expect(() => reshapeForGemini(schema, 'deep')).toThrowError(ToolSchemaError); + }); +}); + +describe('GeminiToolCallIds — synthesized, stable tool-call ids', () => { + it('mints unique ids and matches functionResponses FIFO per name across a multi-tool turn', () => { + const ids = new GeminiToolCallIds(); + const a = ids.synthesize('read_file'); // gemini-tool-0-read_file + const b = ids.synthesize('write_file'); // gemini-tool-1-write_file + const c = ids.synthesize('read_file'); // gemini-tool-2-read_file + expect(new Set([a, b, c]).size).toBe(3); // unique + + // Responses reference by name only — resolved FIFO per name, so each maps to its own call id. + expect(ids.resolveResponse('read_file')).toBe(a); + expect(ids.resolveResponse('write_file')).toBe(b); + expect(ids.resolveResponse('read_file')).toBe(c); + }); + + it('throws when a functionResponse has no matching call', () => { + const ids = new GeminiToolCallIds(); + expect(() => ids.resolveResponse('ghost')).toThrowError(ToolSchemaError); + }); +}); + +describe('normalizeToolCall', () => { + it('folds extracted fields into a canonical tool_call ContentPart', () => { + expect( + normalizeToolCall('anthropic', { id: 'c1', name: 'read_file', args: { path: 'x' } }), + ).toEqual({ type: 'tool_call', id: 'c1', name: 'read_file', args: { path: 'x' } }); + }); + + it('rejects an empty id or name from a misbehaving provider', () => { + expect(() => normalizeToolCall('openai', { id: '', name: 'f', args: {} })).toThrowError( + ToolSchemaError, + ); + expect(() => normalizeToolCall('openai', { id: 'c1', name: '', args: {} })).toThrowError( + ToolSchemaError, + ); + }); +}); diff --git a/packages/llm/src/tool-normalizer.ts b/packages/llm/src/tool-normalizer.ts new file mode 100644 index 00000000..9c2b81f3 --- /dev/null +++ b/packages/llm/src/tool-normalizer.ts @@ -0,0 +1,294 @@ +import type { JSONSchema7 } from 'json-schema'; + +import type { ContentPart } from '@relavium/shared'; + +import { ToolSchemaError } from './errors.js'; +import type { ProviderId, ToolDef } from './types.js'; + +/** + * The `ToolNormalizer` — one canonical `ToolDef` in, three native wire shapes out, and a provider + * tool-call response folded back into one canonical `tool_call` `ContentPart` (1.E). It lives on + * the **Relavium side** of the seam: `toWire` builds the plain object each SDK expects without ever + * importing a vendor type (the fence forbids that outside `src/adapters/*`), so the adapters stay + * dumb. See [llm-provider-seam.md](../../../docs/reference/shared-core/llm-provider-seam.md). + */ + +// --- Wire shapes (Relavium-defined; no vendor type) ------------------------------------------ + +/** OpenAI / DeepSeek: `{ type: 'function', function: { name, description?, parameters } }`. */ +export interface OpenAiToolWire { + type: 'function'; + function: { name: string; description?: string; parameters: JSONSchema7 }; +} + +/** Anthropic: `{ name, description?, input_schema }`. */ +export interface AnthropicToolWire { + name: string; + description?: string; + input_schema: JSONSchema7; +} + +/** Gemini: `{ functionDeclarations: [{ name, description?, parameters }] }` (OpenAPI-subset schema). */ +export interface GeminiToolWire { + functionDeclarations: Array<{ + name: string; + description?: string; + parameters: Record; + }>; +} + +export type ToolWire = OpenAiToolWire | AnthropicToolWire | GeminiToolWire; + +/** Build the native wire tool definition for a provider. `gemini` applies the OpenAPI-subset reshape. */ +export function toWire(toolDef: ToolDef, providerId: ProviderId): ToolWire { + const { name, description, parameters } = toolDef; + const desc = description === undefined ? {} : { description }; + switch (providerId) { + case 'anthropic': + return { name, input_schema: parameters, ...desc }; + case 'openai': + case 'deepseek': + return { type: 'function', function: { name, parameters, ...desc } }; + case 'gemini': + return { + functionDeclarations: [{ name, parameters: reshapeForGemini(parameters, name), ...desc }], + }; + default: { + const unreachable: never = providerId; + throw new Error(`unhandled provider id: ${String(unreachable)}`); + } + } +} + +// --- Gemini OpenAPI-subset reshape ----------------------------------------------------------- + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Keywords Gemini's `functionDeclarations` schema (the OpenAPI-3.0 subset) accepts; everything + * else is stripped. Includes `anyOf` (the union idiom — dropping it would destroy the constraint) + * and the numeric/string bounds Gemini honors. `oneOf` / `allOf` are deliberately omitted (Gemini's + * support is unreliable) and so are stripped. + */ +const GEMINI_ALLOWED_KEYWORDS = new Set([ + 'type', + 'description', + 'enum', + 'properties', + 'required', + 'items', + 'anyOf', + 'nullable', + 'format', + 'minItems', + 'maxItems', + 'minimum', + 'maximum', + 'minLength', + 'maxLength', + 'pattern', + 'default', +]); + +/** `format` values Gemini accepts; an unsupported format is dropped, not sent. */ +const GEMINI_SAFE_FORMATS = new Set(['date-time', 'int32', 'int64', 'float', 'double']); + +/** Keep a `format` only if Gemini accepts it; an unsupported format is dropped. */ +function reshapeFormat(value: unknown): Record { + return typeof value === 'string' && GEMINI_SAFE_FORMATS.has(value) ? { format: value } : {}; +} + +/** + * Lower a JSON-Schema `type` array to Gemini's scalar `type` plus the separate `nullable` flag: + * `['string', 'null']` → `{ type: 'string', nullable: true }`. An inexpressible union (zero or two+ + * non-null members) throws, mirroring the strip-or-throw contract of the rest of the reshape. + */ +function reshapeTypeArray(value: unknown[], toolName: string): Record { + const nonNull = value.filter((member) => member !== 'null'); + if (nonNull.length !== 1) { + throw new ToolSchemaError( + 'gemini', + toolName, + `type union ${JSON.stringify(value)} is not expressible (need exactly one non-null type)`, + ); + } + return value.includes('null') ? { type: nonNull[0], nullable: true } : { type: nonNull[0] }; +} + +/** + * Cap on JSON-Schema nesting depth — far above any real tool schema. Bounds the recursion so an + * adversarial deeply-nested schema raises a typed `ToolSchemaError` instead of overflowing the + * call stack (the `parameters` reshape walks untrusted, possibly agent-authored, input). + */ +const MAX_SCHEMA_DEPTH = 100; + +/** + * Reshape every member schema under a `properties` map. Members are written with `defineProperty`, + * not bracket assignment, so a property literally named `__proto__` becomes an own key rather than + * invoking the prototype setter (which would silently drop it and mutate the node's prototype). + */ +function reshapeProperties( + value: Record, + toolName: string, + depth: number, +): Record { + const properties: Record = {}; + for (const [propName, propSchema] of Object.entries(value)) { + Object.defineProperty(properties, propName, { + value: reshapeNode(propSchema, toolName, depth + 1), + enumerable: true, + writable: true, + configurable: true, + }); + } + return properties; +} + +/** Reshape one allow-listed keyword into the fragment to merge onto the output node. */ +function reshapeKeyword( + key: string, + value: unknown, + toolName: string, + depth: number, +): Record { + if (key === 'format') { + return reshapeFormat(value); + } + if (key === 'type' && Array.isArray(value)) { + return reshapeTypeArray(value, toolName); + } + if (key === 'properties' && isRecord(value)) { + return { properties: reshapeProperties(value, toolName, depth) }; + } + if (key === 'items' || key === 'anyOf') { + // `items` is a schema; `anyOf` an array of schemas — reshapeNode handles both (a `$ref` inside a + // branch still throws), so member schemas are reshaped too. + return { [key]: reshapeNode(value, toolName, depth + 1) }; + } + return { [key]: value }; // enum / required / nullable / min|max(Items|Length) / pattern / default — verbatim +} + +function reshapeNode(node: unknown, toolName: string, depth: number): unknown { + if (depth > MAX_SCHEMA_DEPTH) { + throw new ToolSchemaError( + 'gemini', + toolName, + `schema nesting exceeds the maximum supported depth (${MAX_SCHEMA_DEPTH})`, + ); + } + if (Array.isArray(node)) { + return node.map((entry) => reshapeNode(entry, toolName, depth + 1)); + } + if (!isRecord(node)) { + return node; // a primitive (string type, enum value, etc.) — keep as-is + } + if ('$ref' in node) { + // A reference can't be expressed without resolution — the normalizer doesn't inline refs. + throw new ToolSchemaError( + 'gemini', + toolName, + '`$ref` is not expressible in the Gemini schema subset', + ); + } + const out: Record = {}; + let nullableFromTypeUnion = false; + for (const [key, value] of Object.entries(node)) { + // Strip an unsupported keyword (additionalProperties, patternProperties, …); reshape the rest. + if (!GEMINI_ALLOWED_KEYWORDS.has(key)) { + continue; + } + const fragment = reshapeKeyword(key, value, toolName, depth); + if (key === 'type' && 'nullable' in fragment) { + nullableFromTypeUnion = true; + } + Object.assign(out, fragment); + } + // A `null` in a `type` union is authoritative — don't let a verbatim sibling `nullable` clobber it. + if (nullableFromTypeUnion) { + out['nullable'] = true; + } + return out; +} + +/** + * Validate + strip a canonical JSON-Schema down to Gemini's OpenAPI subset. Throws + * `ToolSchemaError` when the schema can't be expressed (a `$ref`, a non-object root, or nesting past + * `MAX_SCHEMA_DEPTH`). A typeless object root (e.g. a no-argument tool's `parameters: {}`) is + * defaulted to `type: 'object'` — valid for the other providers, and Gemini requires the explicit type. + */ +export function reshapeForGemini(schema: JSONSchema7, toolName: string): Record { + const reshaped = reshapeNode(schema, toolName, 0); + if (!isRecord(reshaped)) { + throw new ToolSchemaError('gemini', toolName, 'tool parameters must be an object schema'); + } + const type = reshaped['type']; + if (type === undefined) { + return { ...reshaped, type: 'object' }; + } + if (type !== 'object') { + throw new ToolSchemaError('gemini', toolName, 'tool parameters must be an object schema'); + } + return reshaped; +} + +// --- Tool-call response → canonical ContentPart ---------------------------------------------- + +type ToolCallPart = Extract; + +/** + * Synthesizes and tracks tool-call ids for Gemini, which exposes **no native tool-call id**. An id + * is minted by call order on the `functionCall`, then matched back (FIFO per name) when the + * `functionResponse` — which references by name only — arrives. So callers always see stable ids. + * + * **Lifetime:** construct one instance per Gemini request/stream; never reuse or share it across + * requests — a shared instance would interleave the per-name queues and mis-pair ids. + */ +export class GeminiToolCallIds { + #counter = 0; + readonly #pendingByName = new Map(); + + /** Mint a stable id for a Gemini `functionCall` and remember it for the matching response. */ + synthesize(name: string): string { + const id = `gemini-tool-${this.#counter}-${name}`; + this.#counter += 1; + const queue = this.#pendingByName.get(name) ?? []; + queue.push(id); + this.#pendingByName.set(name, queue); + return id; + } + + /** Resolve the id for a `functionResponse` (referenced by name) — FIFO per name. */ + resolveResponse(name: string): string { + const id = this.#pendingByName.get(name)?.shift(); + if (id === undefined) { + throw new ToolSchemaError( + 'gemini', + name, + 'functionResponse has no matching synthesized tool-call id', + ); + } + return id; + } +} + +/** + * Fold a provider's tool-call response into one canonical `tool_call` `ContentPart`. The adapter + * extracts `{ id, name, args }` from its SDK's shape first (for Gemini, minting `id` via + * `GeminiToolCallIds`), so every caller sees a normal id. `id` and `name` come from the (untrusted) + * provider SDK shape, so an empty one is rejected here with a typed error rather than left to fail + * the downstream `ContentPartSchema` (which requires non-empty ids/names). + */ +export function normalizeToolCall( + provider: ProviderId, + extracted: { id: string; name: string; args: unknown }, +): ToolCallPart { + if (extracted.id.length === 0 || extracted.name.length === 0) { + throw new ToolSchemaError( + provider, + extracted.name.length === 0 ? '(unnamed)' : extracted.name, + 'a tool call must carry a non-empty id and name', + ); + } + return { type: 'tool_call', id: extracted.id, name: extracted.name, args: extracted.args }; +} diff --git a/packages/llm/src/types.test.ts b/packages/llm/src/types.test.ts new file mode 100644 index 00000000..ab568399 --- /dev/null +++ b/packages/llm/src/types.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import { + CapabilityFlagsSchema, + LlmErrorKindSchema, + LlmErrorSchema, + LlmMessageSchema, + LlmRequestSchema, + LlmResultSchema, + StreamChunkSchema, + ToolChoiceSchema, + ToolDefSchema, + UsageSchema, +} from './types.js'; +import type { LlmProvider, LlmResult, ProviderId, StreamChunk } from './types.js'; + +const usage = { inputTokens: 10, outputTokens: 20 }; + +describe('seam request/message/tool schemas', () => { + it('accepts a minimal valid request and rejects an empty model', () => { + const req = { + model: 'claude-sonnet-4-6', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + }; + expect(LlmRequestSchema.safeParse(req).success).toBe(true); + expect(LlmRequestSchema.safeParse({ ...req, model: '' }).success).toBe(false); + expect(LlmRequestSchema.safeParse({ ...req, maxTokens: 0 }).success).toBe(false); // positive + expect(LlmRequestSchema.safeParse({ ...req, signal: 123 }).success).toBe(false); // not AbortSignalLike + expect( + LlmRequestSchema.safeParse({ + ...req, + signal: { + aborted: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }, + }).success, + ).toBe(true); // a structurally valid AbortSignalLike passes + // The tightening must also reject a PARTIAL object (missing the listeners) and a wrong-typed + // `aborted` — not just a fully-invalid scalar. + expect(LlmRequestSchema.safeParse({ ...req, signal: { aborted: false } }).success).toBe(false); + expect( + LlmRequestSchema.safeParse({ + ...req, + signal: { + aborted: 'yes', + addEventListener: () => undefined, + removeEventListener: () => undefined, + }, + }).success, + ).toBe(false); // `aborted` must be a boolean + }); + + it('accepts a request with tools, toolChoice, and the providerOptions escape hatch', () => { + expect( + LlmRequestSchema.safeParse({ + model: 'gpt-4o', + system: 'be terse', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + tools: [{ name: 'read_file', parameters: { type: 'object' } }], + toolChoice: { name: 'read_file' }, + temperature: 0.2, + maxTokens: 256, + providerOptions: { reasoning: { effort: 'high' } }, + }).success, + ).toBe(true); + }); + + it('validates a message carries normalized ContentParts, not a raw string', () => { + expect(LlmMessageSchema.safeParse({ role: 'assistant', content: 'plain string' }).success).toBe( + false, + ); + expect( + LlmMessageSchema.safeParse({ + role: 'assistant', + content: [{ type: 'tool_call', id: 'c1', name: 'read_file', args: {} }], + }).success, + ).toBe(true); + }); + + it('accepts an object ToolDef.parameters and rejects a non-object', () => { + expect(ToolDefSchema.safeParse({ name: 'f', parameters: { type: 'object' } }).success).toBe( + true, + ); + expect(ToolDefSchema.safeParse({ name: 'f', parameters: 'nope' }).success).toBe(false); + expect(ToolDefSchema.safeParse({ name: 'f', parameters: [] }).success).toBe(false); // an array is not an object schema + expect(ToolDefSchema.safeParse({ name: '', parameters: {} }).success).toBe(false); // non-empty name + }); + + it('accepts the three toolChoice forms', () => { + for (const tc of ['auto', 'none', 'required', { name: 'f' }]) { + expect(ToolChoiceSchema.safeParse(tc).success).toBe(true); + } + expect(ToolChoiceSchema.safeParse('maybe').success).toBe(false); + }); +}); + +describe('seam result/usage/error/capability schemas', () => { + it('pins Usage to non-negative integers', () => { + expect(UsageSchema.safeParse(usage).success).toBe(true); + expect(UsageSchema.safeParse({ ...usage, inputTokens: -1 }).success).toBe(false); + expect(UsageSchema.safeParse({ ...usage, outputTokens: 1.5 }).success).toBe(false); + }); + + it('accepts a result with normalized content + a stop reason', () => { + expect( + LlmResultSchema.safeParse({ + content: [{ type: 'text', text: 'done' }], + stopReason: 'stop', + usage, + raw: { id: 'msg_1' }, + }).success, + ).toBe(true); + // stopReason is the closed StopReason enum (re-exported from @relavium/shared) + expect( + LlmResultSchema.safeParse({ content: [], stopReason: 'banana', usage, raw: null }).success, + ).toBe(false); + }); + + it('classifies LlmError and pins the kind set', () => { + expect( + LlmErrorSchema.safeParse({ + kind: 'rate_limit', + retryable: true, + status: 429, + provider: 'anthropic', + message: 'slow down', + }).success, + ).toBe(true); + expect(LlmErrorKindSchema.options).toHaveLength(9); + expect( + LlmErrorSchema.safeParse({ kind: 'boom', retryable: false, provider: 'openai', message: 'x' }) + .success, + ).toBe(false); + // provider is the closed seam id set + expect( + LlmErrorSchema.safeParse({ kind: 'auth', retryable: false, provider: 'cohere', message: 'x' }) + .success, + ).toBe(false); + }); + + it('requires every capability flag', () => { + const flags = { + tools: true, + streaming: true, + parallelToolCalls: false, + vision: false, + promptCache: false, + reasoning: false, + }; + expect(CapabilityFlagsSchema.safeParse(flags).success).toBe(true); + const missing = { + tools: true, + streaming: true, + parallelToolCalls: false, + vision: false, + promptCache: false, + }; + expect(CapabilityFlagsSchema.safeParse(missing).success).toBe(false); // missing `reasoning` + }); +}); + +describe('StreamChunk union', () => { + const chunks: StreamChunk[] = [ + { type: 'text_delta', text: 'he' }, + { type: 'tool_call_start', id: 'c1', name: 'read_file' }, + { type: 'tool_call_delta', id: 'c1', argsJsonDelta: '{"path":' }, + { type: 'tool_call_end', id: 'c1' }, + { type: 'stop', stopReason: 'tool_use', usage }, + { + type: 'error', + error: { kind: 'overloaded', retryable: true, provider: 'gemini', message: 'busy' }, + }, + ]; + + it.each(chunks)('accepts the %o chunk', (chunk) => { + expect(StreamChunkSchema.safeParse(chunk).success).toBe(true); + }); + + it('rejects an unknown chunk type', () => { + expect(StreamChunkSchema.safeParse({ type: 'thinking', text: 'x' }).success).toBe(false); + }); +}); + +describe('seam types are pure Relavium types (no vendor SDK type crosses the seam)', () => { + it('pins ProviderId to the closed Relavium id set', () => { + expectTypeOf().toEqualTypeOf<'anthropic' | 'openai' | 'gemini' | 'deepseek'>(); + }); + + it('LlmResult is the Relavium shape end-to-end', () => { + expectTypeOf().toEqualTypeOf< + 'stop' | 'length' | 'tool_use' | 'content_filter' | 'error' + >(); + }); + + it('LlmProvider is implementable with only Relavium types', () => { + // A stub that typechecks proves the interface needs no vendor SDK type — a leaked vendor type + // would make this fail to compile (and the import-zone fence forbids the import outright). + const stub: LlmProvider = { + id: 'anthropic', + generate: () => Promise.resolve({ content: [], stopReason: 'stop', usage, raw: null }), + stream: async function* () { + await Promise.resolve(); + yield { type: 'text_delta', text: 'hi' } satisfies StreamChunk; + }, + supports: { + tools: true, + streaming: true, + parallelToolCalls: false, + vision: false, + promptCache: false, + reasoning: false, + }, + }; + expect(stub.id).toBe('anthropic'); + expectTypeOf().returns.resolves.toEqualTypeOf(); + }); +}); diff --git a/packages/llm/src/types.ts b/packages/llm/src/types.ts new file mode 100644 index 00000000..10536ab2 --- /dev/null +++ b/packages/llm/src/types.ts @@ -0,0 +1,176 @@ +import type { JSONSchema7 } from 'json-schema'; +import { z } from 'zod'; + +import { ContentPartSchema, LLM_PROVIDERS, StopReasonSchema } from '@relavium/shared'; +import type { AbortSignalLike, LlmProviderId } from '@relavium/shared'; + +/** + * The **`LLMProvider` seam** — the provider-agnostic boundary every multi-LLM call in Relavium + * crosses ([llm-provider-seam.md](../../../docs/reference/shared-core/llm-provider-seam.md), + * [ADR-0011](../../../docs/decisions/0011-internal-llm-abstraction.md)). Every type here is a + * Relavium/Zod type. **No vendor SDK type ever crosses this seam** — the import-zone fence allows + * provider SDKs only under `src/adapters/*`. The seam's _shape_ is immovable; the adapters behind + * it are reversible, and the _set_ of provider ids is meant to grow (an additive amendment). + * + * The data types are Zod schemas (with inferred TS types) so adapter output can be validated; the + * one exception is `LlmProvider` itself, a behavioural interface (it carries methods). `StopReason` + * and `ContentPart` are owned by `@relavium/shared` and re-exported here (the dependency direction + * is `shared → llm`, so they cannot live here and also be used by the shared session schemas). + */ + +/** Non-empty string — `@relavium/shared` keeps its Zod primitives internal, so the seam owns one. */ +const nonEmptyString = z.string().min(1); +const nonNegativeInt = z.number().int().nonnegative(); + +/** The seam's closed provider-id set (`LLM_PROVIDERS`; additive per ADR-0011). */ +export const ProviderIdSchema = z.enum(LLM_PROVIDERS); +export type ProviderId = LlmProviderId; + +/** + * One canonical tool definition; the `ToolNormalizer` (1.E) reshapes `parameters` to each + * provider's wire form. `parameters` is a JSON-Schema object — the deep subset validation/reshape + * is the normalizer's job, so it is accepted here as any object. + */ +export const ToolDefSchema = z.object({ + name: nonEmptyString, + description: z.string().optional(), + parameters: z.custom( + (value) => typeof value === 'object' && value !== null && !Array.isArray(value), + { message: 'parameters must be a JSON-Schema object' }, + ), +}); +export type ToolDef = z.infer; + +/** A normalized message: a role plus normalized content parts (never a raw string). */ +export const LlmRoleSchema = z.enum(['user', 'assistant', 'tool']); +export type LlmRole = z.infer; + +export const LlmMessageSchema = z.object({ + role: LlmRoleSchema, + content: z.array(ContentPartSchema), +}); +export type LlmMessage = z.infer; + +/** How the model may use tools this turn. */ +export const ToolChoiceSchema = z.union([ + z.enum(['auto', 'none', 'required']), + z.object({ name: nonEmptyString }), +]); +export type ToolChoice = z.infer; + +/** Normalized token usage. `costMicrocents` is Relavium's, computed from the pricing table. */ +export const UsageSchema = z.object({ + inputTokens: nonNegativeInt, + outputTokens: nonNegativeInt, + cacheReadTokens: nonNegativeInt.optional(), + cacheWriteTokens: nonNegativeInt.optional(), + costMicrocents: nonNegativeInt.optional(), +}); +export type Usage = z.infer; + +/** What a provider supports; features off the common path are reached via `providerOptions`. */ +export const CapabilityFlagsSchema = z.object({ + tools: z.boolean(), + streaming: z.boolean(), + parallelToolCalls: z.boolean(), + vision: z.boolean(), + promptCache: z.boolean(), + reasoning: z.boolean(), +}); +export type CapabilityFlags = z.infer; + +/** + * The classified discriminant the `FallbackChain` narrows on (never `error.message`). The first + * four kinds are `retryable: true`; the rest are fatal. The per-provider native→kind mapping lives + * inside each adapter (error-handling.md). + */ +export const LlmErrorKindSchema = z.enum([ + 'rate_limit', + 'overloaded', + 'timeout', + 'transport', + 'auth', + 'bad_request', + 'content_filter', + 'cancelled', + 'unknown', +]); +export type LlmErrorKind = z.infer; + +/** The one error shape that crosses the seam — no vendor SDK error escapes an adapter. */ +export const LlmErrorSchema = z.object({ + kind: LlmErrorKindSchema, + retryable: z.boolean(), + code: nonEmptyString.optional(), // normalized provider/transport code, e.g. 'rate_limit' + status: z.number().int().optional(), // upstream HTTP status, when there was one + provider: ProviderIdSchema, + message: z.string(), // human-readable, already redacted of any secret material + cause: z.unknown().optional(), // original error for debugging — never re-thrown across the seam +}); +export type LlmError = z.infer; + +/** The normalized, provider-agnostic request. `system` is always one top-level field. */ +export const LlmRequestSchema = z.object({ + model: nonEmptyString, // canonical model id, mapped per adapter + system: z.string().optional(), + messages: z.array(LlmMessageSchema), + tools: z.array(ToolDefSchema).optional(), + toolChoice: ToolChoiceSchema.optional(), + temperature: z.number().optional(), + maxTokens: z.number().int().positive().optional(), // required downstream for Anthropic — adapters default it + stopSequences: z.array(z.string()).optional(), + // Host-injected cancellation; the raw key/transport is host-aware (ADR-0018), the type is not. + // Validated structurally so a non-signal value is rejected at the seam, not later when it is observed. + signal: z + .custom( + (v: unknown) => + typeof v === 'object' && + v !== null && + 'aborted' in v && + typeof v.aborted === 'boolean' && + 'addEventListener' in v && + typeof v.addEventListener === 'function' && + 'removeEventListener' in v && + typeof v.removeEventListener === 'function', + { + message: + 'signal must be an AbortSignalLike (aborted: boolean; add/removeEventListener: function)', + }, + ) + .optional(), + providerOptions: z.record(z.string(), z.unknown()).optional(), // typed escape hatch +}); +export type LlmRequest = z.infer; + +/** The normalized non-streaming result. */ +export const LlmResultSchema = z.object({ + content: z.array(ContentPartSchema), // text + any tool_call parts + stopReason: StopReasonSchema, + usage: UsageSchema, + raw: z.unknown(), // provider response, for debugging / the escape hatch +}); +export type LlmResult = z.infer; + +/** The single discriminated chunk union every provider's stream is folded into. */ +export const StreamChunkSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text_delta'), text: z.string() }), + z.object({ type: z.literal('tool_call_start'), id: nonEmptyString, name: nonEmptyString }), + z.object({ type: z.literal('tool_call_delta'), id: nonEmptyString, argsJsonDelta: z.string() }), + z.object({ type: z.literal('tool_call_end'), id: nonEmptyString }), + z.object({ type: z.literal('stop'), stopReason: StopReasonSchema, usage: UsageSchema }), + z.object({ type: z.literal('error'), error: LlmErrorSchema }), +]); +export type StreamChunk = z.infer; + +/** + * The provider seam itself. A behavioural interface (it carries methods), so it is not a Zod + * schema. `key` is "the credential the implementation needs" — a resolved provider key on + * Node-style hosts, a key reference on the desktop (Rust egress), a managed token in managed mode + * (ADR-0018); the `string` type is identical across all of them. + */ +export interface LlmProvider { + readonly id: ProviderId; + generate(req: LlmRequest, key: string): Promise; + stream(req: LlmRequest, key: string): AsyncIterable; + readonly supports: CapabilityFlags; +} diff --git a/packages/llm/tsconfig.build.json b/packages/llm/tsconfig.build.json new file mode 100644 index 00000000..d5c084ce --- /dev/null +++ b/packages/llm/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist" + }, + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json new file mode 100644 index 00000000..304fa221 --- /dev/null +++ b/packages/llm/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + // Typecheck-only config: no emit, so rootDir/outDir live in tsconfig.build.json. + // `types: []` is the platform-type boundary — no ambient @types/node, so a stray + // `process`/`Buffer`/`AbortSignal` is a TS error. The seam is platform-free: it runs in + // Node, the Tauri WebView, the VS Code extension host, and (Phase 2) a Bun worker alike. + "noEmit": true, + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/shared/src/content.test.ts b/packages/shared/src/content.test.ts new file mode 100644 index 00000000..cc194eaa --- /dev/null +++ b/packages/shared/src/content.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { ContentPartSchema } from './content.js'; +import type { AbortSignalLike, ContentPart } from './content.js'; + +describe('ContentPartSchema', () => { + it('accepts each of the three content-part variants', () => { + const parts: ContentPart[] = [ + { type: 'text', text: 'hello' }, + { type: 'tool_call', id: 'c1', name: 'read_file', args: { path: 'x' } }, + { type: 'tool_result', toolCallId: 'c1', result: { ok: true }, isError: false }, + ]; + for (const part of parts) { + expect(ContentPartSchema.safeParse(part).success).toBe(true); + } + }); + + it('accepts a tool_result without the optional isError', () => { + expect( + ContentPartSchema.safeParse({ type: 'tool_result', toolCallId: 'c1', result: 'done' }) + .success, + ).toBe(true); + }); + + it('rejects an unknown content-part type', () => { + expect(ContentPartSchema.safeParse({ type: 'image', url: 'x' }).success).toBe(false); + }); + + it('rejects a tool_call with an empty id or name', () => { + expect( + ContentPartSchema.safeParse({ type: 'tool_call', id: '', name: 'f', args: {} }).success, + ).toBe(false); + expect( + ContentPartSchema.safeParse({ type: 'tool_call', id: 'c1', name: '', args: {} }).success, + ).toBe(false); + }); + + it('rejects a tool_call missing its name (a required field)', () => { + expect(ContentPartSchema.safeParse({ type: 'tool_call', id: 'c1', args: {} }).success).toBe( + false, + ); + }); +}); + +describe('AbortSignalLike', () => { + it('is a usable minimal cancellation handle', () => { + // Constructed structurally — a real `AbortSignal` (typed only in surface code, which carries + // the DOM/node lib) satisfies this same shape, which is the point: the platform-free packages + // thread cancellation through it without pulling in `AbortSignal`'s lib here. + let fired = false; + const signal: AbortSignalLike = { + aborted: false, + addEventListener: (_type, listener) => listener(), + removeEventListener: () => undefined, + }; + signal.addEventListener('abort', () => { + fired = true; + }); + expect(signal.aborted).toBe(false); + expect(fired).toBe(true); + }); +}); diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts new file mode 100644 index 00000000..d8accd0e --- /dev/null +++ b/packages/shared/src/content.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +import { nonEmptyString } from './common.js'; + +/** + * Cross-package runtime contract types the `@relavium/llm` seam — and, later, the session + * message schemas — build on. They live in `@relavium/shared`, the base of the dependency graph + * (`shared → llm → core`), so the seam can re-export `ContentPart` without `@relavium/shared` + * ever importing from `@relavium/llm` (which would invert the package dependency). See + * [ADR-0011](../../../docs/decisions/0011-internal-llm-abstraction.md). + */ + +/** + * A normalized message content part — the one shape every provider's content is folded into, + * shared by an LLM message (the seam's `LlmMessage`) and a persisted session message. `args` + * and `result` are opaque (`unknown`): the engine and adapters own their JSON shapes, not this + * contract. + */ +export const ContentPartSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string() }), + z.object({ + type: z.literal('tool_call'), + id: nonEmptyString, + name: nonEmptyString, + args: z.unknown(), + }), + z.object({ + type: z.literal('tool_result'), + toolCallId: nonEmptyString, + result: z.unknown(), + isError: z.boolean().optional(), + }), +]); +export type ContentPart = z.infer; + +/** + * The minimal structural cancellation handle the seam and the engine thread through. A real + * `AbortSignal` (Node ≥15, browsers, Bun) structurally satisfies it, so the platform-free + * packages (`shared` / `llm` / `core`) need neither the DOM lib nor `@types/node` — the strict + * base's `lib: ["ES2023"]` has no `AbortSignal`. The surface passes a real signal; engine code + * only observes `aborted` and (de)registers an abort listener. + */ +export interface AbortSignalLike { + readonly aborted: boolean; + addEventListener(type: 'abort', listener: () => void): void; + removeEventListener(type: 'abort', listener: () => void): void; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e70057b4..093e392d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -11,6 +11,7 @@ */ export * from './constants.js'; +export * from './content.js'; export * from './agent.js'; export * from './node.js'; export * from './edge.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2887947..b2bef96b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + '@types/json-schema': + specifier: ^7.0.15 + version: 7.0.15 '@types/node': specifier: ^20.11.0 version: 20.19.41 @@ -115,6 +118,28 @@ importers: specifier: 'catalog:' version: 3.2.6(@types/node@20.19.41)(tsx@4.22.4) + packages/llm: + dependencies: + '@relavium/shared': + specifier: workspace:* + version: link:../shared + zod: + specifier: 'catalog:' + version: 3.25.76 + devDependencies: + '@types/json-schema': + specifier: 'catalog:' + version: 7.0.15 + eslint: + specifier: 'catalog:' + version: 9.39.4 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 3.2.6(@types/node@22.19.19)(tsx@4.22.4) + packages/shared: dependencies: zod: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 748b177f..eaa3ba47 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,6 +20,9 @@ catalog: # `node:sqlite`, which ADR-0021 rejects for needing >=22.5) is a type error rather than # a runtime break on the supported floor. '@types/better-sqlite3': ^7.6.13 + # Dev/type-only: the `JSONSchema7` type for the `@relavium/llm` seam's `ToolDef.parameters` + # (a type-only import, erased at build — no runtime dependency, so no ADR needed). + '@types/json-schema': ^7.0.15 '@types/node': ^20.11.0 drizzle-kit: ^0.31.10 # Runtime